Compare commits

..

193 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
iven
823d69a3c3 feat(ai): 知识库 V2 集成 — 多知识源路由 + AI 分析自动注入
- KnowledgeV2Source: 实现 KnowledgeSource trait,自动搜索所有启用的知识库
- AnalysisService.knowledge_sources: 改 Option → Vec 支持多知识源
- 最佳匹配策略:遍历所有知识源取最高 confidence 的上下文注入 system prompt
- main.rs 共享 EmbeddingService + KnowledgeV2Service 实例

Phase 2 Task 12-15
2026-05-27 00:30:49 +08:00
iven
7d1b1f9c7c feat(ai): 向量搜索 + hit test API
- KnowledgeV2Service.vector_search: pgvector 余弦相似度搜索
- SearchHit DTO: chunk_id/document_id/similarity/metadata
- hit_test handler: POST /ai/documents/hit-test (embed query → 搜索)
- AiState 添加 embedding 字段,共享 EmbeddingService 实例
- top_k 限制最大 20

Phase 2 Task 11
2026-05-27 00:24:34 +08:00
iven
e94f5bc00c feat(ai): 文档管理 handler — CRUD + Multipart 上传
- list_documents: 分页列表(按知识库过滤)
- get_document: 文档详情
- create_manual_document: 手动输入文档
- upload_document: Multipart 文件上传(20MB 限制 + 自动解析)
- delete_document: 软删除(级联减计数)
- 5 条路由注册到 /ai/knowledge-bases/{kb_id}/documents + /ai/documents/*

Phase 2 Task 10
2026-05-27 00:17:43 +08:00
iven
0a1f4cb9a9 feat(ai): 文档解析管线 — PDF 解析 + 切片 + 嵌入管线
- 简化版 parser:PDF(pdf-extract) + 纯文本 + 二进制兜底
- 固定窗口切片器(500 字符/50 重叠),5 个单元测试全通过
- DocumentService:手动/上传文档创建 → 切片 → 嵌入 → 存储
- UploadDocumentParams 结构体避免过多参数
- 移除未使用的 docx-rs/calamine 依赖

Phase 2 Task 7-9
2026-05-27 00:13:08 +08:00
iven
23c5bbdb40 feat(ai): 知识库 V2 Handler + 路由注册 + State 初始化
5 个端点:GET/POST /ai/knowledge-bases, GET/PUT/DELETE /ai/knowledge-bases/{id}
AiState 新增 knowledge_v2 字段,main.rs 初始化。
2026-05-26 23:25:38 +08:00
iven
2ccf0801b7 feat(ai): 新增 KnowledgeV2Service — 知识库 CRUD + 原子计数器
知识库列表/创建/更新/删除/单条查询,含分页和过滤。
increment_document_count / increment_chunk_count 使用 SQL 原子递增。
2026-05-26 23:19:09 +08:00
iven
86dbd74f3f feat(ai): 新增知识库 V2 Entity(bases/documents/chunks)
三张新表对应的 SeaORM Entity,embedding 字段标记 #[sea_orm(ignore)]
通过 raw SQL 操作 pgvector 列。
2026-05-26 23:13:42 +08:00
iven
0edb475638 feat(db): 创建 ai_knowledge_documents + ai_knowledge_chunks 表迁移
文档表支持多种来源(上传/URL/手动)、处理状态追踪、嵌入进度计数。
切片表含 vector(1536) 列 + HNSW 索引用于向量相似搜索。
2026-05-26 23:10:57 +08:00
iven
a7526455b4 feat(db): 创建 ai_knowledge_bases 表迁移
知识库 V2 第一张表:支持多类型知识库(规则/参考资料/临床指南/FAQ),
含意图关键词 JSONB、切片策略 JSONB、文档/切片计数器。
2026-05-26 23:07:28 +08:00
iven
dda8be9079 docs(ai): AI 知识库 V2 实施计划 — 7 Phase / 26 Tasks
Phase 0: 数据库迁移 + Entity (3 Tasks)
Phase 1: 后端 Service + Handler (4 Tasks)
Phase 2: 文档处理管线 (5 Tasks)
Phase 3: 混合意图路由 (3 Tasks)
Phase 4: 前端管理 UI (4 Tasks)
Phase 5: AI 客服集成 + 数据迁移 (4 Tasks)
Phase 6: 测试 + 验收 (3 Tasks)
2026-05-26 23:00:46 +08:00
iven
af2484e63b docs(ai): 知识库 V2 设计规格 — review 修复
修复 3 CRITICAL + 4 HIGH 问题:
- C1: 明确禁止 format! 拼接 SQL,所有 pgvector 查询参数化
- C2: 迁移 SQL 改用临时映射表精确关联,防数据重复/丢失
- C3: SSRF 防护细化(DNS rebinding + 超时 + 重定向校验)
- H1: document_count/chunk_count 改为原子 SQL 增量
- H2: embedding 部分失败:NULL embedding 写入 + embedded_count 统计
- H3: chunks 表补充 updated_at/updated_by 审计字段
- H4: 新增 validator crate 依赖说明
- M1: 空知识库边界返回空 context 不报错
- M2: 向量索引改用 HNSW(无需预热)
2026-05-26 22:18:28 +08:00
iven
10c28df152 docs(ai): AI 知识库 V2 设计规格 — 统一知识管理平台
替换旧三层模型(rules/references/guides)为 Dify 风格统一知识库:
- 3 张新表:knowledge_bases / documents / chunks(切片+向量)
- 文档处理管线:PDF/Word/Excel/URL/Markdown/手动录入 → 智能切片 → embedding
- 混合意图路由:关键词粗筛 → 向量检索 → LLM 兜底
- AI 客服集成:RAG context 注入 + [ref:xxx] 引用溯源
2026-05-26 22:11:29 +08:00
iven
3c7b48b6f6 feat(ai): Prompt 管理 Phase 2 — analysis_type 后端选择键 + 筛选修复
- 新增 ai_prompt.analysis_type 列作为后端按链路选择 Prompt 的唯一键
- name 回归显示标识符用途,不再承担选择键角色
- 迁移 000164: 新增 analysis_type 列 + 从 name 回填 + 索引
- 迁移 000165: 修复旧数据从 category 错误回填的问题
- AiPromptList 页面重构: 分析类型/调用链路列、详情抽屉、新建表单
- DrawerForm 组件新增 onValuesChange 回调支持跨字段联动
- 新建表单选择分析类型后自动填充标识符
- 筛选过滤器改为按 analysis_type 而非 category 过滤(后端+前端同步)
- 停用/激活/回滚/删除操作完整可用
2026-05-26 17:04:26 +08:00
iven
3972db4f98 fix(web): 积分商品保存 — 去掉 image_url 中的临时 token 参数
MediaPicker 返回含 ?token= 的 URL 用于预览认证,但保存到数据库时应只存纯路径,
避免 JWT token 持久化。同时改进错误提示显示后端返回的具体消息。
2026-05-26 10:38:30 +08:00
iven
9d6a92e1d7 fix(web): 积分商品图片预览 — 改用 React state 驱动替代 antd Form shouldUpdate
ant Form shouldUpdate + setFieldValue 在 DrawerForm 上下文中无法正确触发重渲染,
改用独立 imageUrl state 管理,Input/预览/MediaPicker/Upload 全部通过 state 同步
2026-05-26 10:30:22 +08:00
iven
42299a6722 fix(web): 积分商品图片选择器 + 更新 422 修复
- 图片字段改用 shouldUpdate 自定义渲染,值正确绑定到 Input 和预览
- updateProduct API 改为嵌套 { data, version } 格式,匹配后端 DTO
- 隐藏 Form.Item 保存 image_url 值到 form store
2026-05-26 10:12:04 +08:00
iven
a2864713d6 feat(web): 积分商品图片选择器 — 媒体库 + 上传替代手动 URL
- PointsProductList: 图片字段改为 Input + 媒体库按钮 + 上传按钮 + 图片预览
- DrawerForm: 新增可选 form prop,允许外部控制表单实例
2026-05-26 10:04:28 +08:00
iven
ba93e6585c fix(web): 文章编辑修复 + ESLint 合规
- ArticleEditor: tag_ids 过滤 null/undefined 值,修复 422 错误
- ArticleCategoryManage: 删除分类传递 version 字段,修复 enforce_version 校验
- articles API: ArticleCategory 接口增加 version 字段
- usePaginatedData: ref 赋值移入 useEffect,修复 react-hooks/refs 规则
- ArticleCategoryManage/ArticleManageList: 函数用 useCallback 包裹,修复 exhaustive-deps
2026-05-26 01:13:59 +08:00
iven
d7fb5da873 feat(health): 积分规则查重 — 同租户同事件类型不可重复创建
- 新增迁移 m20260526_000163:points_rule (tenant_id, event_type) 部分唯一索引(排除软删除行)
- 后端 create_rule 添加 event_type 查重,重复时返回 400 Validation 错误
- 前端 PointsRuleList 提取后端错误消息展示给用户
2026-05-26 01:09:21 +08:00
iven
8027cdd1d9 docs(graphify): 添加知识图谱使用指南
- CLAUDE.md: graphify 从英文规则扩展为中文开发流程指南
  - 定义 5 种使用场景(接手任务/排查bug/理解模块/代码更新/架构审查)
  - 融入闭环工作法,优先级:graphify query > path > Grep/Read
- wiki/index.md: 关键数字添加 Graphify 行 + 模块导航新增开发工具分区
- wiki/infrastructure.md: §3 常用命令添加 Graphify 子节
2026-05-25 14:03:50 +08:00
iven
8ad4329632 chore(mp): 配置优化 + 文档更新
- config: virtualHost + native-components 拷贝配置
- project.config: skylineRenderEnable=false 调试用
- app.config: 移除 lazyCodeLoading 注释(已在 config/index.ts 控制)
- dev.ps1: WECHAT_DEV_MODE=false(真机测试用)
- wiki: 更新 DevTools 卡死根因 + 构建模式说明
- CLAUDE.md: 添加 graphify 知识图谱规则
2026-05-25 13:45:46 +08:00
iven
1a376a255d fix(mp): 导航/请求健壮性 — reLaunch 去重 + 失败降级
- navigateToLogin 添加去重 + reLaunch 失败降级 redirectTo
- request.ts safeReLaunch 添加目标页检测 + 失败降级
- 退出登录 reLaunch 失败降级 redirectTo
- DoctorTabBar / 首页医生端跳转 reLaunch 失败降级
- 网络恢复时正确清理 toast 状态和定时器
2026-05-25 13:45:12 +08:00
iven
485b9bb926 feat(mp): 登录页 UX 优化 — 协议区域就近显示
- 协议勾选移至对应操作区域(微信登录 + 绑定区)更直觉
- 统一 SHOW_DEV_LOGIN 常量控制开发模式入口
- 抽取 requireAgreement() 复用协议检查逻辑
2026-05-25 13:44:35 +08:00
iven
185f411495 feat(mp): 文章详情页改用 mp-html 原生富文本组件
- 引入 mp-html 替代 RichText,支持图文混排、表格等复杂内容
- 新建 RichArticle 组件封装 sanitizeHtml + mp-html
- 通过 native-components 拷贝原生组件到 dist
- 优化文章排版样式(字号、间距、分隔线、底栏安全区)
- sanitize-html 扩展允许 style/data-w-e-type 属性
2026-05-25 13:44:00 +08:00
iven
a24c18155f feat(mp): BLE 血氧仪支持 + 服务发现增强
- 新增 Pulse Oximeter Service (0x1822) 支持,含 SFLOAT 解析
- 连接后自动扫描全部服务,发现并订阅已知健康 UUID
- 设备同步页展示已发现的服务和可用数据类型标签
- 新增 BLEDiscoveredService / BLEDiscoveredCharacteristic 类型
2026-05-25 13:43:16 +08:00
iven
ef1b8eb348 fix(mp): 优化 addChunkPages 分包策略,主包 2MB→766KB(无需 lazyCodeLoading)
分包页面不再注入 common chunk,由分包自己的 vendors 承载。
主包从 2.0MB 降至 766KB,解决 DevTools 和真机调试均卡死的问题。
lazyCodeLoading 仅生产构建启用,dev 模式不启用。
2026-05-24 12:22:08 +08:00
iven
befdeba77c fix(mp): lazyCodeLoading 仅生产构建启用,修复 DevTools/真机调试卡死
dev 模式(watch)下 lazyCodeLoading 导致 DevTools 内存膨胀至 2GB+ 卡死,
这是微信开发者工具的已知兼容问题。改为仅在 production 构建时启用。
2026-05-24 12:02:20 +08:00
iven
b14d0d347f docs(wiki): 更新小程序构建优化 + DevTools 卡死症状 2026-05-24 11:34:06 +08:00
iven
1e59007bd5 fix(mp): DevTools 卡死 + 主包 2MB→766KB + 代码质量 4 项全通过
根因:主包 2MB 全量组件注入导致 DevTools 渲染引擎内存渐增,
叠加离线时固定 3s 抑制期后的请求洪泛。

修复:
- app.config.ts 添加 lazyCodeLoading: requiredComponents
  主包 2.0MB→766KB,taro.js 526→131KB,vendors.js 230→28KB
- request.ts 离线抑制改为指数退避(3s→6s→12s→30s cap)
  后端不可达时自动延长抑制,防止请求风暴
- SegmentTabs Tab 接口改为 readonly,修复 TS 编译错误
- AbortController polyfill 补齐小程序运行时缺失
- 健康首页/设备同步/健康档案/报告/设置页 UI 重构
- 文章页公开端点适配游客访问
- 健康首页 Swiper 间隔优化 4s→5s,动画 500→300ms
2026-05-24 11:32:40 +08:00
iven
675f8a4b10 fix(mp): 小程序真机 TextEncoder 不可用 + DevTools getPhoneNumber 绕过
- secure-storage-aes.ts 用纯 JS 实现 UTF-8 编解码替代 TextEncoder/TextDecoder
- 登录页绑定手机号步骤:DevTools/模拟器中跳过微信 SDK 直接调后端 mock
2026-05-23 13:00:05 +08:00
iven
e56ed9814a fix(mp): 绑定手机号按钮添加 disabled 防重复触发 getPhoneNumber 2026-05-23 12:44:52 +08:00
iven
f11dd59382 feat(auth,mp): 患者登录流程优化 — 智能合并 + 角色冻结 + 页面冻结
- 智能合并:微信注册时用手机号盲索引匹配已有患者档案,避免重复建
  档(AuthState 添加 PiiCrypto + ensure_patient_record 增加盲索引查询)
- 角色冻结:小程序仅允许患者角色登录,医护角色被拦截
  (auth_service.rs 添加反向拦截 + 登录页移除 credential login 表单)
- 页面冻结:10 个非核心页面替换为 FrozenPage 占位组件(用药/知情同意
  /透析/家属/诊断/事件),移除 profile 导航入口,移除医生端预加载
- 医生端代码保留,仅隐藏入口,后续可零成本恢复

讨论记录:docs/discussions/2026-05-23-account-registration-login-flow.md
2026-05-23 12:27:14 +08:00
iven
f7d98a59f0 fix(mp): 助手页输入框下方大片空白
- 移除 padding-bottom 中的 --tk-tabbar-space(100px),TabBar 页面高度已由框架自动扣除
- 仅保留 env(safe-area-inset-bottom) 适配全面屏
2026-05-22 20:17:10 +08:00
iven
b3f53cd437 fix(mp): 商品详情页底部操作栏 fixed 固定定位
- footer 改为 position: fixed 固定在视口底部,不随页面滚动
- 内容区添加 padding-bottom 留出 footer 高度 + 安全区域
- 移除不可靠的 flex + overflow 方案
2026-05-22 20:06:39 +08:00
iven
7f324466bf fix(mp): 商品详情页底部备注显示不全
- 移除 footer 的 position: absolute,改为 flex-shrink:0 自然固定
- 移除 scroll 区域的 padding-bottom: 90px 硬编码留白
- footer 添加 safe-area-inset-bottom 适配全面屏
2026-05-22 20:02:23 +08:00
iven
0748d20b4c fix(mp): 商品详情页加载超时 + 患者兑换权限
- 移除 getProduct 失败后的 listProducts 慢 fallback(拉 100 条),直接报错
- 处理 productId 为空时 loading 永不结束的卡死问题
- 添加 8s 加载超时保护,超时自动显示错误状态+重试按钮
- 新增迁移 000161:患者角色添加 health.points.manage 兑换权限
2026-05-22 19:44:48 +08:00
iven
09013ab94a feat(mp): 积分商城 V2 重设计 — design-handoff 全流程
- 新增 4 个 UI 组件: PointsCard/ProductCard/CheckinCalendar/CheckinModal
- 商城首页 V2: 积分卡 + 快捷操作 + 分类标签 + 商品网格
- 商品详情 V2: 大图 + 信息卡 + 库存/余额状态 + 底部操作栏
- TabBar 新增商城入口(5 Tab: 首页/健康/商城/助手/我的)
- 设计原型 docs/design/mp-05-mall-v2.html + SPEC.md 交付包
- CLAUDE.md 安全规范加固: 新增 §3.7 安全规范 6 条 + Feature DoD 安全清单扩展
2026-05-22 19:15:41 +08:00
iven
1d443ab894 chore(mp): package.json 添加 test/test:watch script 2026-05-22 12:15:24 +08:00
iven
c81c3b73d0 test(mp): request.ts 测试补全 — 新增 9 个测试
- ConcurrencyLimiter 排队/FIFO 释放顺序
- ResponseCache LRU 顺序验证 + TTL 过期
- Token 刷新成功后 401 重试
- Token 刷新失败跳转登录
- isLoggingOut 时立即抛出
- safeReLaunch 并发去重
2026-05-22 12:14:42 +08:00
iven
5816ebb5e6 perf(mp): 缓存优化 — restore 条件清理 + LRU + 差异化 TTL
- auth store: clearRequestCache 仅在 user/roles/patient 变更时调用
- ResponseCache: get() 命中时 delete+set 更新 Map 顺序(真 LRU)
- 告警 API 缓存 TTL 10s,通知未读数 TTL 10s,其余保持 60s 默认
2026-05-22 12:11:59 +08:00
iven
22e33114b1 feat(mp): 微信模板消息订阅统一封装
- 新增 requestSubscribe() 统一订阅函数,消除页面内类型断言重复
- 用药页面新增 requestSubscribeMessage 订阅(MEDICATION_REMINDER 模板)
- 告警页面改用 requestSubscribe('CRITICAL_HEALTH_ALERT')
- wechat-templates 新增 MEDICATION_REMINDER 模板 ID 环境变量
2026-05-22 12:08:49 +08:00
iven
0dfbe3130c feat(mp): App 级告警长轮询 + 健康总览 TS 修复
- 新增 useAlertPolling hook:10s 间隔轮询 critical 告警
- requestUnlimited 独立通道,不占并发槽位
- generation counter 防重叠 + 失败指数退避(max 30s/10次)
- 新告警弹窗 Taro.showModal + TabBar 角标
- 修复 HealthThreshold 属性名(indicator/level 非 indicator_name/severity)
- 修复 usePageData fetchData 返回类型
2026-05-22 12:06:02 +08:00
iven
d24aefe750 fix(mp): 安全修复 + 健康Tab重构为总览
Phase 0 安全修复:
- 移除 secure-storage-aes.ts 硬编码 'hms-default-key' fallback
- production 模式空密钥时拒绝加解密(返回空/不加密)
- dev 模式保留明文兼容(warn 日志提醒)
- .env/.env.h5 注入随机加密密钥
- secureGet 明文 fallback 按环境分级处理
- 新增 8 个测试覆盖空密钥 dev/production 行为

Phase 1 健康Tab重构:
- health/index.tsx 从体征录入页改为健康总览Dashboard
- 新增今日体征摘要卡片(2x2 网格 + 状态标签)
- 新增快捷入口(录入体征/趋势/报告/用药)
- 新增告警提示卡片(待处理告警数量)
- 体征录入移至 pkg-health/input/index(已有页面)
- useHealthData → useHealthOverview(新增 alertCount)

首页增强:
- useHomeData 新增告警计数查询(listPatientAlerts)
- 首页新增告警提示卡片入口
- "记录体征"按钮改为跳转录入页而非健康Tab
2026-05-22 11:48:57 +08:00
iven
490ae075b7 feat(health+mp): S2-3 Patient DTO 最小化
后端:
- 新增 PatientSummary DTO(id/name/gender/birth_date/status 5 字段)
- 新增 GET /health/patients/summary 端点(权限 health.patient.list)
- patient_service::list_summaries 仅查询非敏感字段

前端:
- 新增 PatientSummary 类型 + getPatientSummaries() API
- auth store loadPatients 改用 summary 端点
- setCurrentPatient 仅存储非敏感字段到 secureSet
2026-05-22 10:56:03 +08:00
iven
437f5d1ae9 docs(wiki): 关键数字更新 — 小程序 Phase 2+3 完成
- TS/TSX 文件 168→180
- 组件 35→34(拆分后重新计数)
- Service 文件 42→45
- Hooks 10→12(useCanvasTokens/useNavigationState)
- 测试文件 9→12,断言 201→127(含新 request-signer 8 测试)
- Design Token 新增 5 动画时序 token
- Git 提交 968→996
2026-05-22 09:01:25 +08:00
iven
c2c9657b4d feat(mp): S3-1 API 请求签名工具(前端,待后端集成)
- 新增 request-signer.ts:HMAC-SHA256 签名 + nonce + timestamp
- 使用 @noble/hashes v1 纯 JS 实现(小程序无 crypto.subtle)
- 签名密钥仅存内存(setSigningKey/clearSigningKey)
- 8 个单元测试覆盖签名生成 + nonce + HMAC
- 集成到 request.ts 待后端 signing_key 支持后启用
2026-05-22 08:59:15 +08:00
iven
a5efab2a13 ci(mp): E3-4 小程序 CI 集成
- Gitea CI 新增 miniprogram-test job(tsc + vitest)
- GitHub Actions 新增 miniprogram-test job(tsc + vitest)
- 与 Rust/Web CI 并行执行,加速反馈周期
2026-05-22 08:49:25 +08:00
iven
be8ae84d45 feat(mp): U3-1 医生端导航状态保持
- 新增 useNavigationState hook (saveDoctorPage/getDoctorLastPage)
- 首页医生重定向使用 getDoctorLastPage 代替硬编码路径
- 医生端工作台入口自动保存最后访问路径
- 医生再次打开首页时自动回到上次使用的医生端页面
2026-05-22 08:48:04 +08:00
iven
148cd875dc perf(mp): E3-3 构建优化 — 独立分包 + Terser 压缩增强
- 医生端分包(pkg-doctor-core/pkg-doctor-clinical)设 independent:true
  减少主包体积,独立加载不依赖主包
- prod terser 添加 passes:2 + unsafe 压缩 + toplevel mangle
  提升代码压缩率
- base config 添加 miniCssExtractPluginOption ignoreOrder
  消除 CSS 顺序警告
2026-05-22 08:45:15 +08:00
iven
4fcbf705ca refactor(mp): E3-2 大文件拆分 + U3-2 微交互统一
E3-2 大文件拆分(3 文件 → 6 文件):
- daily-monitoring 449L → useDailyMonitoring.ts hook(238L) + 页面(255L)
- request.ts 376L → cache.ts(75L) + limiter.ts(32L) + 主文件(278L)
- BLEManager.ts 363L → BLEConnection.ts(212L) + 主文件(228L)

U3-2 微交互统一:
- 新增 haptic.ts 工具(light/medium/heavy 三级触觉反馈)
- PrimaryButton 点击触发 hapticLight()
- tokens.scss 新增 5 个动画时序 token(duration/easing)
- mixins.scss 新增 fade-in() mixin(支持 fast/normal/slow 三档)
2026-05-22 08:41:12 +08:00
iven
c9fe654d44 refactor(miniprogram): 消灭全部 any 类型 — 32处 → 0 (E3-1)
- catch (err: any) → catch (err: unknown) + instanceof Error 类型缩窄(6 文件 13 处)
- BLE 回调类型提取 BLEScanResult/BLEConnectionChangeResult/BLECharacteristicChangeResult/BLEServiceItem
- BLEManager 7 处 any 注解替换为具体接口类型
- request.ts method as any → method as ValidMethod 字面量联合类型
- appointment ScheduleItem 接口定义替代 any[]
- Taro.requestSubscribeMessage 使用类型断言替代 as any
- globalThis.__hms 使用 Record<string, unknown>
- TrendChart Canvas/useRef 保留 eslint-disable(微信 API 限制)
- 0 TS 错误,119 测试通过
2026-05-22 08:30:01 +08:00
iven
bdc2d07c1c feat(miniprogram): 血压录入跳焦 + 历史参考值 (U2-2)
- 收缩压 onConfirm 自动聚焦到舒张压输入框
- 显示上次血压测量值作为参考
- 新增 .input-field-ref 样式
2026-05-22 08:22:44 +08:00
iven
8d2c377b68 feat(miniprogram): TrendChart Canvas 适老化 — useCanvasTokens + 斜线纹理 + tooltip 常驻 (U2-1)
- 替换所有硬编码字号/颜色为 useCanvasTokens 动态 token
- 关怀模式 Y/X 轴字号 14px,异常点半径 8px,正常点半径 5px
- 参考区间带增加斜线纹理增强区分度(关怀模式)
- 关怀模式 tooltip 默认显示最后一个数据点(常驻)
- tooltip SCSS 适老:padding 12/16px,min-height 44px
2026-05-22 08:20:57 +08:00
iven
b44ed6dfd2 fix(miniprogram): 健康阈值缓存加密 — secureGet/secureSet 替换明文 Storage (S2-2)
- getHealthThresholds 使用 AES-GCM 加密存储替代明文 Taro.getStorageSync
- 移除未使用的 Taro import
2026-05-22 08:19:31 +08:00
iven
2aa393dd65 fix(miniprogram): Analytics PII 清理 — 移除 userId/patientId 字段 + sanitizeProperties (S2-1)
- 移除 AnalyticsEvent 接口中的 userId/patientId 字段
- 新增 sanitizeProperties 运行时过滤 14 种 PII 标识字段
- trackEvent 自动清理 properties 中的 PII
- 3 个单元测试覆盖 PII 过滤场景
2026-05-22 08:17:58 +08:00
iven
ca9d065d31 feat(miniprogram): Token 常量生成脚本 + useCanvasTokens hook (E2-1 Phase 2)
- 新增 scripts/generate-tokens.ts 从 SCSS 解析 CSS 变量生成 token-values.ts
- 新增 useCanvasTokens hook 供 Canvas 组件适老化/医生端切换
- vitest include 扩展覆盖 scripts/__tests__/
- 10 单元测试覆盖 SCSS 解析和变量替换
2026-05-22 08:13:28 +08:00
iven
96a6196373 feat(health): consent 门控 — handler 层 check_consent_active 患者数据访问拦截
- 新增 consent_check.rs: check_consent_active() 检查患者有效同意记录
- 医护角色 (admin/doctor/nurse/health_manager) 自动跳过检查
- 5 个 handler / 10 处端点添加 consent 门控:
  - daily_monitoring_handler: list_daily_monitoring
  - vital_signs_daily_handler: get_daily_aggregations
  - alert_handler: list_alerts
  - health_data_handler: 5 个列表/趋势/时间序列端点
  - device_reading_handler: list_readings + list_hourly
2026-05-22 00:24:41 +08:00
iven
898e22c715 feat(mp): Phase 1 测试覆盖 + UX 无障碍 — 106 tests PASS + ARIA + focus ring
测试:
- secure-storage: 26 tests (AES 加解密/明文 fallback/迁移/Base64 边界)
- request.ts: 16 tests (扩展 ResponseCache/patientId 隔离/requestUnlimited)
- mock-api: 修复 getCachedPatientId 缺失导致 health 测试失败

UX 无障碍 (10 组件):
- SegmentTabs/DoctorTabBar: role=tablist/tab + aria-selected
- PrimaryButton/SecondaryButton: role=button + aria-disabled/aria-busy
- Loading/LoadingCard: role=status + aria-live=polite
- EmptyState: role=status + aria-live=polite
- ErrorState: role=alert + aria-live=assertive
- TrendChart tooltip: role=tooltip + aria-live=polite
- FormInput: aria-invalid + aria-label

焦点管理:
- 新增 _focus-ring.scss mixin (focus + focus-visible)
- 5 组件 SCSS 应用 focus-ring
2026-05-22 00:24:06 +08:00
iven
02a96682f6 fix(mp): 修复 72 个 TypeScript 类型错误 — noImplicitAny 全量通过
- 移除 28 个文件中不再需要的 import React (jsx: react-jsx)
- 修复 catch(err: any) → catch(err: unknown) 类型收窄
- 修复 __wxConfig / CanvasRenderingContext2D 全局类型声明
- 修复 DTO 类型不匹配 (UpdateDialysisRecordReq, notificationService)
- 移除 52 个未使用的 import/变量/常量
- 修复 services 类型 (auth.ts tenant_id, actionInbox boolean, device-sync)
2026-05-22 00:13:58 +08:00
iven
21f8040994 feat(mp): AES-256-GCM 加密存储 + 安全日志 + ErrorBoundary 升级 + BLE 并发修复
- secure-storage-aes: AES-256-GCM 替代 XOR,保留 XOR 迁移读取
- crypto-polyfill: wx.getRandomValuesSync → crypto.getRandomValues
- logger.ts: dev/prod 区分日志级别,生产不输出详情
- ErrorBoundary: 错误分类(network/render/unknown) + 结构化日志
- DataSyncScheduler: isSyncing 互斥防并发重复同步
- app.tsx 首行导入 crypto-polyfill
2026-05-22 00:13:37 +08:00
iven
29543ef0e7 refactor(mp): Phase 0 工程基础 — TS strict + ESLint + Prettier + 安全加固
- tsconfig: noImplicitAny:true + jsx:react-jsx + target:ES2018 + skipLibCheck
- 新增 eslint.config.mjs (flat config v9, no-explicit-any:error)
- 新增 .prettierrc + lint/format/typecheck 脚本
- prod: pure_funcs 添加 console.warn 防泄漏
- config: 生产环境强制清空 DEV_USER/DEV_PASS
- .env.production: 清空硬编码 tenant_id
- @types/node + @noble/ciphers 依赖
2026-05-22 00:13:17 +08:00
iven
408527375f docs(mp): Phase 2+3 实施计划 — Canvas 适老 + 全面提升 + CI(14 Tasks)
Phase 2: Token 常量生成 + Analytics PII 清理 + 阈值加密 + DTO 最小化 + Canvas 适老 + 表单跳焦
Phase 3: API 签名 + any 清零 + 大文件拆分 + 构建优化 + CI + 导航状态 + 微交互
2026-05-21 23:47:16 +08:00
iven
9c61156ab3 docs(mp): Phase 1 实施计划 — 测试覆盖 + UX 合规(9 Tasks)
测试覆盖: secure-storage/request/auth/DataSyncScheduler 测试扩展
UX 合规: ARIA 角色标注 + 表单可访问性 + aria-live + 焦点管理
安全: 后端 consent 拦截器
2026-05-21 23:46:06 +08:00
iven
6c21f9eb2a docs(mp): Phase 0 实施计划 — 安全 P0 + 工程基础(10 Tasks / 3 Chunks)
Chunk 1: TS strict + ESLint + ErrorBoundary
Chunk 2: AES-256-GCM 加密替换 + auth store 集成
Chunk 3: Tenant ID / console 脱敏 / dev 登录 / 并发安全
2026-05-21 23:39:26 +08:00
iven
685cf53673 docs(wiki): 更新小程序关键数字 — 五维度分析结果 + 改进路线图
- 页面数: 48→61 (15主包+46分包)
- 组件数: 10→35 (ui 21 + patterns 4 + 独立 10)
- 新增 store/hook/service 统计
- 小程序测试: 9 单元测试 + 4 E2E spec
- 新增设计规格索引: 小程序安全优先全面改进路线图
- 综合评分 6.7/10(架构7.25/安全6.0/UX7.4/工程6.2)
2026-05-21 23:26:22 +08:00
iven
89fa322d7a docs(mp): 小程序安全优先全面改进路线图设计规格
四专家组五维度深度分析(架构7.25/安全6.0/UX7.4/工程6.2),
综合 6.7/10 → 目标 8.5/10。安全优先策略,4 Phase / 8 周,
覆盖 XOR→AES 替换、ARIA 可访问性、测试覆盖提升、Canvas 适老化。
2026-05-21 23:25:18 +08:00
iven
093b9fe9a3 fix(web): 剩余前端修复 — 对比度/暗色主题/静默吞错/ESLint 抑制
- index.css: 灰色文字 #94a3b8→#64748b 提升对比度 2.56→4.6:1
- AdminDashboard: 暗色主题背景色使用 CSS 变量
- 5 文件静默吞错 .catch(() => {}) → console.warn
- 2 处预存 ESLint error 添加 eslint-disable 抑制(setState-in-effect)
2026-05-21 22:41:25 +08:00
iven
a7b5548b35 fix(web): 前端错误处理修复 — DrawerForm/usePaginatedData/useStatsData/静默吞错
- DrawerForm: validateFields 添加 try-catch 防止 unhandled rejection
- usePaginatedData: 合并双重 useEffect 消除重复请求
- useStatsData: 模块级缓存+Promise 去重,避免 6 组件实例×7 API=42 请求
- appointments API: 补传 patientSearch/appointmentType 参数
- Home/Roles/DoctorSelect/OperatorWorkbench: .catch(() => {}) → console.warn
2026-05-21 22:40:42 +08:00
iven
d70b027f20 fix(health): 全 handler page_size 上限 100 防止 DoS
22 个 handler 文件统一添加 .min(100) 限制分页大小
2026-05-21 22:38:29 +08:00
iven
4b40d47b71 fix(health): DTO 输入校验补全 + handler .validate() 调用
- daily_monitoring_dto: Create/Update 添加 Validate derive + 血压/体重/血糖/入液量范围校验
- health_data_dto: LabReport/HealthRecord Create/Update/Review 添加 Validate derive
- consultation_dto: CreateSessionReq/CreateMessageReq 添加 Validate + content length
- article_dto: title max=500→200 匹配 DB VARCHAR(200)
- health_data_handler: 7 个 create/update handler 添加 .validate() 调用
- consultation_handler: create_session/create_message 添加 .validate() 调用
- daily_monitoring_handler: create/update 添加 .validate() 调用
2026-05-21 22:37:26 +08:00
iven
21481dbd88 fix(web): ArticlePhonePreview XSS 修复 — DOMPurify 净化 dangerouslySetInnerHTML
- 安装 dompurify + @types/dompurify
- ArticlePhonePreview 使用 DOMPurify.sanitize() 防止 HTML 注入
2026-05-21 22:34:58 +08:00
iven
fd994edf3e fix(mp): 存储层语义统一 + UTF-16 截断修复
- secureGet: 增加 TextEncoder/TextDecoder 替代 charCodeAt 避免 UTF-16 截断
- secureGet: _es_ 前缀键返回空时增加明文键 fallback(对齐 storageGet 语义)
- request.ts safeGet / auth.ts storageGet: 简化为直接委托 secureGet
2026-05-21 22:34:14 +08:00
iven
ee7dd0d6e1 docs(wiki): 关键数字全文校正 — 多专家组审计后更新
更新项:
- Rust 源文件 703→705,erp-health 214→216 文件,erp-ai 62→95 文件
- 全系统 Entity 109→115(58 health + 20 ai + 33 基础 + 4 core)
- 后端测试 1024→1030(839 同步 + 191 异步),96 测试文件
- Web 前端 36→54 活跃路由,54→83 API 模块
- 小程序 167→168 文件,93→102 SCSS,38→42 service
- Git 提交 948→968
- 系统分析评分 6.3→6.9(多专家组审计后提升)
- 新增 DevOps 基础设施指标行
- 项目阶段更新:P0 阻塞项已加固
2026-05-21 18:51:22 +08:00
iven
7571ad74cb docs(wiki): 症状导航新增 6 条 — 多专家组审计修复(审计日志/身份证校验/any清理/DevOps) 2026-05-21 18:32:27 +08:00
iven
05e679b5ef fix(web): 清理 TypeScript any 类型(16→1)
- api/client.ts: AxiosRequestConfig 模块增强 + AxiosHeaders 替代 {} as any(消 8 处)
- api/health/points.ts: 移除 7 处 as any(由 AxiosRequestConfig 增强解决)
- hooks/usePaginatedData.ts: 提取 OptionsConfig 类型消除 4 处 as any
- pages/health/MediaLibrary.tsx: Tree 组件使用 TreeNode 接口替代 any(消 2 处)
- pages/health/articleEditor/ArticleEditor.tsx: 保留 1 处 any(wangEditor 限制)
2026-05-21 18:30:53 +08:00
iven
f59e40e6fe fix(mp): inject-auth 清除 _es_ 旧加密值,避免 secureGet 读到过期 token
inject_auth 写入明文 storage 键但不清除 _es_ 前缀的旧加密值,
导致 secureGet 优先读到旧的/过期的加密 token,所有 API 请求 401。
修复:写入前先 removeStorageSync 所有 _es_ 前缀键。
2026-05-21 18:23:46 +08:00
iven
bc571c7749 feat(docker): 生产环境 DevOps 基础设施 — TLS + 备份加密 + Prometheus + Redis 持久化
新增:
- nginx/nginx.conf: TLS 1.2/1.3 终端 + HSTS/CSP 安全头 + SSE 长连接 + 50M 上传限制
- prometheus/prometheus.yml: HMS/PostgreSQL/Redis/Nginx 四指标源
- prometheus/alerts.yml: 4 组告警规则(系统/应用/数据库/Redis),含 5xx 错误率 + 内存 + 连接数
- restore.sh: 备份恢复脚本(支持加密备份解密恢复)

改进:
- backup.sh: 新增 BACKUP_PASSPHRASE 加密(AES-256-CBC)+ 完整性校验 + 恢复指引
- docker-compose.production.yml: 添加 Nginx/Prometheus/Grafana/uploads-backup 容器
- docker-compose.yml: Redis 添加 --appendonly yes 持久化
- .env.production.example: 添加 DevOps 相关环境变量模板
2026-05-21 18:21:51 +08:00
iven
8e616f2210 fix(health): 身份证号18位校验位验证 + 手机号1[3-9]格式校验
后端:
- validation.rs: 新增 validate_id_number(含加权校验位)和 validate_phone(1[3-9]\d{9})
- patient_dto.rs: CreatePatientReq/UpdatePatientReq/FamilyMemberReq 添加 Validate derive
- patient_handler.rs: create/update/family_member handler 调用格式校验

前端:
- PatientList/PatientDetail/FamilyMembersTab: Form.Item 添加 pattern rules + maxLength

测试:15 个新测试用例全部通过
2026-05-21 18:16:41 +08:00
iven
58afc59676 fix(web): 审计日志显示用户名替代 UUID + 咨询日期选择器中文化
- AdminDashboard: audit log 使用后端返回的 user_name 字段,无则回退 EntityName
- ConsultationList: RangePicker placeholder 改为中文"开始日期"/"结束日期"
2026-05-21 18:01:51 +08:00
iven
d213afc649 fix(mp): auth storage 明文回退 + 首页医护跳转防重入
- auth store restore() 增加 storageGet() 回退:_es_ 加密键为空时
  尝试明文键(兼容 MCP 注入等场景),修复 inject_auth 后功能表面化
- Index 首页医护 reLaunch 添加 redirectingRef 防重入,
  避免 DevTools 中重复 reLaunch 导致卡死
2026-05-21 17:54:53 +08:00
iven
b0f96258ee fix(health+server): 多专家组生产就绪度分析 — DTO 校验补全 + 审计日志用户名
五维度分析结果(DevOps 4.0/10, 医疗合规 9C/6P/1NC, 前端 Lighthouse 94/100/100):

1. Article/Category/Tag DTO 补全 #[derive(Validate)] + handler .validate() 调用(6 DTO + 8 handler)
2. 审计日志 API 新增 user_name 字段(批量关联 users 表),仪表盘显示用户名而非 UUID
3. 多专家组分析报告存档 docs/discussions/
2026-05-21 17:53:00 +08:00
iven
7ad5ddb898 fix(mp): Phase 3 品质打磨 — Loading优化+ErrorBoundary重试上限+登录安全+输入限制
- Loading 组件区分列表底部状态(无spinner)vs 加载中状态
- ErrorBoundary 添加 MAX_RETRIES=3 限制,超出提示重启
- login 页 IS_SIMULATOR 改为 === 'develop' 精确匹配
- login 密码输入 type 改为 safe-password 防截屏
- appointment/create 备注输入添加 maxlength=200
- GuestHome "查看全部" 导航到文章列表页
2026-05-21 16:30:50 +08:00
iven
4e9eb7b397 feat(mp): Phase 2 功能补全 — SOS+推送+趋势图tooltip+家属安全存储
- index: 添加 SOS 紧急求助悬浮按钮(仅患者可见)
- alerts: 告警页面添加微信推送订阅 + critical 推送标识
- TrendChart: 添加触摸 tooltip 显示日期和数值
- family: edit_patient 改用 secureSet/secureGet 安全存储
2026-05-21 16:24:40 +08:00
iven
6338cd7428 fix(mp): Phase 1 核心体验修复 — 咨询描述+体征校验+商城+医生端+跳转
- consultation: 添加 description 字段 + 症状描述输入 + 建议填写提醒
- health/index: 使用 validateNum 添加体征范围校验(血压/心率/血糖/体重)
- mall: 隐藏未实现的积分任务空壳入口
- pkg-doctor-core: 工作台加载失败添加重试按钮和错误状态
- index: 医护人员跳转返回 null 替代 Loading 避免无用渲染
2026-05-21 16:18:20 +08:00
iven
23f7bcb8ce fix(mp): Phase 0 基础设施修复 — secureGet 解密 + Storage 一致性
- secureGet: 移除错误的 startsWith 条件,始终尝试 XOR 解密
- request.ts: current_patient_id 读取改用 safeGet,清理改用 secureRemove
- health.ts: getTodaySummary 使用 getCachedPatientId 替代直接 Storage
- auth.ts: analytics_queue 清理改用明文 Taro.removeStorageSync
2026-05-21 16:13:43 +08:00
iven
43795b2fb7 docs(spec): 修正 spec review 反馈的 6 个问题
- secureGet 修复移除无意义的 length > 0 验证
- P0-2 补充 auth.ts logout 中 current_patient_id 清理链路
- P1-1 补充 consultation.ts service 层类型修改
- P1-2 改为复用 input/index.tsx 已有的 num() 校验
- H4 修正医生端描述(非卡死,缺重试)
- C7 修正开发登录保护方案(IS_SIMULATOR 体验版泄漏)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:25:57 +08:00
iven
ce0561001f docs(spec): 小程序上线前全面改进设计规格
5 Phase / 14 天改进方案,基于 MCP 实操审查 + 3 专家组并行分析
(UX/前端工程/产品安全),涵盖 8 CRITICAL / 11 HIGH / 22 MEDIUM 问题

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:09:53 +08:00
iven
6e33c106d7 docs: 小程序正式发布前待办清单(P0审核阻断+P1/P2优化项)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:13:46 +08:00
iven
fde510f8a3 docs(wiki): 症状导航新增 5 条 — 第二轮行业标准审计修复(安全存储+UX+合规)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:07:39 +08:00
iven
345e46002a fix(mp): 行业标准第二轮审计修复 — 安全存储+UX+合规
- 安全:AI聊天历史、患者档案、设备同步数据统一走 secureSet/secureGet 加密存储
- 合规:TabBar "消息" 改为 "助手" 消除命名误导
- 合规:新增 .env.production 模板配置 HTTPS API URL
- UX:AI发送按钮 40→44px、反馈按钮 32→44px、协议勾选框 44px 点击热区
- UX:5处硬编码 10-12px 字号替换为 design token(DoctorTabBar/ShortcutButton/TodoAlert/mall)
- UX:6处安全区域写法统一(全部使用 --tk-page-padding/--tk-tabbar-space + env fallback)
- 新增 safe-bottom-padded / safe-bottom-tabbar 两个 mixin

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:06:29 +08:00
iven
d576b8ba8f fix(mp): 空 catch 块添加 console.warn 日志(82 处)
55 个文件中 82 处空 catch 块添加模块前缀日志输出:
- stores: auth/health/points (7 处)
- services: request/ai-chat/health/ble/* (10 处)
- hooks: useLongPolling/usePagination (3 处)
- pages: 核心+子包共 35 个页面 (62 处)

保留静默的 catch: secure-storage fallback、Storage 恢复、
analytics 防洪、BLE 断连清理、用户拒绝订阅等合理忽略场景
2026-05-21 13:44:13 +08:00
iven
652cccf66c fix(mp): 五专家组全面审计修复 — 安全+功能+UX+性能+代码质量
安全修复:
- 移除硬编码管理员凭据 admin/Admin@2026,改用环境变量注入
- 移除 forceSetAuth 全局 bridge 方法,减少攻击面
- sanitizeHtml 从黑名单正则升级为白名单方式
- secure-storage 实现 XOR+Base64 加密存储,不再明文
- 添加旧数据迁移逻辑 migrateLegacyStorage

功能修复:
- 新增咨询创建页(consultation/create),修复"发起咨询"按钮导航失败
- 修复咨询详情页长轮询可能永远不启动(dataLoadedRef → useState)
- 新增 createSession service API
- 预约页面从主包移至分包,配置 commonChunks 优化主包体积

UX 修复:
- 65 处硬编码字号 → var(--tk-font-*) token 替换
  - AI 聊天页 13 处、咨询详情页 14 处、医生端核心页 38 处
- StatusTag 色值对齐设计系统色板
- Loading 文字从 --tk-font-h1(28px) 修正为 --tk-font-body-sm
- EmptyState 文字从 --tk-font-num(30px)/--tk-font-h2(22px) 修正
- 医生端 5 处硬编码颜色 → SCSS 变量
2026-05-21 13:35:46 +08:00
iven
e769a5785a docs(wiki): 侧边栏 Menu 重构 + 提交数 948
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 12:25:41 +08:00
iven
831d2ba598 refactor(web): 侧边栏菜单改用 Ant Design Menu 组件
用 Ant Design <Menu mode="inline"> 替代自定义 div 渲染,对齐 ProLayout 体验:
- buildMenuItems() 将后端 MenuInfo 树转为 Menu items 格式
- 目录图标渲染(HeartOutlined/FormOutlined 等)
- 原生折叠动画 + 侧边栏折叠时 popover 子菜单
- openKeys 自动展开包含当前路由的父级
- 键盘导航 + ARIA 无障碍(Menu 内置)
- 插件菜单合并为统一 Menu items
- 删除 ~150 行自定义组件,清理对应 CSS

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 12:08:19 +08:00
iven
8c9d177642 feat(web): 侧边栏一级目录分组可折叠
新增 CollapsibleDirectoryGroup 组件,点击目录标题可展开/折叠子菜单,
默认展开,导航到子菜单时自动展开。侧边栏整体折叠时回落到图标模式。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 08:36:21 +08:00
iven
c1458b1e4b docs(wiki): 迁移数 164 + 菜单方案B重组更新
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 08:14:05 +08:00
iven
b8c84ed9af feat(health): 菜单方案B重组 — 患者中心+随访关怀+配置归入系统管理+文章标签合并
方案B业务流程导向菜单优化:
- "患者管理" → "患者中心",吸收日常监测/诊断/知情同意/咨询
- "诊疗服务" → "随访关怀",只保留随访相关
- 告警规则/危急值阈值 → 系统管理
- 文章分类/标签菜单软删除,合并为文章管理页内 Tab

变更文件:
- 迁移 164: 重命名目录+移动叶子菜单+重建 menu_roles
- ArticleManageList.tsx: 分类/标签管理合并为页内 Tab
- 讨论记录 + 可视化原型 HTML

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 08:13:23 +08:00
iven
2644926fb6 docs(wiki): 迁移数 163 + action-inbox 症状导航 2026-05-21 08:09:20 +08:00
iven
4d8658ae98 fix(health+web): action-inbox 参数绑定修复 + antd 弃用警告清理
- action_inbox_service: count_sql 补齐 $4/$5 占位符,修复参数绑定数不匹配导致 500
- AiSidebar: Drawer width → size(antd v5 弃用替换)
- 6 个页面 Statistic: valueStyle → styles.content(antd v5 弃用替换)
- RealtimeMonitor: 移除未使用的 _alerts 变量
2026-05-21 08:08:47 +08:00
iven
a3c84fc12a feat(server): 侧边栏菜单按业务流程重组 — 3 目录 → 7 目录
将"健康业务"(30+ 项扁平列表)拆分为 5 个业务域顶级目录:
- 患者管理(患者/标签/医护)
- 诊疗服务(随访/咨询/诊断/同意/监测)
- 健康监测(实时监控/告警/设备/网关/危急值)
- 运营管理(文章/积分/媒体/轮播图)
- AI 助手(对话/Prompt/分析/知识库/用量/配置)

系统管理吸收 OAuth 合作方,工作台保持不变。
重建 menu_roles 按 doctor/nurse/health_manager/operator 精确绑定。
新增迁移 163,菜单系统 100% 数据库驱动,前端无需改动。
2026-05-21 07:20:21 +08:00
iven
c5caed73b3 docs(wiki): 关键数字更新 — 迁移 162 + 路由 382+ + 测试 1024+ + 15 消费者模块 + 14 条症状导航新增 2026-05-21 01:47:57 +08:00
iven
41a865cf68 feat(health+core+ai): 业务流程全面修复 Phase 4-6 + 集成测试修复
Phase 4 — Dead-letter 重试 + 内容推送 + 安全加固:
- erp-core: retry_dead_letters() 定时重试 + PII payload 脱敏
- erp-core: audit_service 哈希链定时验证 + 写入失败告警
- erp-health: article.published 消费者匹配 patient_tag 推送消息
- erp-health: care_plan 事件消费者 (激活通知 + 完成积分)

Phase 5 — 患者批量操作 + 咨询增强 + 护理事件:
- patient: batch_import_patients + bind_by_phone + refer_patient
- consultation: rate_session 满意度评价 (rating + feedback)
- consent: patient_sign_consent 患者端签署
- validation: source 枚举 (7值) + relationship 枚举 (7值) + 12 单元测试

Phase 6 — 咨询文件上传 + AI 引用标注:
- consultation_message: media_id 附件上传端点
- ai_suggestion: references JSONB + [ref:id] 格式引用标注
- AI system prompt 增加引用指令 + output_parser 提取逻辑

迁移: 000161 (media_id + references) + 000162 (rating + feedback)
集成测试: consultation/follow_up/pii_encryption 新字段同步修复
讨论文档: 2026-05-20-business-process-brainstorm.md (10域审核报告)
2026-05-21 01:34:20 +08:00
iven
9033ec8ca2 fix(miniprogram): 首页体征数据加载时序 + 并发控制 + 权限修复
- ConcurrencyLimiter 12→8 预留长轮询通道,避免超微信 10 并发限制
- usePageData 添加 AbortController,页面隐藏/卸载自动取消请求
- useHomeData 添加 useEffect 监听 currentPatient 变化自动触发数据加载
- 医护人员首页跳转前不渲染 HomeDashboard,避免触发无用 API 请求
- auth.ts getPatients 正确提取分页响应 .data 数组
- health.ts getTodaySummary 从 Storage 回退读取 patient_id
- health store refreshToday 从 auth store 回退获取 currentPatient.id
- auth store restore() 状态变化时清理请求缓存,避免返回过期数据
2026-05-21 01:08:29 +08:00
iven
ec7f76127d feat(health): 积分触发扩展 + 随访模板关联 — Phase 3
- 新增 follow_up.completed 事件积分消费者,随访完成触发 30 积分
- follow_up_task 新增 template_id FK 关联随访模板
- follow_up_record 新增 form_data JSONB 存储结构化表单数据
- 新增 POST /health/follow-up-tasks/from-template 基于模板创建随访任务端点
- 数据库迁移 160:follow_up_task.template_id + follow_up_record.form_data + 积分规则种子
2026-05-21 00:50:29 +08:00
iven
5877342a4d docs(wiki): 症状导航新增 3 条 — PII解密日志 + 负年龄 + 随访placeholder
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 21:04:22 +08:00
iven
4728794604 fix(health+web): PII 解密日志 + 负年龄防护 + 随访页面中文 placeholder
- helper.rs: 提取 decrypt_field 辅助函数,解密失败时输出 warn 日志而非静默返回 None
- format.ts: calcAge 负年龄(未来出生日期)返回 '--' 而非 '-72岁'
- FollowUpTaskList.tsx: DatePicker.RangePicker 添加中文 placeholder

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 21:01:55 +08:00
iven
e3318e8266 fix(db): 迁移 157 修复 — points_rule 列名 points → points_value
迁移 SQL 中 INSERT 使用了错误的列名 `points`,
实际表结构为 `points_value`。修复后迁移成功执行:
- follow_up_task 新增 source_type/source_id 列
- points_rule 新增 3 条积分规则种子数据
2026-05-20 19:36:36 +08:00
iven
80ee83861c docs(wiki): 关键数字更新 — 迁移 157 + 提交 933 + 3 项安全修复已标记 2026-05-20 17:54:36 +08:00
iven
853a0ca2b4 docs: V1 发布策略头脑风暴 + 六维度发布就绪度分析文档 2026-05-20 17:53:10 +08:00
iven
65cf96f119 fix(security): 安全加固 — analytics 权限校验 + HSTS/CSP 安全头 + SSE no-cache + SQL 参数化
- analytics batch() 添加 require_permission + 事件数上限 100
- main.rs 添加 HSTS/Content-Security-Policy/Permissions-Policy 安全头
- sse_handler SSE 响应添加 Cache-Control: no-store 防 token 泄漏
- action_inbox_service SQL 查询改为参数化,防注入
- wechat_handler 日志脱敏,不打印 appid/secret 长度
- dynamic_table sanitize_identifier 添加 63 字节限制
2026-05-20 17:52:28 +08:00
iven
fa1dc764a3 feat(health+ai): P2 咨询联动 + AI 巡检消费 — 全链路打通
业务链路打通 5/5 断点全部完成:
- 咨询→随访:医生端新增"创建随访"按钮,从咨询会话直接创建随访任务
- 咨询→AI:医生端新增"AI 分析"按钮,对咨询上下文触发 AI 分析
- 告警→咨询:小程序告警详情页新增"在线咨询"快捷入口
- AI 巡检消费:erp-ai 新增 patrol_consumer,订阅 ai.patrol.requested 事件
- 前端联动:Web ConsultationDetail + 小程序 alerts 页面联动实现

后端:2 新 API + 2 handler + 1 service + AI event consumer
前端:Web 2 API + 1 页面改造 + 小程序 2 页面改造
测试:Web consultations.test.ts 9/9 通过
2026-05-20 17:50:49 +08:00
iven
5f34e5715a feat(health): AI 主动巡检定时任务 — 每日扫描异常患者触发 AI 分析
- 新增 start_ai_patrol 定时任务(启动延迟 10 分钟 + 每 24 小时执行)
- 新增 get_patrol_candidates 函数:查询最近 7 天有未处理告警的患者
- 每个候选患者发布 ai.patrol.requested 事件(含 patient_id/doctor_id/reason)
- AI 模块可订阅此事件执行自动化分析(erp-ai 侧消费)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:30:04 +08:00
iven
17114d492e feat(health): 业务链路打通 — 告警自动随访 + 健康数据积分激励
- 迁移 000157: follow_up_task 新增 source_type/source_id 字段追踪任务来源
- 迁移 000157: points_rule 新增 health_data_report/lab_report_upload/streak_7_days 种子规则
- P0-2: follow_up 事件处理器新增 health_data.critical_alert 消费,告警触发时自动创建随访任务
  - 自动查找管床医生分配,critical 级别 1 天内、warning 级别 3 天内
  - 新增 alert_auto 随访类型,source_type 标记来源为 critical_alert
- P1-1: points 事件处理器新增 daily_monitoring.created 消费,日常监测上报自动获取积分
- P1-1: points 事件处理器新增 lab_report.uploaded 消费,化验报告上传自动获取积分
- 更新 HMS 系统设计思路文档 v2.0(实体数/链路图/业务链路章节全面更新)
- 新增业务链路打通讨论记录

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:25:28 +08:00
iven
e83101dd23 fix(health+plugin): 空标签名校验 + 出生日期校验 + metrics 错误映射 + 测试报告修正
- C1 已修复: CreateTagReq 添加 validate(length(min=1)) + handler 调 .validate()
- C2 非BUG: 媒体库实际路径 /health/media-folders(非 /health/media/folders)
- H6 已修复: create/update patient 添加 birth_date <= today 校验
- H7 已修复: 插件 metrics 移除手动 map_err,用 From trait 自动映射
- H1-H5 非BUG: 测试使用了错误的 API 路径(积分/随访/告警/设备)
- M1-M2 非BUG: Pagination 已有 .min(100) 上限 + u64 不接受负数
- 测试报告更新: Go/No-Go 从 CONDITIONAL GO 升级为 GO

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 07:25:38 +08:00
iven
3c94f5d585 fix(mp): 医生端添加底部导航栏解决无法退出登录问题
医生端工作台是分包页面,不在 TabBar 配置中,redirectTo 后底部
导航消失导致无法到达"我的"页面退出登录。新增 DoctorTabBar 组件
模拟底部导航,包含工作台/患者/咨询/我的四个入口,使用 reLaunch
切换避免页栈溢出。
2026-05-20 07:18:18 +08:00
iven
03c50f6712 docs(wiki): V1 E2E 测试结果 — 症状导航新增 4 条待修复项 + 关键数字更新
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 07:00:30 +08:00
iven
6e8239daf0 docs: V1 测试版本全面端到端测试报告 + 专家评估 + wiki 更新
- 测试报告: 157 端点测试, Health 63% / AI+Dialysis+Plugin 92.4%
- 专家评估: 产品7.3/架构7.6/安全7.0/测试4.1/UX7.6, 综合6.2 B-
- CRITICAL×2: 空标签名500 + 媒体库路由冲突
- CONDITIONAL GO: 修复 P0 问题后可发布

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 06:59:31 +08:00
iven
f3bf8b3b1d fix: DTO 输入校验补全 + 编译修复 + AuthButton 类型修复
- erp-auth/config/workflow/message/plugin/health: 44 处 DTO 校验缺失修复
- erp-plugin/data_dto: utoipa derive 宏 import 修复
- erp-server/main: tracing 宏类型推断修复
- web AuthButton: AiAnalysisCard/VitalSignsTab Button 包裹在 children 内

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 06:58:54 +08:00
iven
d74c7a61de docs(wiki): AI 对话全链路修复 — 关键数字+症状导航+模块描述更新 2026-05-19 21:38:20 +08:00
iven
c6d4e76b62 fix(ai): AI 对话全链路修复 + 菜单配置 + 会话消息持久化
- 修复 ai_tenant_config Entity 表名错误(复数→单数)导致 budget_status 500
- 修复 ai_usage 表 SQL 引用不存在的 deleted_at 列
- 修复 risk_service SQL 列名/表名与实际数据库 schema 不匹配
- chat_handler provider 选择改为配置优先(default_provider→fallback chain)
- 新增 Ollama 非 FC provider 的 generate() 降级路径
- 新增 GET /ai/chat/sessions/{id}/messages 端点
- 前端 ChatPage 切换会话时从后端加载历史消息
- AiConfigPage 新增 default_provider 和 system_prompt 配置字段
- 迁移 000155-000156:AI 菜单调整 + AI 客服菜单 + 角色绑定
- 配额检查错误处理区分配额耗尽和 DB 异常
2026-05-19 21:36:01 +08:00
iven
8fbe1543cb fix(ai): ChatPage import/layout 修复 + 迁移表名列名修正 + 路由权限注册
- ChatPage: 图标从 antd 移到 @ant-design/icons,Layout/Sider 改为 div 布局避免 Header 遮挡
- routeConfig: 注册 /ai/chat 路由权限 (ai.chat.session.list/manage)
- 迁移 153: ai_tenant_configs → ai_tenant_config 表名修正
- 迁移 154: menus.name/is_external/status → title/visible/menu_type 列名修正
- 迁移 151/152: AI 配置菜单父级修复 + AI Provider 权限 seed
2026-05-19 17:48:37 +08:00
iven
975928233f feat(mp): Day 10 — 小程序会话 API 封装
- sendAiMessage 支持 sessionId 参数
- 新增 createSession / listSessions / renameSession / closeSession
- AiChatSession 接口定义
2026-05-19 11:45:22 +08:00
iven
8e5bc97f93 feat(web): Day 9 — Web ChatPage + 会话 API 前端
- ChatPage 组件: 左侧会话列表(260px) + 右侧聊天区 + RichMessage 富消息
- 新建/选择/重命名/关闭会话,session_id 模式消息持久化
- aiChatApi 新增 createSession/listSessions/renameSession/closeSession
- 路由 /ai/chat 注册,支持 display_hints 富消息渲染
- App.tsx 路由权限校验覆盖
2026-05-19 11:44:38 +08:00
iven
a48a3d9906 feat(ai): Day 8 — 会话 CRUD API + chat_handler session 模式
- 新增 4 个会话端点: POST/GET /ai/chat/sessions, PUT rename, POST close
- ChatRequest 增加 session_id 字段(Optional,向后兼容)
- session_id 模式: DB 加载历史 + 持久化消息 + Tool 调用日志写入
- 无 session_id 时保持原有 history 数组模式不变
- 权限: ai.chat.session.list / ai.chat.session.manage
2026-05-19 11:39:25 +08:00
iven
de342f9195 feat(ai): Day 7 — 会话持久化 Entity + Service
- 新增 3 个 SeaORM Entity: ai_chat_session / ai_chat_message / ai_tool_call_log
- ChatSessionService: create / list / get / close / rename
- ChatMessageService: save_message / list_messages / save_tool_call_log
- 参数封装为 SaveMessageParams / SaveToolCallLogParams 避免 clippy too_many_arguments
- AiState 注册 chat_session + chat_message 服务
- erp-server main.rs 初始化注入
2026-05-19 11:33:37 +08:00
iven
b03ea47fed test(ai): Day 5.3 — 补充 5 个老 Tool 单元测试
- query_vitals: tool_name + schema_has_days_param
- query_lab_reports: tool_name
- query_appointments: tool_name
- query_medications: tool_name
- search_medical_knowledge: tool_name + schema_requires_query
2026-05-19 11:15:22 +08:00
iven
bcff978ea0 feat(ai): Day 5 — ChatResponse display_hints + Web RichMessage 渲染
- ChatResponse 增加 display_hints 字段,Orchestrator 收集 Tool 产生的 DisplayHint
- DisplayHint 实现 utoipa::ToSchema
- Web ChatResponse 类型同步,DisplayHint 8 种联合类型
- RichMessage 组件:InsightCard/RiskAlert/LabReportCard/TrendChart/PatientProfile/VitalCard
- AiSidebar 消息中渲染 display_hints 富消息
- 小程序 AiChatResponse 类型同步
2026-05-19 11:10:07 +08:00
iven
8064db3475 feat(ai): Day 4 — 策略 Prompt 优化 + Tool 调用日志
- System Prompt 增加 10 个 Tool 的使用时机指引,Agent 自动选择最合适的 Tool
- 优先使用 get_health_insights 作为首次对话开场工具
- AgentRunResult 新增 tool_calls: Vec<ToolCallLog>,记录每次调用名称/耗时/成功状态
- ToolCallLog 将在 Phase 2 写入 ai_tool_call_logs 表
2026-05-19 11:01:03 +08:00
iven
8b59f2d7d9 feat(ai): Day 3 — GetHealthInsightsTool + 配额前置检查 + Token 预算限制
- 新增 GetHealthInsightsTool:聚合档案摘要+化验异常+体征异常,输出 InsightCard
- 注册到 Patient/MedicalStaff 沙箱(10 个 Tool 全部就位)
- chat_handler 添加 QuotaService 配额前置检查(月度 Token/患者日限额)
- AgentRunParams 新增 token_budget 字段,Orchestrator 每轮累计检查超预算强制结束
2026-05-19 10:56:09 +08:00
iven
6f088347ce feat(ai): Agent 分析 Tool — AnalyzeLabReport + AnalyzeHealthTrends
- AnalyzeLabReportTool: 获取化验报告详细指标(异常标记+参考范围)
- AnalyzeHealthTrendsTool: 趋势分析(回归方向/日变化/异常检测)
- 沙箱: MedicalStaff 专属分析 Tool,Patient 不可用
2026-05-19 10:45:32 +08:00
iven
7edf1ed1d3 feat(ai): Agent Tool 扩展 — QueryPatientProfile + DisplayHint 新增 3 变体
- QueryPatientProfileTool: 查询患者档案摘要(年龄/性别/慢性病/用药/家族史)
- DisplayHint 新增 TrendChart/InsightCard/PatientProfile 变体
- 沙箱: Patient + MedicalStaff 添加 query_patient_profile
2026-05-19 10:41:29 +08:00
iven
8b88cb4a50 feat(ai): Phase 3A RAG 知识库 — CRUD API + Agent Tool + 向量知识源 + 前端管理页
- 知识库 REST API: 10 个端点 (references/guides CRUD + re-embed)
- search_medical_knowledge Agent Tool: 语义检索参考资料和临床指南
- VectorKnowledgeSource: 实现 KnowledgeSource trait,自动降级
- 沙箱配置: Patient/MedicalStaff 允许使用知识库检索
- 前端 AiKnowledgePage: Tabs(参考资料/临床指南) + Table + Modal CRUD
- 权限码 seed 迁移: ai.knowledge.list + ai.knowledge.manage + 菜单
2026-05-19 09:10:53 +08:00
iven
c0570dfbfc feat(ai): Phase 3A-3 知识库 CRUD 服务 — references/guides 创建/更新/删除/列表
- KnowledgeService: 完整 CRUD(创建含自动 embedding 生成)
- Embedding 失败时降级为 NULL(条目仍可 CRUD,但不可向量搜索)
- re_embed 方法支持单条重新生成向量
- 所有操作通过 raw SQL 写入 pgvector embedding 列
- 软删除 + tenant_id 隔离
2026-05-19 08:53:29 +08:00
iven
7658bc3cdf feat(ai): Phase 3A-1/2 RAG 知识库基础 — Embedding 服务 + pgvector 向量搜索
- EmbeddingService: OpenAI 兼容 embedding API 客户端(单条+批量)
- 从 settings 表读取配置(base_url/api_key/model)
- KnowledgeSearchRepository: pgvector 余弦相似度搜索(references+guides UNION)
- format_vector 辅助函数,Embedding 失败降级为 NULL
- 6 个 embedding 单元测试通过
2026-05-19 08:46:36 +08:00
iven
9576e80175 feat(ai): Phase 2B 洞察→推送→反馈闭环 — 风险评分+通知+建议反馈
- 风险评分引擎 load_patient_data 实装(体征+化验异常)
- refresh_all_patients 高风险自动创建洞察+事件推送
- erp-message 订阅 copilot.insight.created 推送医护通知
- 每日 cron 增加洞察过期清理+建议过期清理
- POST /ai/suggestions/{id}/feedback 建议反馈端点
- SuggestionFeedbackService 反馈服务层
- 小程序健康页建议卡片增加采纳/忽略/咨询医生按钮
2026-05-19 01:19:09 +08:00
iven
2660f1afff feat(ai): Phase 2A-3 随访页 AI 辅助生成小结 — SSE 端点 + 前端集成
- AnalysisType 新增 FollowUpSummary 变体(as_str/prompt_name)
- HealthDataProvider 新增 get_follow_up_summary_data() + FollowUpSummaryDataDto
- erp-health 实现随访数据查询(task + records + PII 解密)
- 新增 /ai/analyze/follow-up-summary SSE 端点
- SanitizationService 新增 sanitize_follow_up_data()
- 前端 analysisSse.ts/AiAnalysisCard 支持 follow-up-summary 类型
- FollowUpTaskList 操作列新增「AI 小结」按钮
2026-05-19 00:54:15 +08:00
iven
205f6fb5a2 feat(web): Phase 2A-2 患者档案 AI 自动摘要 — 侧边栏顶部摘要卡片
当 AI 侧边栏打开且当前页面为患者详情时,自动调用 GET /ai/health-summary
并在侧边栏顶部显示摘要卡片:
- 风险等级标签(低/中/高/严重,对应绿/橙/红/深红)
- 活跃洞察数量 + 近期分析次数
- 最多 3 条摘要项(按 category + severity 着色)
- 最新洞察标题(带警告图标)
- 离开患者页面或关闭侧边栏时自动清除

同时:
- analysis.ts 新增 getHealthSummary API + HealthSummaryResponse 类型
- 权限检查:需要 ai.analysis.list 才显示摘要卡片
2026-05-19 00:37:46 +08:00
iven
1e2ad6170a feat(web): Phase 2A-1 AI 侧边栏骨架 — 浮动按钮 + 聊天 Drawer
新增 Web 管理后台 AI 侧边栏:
- 右下角渐变色浮动按钮(RobotOutlined),hover 放大效果
- AiSidebar Drawer 组件:聊天消息列表 + 输入框 + 发送按钮
- 自动检测当前页面患者 ID,携带 patient_id 上下文到 /ai/chat
- 权限检查:无 ai.chat.send 权限时禁用输入并提示
- 气泡样式对话:用户消息蓝色右对齐,助手消息灰色左对齐
- 清空对话、加载态(思考中 Spin)、Enter 发送 + Shift+Enter 换行

新增文件:
- apps/web/src/api/ai/chat.ts — AI 聊天 API 模块
- apps/web/src/components/ai/AiSidebar.tsx — 侧边栏组件

修改文件:
- apps/web/src/layouts/MainLayout.tsx — 集成浮动按钮 + AiSidebar
2026-05-19 00:32:08 +08:00
iven
b2053d5bcc feat(ai): Phase 2A-4 新增 3 个 Agent Tool — 化验报告/预约/用药查询
新增 3 个 AI Agent Tool 扩展医护沙箱能力:
- query_patient_lab_reports: 查询患者化验报告列表(含异常计数)
- query_patient_appointments: 查询患者即将到来的预约
- query_patient_medications: 查询患者当前用药列表

同时:
- HealthDataProvider trait 新增 get_patient_lab_reports 方法 + LabReportListItemDto
- erp-health 实现新 trait 方法(含 PII 解密)
- sandbox.rs 更新角色权限:Patient 可查体征/化验/用药,MedicalStaff 额外可查预约
- 修复 ai_prompt_tests.rs 中 AnalysisService::new 签名变更的遗留编译错误
- 新增 5 个 agent 测试覆盖新 Tool 和沙箱权限过滤
2026-05-19 00:19:10 +08:00
iven
89581b070f feat(ai): Phase 1C 管理看板 — 用量/成本/功能开关三合一
- UsageService 新增 get_daily_usage + aggregate_daily 日聚合能力
- 新增 3 个管理端点: /ai/admin/daily-usage, /ai/admin/flags (GET+POST)
- AiUsageDashboard 扩展为三 Tab: 用量概览/成本分析/功能开关
- 功能开关支持 Switch 实时切换,权限码 ai.admin.flags
- 日聚合用量 30 天趋势表,含 Token/成本汇总统计
2026-05-18 23:36:33 +08:00
iven
5ba28ea349 feat(ai): Phase 1B 角色沙箱 — 三级权限隔离 + Tool 过滤 + 输出控制
- 新增 agent/sandbox.rs: UserRole/SandboxConfig/OutputFilter 三级模型
- resolve_role() 从 JWT roles 解析为 Patient/MedicalStaff/Admin
- ToolRegistry.tool_definitions_filtered() 按角色白名单过滤
- orchestrator.run() 新增 allowed_tools 参数,Tool 执行时二次校验
- chat_handler 集成沙箱:角色 Prompt 后缀 + 患者免责声明追加
2026-05-18 23:28:30 +08:00
iven
7e3d27ecf3 feat(ai): Phase 1A 收尾 — 用量记录 + 健康摘要端点 + 小程序组件
- chat_handler 添加 log_usage 精确记录 token 消耗(provider + model)
- SSE build_sse_stream 添加估算 token 用量记录(4 字符 ≈ 1 token)
- 新增 GET /ai/health-summary 端点聚合患者洞察+分析记录
- 小程序 AiHealthSummaryCard 组件(风险等级+洞察统计+摘要列表)
- 小程序 services/ai-analysis 新增 getHealthSummary API
2026-05-18 23:20:06 +08:00
iven
281c71ebfc feat(web): AiAnalysisCard 通用组件 — 封装 SSE 流式分析 + 加载/错误/成功态
- 新增 AiAnalysisCard: 支持 4 种分析类型, 自动组装 request body
- 4 种状态: idle(按钮) / loading(骨架屏) / error(重试) / success(结果卡片)
- 权限控制通过 AuthButton 集成, 供多触点复用

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:58:51 +08:00
iven
bf37acc681 feat(ai): AI 健康管家 V2 基础设施 — 功能开关 + 角色沙箱准备 + 体征页 AI 趋势分析
- 迁移 000153: 新增 ai_feature_flags / ai_usage_daily / ai_suggestion_feedback 三张表,
  ai_tenant_configs 增加 billing_enabled 列, seed 12 个功能开关 + 2 个管理权限码
- 新增 FeatureFlagService: 5 分钟缓存 + DB 回退 + 即时更新
- VitalSignsTab 添加 AI 趋势分析按钮 (SSE 流式)
- 新增 3 个 Entity (ai_feature_flags / ai_usage_daily / ai_suggestion_feedback)
- AiState 扩展 feature_flags 字段
- 设计规格 + 讨论记录文档

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:55:40 +08:00
iven
d623f8b2ff fix: V1 测试版本端到端验证修复 — 6 CRITICAL + 3 HIGH 问题全量修复
修复项:
- fix(db): 迁移 149 — 修复 Admin 角色权限绑定被迁移链破坏 (FE-C1)
- fix(health): 4 个 handler 添加空名称验证 — Doctor/Article/AlertRule/Tag (API-C1~C4)
- fix(health): Stats 仪表盘 new_this_week 查询修复 — SeaORM date_trunc bug (FE-C2)
- fix(server): 添加安全响应头 — X-Frame-Options/CSP/XSS-Protection/Referrer-Policy (SEC-H1)
- fix(mp): 预约创建契约修复 — notes/reason 字段映射 + 移除 schedule_id (MP-H1)
- fix(mp): 咨询会话 subject/last_message 字段改为可选 (MP-H3)
- fix(ai): AiConfig Default derive 替代手写 impl (clippy)

测试报告:
- 8 维度端到端测试全部完成 (后端 87 用例 / 前端 30 页面 / 小程序 80+ API / 安全 20 项 / 性能 20 端点)
- 多角色 7 角色 49 检查 100% 通过
- 综合测试报告 + 专家评估报告
2026-05-18 10:24:40 +08:00
iven
38b0d91407 feat(mp): AI 聊天传递 patient_id 支持体征数据查询 Tool Call
- ai-chat service: sendAiMessage 新增 patientId 可选参数
- messages 页面: 从 authStore 获取 currentPatient.id 传入

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:35:18 +08:00
iven
20714661d2 docs(qa): 五专家组头脑风暴 V1 测试发布就绪评估报告
综合评分 6.8/10 (B),有条件通过内部测试发布。
9 个章节完整覆盖:执行摘要 / 产品 / 架构 / 安全 / 测试 / UX / 行动计划 / 风险 / V1.1 路线图。
2026-05-18 04:50:36 +08:00
iven
edea8f49d1 docs(wiki): 关键数字更新 — AI Agent Phase 0 完成(迁移 148、AI 表 13、权限码 132) 2026-05-18 04:00:02 +08:00
iven
882b27ab7a fix(ai): Agent chat handler 精确选择 FC-capable provider + 环境变量适配
- chat_handler: 使用 get_provider("claude") 精确获取,避免 resolve fallback 到 Ollama
- ProviderRegistry: 新增 get_provider() 方法(无 health check,无 fallback)
- orchestrator: 从 ANTHROPIC_DEFAULT_SONNET_MODEL 读取模型名,兼容智谱代理
- erp-server: Claude provider 注册优先读 ANTHROPIC_AUTH_TOKEN + ANTHROPIC_BASE_URL

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 03:58:38 +08:00
iven
e47fe547c8 test(ai): Phase 0 集成测试 — Agent 循环 + Tool 执行 + Mock Provider 2026-05-18 03:17:34 +08:00
iven
aab4dfea79 feat(ai): 改造 chat_handler 接入 AgentOrchestrator — ReAct Agent 首次跑通 + 新增会话权限码 2026-05-18 03:12:33 +08:00
iven
f42669f934 feat(ai): 实现 query_patient_vitals Tool — 首个端到端 Agent Tool 2026-05-18 02:58:38 +08:00
iven
2d62605812 feat(ai): AgentTool trait + ToolRegistry + AgentOrchestrator — ReAct 循环(最多 5 轮 Tool Call) 2026-05-18 02:56:26 +08:00
iven
877e9831f6 feat(db): 迁移 000148 — AI 聊天会话/消息/工具日志/用户画像 4 张表 2026-05-18 02:51:58 +08:00
iven
f668f0995a feat(core): HealthDataProvider 新增 get_upcoming_appointments + get_medication_list 2026-05-18 02:47:15 +08:00
iven
46b30504a5 feat(ai): Ollama Provider 声明不支持 Function Calling 2026-05-18 02:37:12 +08:00
iven
f42e3ba611 feat(ai): OpenAI Provider 实现 generate_with_tools — function calling 支持 2026-05-18 02:35:50 +08:00
iven
64456d0172 feat(ai): Claude Provider 实现 generate_with_tools — tool_use/tool_result 解析 2026-05-18 02:32:39 +08:00
iven
cad48a97d5 feat(ai): AiProvider trait 新增 generate_with_tools 默认方法 + UnsupportedOperation 错误变体 2026-05-18 02:29:19 +08:00
iven
01c75dbf5d feat(ai): 添加 Agent Function Calling DTO — ChatMessage/ToolDefinition/ToolCall/AgentGenerateResponse 2026-05-18 02:26:57 +08:00
569 changed files with 59546 additions and 5929 deletions

View File

@@ -67,3 +67,19 @@ jobs:
with:
node-version: "20"
- run: cd apps/web && corepack enable && pnpm install --frozen-lockfile && pnpm audit
miniprogram-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/miniprogram
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: corepack enable && pnpm install --frozen-lockfile
- name: TypeScript check
run: npx tsc --noEmit
- name: Run tests
run: npx vitest run

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,4 +81,29 @@ 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
defaults:
run:
working-directory: apps/miniprogram
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: apps/miniprogram/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- name: TypeScript check
run: npx tsc --noEmit
- name: Run tests
run: npx vitest run

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/

120
CLAUDE.md
View File

@@ -177,8 +177,13 @@
- [ ] 新增端点有权限声明(默认拒绝,不是默认放行)
- [ ] 敏感数据有脱敏/加密处理PII 字段走 AES-256-GCM
- [ ] 用户输入已验证和消毒(防 SQL 注入、XSS
- [ ] 无 CORS 通配符、无硬编码密钥
- [ ] 用户输入已验证和消毒(防 SQL 注入、XSS、命令注入、路径穿越
- [ ] 无 CORS 通配符、无硬编码密钥、无 fallback 默认密钥
- [ ] 日志中无敏感数据输出密码、token、身份证号、手机号等
- [ ] 文件上传有 MIME 类型验证 + 大小限制 + 路径穿越防护
- [ ] API 响应不暴露内部实现细节(数据库错误、堆栈跟踪、文件路径)
- [ ] 速率限制已配置(认证端点更严格)
- [ ] 密钥通过环境变量注入,`.env.example` 已同步更新
#### 文档一致性
@@ -224,7 +229,7 @@
#### 新增 API 端点安全检查(强制)
> 历史数据25 次安全 fix 中 80% 源于默认放行模式。
> 默认拒绝是安全基线 — 绝大多数安全修复源于默认放行模式。
> 新增端点时**必须**逐项确认:
- [ ] 端点已添加 `require_permission` 权限守卫(非公开端点)
@@ -237,7 +242,7 @@
#### 前后端接口同步检查(强制)
> 历史数据35 次 fix 源于前后端接口不一致。
> 前后端接口不一致是高频 bug 来源 — 任何 DTO 变更必须双向同步
> 后端 DTO 变更时**必须**同步检查前端:
- [ ] DTO 字段名变更 → 前端 TypeScript 接口同步更新
@@ -247,6 +252,21 @@
- [ ] 枚举值变更 → 前端类型定义和 UI 映射同步更新
- [ ] 后端新增端点 → 前端 API 模块同步添加调用函数,不允许留空
#### DTO 输入校验检查(强制)
> DTO 输入校验是安全防线 — 缺失校验等于暴露攻击面Update 和 Create 必须对称。
> 新增/修改 DTO 时**必须**逐项确认:
- [ ] 所有请求结构体已 `derive(Validate)`(包括 Update\*Req、查询参数
- [ ] Update\*Req 与 Create\*Req 校验对称(不允许 Update 降级)
- [ ] 字符串字段有 `#[validate(length(min, max))]`
- [ ] 枚举/类型字段有 `#[validate(custom)]` 限制合法值
- [ ] 集合字段有 `#[validate(length(min = 1))]` 非空检查
- [ ] 数值范围字段有 `#[validate(range(min, max))]`
- [ ] URL 字段有 SSRF 防护(禁止 localhost/内网地址,仅 http/https
- [ ] 密码字段有 `max = 128` 防止 DoS
- [ ] handler 层已调用 `req.validate().map_err(|e| AppError::Validation(e.to_string()))?`
### 3.4 事件总线
- 模块间通信**只能**通过 `EventBus`
@@ -276,6 +296,50 @@
// 国际化文案使用 i18n key不硬编码中文
```
### 3.7 安全规范
#### 密钥与凭据管理
- 所有密钥、token、密码**必须**通过环境变量或密钥管理服务注入,**禁止**硬编码在源码中
- 开发环境密钥**必须**与生产环境严格隔离(`cfg(debug_assertions)` 编译期防护)
- 生产密钥**禁止**有 fallback 默认值,缺失时启动 panic
- 新增密钥时必须同步更新 `.env.example` 和 `wiki/infrastructure.md`
#### 依赖安全
- 新增依赖前**必须**检查已知漏洞(`cargo audit` / `npm audit`
- 禁止引入有未修补高危漏洞的依赖版本
- 定期更新依赖到最新安全补丁版本
#### 数据安全
- PII 数据(姓名、身份证号、手机号、地址等)**必须**加密存储AES-256-GCM
- 日志中**禁止**输出 PII 数据和认证凭据密码、token、session key
- 敏感操作(登录、权限变更、数据导出、批量删除)**必须**记录审计日志(操作者、时间、目标、结果)
- 文件上传**必须**验证 MIME 类型 + 限制文件大小 + 防止路径穿越(文件名 sanitize
#### 传输安全
- 生产环境**必须**强制 HTTPS**禁止**降级到 HTTP
- HTTP 响应**必须**包含安全头HSTS、CSP、X-Frame-Options、X-Content-Type-Options、Permissions-Policy
- SSE/WebSocket 长连接认证 token 不通过 URL query 参数传递(使用 header 或 cookie
- API 响应**禁止**暴露内部实现细节堆栈跟踪、数据库错误、文件路径、SQL 语句)
#### 认证与授权
- 密码**必须**使用单向哈希bcrypt/argon2**禁止**明文或可逆加密存储
- JWT **必须**设置合理过期时间,支持 token 吊销机制
- 敏感操作(删除数据、权限变更)需要二次确认
- 权限检查在 handler 层执行,**禁止**仅依赖前端隐藏控制访问
- `tenant_id` **必须**从 JWT 中间件注入,**禁止**信任客户端传递的值
#### 速率限制
- 所有 API 端点**必须**配置速率限制
- 认证相关端点(登录、注册、密码重置)限制更严格
- 批量操作和数据导出需要独立的速率限制策略
- 速率限制超出时返回 429 状态码,响应包含 `Retry-After` header
---
## 4. 测试与验证
@@ -394,14 +458,24 @@ chore(docker): 添加 PostgreSQL 健康检查
- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步。每次新会话开始先检查未推送提交
- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档
- ❌ **不要**连续 5 个代码提交不更新 wiki — wiki 是团队唯一真相源,过期数据比没有数据更有害5 月实测89 fix 仅 11 有 wiki 更新,关键数字迁移数差 8 个)
- ❌ **不要**连续 5 个代码提交不更新 wiki — wiki 是团队唯一真相源,过期数据比没有数据更有害
- ❌ **不要**修复 bug 后跳过症状导航更新 — 每个修复都应该帮助未来遇到同类问题的人快速定位根因
- ❌ **不要**新增功能后不更新 wiki 关键数字 — 迁移数/路由数/实体数/测试数必须与代码同步,否则 wiki 指标表就是废数据
- ❌ **不要**一次性输出长文档 — 超过 200 行的文档必须分步编写(先大纲 → 逐章 → 整合),否则会因上下文过长卡死
- ❌ **不要**忽略讨论记录 — 每次发散式讨论结束后必须建立文档到 `docs/discussions/`,不要口头确认后就忘
- ❌ **不要**跳过 Feature DoD — 每个功能标记"完成"前必须通过 §2.6 检查清单,不全面验证就提交 = 后续 5 次 fix媒体库教训
- ❌ **不要**新增端点时默认放行 — 所有端点默认拒绝访问,必须显式声明权限安全教训25 次 fix 源于默认放行)
- ❌ **不要**后端 DTO 变更不同步前端 — 字段名/路径/类型变更时必须同步更新前端 TypeScript 接口35 次 fix 教训)
- ❌ **不要**跳过 Feature DoD — 每个功能标记"完成"前必须通过 §2.6 检查清单,不全面验证就提交将导致后续反复修复
- ❌ **不要**新增端点时默认放行 — 所有端点默认拒绝访问,必须显式声明权限
- ❌ **不要**后端 DTO 变更不同步前端 — 字段名/路径/类型变更时必须同步更新前端 TypeScript 接口
- ❌ **不要**写 Update DTO 时省略校验 — Update*Req 必须与 Create*Req 校验对称Validate derive / 枚举 custom / Vec min=1 / 密码 max=128
- ❌ **不要**URL 字段不做 SSRF 防护 — 禁止 localhost/127.0.0.1/内网地址,仅允许 http/https 协议
- ❌ **不要**handler 层跳过 `.validate()` — 所有 `Json<T>` handler 函数体第一行必须调 `req.validate().map_err(\|e\| AppError::Validation(e.to_string()))?`
- ❌ **不要**在日志中输出敏感数据 — 密码、token、身份证号、手机号等 PII 信息禁止写入日志
- ❌ **不要**信任客户端传递的 `tenant_id` — 必须从 JWT 中间件注入,客户端可伪造
- ❌ **不要**在生产代码中使用 `unwrap()` — 必须处理所有错误,使用 `?` 或 `map_err`
- ❌ **不要**将内部错误信息返回给客户端 — 数据库错误、堆栈跟踪、文件路径等必须转换为用户友好的错误消息
- ❌ **不要**使用 HTTP 传输敏感数据 — 生产环境必须 HTTPS
- ❌ **不要**跳过依赖安全检查 — 新增依赖前运行 `cargo audit` / `npm audit`,禁止引入有高危漏洞的版本
- ❌ **不要**文件上传不做安全处理 — 必须验证 MIME 类型 + 限制大小 + sanitize 文件名防路径穿越
### 场景化指令
@@ -412,6 +486,7 @@ chore(docker): 添加 PostgreSQL 健康检查
- 当遇到**新增表** → 创建 SeaORM migration + Entity包含所有标准字段
- 当遇到**新增页面** → 使用 Ant Design 组件i18n key 引用文案
- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程和 `.claude/skills/plugin-development/SKILL.md`,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component。**权限码必须与实体名一致(每个实体声明 `.list` + `.manage`**
- 当遇到**新增/修改 DTO** → 参考 `wiki/architecture.md` §4 DTO 输入校验规范:`derive(Validate)` + 字段级校验 + handler 层 `validate()` 调用 + 单元测试
---
@@ -429,3 +504,32 @@ chore(docker): 添加 PostgreSQL 健康检查
| 设计文档索引 | `wiki/index.md` |
| 开发进度、模块状态 | `wiki/index.md` 关键数字 |
| 环境配置、连接信息、登录凭据 | `wiki/infrastructure.md` §2 |
## graphify — 代码知识图谱
> 项目知识图谱位于 `graphify-out/`当前规模18,517 节点 / 22,666 边 / 1,841 社区(纯 AST 解析,无 API 成本)。
> 工具:`python -m graphify`(已安装 graphifyy 0.8.18)。
### 开发流程中的使用场景
| 时机 | 命令 | 目的 |
|------|------|------|
| **接手新任务,理解代码关系** | `graphify query "概念名"` | 搜索相关节点,比 Grep 更精准(按调用/引用/包含关系) |
| **排查 bug追踪调用链** | `graphify path "A" "B"` | 查找两个模块/函数间的最短路径 |
| **理解某个模块的职责** | `graphify explain "模块名"` | 自然语言解释节点及其邻居 |
| **代码改动后** | `graphify update .` | 增量更新图谱AST-only秒级完成 |
| **宏观架构审查** | 读 `graphify-out/GRAPH_REPORT.md` | 全局社区结构、跨文件关系概览 |
### 使用优先级(融入 §2.5 闭环工作法)
在 §2.5 步骤 1「现状确认」中**优先使用 graphify 替代盲目 Grep**
1. **先 `graphify query`** — 精确定位相关节点和社区(比 Grep 返回更结构化的结果)
2. **再 `graphify path`** — 确认模块间依赖路径(避免遗漏间接依赖)
3. **最后 Grep/Glob/Read** — 确认 graphify 发现的具体文件内容
### 注意事项
- `graphify update .` 纯本地 AST 解析,不消耗 LLM token每次代码改动后都可以运行
- 查询结果比 GRAPH_REPORT.md 更精准,优先使用 query/path/explain仅在需要全局视图时读报告
- 首次生成需几分钟1712 文件),后续增量更新秒级完成

8
Cargo.lock generated
View File

@@ -1429,6 +1429,7 @@ dependencies = [
"handlebars",
"hex",
"redis",
"regex-lite",
"reqwest",
"sea-orm",
"serde",
@@ -1614,6 +1615,7 @@ dependencies = [
"tracing",
"utoipa",
"uuid",
"validator",
"wasmtime",
"wasmtime-wasi",
]
@@ -3979,6 +3981,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
version = "0.8.10"

View File

@@ -33,7 +33,7 @@ tokio = { version = "1", features = ["full"] }
# Web
axum = { version = "0.8", features = ["multipart"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "fs"] }
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "fs", "set-header"] }
# Database
sea-orm = { version = "1.1", features = [
@@ -91,6 +91,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
aes = "0.8"
cbc = "0.1"
hex = "0.4"
regex-lite = "0.1"
# CSV and Excel export
csv = "1"
@@ -119,6 +120,9 @@ handlebars = "6"
# HTML sanitization
ammonia = "4"
# Document parsing
pdf-extract = "0.7"
# Metrics
metrics = "0.24"
metrics-exporter-prometheus = "0.16"

View File

@@ -0,0 +1,5 @@
TARO_APP_API_URL=https://api.hms.example.com/api/v1
TARO_APP_DEFAULT_TENANT_ID=
# TARO_APP_ENCRYPTION_KEY 不在此文件设置
# 生产密钥通过 CI/CD 环境变量注入dotenv 不覆盖已有 env var
# 本地 build:weapp 测试时自动回退到 .env 中的开发密钥

View File

@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always"
}

View File

@@ -11,6 +11,9 @@ vi.mock('@/services/request', () => ({
clearRequestCache: vi.fn(),
markLoggingOut: vi.fn(),
clearLoggingOut: vi.fn(),
getCachedPatientId: vi.fn(() => ''),
setCachedPatientId: vi.fn(),
resetForTesting: vi.fn(),
}));
/** 创建一个成功的 API 响应 */

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('@tarojs/taro', () => ({
default: {
getStorageSync: vi.fn(() => []),
setStorage: vi.fn(),
removeStorageSync: vi.fn(),
},
}));
vi.mock('@/services/request', () => ({
api: { post: vi.fn().mockResolvedValue({ success: true }) },
}));
describe('Analytics PII 清理', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('flushEvents 发送的 batch 不含 PII', async () => {
const { trackEvent, flushEvents } = await import('@/services/analytics');
const { api } = await import('@/services/request');
trackEvent('page_view', {
page: 'health',
userId: 'should-be-removed',
patientId: 'should-be-removed',
user_name: 'should-be-removed',
phone: 'should-be-removed',
id_card: 'should-be-removed',
});
await flushEvents();
const postCall = vi.mocked(api.post).mock.calls[0];
const body = postCall[1] as { events: Array<Record<string, unknown>> };
const evt = body.events[0];
// 事件级别不应有 userId/patientId
expect(evt).not.toHaveProperty('userId');
expect(evt).not.toHaveProperty('patientId');
// properties 中不应有 PII 字段
const props = evt.properties as Record<string, unknown>;
expect(props).not.toHaveProperty('userId');
expect(props).not.toHaveProperty('patientId');
expect(props).not.toHaveProperty('user_name');
expect(props).not.toHaveProperty('phone');
expect(props).not.toHaveProperty('id_card');
// 正常字段保留
expect(props.page).toBe('health');
});
it('trackEvent 不在事件级别包含 userId/patientId', async () => {
const { trackEvent, flushEvents } = await import('@/services/analytics');
const { api } = await import('@/services/request');
trackEvent('test_event');
await flushEvents();
const postCall = vi.mocked(api.post).mock.calls[0];
const body = postCall[1] as { events: Array<Record<string, unknown>> };
const evt = body.events[0];
expect(evt).not.toHaveProperty('userId');
expect(evt).not.toHaveProperty('patientId');
});
it('sanitizeProperties 过滤所有 PII 标识字段', async () => {
const { trackEvent, flushEvents } = await import('@/services/analytics');
const { api } = await import('@/services/request');
trackEvent('test', {
openid: 'oXXX',
access_token: 'tok',
refresh_token: 'ref',
email: 'test@test.com',
address: '某地',
mobile: '13800001111',
page: 'settings', // 非 PII 字段
});
await flushEvents();
const postCall = vi.mocked(api.post).mock.calls[0];
const body = postCall[1] as { events: Array<Record<string, unknown>> };
const props = body.events[0].properties as Record<string, unknown>;
// 全部 PII 被过滤,只剩 page
expect(props).not.toHaveProperty('openid');
expect(props).not.toHaveProperty('access_token');
expect(props).not.toHaveProperty('refresh_token');
expect(props).not.toHaveProperty('email');
expect(props).not.toHaveProperty('address');
expect(props).not.toHaveProperty('mobile');
expect(props.page).toBe('settings');
});
});

View File

@@ -7,7 +7,7 @@ vi.mock('@tarojs/taro', () => ({
getStorageSync: vi.fn(() => ''),
setStorageSync: vi.fn(),
showToast: vi.fn(),
reLaunch: vi.fn(),
reLaunch: vi.fn(() => Promise.resolve()),
getCurrentPages: vi.fn(() => []),
},
}));
@@ -23,7 +23,7 @@ vi.mock('@/utils/secure-storage', () => ({
}));
import Taro from '@tarojs/taro';
import { api, clearRequestCache, resetForTesting } from '@/services/request';
import { api, clearRequestCache, resetForTesting, setCachedPatientId } from '@/services/request';
describe('request module', () => {
beforeEach(() => {
@@ -148,4 +148,218 @@ describe('request module', () => {
await expect(api.get('/bad-params')).rejects.toThrow('参数错误');
});
});
describe('ResponseCache', () => {
it('should cache GET responses and return cached on second call', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { id: '1' } } } as any);
await api.get('/cached-test');
await api.get('/cached-test');
expect(Taro.request).toHaveBeenCalledTimes(1);
});
it('should not cache POST requests', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: {} } } as any);
await api.post('/no-cache', { a: 1 });
await api.post('/no-cache', { a: 1 });
expect(Taro.request).toHaveBeenCalledTimes(2);
});
it('clearRequestCache should clear cached entries', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { v: 1 } } } as any);
await api.get('/clear-test');
clearRequestCache();
await api.get('/clear-test');
expect(Taro.request).toHaveBeenCalledTimes(2);
});
});
describe('setCachedPatientId', () => {
it('should isolate cache entries by patient ID', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { v: 1 } } } as any);
setCachedPatientId('patient-A');
await api.get('/health/data');
setCachedPatientId('patient-B');
await api.get('/health/data');
// 不同 patient ID 应各自发请求(缓存隔离)
expect(Taro.request).toHaveBeenCalledTimes(2);
});
});
describe('requestUnlimited', () => {
it('should bypass concurrency limiter', async () => {
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: 'ok' } } as any);
const { requestUnlimited } = await import('@/services/request');
await requestUnlimited('GET', '/health/test');
expect(Taro.request).toHaveBeenCalledWith(
expect.objectContaining({ method: 'GET' }),
);
});
});
describe('ConcurrencyLimiter', () => {
it('should queue requests when at capacity', async () => {
const { ConcurrencyLimiter } = await import('@/services/request/limiter');
const limiter = new ConcurrencyLimiter(2);
const order: number[] = [];
const acquire1 = limiter.acquire();
const acquire2 = limiter.acquire();
// Third acquire should queue
const acquire3 = limiter.acquire().then(() => order.push(3));
order.push(1);
order.push(2);
// Release one to unblock the third
limiter.release();
await acquire3;
expect(order).toContain(3);
limiter.release();
limiter.release();
});
it('should release in FIFO order', async () => {
const { ConcurrencyLimiter } = await import('@/services/request/limiter');
const limiter = new ConcurrencyLimiter(1);
const order: string[] = [];
await limiter.acquire(); // fills the slot
const p2 = limiter.acquire().then(() => order.push('second'));
const p3 = limiter.acquire().then(() => order.push('third'));
limiter.release(); // releases second
await p2;
limiter.release(); // releases third
await p3;
expect(order).toEqual(['second', 'third']);
});
});
describe('ResponseCache LRU', () => {
it('should update insertion order on cache hit', async () => {
const { ResponseCache } = await import('@/services/request/cache');
const cache = new ResponseCache(3, 60_000);
cache.setPatientId('p1');
cache.set('/a', 'data-a');
cache.set('/b', 'data-b');
cache.set('/c', 'data-c');
// Access /a to move it to the end (most recently used)
cache.get('/a');
// Adding /d should evict /b (oldest after /a was accessed)
cache.set('/d', 'data-d');
expect(cache.get('/b')).toBeNull();
expect(cache.get('/a')).toBe('data-a');
expect(cache.get('/d')).toBe('data-d');
});
it('should expire entries based on TTL', async () => {
const { ResponseCache } = await import('@/services/request/cache');
vi.useFakeTimers();
const cache = new ResponseCache(100, 1000);
cache.setPatientId('p1');
cache.set('/expiring', 'data', 500);
expect(cache.get('/expiring')).toBe('data');
vi.advanceTimersByTime(600);
expect(cache.get('/expiring')).toBeNull();
vi.useRealTimers();
});
});
describe('token refresh & 401 retry', () => {
it('should throw immediately when isLoggingOut is true', async () => {
const { markLoggingOut } = await import('@/services/request');
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 401, data: {} } as any);
markLoggingOut();
await expect(api.get('/protected')).rejects.toThrow('登录已过期');
});
it('should attempt token refresh on 401', async () => {
const mockStore: Record<string, string> = {
access_token: 'expired-token',
refresh_token: 'valid-refresh',
tenant_id: 'test-tenant',
};
// Override secureGet for this test
const { secureGet } = await import('@/utils/secure-storage');
vi.mocked(secureGet).mockImplementation((key: string) => mockStore[key] || '');
// First call: 401 → triggers refresh
// Refresh call: success
// Retry call: success
vi.mocked(Taro.request)
.mockResolvedValueOnce({ statusCode: 401, data: {} } as any)
.mockResolvedValueOnce({ statusCode: 200, data: { success: true, data: { access_token: 'new-token', refresh_token: 'new-refresh', expires_in: 3600 } } } as any)
.mockResolvedValueOnce({ statusCode: 200, data: { success: true, data: { result: 'ok' } } } as any);
const result = await api.get('/needs-auth');
expect(result).toEqual({ result: 'ok' });
// 3 calls: initial 401 + refresh + retry
expect(Taro.request).toHaveBeenCalledTimes(3);
});
it('should redirect to login when refresh fails', async () => {
const mockStore: Record<string, string> = {
access_token: 'expired-token',
refresh_token: 'bad-refresh',
tenant_id: 'test-tenant',
};
const { secureGet } = await import('@/utils/secure-storage');
vi.mocked(secureGet).mockImplementation((key: string) => mockStore[key] || '');
vi.mocked(Taro.getCurrentPages).mockReturnValue([{ path: 'pages/home' } as any]);
vi.mocked(Taro.request)
.mockResolvedValueOnce({ statusCode: 401, data: {} } as any)
.mockResolvedValueOnce({ statusCode: 401, data: {} } as any); // refresh fails
await expect(api.get('/protected-resource')).rejects.toThrow('登录已过期');
expect(Taro.reLaunch).toHaveBeenCalledWith({ url: '/pages/login/index' });
});
});
describe('safeReLaunch dedup', () => {
it('should only call reLaunch once for concurrent requests', async () => {
const { markLoggingOut } = await import('@/services/request');
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 401, data: {} } as any);
vi.mocked(Taro.getCurrentPages).mockReturnValue([{ path: 'pages/home' } as any]);
const mockStore: Record<string, string> = {
access_token: 'expired',
refresh_token: 'bad',
tenant_id: 't1',
};
const { secureGet } = await import('@/utils/secure-storage');
vi.mocked(secureGet).mockImplementation((key: string) => mockStore[key] || '');
// First call sets isLoggingOut, second call hits early exit
await expect(api.get('/test1')).rejects.toThrow();
await expect(api.get('/test2')).rejects.toThrow();
// reLaunch should be called at most once
expect(vi.mocked(Taro.reLaunch).mock.calls.length).toBeLessThanOrEqual(1);
});
});
});

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { signRequest, generateNonce, hmacSha256Sync } from '@/utils/request-signer';
describe('generateNonce', () => {
it('生成 16 字符十六进制字符串', () => {
const nonce = generateNonce();
expect(nonce).toHaveLength(16);
expect(nonce).toMatch(/^[0-9a-f]{16}$/);
});
it('连续调用生成不同值', () => {
const n1 = generateNonce();
const n2 = generateNonce();
expect(n1).not.toBe(n2);
});
});
describe('hmacSha256Sync', () => {
it('相同输入产生相同输出', () => {
const key = 'test-key';
const msg = 'hello world';
const r1 = hmacSha256Sync(key, msg);
const r2 = hmacSha256Sync(key, msg);
expect(r1).toBe(r2);
expect(r1).toHaveLength(64); // SHA-256 = 32 bytes = 64 hex chars
});
it('不同输入产生不同输出', () => {
const r1 = hmacSha256Sync('key', 'msg1');
const r2 = hmacSha256Sync('key', 'msg2');
expect(r1).not.toBe(r2);
});
});
describe('signRequest', () => {
const signingKey = 'test-signing-key-256-bit!!!!!!!!!!!';
it('GET 请求生成正确的签名头', () => {
const headers = signRequest('GET', '/health/patients', undefined, signingKey);
expect(headers).toHaveProperty('X-Signature');
expect(headers).toHaveProperty('X-Timestamp');
expect(headers).toHaveProperty('X-Nonce');
expect(headers['X-Nonce']).toHaveLength(16);
expect(headers['X-Signature']).toHaveLength(64);
});
it('POST 请求包含 body hash', () => {
const headers = signRequest('POST', '/health/vital-signs', { value: 120 }, signingKey);
expect(headers).toHaveProperty('X-Signature');
expect(headers['X-Signature']).toHaveLength(64);
});
it('无 body 时也能正常签名', () => {
const headers = signRequest('GET', '/test', undefined, signingKey);
expect(headers['X-Signature']).toBeTruthy();
});
it('相同参数不同 nonce 产生不同签名', () => {
const h1 = signRequest('GET', '/test', undefined, signingKey);
// 由于 nonce 不同,签名也不同
const h2 = signRequest('GET', '/test', undefined, signingKey);
expect(h1['X-Signature']).not.toBe(h2['X-Signature']);
});
});

View File

@@ -0,0 +1,324 @@
/**
* secure-storage AES-256-GCM 测试
* 覆盖加解密对称性、空值删除、明文兼容、迁移、Base64 边界
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
// --- crypto.getRandomValues polyfill ---
if (!globalThis.crypto?.getRandomValues) {
globalThis.crypto = {
getRandomValues: (arr: any) => {
for (let i = 0; i < arr.length; i++) arr[i] = (Math.random() * 256) | 0;
return arr;
},
} as any;
}
// --- Mock @tarojs/taro (覆盖 setup.ts 中的默认 mock添加 base64 方法) ---
const storage = new Map<string, string>();
vi.mock('@tarojs/taro', () => ({
default: {
getStorageSync: vi.fn((key: string) => storage.get(key) ?? ''),
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
removeStorageSync: vi.fn((key: string) => { storage.delete(key); }),
arrayBufferToBase64: vi.fn((buf: ArrayBuffer) => {
const bytes = new Uint8Array(buf);
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}),
base64ToArrayBuffer: vi.fn((b64: string) => {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}),
},
}));
// --- Mock 加密密钥 ---
process.env.TARO_APP_ENCRYPTION_KEY =
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
process.env.NODE_ENV = 'development';
// --- 导入被测模块(在 mock 之后) ---
import { secureSet, secureGet, secureRemove, migrateLegacyStorage } from '@/utils/secure-storage';
// --- 辅助:直接访问 mock 函数 ---
import Taro from '@tarojs/taro';
const mockGet = Taro.getStorageSync as ReturnType<typeof vi.fn>;
const mockSet = Taro.setStorageSync as ReturnType<typeof vi.fn>;
const mockRemove = Taro.removeStorageSync as ReturnType<typeof vi.fn>;
describe('secure-storage AES-256-GCM', () => {
beforeEach(() => {
storage.clear();
vi.clearAllMocks();
});
// ================================================================
// 1. AES 加解密对称性
// ================================================================
describe('AES 加解密对称性', () => {
const cases: Array<[string, string]> = [
['英文', 'hello world'],
['中文', '你好世界'],
['emoji', '\u{1F600}\u{1F680}\u{1F4A9}'],
['特殊字符', '<script>alert("xss")</script>&"\''],
['JSON', '{"name":"张三","age":30,"nested":{"key":"val"}}'],
['超长字符串', 'A'.repeat(10000)],
['混合内容', 'Hello 你好 \u{1F600} !@#$%^&*()'],
['空格和换行', ' line1\nline2\ttabbed '],
['Unicode 补充', '\u{1F1E8}\u{1F1F3}\u{1F1FA}\u{1F1F8}'],
];
cases.forEach(([label, value]) => {
it(`roundtrip: ${label}`, () => {
secureSet('test_key', value);
const result = secureGet('test_key');
expect(result).toBe(value);
});
});
it('多次写入同一 key 产生不同密文nonce 随机)', () => {
secureSet('dup', 'same-value');
const first = storage.get('_es_dup')!;
secureSet('dup', 'same-value');
const second = storage.get('_es_dup')!;
// 密文应该不同nonce 不同),但都能解密
expect(first).not.toBe(second);
expect(secureGet('dup')).toBe('same-value');
});
it('不同 key 互不干扰', () => {
secureSet('key_a', 'value_a');
secureSet('key_b', 'value_b');
expect(secureGet('key_a')).toBe('value_a');
expect(secureGet('key_b')).toBe('value_b');
});
});
// ================================================================
// 2. 空 value 触发 remove
// ================================================================
describe('空 value 触发 remove', () => {
it('空字符串触发 removeStorageSync', () => {
secureSet('empty', '');
expect(mockRemove).toHaveBeenCalledWith('_es_empty');
expect(secureGet('empty')).toBe('');
});
it('先写入再清空应删除存储', () => {
secureSet('temp', 'some-data');
expect(secureGet('temp')).toBe('some-data');
secureSet('temp', '');
expect(secureGet('temp')).toBe('');
});
});
// ================================================================
// 3. 明文 fallback 读取兼容
// ================================================================
describe('明文 fallback 兼容', () => {
it('直接读取无前缀的明文存储', () => {
storage.set('access_token', 'plain-token-123');
expect(secureGet('access_token')).toBe('plain-token-123');
});
it('加密存储优先于明文存储', () => {
storage.set('access_token', 'plain-token');
secureSet('access_token', 'encrypted-token');
expect(secureGet('access_token')).toBe('encrypted-token');
});
it('存储值非字符串时回退到明文', () => {
// getStorageSync mock 返回 ''默认值prefixed key 不存在
// 明文 key 也返回 ''
expect(secureGet('nonexistent')).toBe('');
});
});
// ================================================================
// 4. migrateLegacyStorage 迁移逻辑
// ================================================================
describe('migrateLegacyStorage', () => {
it('将明文数据迁移到加密存储并删除原始 key', () => {
storage.set('access_token', 'legacy-token');
storage.set('refresh_token', 'legacy-refresh');
storage.set('user_data', '{"id":"123"}');
migrateLegacyStorage();
// 明文 key 应被删除
expect(storage.has('access_token')).toBe(false);
expect(storage.has('refresh_token')).toBe(false);
expect(storage.has('user_data')).toBe(false);
// 加密 key 存在且可解密
expect(secureGet('access_token')).toBe('legacy-token');
expect(secureGet('refresh_token')).toBe('legacy-refresh');
expect(secureGet('user_data')).toBe('{"id":"123"}');
});
it('已加密的 key 不重复迁移', () => {
secureSet('access_token', 'already-encrypted');
const spy = vi.spyOn(storage, 'set');
migrateLegacyStorage();
// 不应产生新的 set 调用(除了可能内部的 secureSet 已有的)
// 关键:值不变
expect(secureGet('access_token')).toBe('already-encrypted');
});
it('非字符串的明文数据不迁移', () => {
// 我们的 mock getStorageSync 对不存在的 key 返回 ''
// 模拟: 存一个空字符串的明文值(不迁移)
storage.set('tenant_id', '');
migrateLegacyStorage();
// 空字符串不被视为有效数据,不做迁移
expect(storage.has('_es_tenant_id')).toBe(false);
});
it('MIGRATION_KEYS 中未列出的 key 不受影响', () => {
storage.set('custom_key', 'custom-value');
migrateLegacyStorage();
expect(storage.get('custom_key')).toBe('custom-value');
expect(storage.has('_es_custom_key')).toBe(false);
});
it('prefixed key 存在但非 aes: 前缀时重新加密为 AES', () => {
// 直接放一个非 aes: 前缀的值到 prefixed key
// migrateLegacyStorage 发现 prefixed key 存在且不以 aes: 开头,
// 会调用 secureGet → 明文 fallback 返回该值,然后 secureSet 重新加密
storage.set('_es_access_token', 'legacy-ciphertext-or-plain');
migrateLegacyStorage();
// 应被重新加密为 AES 格式
const stored = storage.get('_es_access_token')!;
expect(stored.startsWith('aes:')).toBe(true);
});
});
// ================================================================
// 5. secureRemove
// ================================================================
describe('secureRemove', () => {
it('删除加密存储', () => {
secureSet('remove_test', 'to-be-removed');
expect(secureGet('remove_test')).toBe('to-be-removed');
secureRemove('remove_test');
expect(secureGet('remove_test')).toBe('');
});
it('删除不存在的 key 不报错', () => {
expect(() => secureRemove('nonexistent')).not.toThrow();
});
});
// ================================================================
// 6. Base64 边界
// ================================================================
describe('Base64 边界', () => {
it('存储值可正确通过 base64 编解码', () => {
const value = 'Test with special chars: ';
secureSet('b64_test', value);
const result = secureGet('b64_test');
expect(result).toBe(value);
});
it('二进制丰富内容加解密', () => {
// 构造包含各种 Unicode 范围的字符串
const chars = [];
for (let i = 0x20; i < 0x7f; i++) chars.push(String.fromCharCode(i));
// CJK 基本区
for (let i = 0x4e00; i < 0x4e10; i++) chars.push(String.fromCharCode(i));
// emoji
chars.push('\u{1F600}', '\u{1F680}', '\u{1F970}');
const value = chars.join('');
secureSet('b64_binary', value);
expect(secureGet('b64_binary')).toBe(value);
});
it('损坏的 AES 密文返回 null 后走明文 fallback', () => {
storage.set('_es_corrupt', 'aes:INVALID_BASE64!!!');
// aesDecrypt 失败返回 nullhasEncryptionKey=true 所以不走 dev plaintext
// 最终返回 raw 值
expect(secureGet('corrupt')).toBe('aes:INVALID_BASE64!!!');
});
});
// ================================================================
// 7. 空密钥 dev 模式 — 明文存储兼容
// ================================================================
describe('空密钥 dev 模式', () => {
const originalKey = process.env.TARO_APP_ENCRYPTION_KEY;
const originalEnv = process.env.NODE_ENV;
beforeEach(() => {
storage.clear();
vi.clearAllMocks();
});
afterEach(() => {
process.env.TARO_APP_ENCRYPTION_KEY = originalKey;
process.env.NODE_ENV = originalEnv;
});
it('dev 模式空密钥secureSet 存明文secureGet 读取成功', () => {
process.env.TARO_APP_ENCRYPTION_KEY = '';
process.env.NODE_ENV = 'development';
secureSet('dev_plain', 'hello-dev');
// 应以明文存储
expect(storage.get('_es_dev_plain')).toBe('hello-dev');
expect(secureGet('dev_plain')).toBe('hello-dev');
});
it('dev 模式空密钥:读取 MCP 注入的明文成功', () => {
process.env.TARO_APP_ENCRYPTION_KEY = '';
process.env.NODE_ENV = 'development';
storage.set('access_token', 'mcp-injected-token');
expect(secureGet('access_token')).toBe('mcp-injected-token');
});
});
// ================================================================
// 8. production 模式空密钥 — 拒绝加解密
// ================================================================
describe('production 模式空密钥', () => {
const originalKey = process.env.TARO_APP_ENCRYPTION_KEY;
const originalEnv = process.env.NODE_ENV;
beforeEach(() => {
storage.clear();
vi.clearAllMocks();
});
afterEach(() => {
process.env.TARO_APP_ENCRYPTION_KEY = originalKey;
process.env.NODE_ENV = originalEnv;
});
it('production 空密钥secureSet 存明文无加密可用secureGet 返回空', () => {
process.env.TARO_APP_ENCRYPTION_KEY = '';
process.env.NODE_ENV = 'production';
secureSet('prod_test', 'sensitive-data');
// 应以明文存储(无 key 时 aesEncrypt 返回 null
expect(storage.get('_es_prod_test')).toBe('sensitive-data');
// secureGet: prefixed key 有值但非 aes: 前缀 + hasEncryptionKey=false → 返回 raw
expect(secureGet('prod_test')).toBe('sensitive-data');
});
it('production 空密钥AES 解密失败返回空字符串', () => {
process.env.TARO_APP_ENCRYPTION_KEY = '';
process.env.NODE_ENV = 'production';
// 模拟存在一个 aes: 前缀的旧数据
storage.set('_es_old_data', 'aes:INVALID_CORRUPT_DATA');
expect(secureGet('old_data')).toBe('');
});
});
});

View File

@@ -2,6 +2,12 @@ import { defineConfig } from '@tarojs/cli';
import path from 'path';
export default defineConfig(async (merge) => {
// 生产构建缺少加密密钥时发出警告(不阻断构建,但提示开发者/CI 配置)
if (process.env.NODE_ENV === 'production' && !process.env.TARO_APP_ENCRYPTION_KEY) {
console.warn('[config] ⚠ TARO_APP_ENCRYPTION_KEY 未设置,将回退到 .env 中的开发密钥');
console.warn('[config] 生产部署应通过 CI/CD 环境变量注入独立密钥');
}
const baseConfig = {
projectName: 'hms-miniprogram',
date: '2026-4-23',
@@ -19,7 +25,14 @@ export default defineConfig(async (merge) => {
'process.env.TARO_APP_WX_TEMPLATE_REPORT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_REPORT || ''),
'process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT || ''),
'process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL || ''),
'process.env.TARO_APP_WX_TEMPLATE_MEDICATION': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_MEDICATION || ''),
'process.env.TARO_APP_DEFAULT_TENANT_ID': JSON.stringify(process.env.TARO_APP_DEFAULT_TENANT_ID || ''),
'process.env.TARO_APP_DEV_USER': JSON.stringify(
process.env.NODE_ENV === 'production' ? '' : (process.env.TARO_APP_DEV_USER || '')
),
'process.env.TARO_APP_DEV_PASS': JSON.stringify(
process.env.NODE_ENV === 'production' ? '' : (process.env.TARO_APP_DEV_PASS || '')
),
},
copy: { patterns: [], options: {} },
framework: 'react',
@@ -31,10 +44,39 @@ export default defineConfig(async (merge) => {
resource: ['src/styles/variables.scss'],
},
mini: {
virtualHost: true,
copy: {
patterns: [
{ from: 'src/native-components/', to: 'dist/native-components/', ignore: ['*.ts'] },
],
options: {},
},
compile: {
exclude: [],
include: [],
},
commonChunks: ['runtime', 'vendors', 'taro', 'common'],
addChunkPages(pages) {
// 主包 TabBar 页面保持 common chunk
const tabBarPages = new Set([
'pages/index/index',
'pages/health/index',
'pages/mall/index',
'pages/messages/index',
'pages/profile/index',
]);
pages.forEach((page) => {
if (page.name === 'app') return;
// 分包页面不注入 common chunk由分包自己的 vendors.js 承载
if (page.name.startsWith('pages/pkg-')) return;
if (tabBarPages.has(page.name) || !page.name.startsWith('pages/')) {
page.chunks?.unshift('common');
}
});
},
miniCssExtractPluginOption: {
ignoreOrder: true,
},
postcss: {
pxtransform: { enable: true, config: {} },
cssModules: {

View File

@@ -8,11 +8,18 @@ export default {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.info', 'console.debug'],
pure_funcs: ['console.log', 'console.info', 'console.debug', 'console.warn'],
passes: 2,
unsafe: true,
unsafe_comps: true,
unsafe_math: true,
},
format: {
comments: false,
},
mangle: {
toplevel: true,
},
},
},
h5: {

View File

@@ -0,0 +1,35 @@
import js from '@eslint/js';
import ts from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
export default [
{ ignores: ['dist/', 'dist-h5/', 'lib/', 'node_modules/', 'config/'] },
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
},
plugins: {
'@typescript-eslint': ts,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...ts.configs.recommended.rules,
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
];

View File

@@ -35,8 +35,10 @@ async function main() {
console.log('3. 写入 storage (明文模式)...');
const result = await mp.evaluate((at, rt, ud, ur, tid, pid) => {
try {
// 无加密密钥时 secureSet 走明文
// 但我们直接用 wx.setStorageSync 确保
// 清除 _es_ 前缀旧加密值,避免 secureGet 读到过期 token
['access_token','refresh_token','user_data','user_roles','tenant_id','current_patient_id','current_patient','token_expires_at'].forEach(k => {
wx.removeStorageSync('_es_' + k);
});
wx.setStorageSync('access_token', at);
wx.setStorageSync('refresh_token', rt);
wx.setStorageSync('user_data', ud);

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

@@ -4,8 +4,16 @@
"private": true,
"description": "HMS 健康管理平台患者小程序",
"scripts": {
"generate-tokens": "tsx scripts/generate-tokens.ts",
"build:weapp": "taro build --type weapp",
"dev:weapp": "taro build --type weapp --watch",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write 'src/**/*.{ts,tsx}'",
"format:check": "prettier --check 'src/**/*.{ts,tsx}'",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "vitest run --config e2e/vitest.config.ts",
"dev:h5": "dotenv -e .env.h5 -- taro build --type h5 --watch",
"build:h5": "dotenv -e .env.h5 -- taro build --type h5"
@@ -16,6 +24,8 @@
"ios >= 8"
],
"dependencies": {
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.8.0",
"@tarojs/components": "4.2.0",
"@tarojs/helper": "4.2.0",
"@tarojs/plugin-framework-react": "4.2.0",
@@ -24,6 +34,7 @@
"@tarojs/runtime": "4.2.0",
"@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0",
"mp-html": "^2.5.2",
"react": "^18.3.0",
"react-dom": "18.3.1",
"zustand": "^5.0.0"
@@ -33,14 +44,23 @@
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/runtime": "^7.27.0",
"@eslint/js": "^9.0.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@tarojs/cli": "4.2.0",
"@tarojs/plugin-platform-h5": "^4.2.0",
"@tarojs/webpack5-runner": "4.2.0",
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"babel-preset-taro": "^4.2.0",
"dotenv-cli": "^11.0.0",
"eslint": "^9.0.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.0",
"miniprogram-automator": "^0.12.1",
"prettier": "^3.0.0",
"react-refresh": "^0.14.0",
"sass": "^1.87.0",
"typescript": "^5.8.0",

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,8 @@
"minifyWXML": true,
"packNpmManually": false,
"packNpmRelationList": [],
"ignoreUploadUnusedFiles": true
"ignoreUploadUnusedFiles": true,
"skylineRenderEnable": false
},
"condition": {}
}

View File

@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest';
import {
parseTokensFromScss,
parseScssVariables,
resolveTokenValues,
} from '../generate-tokens';
describe('parseScssVariables', () => {
it('extracts SCSS variable declarations', () => {
const scss = '$pri: #C4623A;\n$bg: #F5F0EB;';
const vars = parseScssVariables(scss);
expect(vars).toEqual({ pri: '#C4623A', bg: '#F5F0EB' });
});
it('ignores comments and non-variable lines', () => {
const scss = '// comment\n$pri: #C4623A;';
const vars = parseScssVariables(scss);
expect(vars).toEqual({ pri: '#C4623A' });
});
it('handles values with spaces and units', () => {
const scss = '$shadow-sm: 0 1px 4px rgba(45, 42, 38, 0.06);\n$r: 16px;';
const vars = parseScssVariables(scss);
expect(vars['shadow-sm']).toBe('0 1px 4px rgba(45, 42, 38, 0.06)');
expect(vars['r']).toBe('16px');
});
});
describe('parseTokensFromScss', () => {
it('extracts --tk-* CSS variables from page selector', () => {
const scss = `page {
--tk-font-h1: 28px;
--tk-font-body: 16px;
}`;
const tokens = parseTokensFromScss(scss, 'page');
expect(tokens).toEqual({ 'font-h1': '28px', 'font-body': '16px' });
});
it('extracts from class selector', () => {
const scss = `.elder-mode {
--tk-font-h1: 32px;
}`;
const tokens = parseTokensFromScss(scss, '.elder-mode');
expect(tokens).toEqual({ 'font-h1': '32px' });
});
it('captures SCSS variable references as-is for later resolution', () => {
const scss = `page {
--tk-pri: #{$pri};
--tk-font-body: 16px;
}`;
const tokens = parseTokensFromScss(scss, 'page');
expect(tokens['pri']).toBe('#{$pri}');
expect(tokens['font-body']).toBe('16px');
});
it('returns empty object when selector not found', () => {
const scss = `page { --tk-font-h1: 28px; }`;
const tokens = parseTokensFromScss(scss, '.nonexistent');
expect(tokens).toEqual({});
});
});
describe('resolveTokenValues', () => {
it('replaces SCSS variable references with actual values', () => {
const tokensScss = `page {
--tk-pri: #{$pri};
}`;
const varsScss = '$pri: #C4623A;';
const result = resolveTokenValues(tokensScss, varsScss, 'page');
expect(result).toEqual({ pri: '#C4623A' });
});
it('handles plain values without SCSS references', () => {
const tokensScss = `page {
--tk-card-radius: 16px;
--tk-font-body: 16px;
}`;
const varsScss = '$r: 16px;';
const result = resolveTokenValues(tokensScss, varsScss, 'page');
expect(result['card-radius']).toBe('16px');
expect(result['font-body']).toBe('16px');
});
it('handles multiple variable references', () => {
const tokensScss = `page {
--tk-pri: #{$pri};
--tk-pri-l: #{$pri-l};
--tk-font-body: 16px;
}`;
const varsScss = '$pri: #C4623A;\n$pri-l: #F0DDD4;';
const result = resolveTokenValues(tokensScss, varsScss, 'page');
expect(result['pri']).toBe('#C4623A');
expect(result['pri-l']).toBe('#F0DDD4');
expect(result['font-body']).toBe('16px');
});
});

View File

@@ -0,0 +1,133 @@
// scripts/generate-tokens.ts
// 从 src/styles/tokens.scss + variables.scss 解析 CSS 变量,
// 输出 src/styles/token-values.ts 供 JS 运行时使用
import { readFileSync, writeFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
interface TokenMap {
[key: string]: string;
}
/** 解析 SCSS 文件中的 CSS 变量声明 */
function parseTokensFromScss(
scssContent: string,
selector: string = 'page',
): TokenMap {
const tokens: TokenMap = {};
const blockRegex = new RegExp(
`${selector.replace('.', '\\.')}\\s*\\{([\\s\\S]+?)\\n\\}`,
);
const blockMatch = scssContent.match(blockRegex);
if (!blockMatch) return tokens;
const varRegex = /--tk-([\w-]+)\s*:\s*([^;]+);/g;
let match: RegExpExecArray | null;
while ((match = varRegex.exec(blockMatch[1])) !== null) {
tokens[match[1]] = match[2].trim();
}
return tokens;
}
/** 解析 SCSS 变量(如 $pri: #C4623A用于替换 #{...} 引用 */
function parseScssVariables(scssContent: string): Record<string, string> {
const vars: Record<string, string> = {};
const regex = /\$([\w-]+)\s*:\s*([^;]+);/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(scssContent)) !== null) {
vars[match[1]] = match[2].trim();
}
return vars;
}
/** 解析 tokens.scss替换 SCSS 变量引用 */
function resolveTokenValues(
tokensContent: string,
variablesContent: string,
selector: string,
): TokenMap {
const scssVars = parseScssVariables(variablesContent);
const rawTokens = parseTokensFromScss(tokensContent, selector);
const resolved: TokenMap = {};
for (const [key, value] of Object.entries(rawTokens)) {
let resolvedValue = value;
resolvedValue = resolvedValue.replace(
/#\{\$([\w-]+)\}/g,
(_, varName) => scssVars[varName] || '',
);
resolved[key] = resolvedValue;
}
return resolved;
}
function generateTokenFile(
tokensPath: string,
variablesPath: string,
outputPath: string,
): void {
const tokensContent = readFileSync(tokensPath, 'utf-8');
const variablesContent = readFileSync(variablesPath, 'utf-8');
const normalTokens = resolveTokenValues(
tokensContent,
variablesContent,
'page',
);
const elderTokens = resolveTokenValues(
tokensContent,
variablesContent,
'.elder-mode',
);
const doctorTokens = resolveTokenValues(
tokensContent,
variablesContent,
'.doctor-mode',
);
const output = `// Auto-generated by scripts/generate-tokens.ts — DO NOT EDIT
// Generated at: ${new Date().toISOString()}
export const TOKEN_VALUES = ${JSON.stringify(normalTokens, null, 2)} as const;
export const ELDER_TOKEN_OVERRIDES = ${JSON.stringify(elderTokens, null, 2)} as const;
export const DOCTOR_TOKEN_OVERRIDES = ${JSON.stringify(doctorTokens, null, 2)} as const;
// Canvas 专用:字号(数字,单位 px
export const CANVAS_FONT_NORMAL = {
yLabel: 10,
xLabel: 10,
tooltip: 12,
pointNormal: 3,
pointAbnormal: 5,
} as const;
export const CANVAS_FONT_ELDER = {
yLabel: 14,
xLabel: 14,
tooltip: 16,
pointNormal: 5,
pointAbnormal: 8,
} as const;
`;
writeFileSync(outputPath, output, 'utf-8');
console.log(`[generate-tokens] Generated ${outputPath}`);
console.log(` Normal: ${Object.keys(normalTokens).length} tokens`);
console.log(` Elder: ${Object.keys(elderTokens).length} overrides`);
console.log(` Doctor: ${Object.keys(doctorTokens).length} overrides`);
}
// CLI 入口
if (process.argv[1]?.endsWith('generate-tokens.ts')) {
const root = resolve(__dirname, '..');
generateTokenFile(
resolve(root, 'src/styles/tokens.scss'),
resolve(root, 'src/styles/variables.scss'),
resolve(root, 'src/styles/token-values.ts'),
);
}
export { parseTokensFromScss, parseScssVariables, resolveTokenValues, generateTokenFile };

View File

@@ -1,15 +1,14 @@
export default defineAppConfig({
...(process.env.NODE_ENV === 'production' ? { lazyCodeLoading: 'requiredComponents' as const } : {}),
pages: [
'pages/index/index',
'pages/login/index',
'pages/health/index',
'pages/messages/index',
'pages/consultation/index',
'pages/consultation/create/index',
'pages/mall/index',
'pages/profile/index',
'pages/appointment/index',
'pages/appointment/create/index',
'pages/appointment/detail/index',
'pages/legal/user-agreement',
'pages/legal/privacy-policy',
],
@@ -20,6 +19,7 @@ export default defineAppConfig({
},
{
root: 'pages/pkg-doctor-core',
independent: true,
pages: [
'index', 'patients/index', 'patients/detail/index',
'consultation/index', 'consultation/detail/index',
@@ -29,6 +29,7 @@ export default defineAppConfig({
},
{
root: 'pages/pkg-doctor-clinical',
independent: true,
pages: [
'dialysis/index', 'dialysis/detail/index', 'dialysis/create/index',
'prescription/index', 'prescription/detail/index', 'prescription/create/index',
@@ -59,6 +60,10 @@ export default defineAppConfig({
root: 'pages/article',
pages: ['index', 'detail/index'],
},
{
root: 'pages/appointment',
pages: ['index', 'create/index', 'detail/index'],
},
{
root: 'pages/pkg-consultation',
pages: ['detail/index'],
@@ -72,19 +77,24 @@ export default defineAppConfig({
list: [
{ pagePath: 'pages/index/index', text: '首页', iconPath: 'assets/tabbar/home.png', selectedIconPath: 'assets/tabbar/home-active.png' },
{ pagePath: 'pages/health/index', text: '健康', iconPath: 'assets/tabbar/health.png', selectedIconPath: 'assets/tabbar/health-active.png' },
{ pagePath: 'pages/messages/index', text: '消息', iconPath: 'assets/tabbar/message.png', selectedIconPath: 'assets/tabbar/message-active.png' },
{ pagePath: 'pages/mall/index', text: '商城', iconPath: 'assets/tabbar/mall.png', selectedIconPath: 'assets/tabbar/mall-active.png' },
{ pagePath: 'pages/messages/index', text: '助手', iconPath: 'assets/tabbar/message.png', selectedIconPath: 'assets/tabbar/message-active.png' },
{ pagePath: 'pages/profile/index', text: '我的', iconPath: 'assets/tabbar/profile.png', selectedIconPath: 'assets/tabbar/profile-active.png' },
],
},
preloadRule: {
'pages/index/index': {
network: 'all',
packages: ['pages/pkg-health', 'pages/pkg-doctor-core', 'pages/article'],
packages: ['pages/pkg-health', 'pages/article'],
},
'pages/health/index': {
network: 'all',
packages: ['pages/pkg-health'],
},
'pages/mall/index': {
network: 'all',
packages: ['pages/pkg-mall'],
},
'pages/consultation/index': {
network: 'all',
packages: ['pages/pkg-consultation'],

View File

@@ -1,9 +1,13 @@
import './utils/crypto-polyfill';
import './utils/abort-controller-polyfill';
import { useEffect, useRef, PropsWithChildren } from 'react';
import Taro, { useDidShow, useDidHide } from '@tarojs/taro';
import { useDidShow, useDidHide } from '@tarojs/taro';
import ErrorBoundary from './components/ErrorBoundary';
import { flushEvents } from './services/analytics';
import { useAuthStore } from './stores/auth';
import { useUIStore } from './stores/ui';
import { useAlertPolling } from './hooks/useAlertPolling';
import { migrateLegacyStorage } from './utils/secure-storage';
import './app.scss';
function App({ children }: PropsWithChildren<Record<string, unknown>>) {
@@ -12,20 +16,23 @@ function App({ children }: PropsWithChildren<Record<string, unknown>>) {
// useDidShow 在首次 mount 时也会触发,不需要 useEffect 重复调用
useDidShow(() => {
migrateLegacyStorage();
restoreAuth();
restoreUI();
});
// 告警轮询:登录态下自动监听 critical 告警
useAlertPolling();
// 暴露全局 bridge 供 MCP/自动化测试调用(仅 dev 模式)
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;
(globalThis as any).__hms = {
(globalThis as Record<string, unknown>).__hms = {
restoreAuth: () => { restoreAuth(); return useAuthStore.getState(); },
restoreUI,
getAuthState: () => useAuthStore.getState(),
forceSetAuth: (state: Record<string, unknown>) => useAuthStore.setState(state),
};
return () => { delete (globalThis as any).__hms; };
return () => { delete (globalThis as Record<string, unknown>).__hms; };
}, []);
// Analytics 定时器:仅在页面可见时运行,后台时暂停以节省资源

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 B

View File

@@ -27,13 +27,13 @@
}
.empty-state-text {
font-size: var(--tk-font-num);
font-size: var(--tk-font-body-lg);
color: $tx2;
margin-bottom: var(--tk-gap-xs);
}
.empty-state-hint {
font-size: var(--tk-font-h2);
font-size: var(--tk-font-body-sm);
color: var(--tk-text-secondary);
margin-bottom: var(--tk-gap-xl);
}

View File

@@ -19,7 +19,7 @@ export default React.memo(function EmptyState({
}: EmptyStateProps) {
const displayChar = icon || text.charAt(0);
return (
<View className='empty-state'>
<View className='empty-state' role="status" aria-live="polite">
<View className='empty-state-icon-wrap'>
<Text className='empty-state-icon-char'>{displayChar}</Text>
</View>

View File

@@ -1,27 +1,77 @@
import React, { Component } from 'react';
import { Component } from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
interface Props {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface State {
hasError: boolean;
retryCount: number;
errorCategory: ErrorCategory;
}
type ErrorCategory = 'network' | 'render' | 'unknown';
const MAX_RETRIES = 3;
function classifyError(error: Error): ErrorCategory {
const msg = error.message?.toLowerCase() || '';
if (
msg.includes('network') ||
msg.includes('fetch') ||
msg.includes('timeout') ||
msg.includes('request:fail')
) {
return 'network';
}
if (
msg.includes('cannot read properties') ||
msg.includes('is not defined') ||
msg.includes('is not a function') ||
msg.includes('render')
) {
return 'render';
}
return 'unknown';
}
function logError(error: Error, info: React.ErrorInfo, category: ErrorCategory): void {
const isDev = process.env.NODE_ENV === 'development';
const entry = {
ts: new Date().toISOString(),
category,
message: error.message,
stack: isDev ? error.stack : undefined,
componentStack: isDev ? info.componentStack : undefined,
};
if (isDev) {
console.error('[ErrorBoundary]', JSON.stringify(entry, null, 2));
} else {
console.error('[ErrorBoundary]', entry.ts, entry.category, entry.message);
}
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
this.state = { hasError: false, retryCount: 0, errorCategory: 'unknown' };
}
static getDerivedStateFromError(): State {
return { hasError: true };
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, errorCategory: classifyError(error) };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('[ErrorBoundary]', error, info.componentStack);
const category = classifyError(error);
logError(error, info, category);
this.setState((prev) => ({
retryCount: prev.retryCount + 1,
errorCategory: category,
}));
}
handleRetry = () => {
@@ -30,19 +80,31 @@ export default class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
const exceeded = this.state.retryCount >= MAX_RETRIES;
const isNetwork = this.state.errorCategory === 'network';
const title = isNetwork ? '网络连接失败' : '页面出了点问题';
const desc = exceeded
? '请重启小程序后重试'
: isNetwork
? '请检查网络后重试'
: '请返回重试';
return (
<View className='error-boundary'>
<View className='error-icon-wrap'>
<Text className='error-icon-text'>!</Text>
</View>
<Text className='error-title'></Text>
<Text className='error-desc'></Text>
<View
className='error-retry-btn'
onClick={this.handleRetry}
>
<Text className='error-retry-text'></Text>
</View>
<Text className='error-title'>{title}</Text>
<Text className='error-desc'>{desc}</Text>
{!exceeded && (
<View className='error-retry-btn' onClick={this.handleRetry}>
<Text className='error-retry-text'></Text>
</View>
)}
</View>
);
}

View File

@@ -12,7 +12,7 @@ export default React.memo(function ErrorState({
onRetry,
}: ErrorStateProps) {
return (
<View className='error-state'>
<View className='error-state' role="alert" aria-live="assertive">
<Text className='error-state-icon'></Text>
<Text className='error-state-text'>{text}</Text>
{onRetry && (

View File

@@ -0,0 +1,48 @@
@import '../../styles/variables.scss';
@import '../../styles/mixins.scss';
.frozen-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
padding: 40px 20px;
}
.frozen-page-icon {
font-size: 48px;
margin-bottom: 24px;
}
.frozen-page-title {
font-size: var(--tk-font-h3);
font-weight: 600;
color: $tx;
margin-bottom: 12px;
}
.frozen-page-desc {
font-size: var(--tk-font-body);
color: $tx3;
margin-bottom: 32px;
}
.frozen-page-btn {
height: 44px;
padding: 0 32px;
border-radius: $r;
background: var(--tk-pri);
@include flex-center;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.frozen-page-btn-text {
font-size: var(--tk-font-body);
font-weight: 500;
color: $white;
}

View File

@@ -0,0 +1,22 @@
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import PageShell from '@/components/ui/PageShell';
import './index.scss';
export default function FrozenPage() {
return (
<PageShell scroll={false}>
<View className="frozen-page">
<Text className="frozen-page-icon">🚧</Text>
<Text className="frozen-page-title">线</Text>
<Text className="frozen-page-desc"></Text>
<View
className="frozen-page-btn"
onClick={() => Taro.navigateBack({ delta: 1 }).catch(() => Taro.switchTab({ url: '/pages/index/index' }))}
>
<Text className="frozen-page-btn-text"></Text>
</View>
</View>
</PageShell>
);
}

View File

@@ -25,6 +25,15 @@
}
.loading-state-text {
font-size: var(--tk-font-h1);
font-size: var(--tk-font-body-sm);
color: var(--tk-text-secondary);
}
.loading-state--end {
padding: 24px 0;
.loading-state-text {
color: var(--tk-text-tertiary);
font-size: var(--tk-caption);
}
}

View File

@@ -7,9 +7,10 @@ interface LoadingProps {
}
export default React.memo(function Loading({ text = '加载中...' }: LoadingProps) {
const isListEnd = text !== '加载中...' && !text.includes('加载');
return (
<View className='loading-state'>
<View className='loading-spinner' />
<View className={`loading-state ${isListEnd ? 'loading-state--end' : ''}`} role="status" aria-live="polite" aria-label="加载中">
{!isListEnd && <View className='loading-spinner' />}
<Text className='loading-state-text'>{text}</Text>
</View>
);

View File

@@ -0,0 +1,54 @@
import { memo, useMemo } from 'react';
import { View } from '@tarojs/components';
import { sanitizeHtml } from '@/utils/sanitize-html';
interface RichArticleProps {
html: string;
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);
}
function RichArticle({ html, className }: RichArticleProps) {
const content = useMemo(() => prepareHtml(html), [html]);
if (!content) return null;
return (
<View className={className}>
<mp-html
content={content}
lazy-load
selectable
container-style="font-size:16px;color:#5A554F;line-height:1.8;word-break:break-word"
tag-style={TAG_STYLE}
/>
</View>
);
}
export default memo(RichArticle);

View File

@@ -15,6 +15,8 @@
justify-content: center;
position: relative;
@include focus-ring;
&--active {
.seg-tab__text {
color: var(--tk-pri);
@@ -54,6 +56,8 @@
justify-content: center;
transition: all 0.2s;
@include focus-ring;
&--active {
background: var(--tk-pri);
box-shadow: var(--tk-shadow-tab);

View File

@@ -3,12 +3,12 @@ import { View, Text } from '@tarojs/components';
import './index.scss';
interface Tab {
key: string;
label: string;
readonly key: string;
readonly label: string;
}
interface SegmentTabsProps {
tabs: Tab[];
tabs: readonly Tab[];
activeKey: string;
onChange: (key: string) => void;
variant?: 'underline' | 'pill';
@@ -21,11 +21,13 @@ export default React.memo(function SegmentTabs({
variant = 'underline',
}: SegmentTabsProps) {
return (
<View className={`seg-tabs seg-tabs--${variant}`}>
<View className={`seg-tabs seg-tabs--${variant}`} role="tablist">
{tabs.map((tab) => (
<View
key={tab.key}
className={`seg-tab ${activeKey === tab.key ? 'seg-tab--active' : ''}`}
role="tab"
aria-selected={activeKey === tab.key}
onClick={() => onChange(tab.key)}
>
<Text className='seg-tab__text'>{tab.label}</Text>

View File

@@ -45,3 +45,33 @@
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
// ── Tooltip ──
.trend-tooltip {
position: absolute;
transform: translateX(-50%);
background: rgba(15, 23, 42, 0.9);
padding: 6px 12px;
border-radius: 6px;
white-space: nowrap;
pointer-events: none;
z-index: 10;
.elder-mode & {
padding: 12px 16px;
border-radius: 8px;
min-height: 44px;
display: flex;
align-items: center;
}
}
.trend-tooltip-text {
font-size: 12px;
color: #fff;
.elder-mode & {
font-size: var(--tk-font-body-sm);
}
}

View File

@@ -1,8 +1,13 @@
import React, { useEffect, useRef, useCallback } from 'react';
import React, { useEffect, useRef, useCallback, useState } from 'react';
import { Canvas, View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useCanvasTokens } from '@/hooks/useCanvasTokens';
import './index.scss';
/** Canvas 2D 上下文类型 — 微信小程序 Canvas 2d 接口 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type CanvasRenderingContext2D = any;
interface TrendChartProps {
data: { date: string; value: number }[];
referenceMin?: number;
@@ -40,7 +45,28 @@ export default React.memo(function TrendChart({
unit = '',
height = 500,
}: TrendChartProps) {
const tokens = useCanvasTokens();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const canvasRef = useRef<any>(null);
const [tooltip, setTooltip] = useState<{ date: string; value: number; x: number } | null>(null);
// 关怀模式默认显示最后一个数据点 tooltip
useEffect(() => {
if (tokens.yLabelFontSize >= 14 && data && data.length > 0 && canvasRef.current) {
const dpr = getDPR();
const w = canvasRef.current.width / dpr;
const pad = { left: 45, right: 15 };
const cw = w - pad.left - pad.right;
const lastIdx = data.length - 1;
setTooltip({
date: data[lastIdx].date,
value: data[lastIdx].value,
x: pad.left + (lastIdx / Math.max(data.length - 1, 1)) * cw,
});
} else if (tokens.yLabelFontSize < 14) {
setTooltip(null);
}
}, [data, tokens.yLabelFontSize]);
const draw = useCallback(() => {
const node = canvasRef.current;
@@ -77,12 +103,29 @@ export default React.memo(function TrendChart({
if (referenceMin != null && referenceMax != null) {
const ry1 = toY(referenceMax);
const ry2 = toY(referenceMin);
ctx.fillStyle = 'rgba(5,150,105,0.08)';
ctx.fillStyle = tokens.referenceBandColor;
ctx.fillRect(pad.left, ry1, cw, ry2 - ry1);
// 关怀模式增加斜线纹理增强区分度
if (tokens.yLabelFontSize >= 14) {
ctx.save();
ctx.strokeStyle = 'rgba(5,150,105,0.18)';
ctx.lineWidth = 1;
const step = 8;
ctx.beginPath();
for (let x = pad.left; x < pad.left + cw; x += step) {
const x1 = Math.min(x, pad.left + cw);
const x2 = Math.min(x + (ry2 - ry1), pad.left + cw);
ctx.moveTo(x1, ry2);
ctx.lineTo(x2, ry1);
}
ctx.stroke();
ctx.restore();
}
}
// Grid lines
ctx.strokeStyle = '#F3F4F6';
ctx.strokeStyle = tokens.gridColor;
ctx.lineWidth = 1;
const gridLines = 4;
for (let i = 0; i <= gridLines; i++) {
@@ -94,8 +137,8 @@ export default React.memo(function TrendChart({
}
// Y-axis labels
ctx.fillStyle = '#94A3B8';
ctx.font = '10px sans-serif';
ctx.fillStyle = tokens.tx2;
ctx.font = `${tokens.yLabelFontSize}px sans-serif`;
ctx.textAlign = 'right';
for (let i = 0; i <= gridLines; i++) {
const val = yMax - (yTotal / gridLines) * i;
@@ -104,6 +147,7 @@ export default React.memo(function TrendChart({
}
// X-axis labels
ctx.font = `${tokens.xLabelFontSize}px sans-serif`;
ctx.textAlign = 'center';
const step = Math.max(1, Math.floor(data.length / 5));
for (let i = 0; i < data.length; i += step) {
@@ -125,13 +169,13 @@ export default React.memo(function TrendChart({
ctx.lineTo(chartPoints[chartPoints.length - 1].x, toY(yMin));
ctx.closePath();
const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + ch);
grad.addColorStop(0, 'rgba(8,145,178,0.15)');
grad.addColorStop(1, 'rgba(8,145,178,0.01)');
grad.addColorStop(0, tokens.areaGradientStart);
grad.addColorStop(1, tokens.areaGradientEnd);
ctx.fillStyle = grad;
ctx.fill();
// Line
ctx.strokeStyle = '#0891B2';
ctx.strokeStyle = tokens.lineColor;
ctx.lineWidth = 2;
drawLine(ctx, chartPoints);
@@ -142,13 +186,33 @@ export default React.memo(function TrendChart({
(referenceMin != null && d.value < referenceMin) ||
(referenceMax != null && d.value > referenceMax);
ctx.beginPath();
ctx.arc(chartPoints[i].x, chartPoints[i].y, isAbnormal ? 5 : 3, 0, Math.PI * 2);
ctx.fillStyle = isAbnormal ? '#DC2626' : '#0891B2';
ctx.arc(chartPoints[i].x, chartPoints[i].y, isAbnormal ? tokens.pointAbnormalRadius : tokens.pointNormalRadius, 0, Math.PI * 2);
ctx.fillStyle = isAbnormal ? tokens.abnormalColor : tokens.lineColor;
ctx.fill();
}
ctx.restore();
}, [data, referenceMin, referenceMax]);
}, [data, referenceMin, referenceMax, tokens]);
const handleTouchStart = useCallback((e: { touches: Array<{ x: number }> }) => {
if (!data || data.length === 0 || !canvasRef.current) return;
const touch = e.touches[0];
const node = canvasRef.current;
const dpr = getDPR();
const x = touch.x;
const w = node.width / dpr;
const pad = { left: 45, right: 15 };
const cw = w - pad.left - pad.right;
const relX = x - pad.left;
const idx = Math.round((relX / cw) * (data.length - 1));
if (idx >= 0 && idx < data.length) {
setTooltip({
date: data[idx].date,
value: data[idx].value,
x: pad.left + (idx / Math.max(data.length - 1, 1)) * cw,
});
}
}, [data]);
useEffect(() => {
const query = Taro.createSelectorQuery();
@@ -160,7 +224,6 @@ export default React.memo(function TrendChart({
if (!node) return;
canvasRef.current = node;
const sysInfo = Taro.getSystemInfoSync();
const canvasW = (sysInfo.windowWidth * 750) / sysInfo.windowWidth;
node.width = sysInfo.windowWidth * getDPR();
node.height = ((height / 750) * sysInfo.windowWidth) * getDPR();
draw();
@@ -181,7 +244,20 @@ export default React.memo(function TrendChart({
type='2d'
id='trend-chart-canvas'
style={{ width: '100%', height: '100%' }}
onTouchStart={handleTouchStart}
/>
{tooltip && (
<View
className='trend-tooltip'
role="tooltip"
aria-live="polite"
style={{ left: `${tooltip.x}px`, top: '8px' }}
>
<Text className='trend-tooltip-text'>
{tooltip.date}: {tooltip.value}{unit ? ` ${unit}` : ''}
</Text>
</View>
)}
</View>
);
});

View File

@@ -0,0 +1,100 @@
.ai-summary-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.ai-summary-title {
font-size: var(--tk-font-size-body-lg, 18px);
font-weight: 600;
color: var(--tk-color-text, #333);
}
.ai-summary-risk {
padding: 4px 16px;
border-radius: 100px;
&-text {
font-size: var(--tk-font-size-cap, 13px);
color: #fff;
font-weight: 500;
}
}
.ai-summary-insight {
margin-bottom: 24px;
&-label {
font-size: var(--tk-font-size-cap, 13px);
color: var(--tk-color-text-secondary, #999);
display: block;
margin-bottom: 8px;
}
&-text {
font-size: var(--tk-font-size-body, 16px);
color: var(--tk-color-text, #333);
line-height: 1.5;
}
}
.ai-summary-stats {
display: flex;
gap: 40px;
margin-bottom: 24px;
}
.ai-summary-stat {
display: flex;
flex-direction: column;
align-items: center;
&-value {
font-size: 28px;
font-weight: 700;
color: var(--tk-color-primary, #1890ff);
}
&-label {
font-size: var(--tk-font-size-cap, 13px);
color: var(--tk-color-text-secondary, #999);
margin-top: 4px;
}
}
.ai-summary-items {
border-top: 1px solid var(--tk-color-border, #f0f0f0);
padding-top: 20px;
}
.ai-summary-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 0;
&-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
&-title {
font-size: var(--tk-font-size-body-sm, 14px);
color: var(--tk-color-text, #333);
line-height: 1.4;
}
}
.ai-summary-loading {
display: flex;
justify-content: center;
padding: 32px 0;
&-text {
font-size: var(--tk-font-size-body-sm, 14px);
color: var(--tk-color-text-secondary, #999);
}
}

View File

@@ -0,0 +1,97 @@
import React, { useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import ContentCard from '../ContentCard';
import { getHealthSummary, type HealthSummary } from '../../../services/ai-analysis';
import './index.scss';
interface AiHealthSummaryCardProps {
patientId: string;
}
const RISK_COLORS: Record<string, string> = {
critical: 'var(--tk-color-danger, #ff4d4f)',
high: 'var(--tk-color-warning, #faad14)',
medium: 'var(--tk-color-info, #1890ff)',
low: 'var(--tk-color-success, #52c41a)',
};
const RISK_LABELS: Record<string, string> = {
critical: '高风险',
high: '较高风险',
medium: '中等风险',
low: '低风险',
};
const AiHealthSummaryCard: React.FC<AiHealthSummaryCardProps> = ({ patientId }) => {
const [summary, setSummary] = useState<HealthSummary | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!patientId) return;
setLoading(true);
getHealthSummary(patientId)
.then((data) => setSummary(data))
.catch(() => setSummary(null))
.finally(() => setLoading(false));
}, [patientId]);
if (loading) {
return (
<ContentCard>
<View className='ai-summary-loading'>
<Text className='ai-summary-loading-text'>AI ...</Text>
</View>
</ContentCard>
);
}
if (!summary) return null;
const riskColor = RISK_COLORS[summary.risk_level] || RISK_COLORS.low;
const riskLabel = RISK_LABELS[summary.risk_level] || '低风险';
return (
<ContentCard>
<View className='ai-summary-header'>
<Text className='ai-summary-title'>AI </Text>
<View className='ai-summary-risk' style={{ backgroundColor: riskColor }}>
<Text className='ai-summary-risk-text'>{riskLabel}</Text>
</View>
</View>
{summary.latest_insight_title && (
<View className='ai-summary-insight'>
<Text className='ai-summary-insight-label'></Text>
<Text className='ai-summary-insight-text'>{summary.latest_insight_title}</Text>
</View>
)}
<View className='ai-summary-stats'>
<View className='ai-summary-stat'>
<Text className='ai-summary-stat-value'>{summary.active_insights_count}</Text>
<Text className='ai-summary-stat-label'></Text>
</View>
<View className='ai-summary-stat'>
<Text className='ai-summary-stat-value'>{summary.recent_analyses_count}</Text>
<Text className='ai-summary-stat-label'>AI </Text>
</View>
</View>
{summary.summary_items.length > 0 && (
<View className='ai-summary-items'>
{summary.summary_items.slice(0, 3).map((item, idx) => (
<View key={idx} className='ai-summary-item'>
<View
className='ai-summary-item-dot'
style={{ backgroundColor: item.severity ? (RISK_COLORS[item.severity] || riskColor) : riskColor }}
/>
<Text className='ai-summary-item-title'>{item.title}</Text>
</View>
))}
</View>
)}
</ContentCard>
);
};
export default React.memo(AiHealthSummaryCard);

View File

@@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';

View File

@@ -0,0 +1,91 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.checkin-calendar {
display: flex;
gap: 6px;
justify-content: center;
padding: $sp-section $sp-lg $sp-md;
&__day {
width: 36px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-xs;
}
&__dot {
width: 36px;
height: 36px;
border-radius: 18px;
@include flex-center;
&--checked {
background: $acc-l;
}
&--today {
background: $pri;
border: 2px solid $pri;
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.3);
}
&--empty {
background: $surface-alt;
}
}
&__check {
font-size: 13px;
line-height: 1;
color: $acc;
.checkin-calendar__dot--today & {
color: $white;
}
}
&__label {
font-size: 11px;
color: $tx3;
font-weight: 400;
&--today {
color: $tx;
font-weight: 600;
}
}
&__tip {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 10px 14px;
background: $acc-l;
border-radius: $r-sm;
display: flex;
align-items: center;
gap: $sp-xs;
}
&__tip-text {
font-size: 12px;
color: $acc;
font-weight: 500;
line-height: 1.4;
}
// 长者模式
.elder-mode & {
&__dot {
width: 40px;
height: 40px;
}
&__label {
font-size: 13px;
}
&__tip-text {
font-size: 14px;
}
}
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
interface CheckinCalendarProps {
consecutiveDays: number;
earnedPoints?: number;
onClose?: () => void;
}
const DAYS = ['一', '二', '三', '四', '五', '六', '日'];
const CheckinCalendar: React.FC<CheckinCalendarProps> = ({
consecutiveDays,
}) => {
const daysUntilReward = 7 - consecutiveDays;
return (
<View className='checkin-calendar'>
{DAYS.map((d, i) => {
const isChecked = i < consecutiveDays;
const isToday = i === consecutiveDays - 1;
return (
<View key={i} className='checkin-calendar__day'>
<View
className={`checkin-calendar__dot ${
isChecked
? isToday
? 'checkin-calendar__dot--today'
: 'checkin-calendar__dot--checked'
: 'checkin-calendar__dot--empty'
}`}
>
{isChecked && <Text className='checkin-calendar__check'>&#10003;</Text>}
</View>
<Text className={`checkin-calendar__label ${isToday ? 'checkin-calendar__label--today' : ''}`}>
{d}
</Text>
</View>
);
})}
{daysUntilReward > 0 && (
<View className='checkin-calendar__tip'>
<Text className='checkin-calendar__tip-text'>
{daysUntilReward} 7 50
</Text>
</View>
)}
</View>
);
};
export default React.memo(CheckinCalendar);

View File

@@ -0,0 +1,142 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.checkin-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
&__overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
}
&__card {
position: relative;
width: 320px;
background: $card;
border-radius: $r;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
overflow: hidden;
z-index: 1;
}
&__header {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
padding: $sp-lg;
padding-bottom: 20px;
text-align: center;
position: relative;
overflow: hidden;
}
&__header-deco {
position: absolute;
top: -15px;
right: -15px;
width: 60px;
height: 60px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.08);
}
&__title {
font-size: var(--tk-font-body);
color: rgba(255, 255, 255, 0.8);
margin-bottom: $sp-xs;
display: block;
}
&__points-row {
display: flex;
align-items: baseline;
justify-content: center;
gap: $sp-2xs;
}
&__points-num {
@include serif-number;
font-size: 36px;
font-weight: 700;
color: $white;
line-height: 1;
}
&__points-unit {
font-size: var(--tk-font-body-sm);
color: rgba(255, 255, 255, 0.8);
}
&__streak {
font-size: var(--tk-font-cap);
color: rgba(255, 255, 255, 0.65);
margin-top: $sp-xs;
display: block;
}
&__calendar {
padding: $sp-section $sp-lg 0;
}
&__calendar-title {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
margin-bottom: $sp-sm;
text-align: center;
display: block;
}
&__calendar-body {
position: relative;
padding-bottom: 52px;
}
&__footer {
padding: 0 $sp-lg $sp-lg;
}
&__btn {
width: 100%;
padding: 12px 0;
border-radius: $r-pill;
background: $pri;
text-align: center;
cursor: pointer;
box-shadow: $shadow-btn;
@include touch-target;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
&__btn-text {
font-size: 15px;
color: $white;
font-weight: 600;
}
// 长者模式
.elder-mode & {
&__card {
width: 340px;
}
&__points-num {
font-size: 42px;
}
&__title {
font-size: 17px;
}
&__btn {
padding: 16px 0;
}
&__btn-text {
font-size: 17px;
}
}
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import CheckinCalendar from '../CheckinCalendar';
import './index.scss';
interface CheckinModalProps {
visible: boolean;
consecutiveDays: number;
earnedPoints: number;
onClose: () => void;
}
const CheckinModal: React.FC<CheckinModalProps> = ({
visible,
consecutiveDays,
earnedPoints,
onClose,
}) => {
if (!visible) return null;
return (
<View className='checkin-modal'>
<View className='checkin-modal__overlay' onClick={onClose} />
<View className='checkin-modal__card'>
{/* 顶部装饰区 */}
<View className='checkin-modal__header'>
<View className='checkin-modal__header-deco' />
<Text className='checkin-modal__title'></Text>
<View className='checkin-modal__points-row'>
<Text className='checkin-modal__points-num'>+{earnedPoints}</Text>
<Text className='checkin-modal__points-unit'></Text>
</View>
{consecutiveDays > 0 && (
<Text className='checkin-modal__streak'>
{consecutiveDays}
</Text>
)}
</View>
{/* 7天日历 */}
<View className='checkin-modal__calendar'>
<Text className='checkin-modal__calendar-title'></Text>
<View className='checkin-modal__calendar-body'>
<CheckinCalendar
consecutiveDays={consecutiveDays}
earnedPoints={earnedPoints}
onClose={onClose}
/>
</View>
</View>
{/* 关闭按钮 */}
<View className='checkin-modal__footer'>
<View className='checkin-modal__btn' onClick={onClose}>
<Text className='checkin-modal__btn-text'></Text>
</View>
</View>
</View>
</View>
);
};
export default React.memo(CheckinModal);

View File

@@ -0,0 +1,54 @@
@import '../../../styles/variables.scss';
.doctor-tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: $tabbar-space;
background: $card;
display: flex;
align-items: flex-start;
padding-top: 6px;
padding-bottom: env(safe-area-inset-bottom, 0px);
box-shadow: 0 -1px 0 $bd, $shadow-md;
z-index: 999;
&__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
@include focus-ring;
&--active {
.doctor-tabbar__icon {
transform: scale(1.15);
}
.doctor-tabbar__label {
color: $doc-pri;
font-weight: 600;
}
}
}
&__icon {
font-size: 22px;
line-height: 1;
transition: transform 0.15s ease;
}
&__label {
font-size: var(--tk-font-micro);
color: $tx3;
line-height: 1.2;
transition: color 0.15s ease;
}
}

View File

@@ -0,0 +1,51 @@
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import './index.scss';
interface TabItem {
key: string;
icon: string;
activeIcon: string;
label: string;
url: string;
}
const DOCTOR_TABS: TabItem[] = [
{ key: 'workbench', icon: '🏠', activeIcon: '🏠', label: '工作台', url: '/pages/pkg-doctor-core/index' },
{ key: 'patients', icon: '👤', activeIcon: '👤', label: '患者', url: '/pages/pkg-doctor-core/patients/index' },
{ key: 'consultation', icon: '💬', activeIcon: '💬', label: '咨询', url: '/pages/pkg-doctor-core/consultation/index' },
{ key: 'settings', icon: '⚙️', activeIcon: '⚙️', label: '我的', url: '/pages/pkg-profile/settings/index' },
];
interface DoctorTabBarProps {
active?: string;
}
export default function DoctorTabBar({ active }: DoctorTabBarProps) {
const currentPath = `/${Taro.getCurrentPages().pop()?.path ?? ''}`;
const activeKey = active ?? DOCTOR_TABS.find((t) => currentPath.startsWith(t.url.replace('/index', '')))?.key ?? 'workbench';
const handleTab = (tab: TabItem) => {
if (tab.key === activeKey) return;
Taro.reLaunch({ url: tab.url }).catch(() => {
Taro.redirectTo({ url: tab.url }).catch(() => {});
});
};
return (
<View className="doctor-tabbar" role="tablist">
{DOCTOR_TABS.map((tab) => (
<View
key={tab.key}
className={`doctor-tabbar__item ${tab.key === activeKey ? 'doctor-tabbar__item--active' : ''}`}
role="tab"
aria-selected={tab.key === activeKey}
onClick={() => handleTab(tab)}
>
<Text className="doctor-tabbar__icon">{tab.key === activeKey ? tab.activeIcon : tab.icon}</Text>
<Text className="doctor-tabbar__label">{tab.label}</Text>
</View>
))}
</View>
);
}

View File

@@ -17,6 +17,12 @@
display: flex;
align-items: center;
transition: border-color 0.2s;
&:focus-within {
outline: $focus-ring-width solid $focus-ring-color;
outline-offset: $focus-ring-offset;
border-color: var(--tk-pri);
}
}
&__control {

View File

@@ -45,6 +45,8 @@ const FormInput: React.FC<FormInputProps> = ({
type={type}
maxlength={maxLength}
disabled={disabled}
aria-invalid={!!error}
aria-label={label || placeholder}
/>
</View>
{error && <Text className='form-input__error'>{error}</Text>}

View File

@@ -12,7 +12,7 @@ const LoadingCard: React.FC<LoadingCardProps> = ({
layout = 'card',
}) => {
return (
<View className={`loading-card-group loading-card-group--${layout}`}>
<View className={`loading-card-group loading-card-group--${layout}`} role="status" aria-label="内容加载中">
{Array.from({ length: count }, (_, i) => (
<View key={i} className="loading-card">
{layout === 'card' && (

View File

@@ -0,0 +1,124 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.points-card {
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
border-radius: $r;
padding: $sp-lg;
padding-bottom: 20px;
margin-bottom: $sp-section;
box-shadow: 0 8px 24px rgba(196, 98, 58, 0.25);
position: relative;
overflow: hidden;
&__deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
&--1 {
top: -20px;
right: -20px;
width: 100px;
height: 100px;
background: rgba(255, 255, 255, 0.08);
}
&--2 {
bottom: -30px;
right: 40px;
width: 80px;
height: 80px;
background: rgba(255, 255, 255, 0.05);
}
&--3 {
top: 20px;
right: 20px;
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.06);
}
}
&__body {
display: flex;
justify-content: space-between;
align-items: flex-start;
position: relative;
z-index: 1;
}
&__left {
display: flex;
flex-direction: column;
}
&__label {
font-size: var(--tk-font-cap);
color: rgba(255, 255, 255, 0.7);
margin-bottom: $sp-xs;
letter-spacing: 1px;
}
&__balance {
@include serif-number;
font-size: 42px;
font-weight: 700;
color: $white;
line-height: 1;
letter-spacing: 2px;
margin-bottom: $sp-2xs;
}
&__streak {
font-size: var(--tk-font-cap);
color: rgba(255, 255, 255, 0.65);
}
&__checkin {
display: flex;
align-items: center;
gap: $sp-2xs;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.4);
padding: 8px 16px;
border-radius: $r-pill;
cursor: pointer;
@include touch-target;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
&--done {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
}
&__checkin-text {
font-size: var(--tk-font-cap);
color: $white;
font-weight: 500;
}
&__checkin--done &__checkin-text {
opacity: 0.6;
}
// 长者模式
.elder-mode & {
&__balance {
font-size: 52px;
}
&__label {
font-size: 15px;
}
&__checkin {
padding: 10px 20px;
}
&__checkin-text {
font-size: 15px;
}
}
}

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
interface PointsCardProps {
balance: number;
consecutiveDays: number;
checkedIn: boolean;
checkinLoading?: boolean;
onCheckin?: () => void;
}
const PointsCard: React.FC<PointsCardProps> = ({
balance,
consecutiveDays,
checkedIn,
checkinLoading = false,
onCheckin,
}) => {
return (
<View className='points-card'>
{/* 装饰圆 */}
<View className='points-card__deco points-card__deco--1' />
<View className='points-card__deco points-card__deco--2' />
<View className='points-card__deco points-card__deco--3' />
<View className='points-card__body'>
<View className='points-card__left'>
<Text className='points-card__label'></Text>
<Text className='points-card__balance'>{balance.toLocaleString()}</Text>
{consecutiveDays > 0 && (
<Text className='points-card__streak'>
{consecutiveDays}
</Text>
)}
</View>
<View
className={`points-card__checkin ${checkedIn ? 'points-card__checkin--done' : ''}`}
onClick={() => !checkedIn && !checkinLoading && onCheckin?.()}
>
<Text className='points-card__checkin-text'>
{checkinLoading ? '...' : checkedIn ? '已签到' : '签到'}
</Text>
</View>
</View>
</View>
);
};
export default React.memo(PointsCard);

View File

@@ -29,6 +29,8 @@
transform: scale(0.98);
}
@include focus-ring;
&--disabled {
opacity: 0.5;
box-shadow: none;

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import { hapticLight } from '@/utils/haptic';
import './index.scss';
interface PrimaryButtonProps {
@@ -27,8 +28,14 @@ const PrimaryButton: React.FC<PrimaryButtonProps> = ({
className,
].filter(Boolean).join(' ');
const handleClick = () => {
if (disabled || loading) return;
hapticLight();
onClick?.();
};
return (
<View className={cls} onClick={!disabled && !loading ? onClick : undefined}>
<View className={cls} role="button" aria-disabled={disabled} aria-busy={loading} onClick={handleClick}>
{loading && <View className='primary-btn__spinner' />}
<Text className='primary-btn__text'>{children}</Text>
</View>

View File

@@ -0,0 +1,116 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.product-card {
background: $card;
border-radius: $r-sm;
overflow: hidden;
box-shadow: $shadow-sm;
@include touch-feedback;
&__thumb-wrap {
position: relative;
}
&__thumb {
width: 100%;
aspect-ratio: 1;
@include flex-center;
flex-direction: column;
gap: $sp-2xs;
&--physical { background: $pri-l; }
&--service { background: $acc-l; }
&--privilege { background: $wrn-l; }
}
&__thumb-type {
font-size: var(--tk-font-cap);
color: $tx3;
font-weight: 500;
}
&__soldout {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.7);
@include flex-center;
}
&__soldout-text {
font-size: var(--tk-font-body-sm);
color: $tx3;
font-weight: 600;
}
&__info {
padding: 10px $sp-sm 14px;
}
&__name {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
height: 40px;
margin-bottom: $sp-xs;
}
&__bottom {
display: flex;
align-items: baseline;
gap: $sp-2xs;
}
&__points-row {
display: flex;
align-items: baseline;
gap: 2px;
}
&__points-num {
@include serif-number;
font-size: 18px;
font-weight: 700;
color: $pri;
}
&__points-unit {
font-size: var(--tk-font-micro);
color: $pri;
font-weight: 500;
}
&__low-stock {
display: inline-block;
font-size: var(--tk-font-micro);
color: $wrn;
font-weight: 500;
background: $wrn-l;
padding: 2px $sp-xs;
border-radius: $r-sm;
margin-top: $sp-2xs;
}
// 长者模式
.elder-mode & {
&__name {
font-size: 16px;
height: 46px;
}
&__points-num {
font-size: 22px;
}
&__points-unit {
font-size: 13px;
}
&__low-stock {
font-size: 13px;
}
}
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import type { PointsProduct } from '@/services/points';
import './index.scss';
interface ProductCardProps {
product: PointsProduct;
onPress?: (product: PointsProduct) => void;
}
const TYPE_BG_CLASS: Record<string, string> = {
physical: 'product-card__thumb--physical',
service: 'product-card__thumb--service',
privilege: 'product-card__thumb--privilege',
};
const TYPE_LABELS: Record<string, string> = {
physical: '实物',
service: '服务券',
privilege: '权益',
};
const ProductCard: React.FC<ProductCardProps> = ({ product, onPress }) => {
const isSoldOut = product.stock <= 0;
const isLowStock = product.stock > 0 && product.stock <= 10;
return (
<View
className='product-card'
onClick={() => onPress?.(product)}
>
<View className='product-card__thumb-wrap'>
<View className={`product-card__thumb ${TYPE_BG_CLASS[product.product_type] || ''}`}>
<Text className='product-card__thumb-type'>{TYPE_LABELS[product.product_type] || '商品'}</Text>
</View>
{isSoldOut && (
<View className='product-card__soldout'>
<Text className='product-card__soldout-text'></Text>
</View>
)}
</View>
<View className='product-card__info'>
<Text className='product-card__name'>{product.name}</Text>
<View className='product-card__bottom'>
<View className='product-card__points-row'>
<Text className='product-card__points-num'>{product.points_cost}</Text>
<Text className='product-card__points-unit'></Text>
</View>
</View>
{isLowStock && (
<Text className='product-card__low-stock'>{product.stock}</Text>
)}
</View>
</View>
);
};
export default React.memo(ProductCard);

View File

@@ -18,6 +18,8 @@
transform: scale(0.98);
}
@include focus-ring;
&--disabled {
opacity: 0.5;
}

View File

@@ -22,7 +22,7 @@ const SecondaryButton: React.FC<SecondaryButtonProps> = ({
].filter(Boolean).join(' ');
return (
<View className={cls} onClick={!disabled ? onClick : undefined}>
<View className={cls} role="button" aria-disabled={disabled} onClick={!disabled ? onClick : undefined}>
<Text className='secondary-btn__text'>{children}</Text>
</View>
);

View File

@@ -37,14 +37,14 @@
text-align: center;
background: $dan;
color: $white;
font-size: 11px;
font-size: var(--tk-font-micro);
font-weight: 700;
border-radius: $r-pill;
padding: 0 4px;
}
&__label {
font-size: 12px;
font-size: var(--tk-font-cap);
color: $tx2;
}

View File

@@ -35,11 +35,11 @@ const DEFAULT_COLOR_MAP: Record<string, TagColor> = {
};
const COLOR_STYLES: Record<TagColor, { bg: string; color: string }> = {
success: { bg: '#ECFDF5', color: '#5B7A5E' },
warning: { bg: '#FFF7ED', color: '#C4873A' },
error: { bg: '#FEF2F2', color: '#B54A4A' },
info: { bg: '#EFF6FF', color: '#3B82F6' },
default: { bg: '#F5F5F4', color: '#78716C' },
success: { bg: '#E8F0E8', color: '#5B7A5E' },
warning: { bg: '#FFF3E0', color: '#C4873A' },
error: { bg: '#FDEAEA', color: '#B54A4A' },
info: { bg: '#E3F0FA', color: '#4A7AB5' },
default: { bg: '#F0EBE5', color: '#7A756E' },
};
const StatusTag: React.FC<StatusTagProps> = ({

View File

@@ -44,7 +44,7 @@
&__subtitle {
display: block;
font-size: 12px;
font-size: var(--tk-font-cap);
color: $tx3;
margin-top: 2px;
overflow: hidden;

View File

@@ -0,0 +1,129 @@
import { useRef, useCallback } from 'react';
import Taro from '@tarojs/taro';
import { useDidShow, useDidHide } from '@tarojs/taro';
import { requestUnlimited } from '@/services/request';
import { useAuthStore } from '@/stores/auth';
import type { Alert } from '@/services/alert';
interface PollState {
generation: number;
timer: ReturnType<typeof setTimeout> | null;
failCount: number;
lastAlertCount: number;
}
const POLL_INTERVAL = 10_000;
const MAX_FAILURES = 10;
const FAIL_BACKOFF_BASE = 2000;
const FAIL_BACKOFF_MAX = 30_000;
/**
* App 级告警长轮询。
*
* - 登录态 + 有 patientId 时启动
* - 使用 requestUnlimited 走独立通道,不占并发槽位
* - generation counter 防止重叠
* - useDidShow/Hide 控制前后台
* - critical 告警弹窗提醒 + TabBar 角标
*/
export function useAlertPolling() {
const stateRef = useRef<PollState>({
generation: 0,
timer: null,
failCount: 0,
lastAlertCount: 0,
});
const patientId = useAuthStore((s) => s.currentPatient?.id);
const isLoggedIn = useAuthStore((s) => !!s.user);
const enabled = isLoggedIn && !!patientId;
const poll = useCallback(async (gen: number, failCount: number) => {
const s = stateRef.current;
if (gen !== s.generation) return;
if (failCount >= MAX_FAILURES) return;
try {
const pid = useAuthStore.getState().currentPatient?.id;
if (!pid) return;
const res = await requestUnlimited<{ data: Alert[]; total: number }>(
'GET',
`/health/alerts?patient_id=${pid}&status=pending&severity=critical&page=1&page_size=5`,
undefined,
8000,
);
if (gen !== s.generation) return;
const count = res.total ?? 0;
// TabBar 角标
try {
if (count > 0) {
Taro.setTabBarBadge({ index: 2, text: String(count) });
} else {
Taro.removeTabBarBadge({ index: 2 });
}
} catch { /* TabBar 可能不存在 */ }
// 告警数量增加时弹窗提醒
if (count > s.lastAlertCount) {
Taro.showModal({
title: '健康告警',
content: `您有 ${count} 条待处理的危急值告警,请及时关注`,
showCancel: false,
confirmText: '知道了',
});
}
s.lastAlertCount = count;
failCount = 0;
} catch (err) {
// 权限不足时立即停止轮询,不再重试(避免反复弹 toast
if (err instanceof Error && err.message === '权限不足') {
s.failCount = MAX_FAILURES;
return;
}
// 网络异常时快速累积失败计数(离线抑制下会在 3s 内快速耗尽重试)
failCount += 3;
}
if (gen !== s.generation) return;
const delay = failCount > 0 ? Math.min(failCount * FAIL_BACKOFF_BASE, FAIL_BACKOFF_MAX) : POLL_INTERVAL;
s.timer = setTimeout(() => {
if (gen === s.generation) poll(gen, failCount);
}, delay);
}, []);
const start = useCallback(() => {
const s = stateRef.current;
s.generation++;
s.failCount = 0;
if (s.timer) { clearTimeout(s.timer); s.timer = null; }
poll(s.generation, 0);
}, [poll]);
const stop = useCallback(() => {
const s = stateRef.current;
s.generation++;
if (s.timer) { clearTimeout(s.timer); s.timer = null; }
try { Taro.removeTabBarBadge({ index: 2 }); } catch { /* ignore */ }
}, []);
useDidShow(() => {
if (enabled) start();
});
useDidHide(() => {
stop();
});
// enabled 变化时启停
const prevEnabledRef = useRef(enabled);
if (enabled !== prevEnabledRef.current) {
prevEnabledRef.current = enabled;
if (enabled) start();
else stop();
}
}

View File

@@ -0,0 +1,89 @@
import { useMemo } from 'react';
import {
TOKEN_VALUES,
ELDER_TOKEN_OVERRIDES,
DOCTOR_TOKEN_OVERRIDES,
CANVAS_FONT_NORMAL,
CANVAS_FONT_ELDER,
} from '@/styles/token-values';
import { useUIStore } from '@/stores/ui';
import { useAuthStore } from '@/stores/auth';
/** Canvas 绘制用的合并 Token */
export interface CanvasTokens {
pri: string;
tx: string;
tx2: string;
gridColor: string;
fontH1: number;
fontBody: number;
fontBodySm: number;
fontCap: number;
yLabelFontSize: number;
xLabelFontSize: number;
tooltipFontSize: number;
pointNormalRadius: number;
pointAbnormalRadius: number;
referenceBandColor: string;
areaGradientStart: string;
areaGradientEnd: string;
lineColor: string;
abnormalColor: string;
}
function pxToInt(val: string | undefined, fallback: number): number {
if (!val) return fallback;
return parseInt(val, 10) || fallback;
}
/** 为 Canvas 组件提供适老化/医生端的 Token 值 */
export function useCanvasTokens(): CanvasTokens {
const mode = useUIStore((s) => s.mode);
const isDoctor = useAuthStore((s) => s.isDoctor());
return useMemo(() => {
const tokens = TOKEN_VALUES as Record<string, string>;
const docTokens = DOCTOR_TOKEN_OVERRIDES as Record<string, string>;
const elderTokens = ELDER_TOKEN_OVERRIDES as Record<string, string>;
let pri = tokens['pri'] || '#C4623A';
const tx = '#2D2A26';
if (isDoctor) {
pri = docTokens['pri'] || pri;
}
const isElder = mode === 'elder';
const fontSet = isElder ? CANVAS_FONT_ELDER : CANVAS_FONT_NORMAL;
return {
pri,
tx,
tx2: isElder ? '#5A554F' : (tokens['text-secondary'] || '#78716C'),
gridColor: isElder ? '#E0DBD5' : '#F3F4F6',
fontH1: pxToInt(tokens['font-h1'], 28),
fontBody: pxToInt(
isElder ? elderTokens['font-body'] : tokens['font-body'],
isElder ? 22 : 16,
),
fontBodySm: pxToInt(
isElder ? elderTokens['font-body-sm'] : tokens['font-body-sm'],
isElder ? 19 : 14,
),
fontCap: pxToInt(
isElder ? elderTokens['font-cap'] : tokens['font-cap'],
isElder ? 18 : 13,
),
yLabelFontSize: fontSet.yLabel,
xLabelFontSize: fontSet.xLabel,
tooltipFontSize: fontSet.tooltip,
pointNormalRadius: fontSet.pointNormal,
pointAbnormalRadius: fontSet.pointAbnormal,
referenceBandColor: isElder ? 'rgba(5,150,105,0.15)' : 'rgba(5,150,105,0.08)',
areaGradientStart: isElder ? 'rgba(8,145,178,0.20)' : 'rgba(8,145,178,0.15)',
areaGradientEnd: 'rgba(8,145,178,0.01)',
lineColor: '#0891B2',
abnormalColor: '#DC2626',
};
}, [mode, isDoctor]);
}

View File

@@ -55,7 +55,8 @@ export function useLongPolling<T>({
onDataRef.current(data);
}
failCount = 0;
} catch {
} catch (err) {
console.warn('[long-polling] 轮询失败:', err);
failCount++;
}
if (gen !== generation.current || !mountedRef.current) return;

View File

@@ -0,0 +1,43 @@
import { useCallback } from 'react';
import Taro from '@tarojs/taro';
import { useAuthStore } from '@/stores/auth';
const NAV_STATE_KEY = 'doctor_last_page';
const DOCTOR_PAGES = [
'/pages/pkg-doctor-core/index',
'/pages/pkg-doctor-core/patients/index',
'/pages/pkg-doctor-core/consultation/index',
'/pages/pkg-doctor-core/followup/index',
'/pages/pkg-doctor-core/action-inbox/index',
];
export function saveDoctorPage(path: string): void {
if (!path.startsWith('/pages/pkg-doctor')) return;
try {
Taro.setStorageSync(NAV_STATE_KEY, path);
} catch { /* ignore */ }
}
export function getDoctorLastPage(): string {
try {
const saved = Taro.getStorageSync(NAV_STATE_KEY);
if (saved && typeof saved === 'string' && saved.startsWith('/pages/pkg-doctor')) {
return saved;
}
} catch { /* ignore */ }
return DOCTOR_PAGES[0];
}
export function useNavigationState() {
const isDoctor = useAuthStore((s) => s.isDoctor);
const navigateToDoctorHome = useCallback(() => {
if (!isDoctor()) return false;
const lastPage = getDoctorLastPage();
Taro.navigateTo({ url: lastPage });
return true;
}, [isDoctor]);
return { navigateToDoctorHome, saveDoctorPage, getDoctorLastPage };
}

View File

@@ -1,5 +1,5 @@
import { useRef, useState, useCallback } from 'react';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import { useRef, useState, useCallback, useEffect } from 'react';
import Taro, { useDidShow, useDidHide, usePullDownRefresh } from '@tarojs/taro';
interface UsePageDataOptions {
throttleMs?: number;
@@ -14,7 +14,7 @@ interface UsePageDataResult {
}
export function usePageData(
fetcher: () => Promise<void>,
fetcher: (signal?: AbortSignal) => Promise<void>,
options?: UsePageDataOptions,
): UsePageDataResult {
const throttleMs = options?.throttleMs ?? 5000;
@@ -26,6 +26,14 @@ export function usePageData(
const lastRunRef = useRef(0);
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
const abortRef = useRef<AbortController | null>(null);
const abort = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
}, []);
const run = useCallback(async (force = false) => {
if (!enabled || loadingRef.current) return;
@@ -33,11 +41,15 @@ export function usePageData(
loadingRef.current = true;
setLoading(true);
lastRunRef.current = Date.now();
abort();
const ac = new AbortController();
abortRef.current = ac;
try {
await fetcherRef.current();
await fetcherRef.current(ac.signal);
} finally {
loadingRef.current = false;
setLoading(false);
if (abortRef.current === ac) abortRef.current = null;
}
}, [enabled, throttleMs]);
@@ -45,6 +57,16 @@ export function usePageData(
run();
});
useDidHide(() => {
abort();
});
useEffect(() => {
return () => {
abort();
};
}, []);
const trigger = useCallback(() => {
run(true);
}, [run]);
@@ -54,11 +76,15 @@ export function usePageData(
loadingRef.current = true;
setLoading(true);
lastRunRef.current = Date.now();
abort();
const ac = new AbortController();
abortRef.current = ac;
try {
await fetcherRef.current();
await fetcherRef.current(ac.signal);
} finally {
loadingRef.current = false;
setLoading(false);
if (abortRef.current === ac) abortRef.current = null;
}
}, []);

View File

@@ -32,8 +32,8 @@ export function usePagination<T>(
setTotal(res.total);
setHasMore(items.length >= pageSize);
pageRef.current += 1;
} catch {
// 错误由调用方处理
} catch (err) {
console.warn('[pagination] 加载分页数据失败:', err);
} finally {
loadingRef.current = false;
setLoading(false);
@@ -53,8 +53,8 @@ export function usePagination<T>(
setTotal(res.total);
setHasMore(items.length >= pageSize);
pageRef.current = 2;
} catch {
// 错误由调用方处理
} catch (err) {
console.warn('[pagination] 加载分页数据失败:', err);
} finally {
loadingRef.current = false;
setLoading(false);

View File

@@ -0,0 +1,8 @@
"use strict";function e(t){"@babel/helpers - typeof";return(e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(t)}function t(e,t,o){return(t=n(t))in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e}function n(t){var n=o(t,"string");return"symbol"==e(n)?n:n+""}function o(t,n){if("object"!=e(t)||!t)return t;var o=t[Symbol.toPrimitive];if(void 0!==o){var i=o.call(t,n||"default");if("object"!=e(i))return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===n?String:Number)(t)}/*!
* mp-html v2.5.2
* https://github.com/jin-yufeng/mp-html
*
* Released under the MIT license
* Author: Jin Yufeng
*/
var i=require("./parser"),r=[];Component({data:{nodes:[]},properties:{containerStyle:String,content:{type:String,value:"",observer:function(e){this.setContent(e)}},copyLink:{type:Boolean,value:!0},domain:String,errorImg:String,lazyLoad:Boolean,loadingImg:String,pauseVideo:{type:Boolean,value:!0},previewImg:{type:null,value:!0},scrollTable:Boolean,selectable:null,setTitle:{type:Boolean,value:!0},showImgMenu:{type:Boolean,value:!0},tagStyle:Object,useAnchor:null},created:function(){this.plugins=[];for(var e=r.length;e--;)this.plugins.push(new r[e](this))},detached:function(){this._hook("onDetached")},methods:{in:function(e,t,n){e&&t&&n&&(this._in={page:e,selector:t,scrollTop:n})},navigateTo:function(e,n){var o=this;return new Promise(function(i,r){if(!o.data.useAnchor)return void r(Error("Anchor is disabled"));var a=wx.createSelectorQuery().in(o._in?o._in.page:o).select((o._in?o._in.selector:"._root")+(e?"".concat(">>>","#").concat(e):"")).boundingClientRect();o._in?a.select(o._in.selector).scrollOffset().select(o._in.selector).boundingClientRect():a.selectViewport().scrollOffset(),a.exec(function(e){if(!e[0])return void r(Error("Label not found"));var a=e[1].scrollTop+e[0].top-(e[2]?e[2].top:0)+(n||parseInt(o.data.useAnchor)||0);o._in?o._in.page.setData(t({},o._in.scrollTop,a)):wx.pageScrollTo({scrollTop:a,duration:300}),i()})})},getText:function(e){var t="";return function e(n){for(var o=0;o<n.length;o++){var i=n[o];if("text"===i.type)t+=i.text.replace(/&amp;/g,"&");else if("br"===i.name)t+="\n";else{var r="p"===i.name||"div"===i.name||"tr"===i.name||"li"===i.name||"h"===i.name[0]&&i.name[1]>"0"&&i.name[1]<"7";r&&t&&"\n"!==t[t.length-1]&&(t+="\n"),i.children&&e(i.children),r&&"\n"!==t[t.length-1]?t+="\n":"td"!==i.name&&"th"!==i.name||(t+="\t")}}}(e||this.data.nodes),t},getRect:function(){var e=this;return new Promise(function(t,n){wx.createSelectorQuery().in(e).select("._root").boundingClientRect().exec(function(e){return e[0]?t(e[0]):n(Error("Root label not found"))})})},pauseMedia:function(){for(var e=(this._videos||[]).length;e--;)this._videos[e].pause()},setPlaybackRate:function(e){this.playbackRate=e;for(var t=(this._videos||[]).length;t--;)this._videos[t].playbackRate(e)},setContent:function(e,t){var n=this;this.imgList&&t||(this.imgList=[]),this._videos=[];var o={},r=new i(this).parse(e);if(t)for(var a=this.data.nodes.length,s=r.length;s--;)o["nodes[".concat(a+s,"]")]=r[s];else o.nodes=r;if(this.setData(o,function(){n._hook("onLoad"),n.triggerEvent("load")}),this.data.lazyLoad||this.imgList._unloadimgs<this.imgList.length/2){var l=0,c=function(e){e&&e.height||(e={}),e.height===l?n.triggerEvent("ready",e):(l=e.height,setTimeout(function(){n.getRect().then(c).catch(c)},350))};this.getRect().then(c).catch(c)}else this.imgList._unloadimgs||this.getRect().then(function(e){n.triggerEvent("ready",e)}).catch(function(){n.triggerEvent("ready",{})})},_hook:function(e){for(var t=r.length;t--;)this.plugins[t][e]&&this.plugins[t][e]()},_add:function(e){e.detail.root=this}}});

View File

@@ -0,0 +1 @@
{"component":true,"usingComponents":{"node":"./node/node"}}

View File

@@ -0,0 +1 @@
<view class="_root {{selectable?'_select':''}}" style="{{containerStyle}}"><slot wx:if="{{!nodes[0]}}"/><node id="_root" childs="{{nodes}}" opts="{{[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]}}" catchadd="_add"/></view>

View File

@@ -0,0 +1 @@
._root{padding:1px 0;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch}._select{-webkit-user-select:text;user-select:text}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"component":true,"usingComponents":{"node":"./node"}}

View File

@@ -0,0 +1 @@
<wxs module="isInline">var e={abbr:!0,b:!0,big:!0,code:!0,del:!0,em:!0,i:!0,ins:!0,label:!0,q:!0,small:!0,span:!0,strong:!0,sub:!0,sup:!0};module.exports=function(n,i){return e[n]||-1!==(i||"").indexOf("inline")};</wxs><template name="el"><block wx:if="{{n.name==='img'}}"><rich-text wx:if="{{n.t}}" style="display:{{n.t}}" nodes="<img class='_img' style='{{n.attrs.style}}' src='{{n.attrs.src}}'>" data-i="{{i}}" catchtap="imgTap"/><block wx:else><image wx:if="{{(opts[1]&&!ctrl[i])||ctrl[i]<0}}" class="_img" style="{{n.attrs.style}}" src="{{ctrl[i]<0?opts[2]:opts[1]}}" mode="widthFix"/><image id="{{n.attrs.id}}" class="_img {{n.attrs.class}}" style="{{ctrl[i]===-1?'display:none;':''}}width:{{ctrl[i]||1}}px;height:1px;{{n.attrs.style}}" src="{{n.attrs.src}}" mode="{{!n.h?'widthFix':(!n.w?'heightFix':(n.m||'scaleToFill'))}}" lazy-load="{{opts[0]}}" webp="{{n.webp}}" show-menu-by-longpress="{{opts[3]&&!n.attrs.ignore}}" data-i="{{i}}" bindload="imgLoad" binderror="mediaError" catchtap="imgTap" bindlongpress="noop"/></block></block><text wx:elif="{{n.text}}" user-select="{{opts[4]=='force'&&isiOS}}" decode>{{n.text}}</text><text wx:elif="{{n.name==='br'}}">{{'\n'}}</text><view wx:elif="{{n.name==='a'}}" id="{{n.attrs.id}}" class="{{n.attrs.href?'_a ':''}}{{n.attrs.class}}" hover-class="_hover" style="display:inline;{{n.attrs.style}}" data-i="{{i}}" catchtap="linkTap"><node childs="{{n.children}}" opts="{{opts}}" style="display:inherit"/></view><video wx:elif="{{n.name==='video'}}" id="{{n.attrs.id}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" autoplay="{{n.attrs.autoplay}}" controls="{{n.attrs.controls}}" loop="{{n.attrs.loop}}" muted="{{n.attrs.muted}}" object-fit="{{n.attrs['object-fit']}}" poster="{{n.attrs.poster}}" src="{{n.src[ctrl[i]||0]}}" data-i="{{i}}" bindplay="play" bindpause="mediaEvent" bindfullscreenchange="mediaEvent" binderror="mediaError"/><audio wx:elif="{{n.name==='audio'}}" id="{{n.attrs.id}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" author="{{n.attrs.author}}" controls="{{n.attrs.controls}}" loop="{{n.attrs.loop}}" name="{{n.attrs.name}}" poster="{{n.attrs.poster}}" src="{{n.src[ctrl[i]||0]}}" data-i="{{i}}" bindplay="play" bindpause="mediaEvent" binderror="mediaError"/><rich-text wx:else id="{{n.attrs.id}}" style="{{n.f}}" user-select="{{opts[4]}}" nodes="{{[n]}}"/></template><block wx:for="{{nodes}}" wx:for-item="n1" wx:for-index="i1" wx:key="i1"><template wx:if="{{!n1.c&&(!n1.children||n1.name==='a'||!isInline(n1.name,n1.attrs.style))}}" is="el" data="{{n:n1,i:''+i1,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n1.attrs.id}}" class="_{{n1.name}} {{n1.attrs.class}}" style="{{n1.attrs.style}}"><block wx:for="{{n1.children}}" wx:for-item="n2" wx:for-index="i2" wx:key="i2"><template wx:if="{{!n2.c&&(!n2.children||n2.name==='a'||!isInline(n2.name,n2.attrs.style))}}" is="el" data="{{n:n2,i:i1+'_'+i2,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n2.attrs.id}}" class="_{{n2.name}} {{n2.attrs.class}}" style="{{n2.attrs.style}}"><block wx:for="{{n2.children}}" wx:for-item="n3" wx:for-index="i3" wx:key="i3"><template wx:if="{{!n3.c&&(!n3.children||n3.name==='a'||!isInline(n3.name,n3.attrs.style))}}" is="el" data="{{n:n3,i:i1+'_'+i2+'_'+i3,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n3.attrs.id}}" class="_{{n3.name}} {{n3.attrs.class}}" style="{{n3.attrs.style}}"><block wx:for="{{n3.children}}" wx:for-item="n4" wx:for-index="i4" wx:key="i4"><template wx:if="{{!n4.c&&(!n4.children||n4.name==='a'||!isInline(n4.name,n4.attrs.style))}}" is="el" data="{{n:n4,i:i1+'_'+i2+'_'+i3+'_'+i4,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n4.attrs.id}}" class="_{{n4.name}} {{n4.attrs.class}}" style="{{n4.attrs.style}}"><block wx:for="{{n4.children}}" wx:for-item="n5" wx:for-index="i5" wx:key="i5"><template wx:if="{{!n5.c&&(!n5.children||n5.name==='a'||!isInline(n5.name,n5.attrs.style))}}" is="el" data="{{n:n5,i:i1+'_'+i2+'_'+i3+'_'+i4+'_'+i5,opts:opts,ctrl:ctrl}}"/><node wx:else id="{{n5.attrs.id}}" class="_{{n5.name}} {{n5.attrs.class}}" style="{{n5.attrs.style}}" childs="{{n5.children}}" opts="{{opts}}"/></block></view></block></view></block></view></block></view></block>

View File

@@ -0,0 +1 @@
._a{padding:1.5px 0 1.5px 0;color:#366092;word-break:break-all}._hover{text-decoration:underline;opacity:.7}._img{max-width:100%;-webkit-touch-callout:none}._b,._strong{font-weight:700}._code{font-family:monospace}._del{text-decoration:line-through}._em,._i{font-style:italic}._h1{font-size:2em}._h2{font-size:1.5em}._h3{font-size:1.17em}._h5{font-size:.83em}._h6{font-size:.67em}._h1,._h2,._h3,._h4,._h5,._h6{display:block;font-weight:700}._ins{text-decoration:underline}._li{display:list-item}._ol{list-style-type:decimal}._ol,._ul{display:block;padding-left:40px;margin:1em 0}._q::before{content:'"'}._q::after{content:'"'}._sub{font-size:smaller;vertical-align:sub}._sup{font-size:smaller;vertical-align:super}._tbody,._tfoot,._thead{display:table-row-group}._tr{display:table-row}._td,._th{display:table-cell;vertical-align:middle}._th{font-weight:700;text-align:center}._ul{list-style-type:disc}._ul ._ul{margin:0;list-style-type:circle}._ul ._ul ._ul{list-style-type:square}._abbr,._b,._code,._del,._em,._i,._ins,._label,._q,._span,._strong,._sub,._sup{display:inline}._blockquote,._div,._p{display:block}

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { View, Text, RichText } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
@@ -44,7 +44,8 @@ export default function AiReportDetail() {
try {
const data = await getAiAnalysisDetail(id);
setAnalysis(data);
} catch {
} catch (err) {
console.warn('[ai-report] 加载分析详情失败:', err);
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { safeNavigateTo } from '@/utils/navigate';
@@ -40,7 +40,8 @@ export default function AiReportList() {
setPage(p);
setHasMore(items.length >= 20);
} catch {
} catch (err) {
console.warn('[ai-report] 加载分析列表失败:', err);
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);

View File

@@ -308,7 +308,7 @@
gap: var(--tk-gap-md);
padding: var(--tk-section-gap) var(--tk-gap-lg);
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: calc(var(--tk-page-padding) + env(safe-area-inset-bottom, 0px));
background: $card;
box-shadow: $shadow-md;
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { View, Text, Input } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { listDoctors, createAppointment, calendarView } from '../../../services/appointment';
@@ -37,6 +37,16 @@ interface TimeSlot {
available_count: number;
}
interface ScheduleItem {
date?: string;
appointment_date?: string;
start_time?: string;
end_time?: string;
available_count?: number;
max_appointments?: number;
current_appointments?: number;
}
export default function AppointmentCreate() {
const [currentStep, setCurrentStep] = useState(0);
const [department, setDepartment] = useState('');
@@ -47,7 +57,7 @@ export default function AppointmentCreate() {
const [reason, setReason] = useState('');
const [loading, setLoading] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
const [schedules, setSchedules] = useState<any[]>([]);
const [schedules, setSchedules] = useState<ScheduleItem[]>([]);
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
const modeClass = useElderClass();
@@ -55,7 +65,7 @@ export default function AppointmentCreate() {
const scheduledDates = useMemo(() => {
if (!schedules) return new Set<string>();
return new Set(schedules.map((s: any) => s.date || s.appointment_date));
return new Set(schedules.map((s) => s.date || s.appointment_date || ''));
}, [schedules]);
const onSelectDept = useCallback(async (dept: string) => {
@@ -64,7 +74,8 @@ export default function AppointmentCreate() {
try {
const res = await listDoctors(dept);
setDoctors(res.data || []);
} catch {
} catch (err) {
console.warn('[appointment] 加载医生列表失败:', err);
Taro.showToast({ title: '加载医生失败', icon: 'none' });
}
}, []);
@@ -79,8 +90,8 @@ export default function AppointmentCreate() {
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const res = await calendarView(fmt(today), fmt(endDate), doctor.id);
setSchedules(res || []);
} catch {
// 日历加载失败不阻塞
} catch (err) {
console.warn('[appointment] 加载日历排班失败:', err);
} finally {
setLoading(false);
}
@@ -90,12 +101,12 @@ export default function AppointmentCreate() {
setAppointmentDate(date);
setTimeSlot('');
const daySlots = schedules
.filter((s: any) => (s.date || s.appointment_date) === date)
.map((s: any) => ({
.filter((s) => (s.date || s.appointment_date) === date)
.map((s) => ({
start_time: s.start_time || '',
end_time: s.end_time || '',
label: `${s.start_time || ''}-${s.end_time || ''}`,
available_count: s.available_count ?? (s.max_appointments - (s.current_appointments || 0)),
available_count: s.available_count ?? (s.max_appointments ?? 0) - (s.current_appointments ?? 0),
}));
setTimeSlots(daySlots);
}, [schedules]);
@@ -121,19 +132,19 @@ export default function AppointmentCreate() {
appointment_date: appointmentDate,
start_time: selectedSlot?.start_time || timeSlot,
end_time: selectedSlot?.end_time || timeSlot,
reason: reason.trim() || undefined,
notes: reason.trim() || undefined,
});
Taro.showToast({ title: '预约成功', icon: 'success' });
trackEvent('appointment_create', { doctor_id: selectedDoctor.id, date: appointmentDate });
const tmplId = TEMPLATE_IDS.APPOINTMENT_REMINDER;
if (tmplId) {
try {
await (Taro.requestSubscribeMessage as any)({ tmplIds: [tmplId] });
await (Taro as { requestSubscribeMessage: (opts: { tmplIds: string[] }) => Promise<unknown> }).requestSubscribeMessage({ tmplIds: [tmplId] });
} catch { /* 用户拒绝 */ }
}
safeSetTimeout(() => Taro.navigateBack(), 1500);
} catch (err: any) {
const msg = err?.message || '预约失败';
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '预约失败';
Taro.showToast({ title: msg.length > 20 ? msg.slice(0, 20) : msg, icon: 'none' });
} finally {
setLoading(false);
@@ -271,6 +282,7 @@ export default function AppointmentCreate() {
className='form-input'
placeholder='请简要描述症状'
value={reason}
maxlength={200}
onInput={(e) => setReason(e.detail.value)}
/>
</View>

View File

@@ -153,7 +153,7 @@
right: 0;
padding: var(--tk-section-gap) var(--tk-gap-lg);
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: calc(var(--tk-page-padding) + env(safe-area-inset-bottom, 0px));
background: $card;
box-shadow: $shadow-md;
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { getAppointment, cancelAppointment } from '../../../services/appointment';
@@ -61,7 +61,8 @@ export default function AppointmentDetail() {
await cancelAppointment(appointment.id, appointment.version);
Taro.showToast({ title: '已取消预约', icon: 'success' });
safeSetTimeout(() => Taro.navigateBack(), 1500);
} catch {
} catch (err) {
console.warn('[appointment] 取消预约失败:', err);
Taro.showToast({ title: '取消失败', icon: 'none' });
} finally {
setCancelling(false);

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useReachBottom } from '@tarojs/taro';
import { safeNavigateTo } from '@/utils/navigate';
@@ -38,7 +38,7 @@ export default function AppointmentList() {
const fetchData = useCallback(async (pageNum: number, isRefresh = false) => {
setLoading(true);
try {
const res = await listAppointments(pageNum);
const res = await listAppointments(undefined, pageNum);
const list = res.data || [];
if (isRefresh) {
setAppointments(list);
@@ -47,7 +47,8 @@ export default function AppointmentList() {
}
setTotal(res.total);
setPage(pageNum);
} catch {
} catch (err) {
console.warn('[appointment] 加载预约列表失败:', err);
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '文章详情',
usingComponents: {
'mp-html': '../../../native-components/mp-html/index',
},
});

View File

@@ -1,19 +1,21 @@
@import '../../../styles/variables.scss';
// 文章详情页 — 对齐原型 docs/design/mp-04-article-report.html → ArticleDetail
// 文章详情页 — 阅读优化排版
.article-detail-page {
padding-bottom: 80px;
padding-bottom: 100px;
background: $card;
}
.article-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-h2);
font-size: var(--tk-font-h1);
font-weight: 700;
color: $tx;
display: block;
line-height: 1.4;
margin-bottom: var(--tk-gap-sm);
line-height: 1.35;
margin-bottom: var(--tk-gap-md);
letter-spacing: 0.5px;
}
.article-meta {
@@ -21,36 +23,38 @@
gap: var(--tk-gap-md);
font-size: var(--tk-font-cap);
color: $tx3;
margin-bottom: var(--tk-gap-md);
margin-bottom: var(--tk-gap-lg);
align-items: center;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.meta-icon {
font-size: 13px;
line-height: 1;
}
.article-divider {
height: 1px;
background: $bd-l;
background: linear-gradient(90deg, $bd-l, $bd, $bd-l);
margin-bottom: var(--tk-section-gap);
}
.article-body {
font-size: 15px;
// RichText 内部样式由 formatArticleHtml 内联注入
// 这里只控制容器间距
font-size: var(--tk-font-body);
color: $tx2;
line-height: 1.8;
// RichText 内部样式
h1, h2, h3 {
font-weight: bold;
// 兜底:万一内联样式未生效的标签
h1, h2, h3, h4, h5, h6 {
color: $tx;
margin: var(--tk-gap-lg) 0 var(--tk-gap-sm);
}
p {
margin-bottom: var(--tk-gap-md);
text-indent: 2em;
}
img {
max-width: 100%;
border-radius: $r-sm;
margin: var(--tk-gap-sm) 0;
line-height: 1.4;
}
}
@@ -59,31 +63,40 @@
bottom: 0;
left: 0;
right: 0;
height: 60px;
height: 64px;
background: $card;
border-top: 1px solid $bd-l;
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
gap: 48px;
padding: 0 var(--tk-page-padding);
z-index: 10;
// 安全区适配
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -2px 12px rgba(45, 42, 38, 0.06);
}
.article-action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
gap: 4px;
min-width: $touch-min;
min-height: $touch-min;
justify-content: center;
border-radius: $r-sm;
transition: background var(--tk-duration-fast);
&:active {
background: $surface-alt;
opacity: var(--tk-touch-feedback-opacity);
}
}
.article-action-icon {
font-size: 20px;
color: $tx3;
font-size: 22px;
color: $tx2;
line-height: 1;
}
@@ -105,3 +118,29 @@
font-size: var(--tk-font-body);
color: $tx3;
}
// ─── 关怀模式覆盖 ───
.elder-mode {
.article-title {
font-size: var(--tk-font-h1);
line-height: 1.3;
}
.article-body {
font-size: var(--tk-font-body);
line-height: 2;
}
.article-bottom-bar {
height: 72px;
gap: 56px;
}
.article-action-icon {
font-size: 26px;
}
.article-action-text {
font-size: var(--tk-font-body-sm);
}
}

View File

@@ -1,13 +1,13 @@
import React, { useState, useCallback } from 'react';
import { View, Text, RichText } from '@tarojs/components';
import { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { getArticleDetail, getPublicArticleDetail, Article } from '../../../services/article';
import { trackEvent } from '@/services/analytics';
import { sanitizeHtml } from '@/utils/sanitize-html';
import { useElderClass } from '../../../hooks/useElderClass';
import { useAuthStore } from '../../../stores/auth';
import PageShell from '@/components/ui/PageShell';
import RichArticle from '@/components/RichArticle';
import './index.scss';
export default function ArticleDetail() {
@@ -34,7 +34,8 @@ export default function ArticleDetail() {
const fetcher = user ? getArticleDetail(id) : getPublicArticleDetail(id);
const data = await fetcher;
setArticle(data);
} catch {
} catch (err) {
console.warn('[article] 加载文章详情失败:', err);
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
@@ -76,14 +77,24 @@ export default function ArticleDetail() {
<Text className='article-title'>{article.title}</Text>
<View className='article-meta'>
{article.author && <Text>{article.author}</Text>}
{article.published_at && <Text>{article.published_at.slice(0, 10)}</Text>}
{article.author && (
<View className='meta-item'>
<Text className='meta-icon'></Text>
<Text>{article.author}</Text>
</View>
)}
{article.published_at && (
<View className='meta-item'>
<Text className='meta-icon'>📅</Text>
<Text>{article.published_at.slice(0, 10)}</Text>
</View>
)}
</View>
<View className='article-divider' />
<View className='article-body'>
<RichText nodes={sanitizeHtml(article.content || '')} />
<RichArticle html={article.content || ''} />
</View>
<View className='article-bottom-bar'>

View File

@@ -3,7 +3,13 @@ import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { safeNavigateTo } from '@/utils/navigate';
import { usePageData } from '@/hooks/usePageData';
import { listArticles, listCategories } from '../../services/article';
import { useAuthStore } from '@/stores/auth';
import {
listArticles,
listCategories,
listPublicArticles,
listPublicCategories,
} from '../../services/article';
import PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard';
import LoadingCard from '@/components/ui/LoadingCard';
@@ -33,9 +39,10 @@ interface ArticleCategory {
export default function ArticleList() {
const modeClass = useElderClass();
const isLoggedIn = !!useAuthStore((s) => s.user);
const [articles, setArticles] = useState<ArticleItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [, setPage] = useState(1);
const [, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [categories, setCategories] = useState<ArticleCategory[]>([]);
@@ -46,32 +53,35 @@ export default function ArticleList() {
setError(false);
try {
const cid = categoryId !== undefined ? categoryId : activeCategory;
const res = await listArticles({
page: p,
category_id: cid || undefined,
});
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 {
} catch (err) {
console.warn('[article] 加载文章列表失败:', err);
setError(true);
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
}, [activeCategory]);
}, [activeCategory, isLoggedIn]);
usePageData(
useCallback(async () => {
try {
const cats = await listCategories();
const cats = isLoggedIn
? await listCategories()
: await listPublicCategories();
setCategories(cats || []);
} catch {
} catch (err) {
console.warn('[article] 加载分类失败:', err);
setCategories([]);
}
await fetchData(1);
}, [fetchData]),
}, [fetchData, isLoggedIn]),
{ throttleMs: 10000, enablePullDown: true },
);

View File

@@ -0,0 +1,79 @@
@import '../../../styles/variables.scss';
.consult-create {
padding: var(--tk-gap-xl);
&__section {
margin-bottom: var(--tk-gap-lg);
}
&__label {
font-size: var(--tk-font-body-sm);
color: $tx2;
margin-bottom: var(--tk-gap-xs);
display: block;
}
&__picker {
display: flex;
align-items: center;
justify-content: space-between;
background: $card;
border: 1px solid $bd;
border-radius: $r-sm;
padding: var(--tk-gap-md) var(--tk-gap-lg);
}
&__picker-text {
font-size: var(--tk-font-body);
color: $tx;
}
&__picker-arrow {
font-size: var(--tk-font-body-lg);
color: $tx3;
}
&__textarea {
width: 100%;
min-height: 120px;
padding: var(--tk-gap-md);
font-size: var(--tk-font-body);
background: $card;
border: 1px solid $bd;
border-radius: $r-sm;
box-sizing: border-box;
}
&__hint {
margin: var(--tk-gap-xl) 0;
padding: var(--tk-gap-md);
background: $surface-alt;
border-radius: $r-sm;
}
&__hint-text {
font-size: var(--tk-font-cap);
color: $tx2;
line-height: 1.6;
}
&__submit {
background: $pri;
border-radius: $r;
padding: var(--tk-gap-lg);
text-align: center;
margin-top: var(--tk-gap-2xl);
box-shadow: $shadow-md;
&--disabled {
opacity: 0.5;
}
}
&__submit-text {
font-size: var(--tk-font-body-lg);
color: $white;
font-weight: 600;
}
}

View File

@@ -0,0 +1,154 @@
import { useState } from 'react';
import { View, Text, Input, Picker } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { createSession } from '@/services/consultation';
import { listDoctors } from '@/services/appointment';
import { useAuthStore } from '@/stores/auth';
import PageShell from '@/components/ui/PageShell';
import { useElderClass } from '@/hooks/useElderClass';
import './index.scss';
const CONSULTATION_TYPES = ['general', 'follow_up', 'urgent'];
const TYPE_LABELS: Record<string, string> = {
general: '普通咨询',
follow_up: '随访咨询',
urgent: '紧急咨询',
};
interface DoctorOption {
id: string;
name: string;
}
export default function ConsultationCreate() {
const currentPatient = useAuthStore((s) => s.currentPatient);
const [doctorList, setDoctorList] = useState<DoctorOption[]>([]);
const [selectedDoctorIdx, setSelectedDoctorIdx] = useState(-1);
const [typeIdx, setTypeIdx] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [doctorsLoaded, setDoctorsLoaded] = useState(false);
const [description, setDescription] = useState('');
const modeClass = useElderClass();
const loadDoctors = async () => {
if (doctorsLoaded) return;
try {
const res = await listDoctors();
const items = (res.data || []).map((d: { id: string; name: string }) => ({ id: d.id, name: d.name }));
setDoctorList(items);
setDoctorsLoaded(true);
} catch (err) {
console.warn('[consultation] 加载医生列表失败:', err);
Taro.showToast({ title: '加载医生列表失败', icon: 'none' });
}
};
const handleSubmit = async () => {
if (!currentPatient?.id) {
Taro.showToast({ title: '请先完善患者档案', icon: 'none' });
return;
}
if (submitting) return;
if (!description.trim()) {
const { confirm } = await Taro.showModal({
title: '提示',
content: '建议描述症状以便医生更快响应,是否继续?',
confirmText: '继续提交',
cancelText: '去填写',
});
if (!confirm) return;
}
setSubmitting(true);
try {
const session = await createSession({
patient_id: currentPatient.id,
doctor_id: selectedDoctorIdx >= 0 ? doctorList[selectedDoctorIdx]?.id : undefined,
consultation_type: CONSULTATION_TYPES[typeIdx],
description: description.trim() || undefined,
});
Taro.showToast({ title: '创建成功', icon: 'success' });
setTimeout(() => {
Taro.redirectTo({ url: `/pages/pkg-consultation/detail/index?id=${session.id}` });
}, 500);
} catch (err) {
console.warn('[consultation] 创建会话失败:', err);
Taro.showToast({ title: '创建失败,请重试', icon: 'none' });
} finally {
setSubmitting(false);
}
};
const doctorNames = doctorList.map((d) => d.name);
const typeLabels = CONSULTATION_TYPES.map((t) => TYPE_LABELS[t] || t);
return (
<PageShell scroll={false}>
<View className={`consult-create ${modeClass}`}>
<View className='consult-create__section'>
<Text className='consult-create__label'></Text>
<Picker mode='selector' range={typeLabels} value={typeIdx} onChange={(e) => setTypeIdx(Number(e.detail.value))}>
<View className='consult-create__picker'>
<Text className='consult-create__picker-text'>{typeLabels[typeIdx]}</Text>
<Text className='consult-create__picker-arrow'></Text>
</View>
</Picker>
</View>
<View className='consult-create__section'>
<Text className='consult-create__label'></Text>
<Picker
mode='selector'
range={doctorNames.length > 0 ? doctorNames : ['点击加载医生列表']}
value={selectedDoctorIdx >= 0 ? selectedDoctorIdx : 0}
onChange={(e) => {
if (!doctorsLoaded) {
loadDoctors();
return;
}
setSelectedDoctorIdx(Number(e.detail.value));
}}
onClick={() => { if (!doctorsLoaded) loadDoctors(); }}
>
<View className='consult-create__picker'>
<Text className='consult-create__picker-text'>
{selectedDoctorIdx >= 0 && doctorsLoaded
? doctorNames[selectedDoctorIdx]
: '不指定(系统分配)'}
</Text>
<Text className='consult-create__picker-arrow'></Text>
</View>
</Picker>
</View>
<View className='consult-create__section'>
<Text className='consult-create__label'></Text>
<Input
className='consult-create__textarea'
type='text'
placeholder='请描述您的症状或问题'
value={description}
onInput={(e) => setDescription(e.detail.value)}
maxlength={500}
/>
</View>
<View className='consult-create__hint'>
<Text className='consult-create__hint-text'>
</Text>
</View>
<View
className={`consult-create__submit ${submitting ? 'consult-create__submit--disabled' : ''}`}
onClick={handleSubmit}
>
<Text className='consult-create__submit-text'>
{submitting ? '创建中...' : '发起咨询'}
</Text>
</View>
</View>
</PageShell>
);
}

View File

@@ -138,3 +138,24 @@
color: $white;
font-weight: 600;
}
/* ─── 告警上下文横幅 ─── */
.consultation-alert-banner {
margin-bottom: var(--tk-gap-sm);
}
.consultation-alert-banner-inner {
display: flex;
align-items: center;
gap: var(--tk-gap-xs);
}
.consultation-alert-banner-icon {
font-size: var(--tk-font-body-lg);
}
.consultation-alert-banner-text {
font-size: var(--tk-font-body-sm);
color: var(--tk-text-secondary);
flex: 1;
}

View File

@@ -16,6 +16,25 @@ import GuestGuard from '@/components/GuestGuard';
import { useElderClass } from '../../hooks/useElderClass';
import './index.scss';
/** 读取当前页面 URL 中的查询参数 */
function getQueryParams(): Record<string, string> {
try {
const instance = Taro.getCurrentInstance();
const params = instance?.router?.params;
if (!params) return {};
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
if (typeof value === 'string') {
result[key] = value;
}
}
return result;
} catch (err) {
console.warn('[consultation] 解析查询参数失败:', err);
return {};
}
}
function formatTime(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
@@ -59,6 +78,9 @@ export default function Consultation() {
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
// Alert context: when navigated from an alert page
const [alertContext, setAlertContext] = useState<{ alertId: string; alertTitle: string } | null>(null);
const loadSessions = useCallback(async (pageNum: number, isRefresh = false) => {
if (isRefresh) setLoading(true);
setError('');
@@ -72,7 +94,8 @@ export default function Consultation() {
}
setTotal(resp.total || 0);
setPage(pageNum);
} catch {
} catch (err) {
console.warn('[consultation] 加载会话列表失败:', err);
if (isRefresh) {
setSessions([]);
setTotal(0);
@@ -87,6 +110,14 @@ export default function Consultation() {
usePageData(
useCallback(async () => {
Taro.setNavigationBarTitle({ title: '在线咨询' });
// Read alert context from URL query params
const params = getQueryParams();
if (params.context === 'alert' && params.alert_id) {
setAlertContext({
alertId: params.alert_id,
alertTitle: params.alert_title ? decodeURIComponent(params.alert_title) : '健康告警',
});
}
if (!user) return;
await loadSessions(1, true);
}, [user, loadSessions]),
@@ -128,6 +159,18 @@ export default function Consultation() {
{/* 副标题 */}
<Text className='consultation-subtitle'></Text>
{/* Alert context banner */}
{alertContext && (
<ContentCard className='consultation-alert-banner'>
<View className='consultation-alert-banner-inner'>
<Text className='consultation-alert-banner-icon'>&#x26A0;</Text>
<Text className='consultation-alert-banner-text'>
: {alertContext.alertTitle}
</Text>
</View>
</ContentCard>
)}
{/* 发起咨询按钮 */}
<View
className='consultation-create-btn'

View File

@@ -2,320 +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-section-gap);
}
.health-title {
@include serif-number;
font-size: var(--tk-font-h1);
font-weight: 700;
color: $tx;
}
/* ─── 录入区 ─── */
.input-section {
margin-bottom: var(--tk-section-gap);
}
.input-group {
margin-bottom: var(--tk-gap-sm);
}
.input-label {
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
display: block;
margin-bottom: var(--tk-gap-2xs);
}
.input-field {
height: 56px;
background: $bg;
border: 2px solid $bd;
border-radius: $r-sm;
padding: 0 var(--tk-gap-md);
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
width: 100%;
box-sizing: border-box;
}
.input-ref {
font-size: var(--tk-font-cap);
color: var(--tk-text-secondary);
display: block;
margin-top: var(--tk-gap-xs);
margin-bottom: var(--tk-gap-2xs);
}
.input-label--secondary {
margin-top: var(--tk-section-gap);
}
/* ─── 血糖时段选择 ─── */
.period-group {
display: flex;
gap: var(--tk-gap-xs);
margin-top: var(--tk-gap-sm);
}
.period-btn {
flex: 1;
height: 48px;
border-radius: $r-sm;
background: $surface-alt;
@include flex-center;
&.period-active {
background: var(--tk-pri);
.period-btn-text {
color: $white;
}
}
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.period-btn-text {
font-size: var(--tk-font-cap);
font-weight: 600;
color: $tx2;
}
/* ─── 保存按钮 ─── */
.save-btn {
width: 100%;
height: 52px;
border-radius: $r-sm;
background: var(--tk-pri);
@include flex-center;
margin-top: var(--tk-section-gap);
box-shadow: 0 2px 8px rgba($pri, 0.25);
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.save-btn-text {
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $white;
}
/* ─── 趋势图 ─── */
.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);
color: $tx2;
}
.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.6;
pointer-events: none;
}
.trend-threshold-label {
position: absolute;
right: 0;
top: -16px;
font-size: var(--tk-font-micro);
color: $wrn;
opacity: 0.8;
}
.trend-bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
justify-content: flex-end;
height: 100vh;
}
.trend-bar {
width: 28px;
border-radius: $r-xs $r-xs 0 0;
min-height: 8px;
opacity: 0.8;
&.trend-bar-normal {
background: var(--tk-pri);
}
&.trend-bar-warn {
background: $wrn;
}
}
.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);
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.device-icon {
width: 48px;
height: 48px;
border-radius: $r-sm;
background: var(--tk-pri-l);
@include flex-center;
/* ─── 分类标签 ─── */
.health-categories {
white-space: nowrap;
padding: var(--tk-gap-xs) var(--tk-page-padding);
margin-bottom: var(--tk-gap-xs);
flex-shrink: 0;
}
.device-icon-text {
font-size: var(--tk-font-body);
.health-cat-tab {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 18px;
margin-right: 8px;
font-size: var(--tk-font-body-sm);
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);
}
}
.device-info {
/* ─── 可滚动内容区 ─── */
.health-scroll {
flex: 1;
overflow: hidden;
/* 微信小程序 ScrollView scrollY 需要显式高度 */
height: 0; /* flex:1 + height:0 让 flex 布局正确分配剩余高度 */
}
/* ─── 文章列表 ─── */
.health-article-list {
display: flex;
flex-direction: column;
gap: var(--tk-gap-sm);
padding: 0 var(--tk-page-padding) var(--tk-gap-lg);
.content-card {
display: flex;
gap: 14px;
}
}
.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-cap);
color: $tx;
font-weight: 500;
}
/* ─── AI 建议卡片 ─── */
.ai-suggestion-card {
background: $acc-l;
border-radius: $r;
padding: var(--tk-gap-md);
margin-bottom: var(--tk-section-gap);
box-shadow: none;
border-left: 4px solid $acc;
}
.ai-card-header {
.health-article-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
margin-bottom: var(--tk-gap-xs);
min-width: 0;
}
.ai-card-title {
font-size: var(--tk-font-cap);
font-weight: 600;
color: $acc;
.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-card-count {
font-size: var(--tk-font-micro);
color: $acc;
opacity: 0.7;
}
.ai-suggestion-item {
display: flex;
align-items: center;
gap: var(--tk-gap-xs);
padding: var(--tk-gap-2xs) 0;
}
.ai-risk-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&.ai-risk-high {
background: $dan;
}
&.ai-risk-medium {
background: $wrn;
}
&.ai-risk-low {
background: $acc;
}
}
.ai-suggestion-text {
.health-article-summary {
font-size: var(--tk-font-cap);
color: $tx2;
line-height: 1.6;
line-height: 1.4;
display: block;
margin-bottom: var(--tk-gap-2xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.health-article-meta {
display: flex;
gap: var(--tk-gap-sm);
font-size: var(--tk-font-micro);
color: $tx3;
align-items: center;
}
.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,336 +1,152 @@
import { useState } from 'react';
import { View, Text, Input } 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 { findThreshold, inputVitalSign, type HealthThreshold } from '../../services/health';
import Loading from '../../components/Loading';
import ErrorState from '../../components/ErrorState';
import GuestGuard from '../../components/GuestGuard';
import SegmentTabs from '../../components/SegmentTabs';
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 { useHealthData, VITAL_TABS, type VitalType } from './useHealthData';
import EmptyState from '../../components/EmptyState';
import ErrorState from '../../components/ErrorState';
import Loading from '../../components/Loading';
import { useElderClass } from '../../hooks/useElderClass';
import './index.scss';
function buildRefRange(t: HealthThreshold[]): Record<VitalType, string> {
const bpSys = findThreshold(t, 'systolic_bp', 'high')?.threshold_value ?? 140;
const bpDia = findThreshold(t, 'diastolic_bp', 'high')?.threshold_value ?? 90;
const hrHigh = findThreshold(t, 'heart_rate', 'high')?.threshold_value ?? 100;
const hrLow = findThreshold(t, 'heart_rate', 'low')?.threshold_value ?? 60;
const bsFasting = findThreshold(t, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1;
const bsPp = findThreshold(t, 'blood_sugar_postprandial', 'high')?.threshold_value ?? 7.8;
return {
blood_pressure: `收缩压 90-${bpSys} / 舒张压 60-${bpDia} mmHg`,
heart_rate: `${hrLow}-${hrHigh} bpm`,
blood_sugar: `空腹 3.9-${bsFasting} / 餐后 <${bsPp} mmol/L`,
weight: '根据 BMI 18.5-24 计算',
};
}
export default function Health() {
const currentPatient = useAuthStore((s) => s.currentPatient);
const modeClass = useElderClass();
const {
user, todaySummary, loading, error, activeTab, trendData, trendLoading,
aiSuggestions, thresholds, handleTabChange, loadTrend, refreshToday, fetchData,
} = useHealthData();
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);
const [systolic, setSystolic] = useState('');
const [diastolic, setDiastolic] = useState('');
const [heartRateVal, setHeartRateVal] = useState('');
const [sugarVal, setSugarVal] = useState('');
const [sugarPeriod, setSugarPeriod] = useState<'fasting' | 'postprandial'>('fasting');
const [weightVal, setWeightVal] = useState('');
const [saving, setSaving] = useState(false);
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>
<ErrorState onRetry={fetchData} />
</PageShell>
);
}
const getWarnStatus = (type: VitalType): string | null => {
if (type === 'blood_pressure') {
const sys = parseFloat(systolic);
const dia = parseFloat(diastolic);
const sysMax = findThreshold(thresholds, 'systolic_bp', 'high')?.threshold_value ?? 140;
const diaMax = findThreshold(thresholds, 'diastolic_bp', 'high')?.threshold_value ?? 90;
if (sys > sysMax || dia > diaMax) return '血压偏高,确认提交?';
} else if (type === 'heart_rate') {
const val = parseFloat(heartRateVal);
const hrHigh = findThreshold(thresholds, 'heart_rate', 'high')?.threshold_value ?? 100;
const hrLow = findThreshold(thresholds, 'heart_rate', 'low')?.threshold_value ?? 60;
if (val > hrHigh || val < hrLow) return '心率异常,确认提交?';
} else if (type === 'blood_sugar') {
const val = parseFloat(sugarVal);
if (sugarPeriod === 'fasting') {
const bsMax = findThreshold(thresholds, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1;
if (val > bsMax) return '血糖偏高,确认提交?';
} else {
const bsMax = findThreshold(thresholds, 'blood_sugar_postprandial', 'high')?.threshold_value ?? 7.8;
if (val > bsMax) return '血糖偏高,确认提交?';
}
}
return null;
};
const refRanges = buildRefRange(thresholds);
const handleSave = async () => {
const patientId = currentPatient?.id;
if (!patientId) {
Taro.showToast({ title: '请先登录', icon: 'none' });
return;
}
const warnMsg = getWarnStatus(activeTab);
if (warnMsg) {
const { confirm } = await Taro.showModal({
title: '异常提示',
content: warnMsg,
confirmText: '确认提交',
cancelText: '再看看',
});
if (!confirm) return;
}
setSaving(true);
const fetchData = useCallback(async (p: number, append = false, categoryId?: string | null) => {
setLoading(true);
setError(false);
try {
switch (activeTab) {
case 'blood_pressure': {
const sys = parseFloat(systolic);
const dia = parseFloat(diastolic);
if (!sys || !dia) { Taro.showToast({ title: '请填写完整', icon: 'none' }); return; }
await inputVitalSign(patientId, {
indicator_type: 'blood_pressure',
value: sys,
extra: { systolic: sys, diastolic: dia },
});
setSystolic('');
setDiastolic('');
break;
}
case 'heart_rate': {
const val = parseFloat(heartRateVal);
if (!val) { Taro.showToast({ title: '请填写心率', icon: 'none' }); return; }
await inputVitalSign(patientId, { indicator_type: 'heart_rate', value: val });
setHeartRateVal('');
break;
}
case 'blood_sugar': {
const val = parseFloat(sugarVal);
if (!val) { Taro.showToast({ title: '请填写血糖值', icon: 'none' }); return; }
const bsType = sugarPeriod === 'fasting' ? 'blood_sugar_fasting' : 'blood_sugar_postprandial';
await inputVitalSign(patientId, { indicator_type: bsType, value: val });
setSugarVal('');
break;
}
case 'weight': {
const val = parseFloat(weightVal);
if (!val) { Taro.showToast({ title: '请填写体重', icon: 'none' }); return; }
await inputVitalSign(patientId, { indicator_type: 'weight', value: val });
setWeightVal('');
break;
}
}
Taro.showToast({ title: '保存成功', icon: 'success' });
refreshToday(true);
loadTrend(activeTab);
} catch {
Taro.showToast({ title: '保存失败', icon: 'none' });
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 {
setSaving(false);
setLoading(false);
}
}, [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);
}
}, [loading, articles.length, total, page, fetchData]);
const handleCategoryChange = (categoryId: string | null) => {
setActiveCategory(categoryId);
fetchData(1, false, categoryId);
};
const maxTrendValue = trendData.reduce((max, d) => Math.max(max, d.value), 1);
const getThresholdValue = (type: VitalType, th: HealthThreshold[]): number | null => {
if (type === 'blood_pressure') return findThreshold(th, 'systolic_bp', 'high')?.threshold_value ?? 140;
if (type === 'heart_rate') return findThreshold(th, 'heart_rate', 'high')?.threshold_value ?? 100;
if (type === 'blood_sugar') return findThreshold(th, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1;
return null;
const formatDate = (dateStr?: string) => {
if (!dateStr) return '';
const d = new Date(dateStr);
const month = d.getMonth() + 1;
const day = d.getDate();
return `${month}${day}`;
};
const dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
return (
<PageShell padding="md" safeBottom={false} scroll={false} className={`health-page ${modeClass}`}>
<View className='health-header'>
<Text className='health-title'></Text>
</View>
{aiSuggestions.length > 0 && (
<View className='ai-suggestion-card' onClick={() => {
const first = aiSuggestions[0];
if (first?.suggestion_type === 'appointment') {
safeNavigateTo(`/pages/appointment/create/index`);
} else if (first?.suggestion_type === 'followup') {
safeNavigateTo('/pages/pkg-profile/followups/index');
} else {
Taro.switchTab({ url: '/pages/health/index' });
}
}}>
<View className='ai-card-header'>
<Text className='ai-card-title'>AI </Text>
<Text className='ai-card-count'>{aiSuggestions.length} </Text>
<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>
{aiSuggestions.map((s) => {
const riskCls = s.risk_level === 'high' ? 'ai-risk-high' : s.risk_level === 'medium' ? 'ai-risk-medium' : 'ai-risk-low';
const typeLabel = s.suggestion_type === 'followup' ? '随访' : s.suggestion_type === 'appointment' ? '预约' : '预警';
const params = s.params as Record<string, unknown> | null;
const reason = (params?.reason as string) || (params?.message as string) || typeLabel;
return (
<View key={s.id} className='ai-suggestion-item'>
<View className={`ai-risk-dot ${riskCls}`} />
<Text className='ai-suggestion-text'>{reason.slice(0, 40)}</Text>
</View>
);
})}
</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>
)}
<SegmentTabs tabs={VITAL_TABS} activeKey={activeTab} onChange={handleTabChange} variant="pill" />
<ContentCard variant="elevated">
{activeTab === 'blood_pressure' && (
<View className='input-group'>
<Text className='input-label'></Text>
<Input
className='input-field'
type='number'
placeholder='如 130'
value={systolic}
onInput={(e) => setSystolic(e.detail.value)}
/>
<Text className='input-label input-label--secondary'></Text>
<Input
className='input-field'
type='number'
placeholder='如 85'
value={diastolic}
onInput={(e) => setDiastolic(e.detail.value)}
/>
<Text className='input-ref'>{refRanges.blood_pressure}</Text>
</View>
)}
{activeTab === 'heart_rate' && (
<View className='input-group'>
<Text className='input-label'></Text>
<Input
className='input-field'
type='digit'
placeholder='如 72'
value={heartRateVal}
onInput={(e) => setHeartRateVal(e.detail.value)}
/>
<Text className='input-ref'>{refRanges.heart_rate}</Text>
</View>
)}
{activeTab === 'blood_sugar' && (
<View className='input-group'>
<Text className='input-label'></Text>
<Input
className='input-field'
type='digit'
placeholder='如 5.6'
value={sugarVal}
onInput={(e) => setSugarVal(e.detail.value)}
/>
<View className='period-group'>
<View
className={`period-btn ${sugarPeriod === 'fasting' ? 'period-active' : ''}`}
onClick={() => setSugarPeriod('fasting')}
>
<Text className='period-btn-text'></Text>
</View>
<View
className={`period-btn ${sugarPeriod === 'postprandial' ? 'period-active' : ''}`}
onClick={() => setSugarPeriod('postprandial')}
>
<Text className='period-btn-text'> 2h</Text>
</View>
</View>
<Text className='input-ref'>{refRanges.blood_sugar}</Text>
</View>
)}
{activeTab === 'weight' && (
<View className='input-group'>
<Text className='input-label'> (kg)</Text>
<Input
className='input-field'
type='digit'
placeholder='如 65.5'
value={weightVal}
onInput={(e) => setWeightVal(e.detail.value)}
/>
<Text className='input-ref'>{refRanges.weight}</Text>
</View>
)}
<View className='save-btn' onClick={handleSave}>
<Text className='save-btn-text'>{saving ? '保存中...' : '保存'}</Text>
</View>
</ContentCard>
<View className='trend-section'>
<Text className='section-title'> 7 </Text>
{trendLoading ? (
<Loading />
) : trendData.length === 0 ? (
<ContentCard padding="md">
<Text className='trend-empty-text'></Text>
</ContentCard>
{/* 文章列表 */}
<ScrollView scrollY className='health-scroll' onScrollToLower={loadMore} lowerThreshold={200}>
{error ? (
<ErrorState onRetry={() => fetchData(1, false, null)} />
) : articles.length === 0 && !loading ? (
<EmptyState text='暂无健康资讯' />
) : (
<ContentCard padding="md">
<View className='trend-bars'>
{getThresholdValue(activeTab, thresholds) && (() => {
const tv = getThresholdValue(activeTab, thresholds)!;
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 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>
);
})()}
{trendData.map((point, i) => {
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
const tv = getThresholdValue(activeTab, thresholds);
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>
))}
</View>
)}
</View>
<ContentCard
onPress={() => safeNavigateTo('/pages/article/index')}
>
<Text className='article-entry-text'> </Text>
</ContentCard>
{loading && <Loading />}
</ScrollView>
</PageShell>
);
}

View File

@@ -1,8 +1,8 @@
import { useState, useRef } from 'react';
import { useState } from 'react';
import { useHealthStore } from '@/stores/health';
import { useAuthStore } from '@/stores/auth';
import { usePageData } from '@/hooks/usePageData';
import { getTrend, getHealthThresholds, DEFAULT_THRESHOLDS, type HealthThreshold } from '@/services/health';
import { getHealthThresholds, DEFAULT_THRESHOLDS, type HealthThreshold } from '@/services/health';
import { listPendingSuggestions, type AiSuggestionItem } from '@/services/ai-analysis';
export type VitalType = 'blood_pressure' | 'heart_rate' | 'blood_sugar' | 'weight';
@@ -44,7 +44,8 @@ export function useHealthData() {
};
const points = await fetchTrend(indicatorMap[type], '7d');
setTrendData(points);
} catch {
} catch (err) {
console.warn('[health] 加载趋势数据失败:', err);
setTrendData([]);
} finally {
setTrendLoading(false);
@@ -55,7 +56,8 @@ export function useHealthData() {
try {
const items = await listPendingSuggestions();
setAiSuggestions(items.slice(0, 3));
} catch {
} catch (err) {
console.warn('[health] 加载 AI 建议失败:', err);
setAiSuggestions([]);
}
};

View File

@@ -0,0 +1,111 @@
import { useState } from 'react';
import { useHealthStore } from '@/stores/health';
import { useAuthStore } from '@/stores/auth';
import { usePageData } from '@/hooks/usePageData';
import { getHealthThresholds, DEFAULT_THRESHOLDS, type HealthThreshold } from '@/services/health';
import { listPendingSuggestions, type AiSuggestionItem } from '@/services/ai-analysis';
import { listPatientAlerts } from '@/services/alert';
export type VitalType = 'blood_pressure' | 'heart_rate' | 'blood_sugar' | 'weight';
export const VITAL_TABS: { key: VitalType; label: string }[] = [
{ key: 'blood_pressure', label: '血压' },
{ key: 'heart_rate', label: '心率' },
{ key: 'blood_sugar', label: '血糖' },
{ key: 'weight', label: '体重' },
];
export interface TrendPoint {
date: string;
value: number;
}
export function useHealthOverview() {
const user = useAuthStore((s) => s.user);
const todaySummary = useHealthStore((s) => s.todaySummary);
const loading = useHealthStore((s) => s.loading);
const refreshToday = useHealthStore((s) => s.refreshToday);
const fetchTrend = useHealthStore((s) => s.getTrend);
const [activeTab, setActiveTab] = useState<VitalType>('blood_pressure');
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [trendLoading, setTrendLoading] = useState(false);
const [aiSuggestions, setAiSuggestions] = useState<AiSuggestionItem[]>([]);
const [thresholds, setThresholds] = useState<HealthThreshold[]>(DEFAULT_THRESHOLDS);
const [alertCount, setAlertCount] = useState(0);
const loadTrend = async (type: VitalType) => {
setTrendLoading(true);
try {
const indicatorMap: Record<VitalType, string> = {
blood_pressure: 'systolic_bp_morning',
heart_rate: 'heart_rate',
blood_sugar: 'blood_sugar',
weight: 'weight',
};
const points = await fetchTrend(indicatorMap[type], '7d');
setTrendData(points);
} catch (err) {
console.warn('[health] 加载趋势数据失败:', err);
setTrendData([]);
} finally {
setTrendLoading(false);
}
};
const loadAiSuggestions = async () => {
try {
const items = await listPendingSuggestions();
setAiSuggestions(items.slice(0, 3));
} catch {
setAiSuggestions([]);
}
};
const loadAlertCount = async () => {
const patientId = useAuthStore.getState().currentPatient?.id;
if (!patientId) return;
try {
const res = await listPatientAlerts(patientId, { status: 'pending', page: 1, page_size: 1 });
setAlertCount(res.total ?? 0);
} catch {
setAlertCount(0);
}
};
const fetchData = async () => {
await Promise.allSettled([
refreshToday(),
loadTrend(activeTab),
loadAiSuggestions(),
loadAlertCount(),
getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); }),
]);
};
usePageData(fetchData, {
throttleMs: 5000,
enablePullDown: true,
enabled: !!user,
});
const handleTabChange = (tab: VitalType) => {
setActiveTab(tab);
loadTrend(tab);
};
return {
user,
todaySummary,
loading,
error: false,
activeTab,
trendData,
trendLoading,
aiSuggestions,
thresholds,
alertCount,
handleTabChange,
fetchData,
};
}

Some files were not shown because too many files have changed in this diff Show More