Compare commits

...

205 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
iven
4e12298ff3 docs: 发散式讨论记录 — AI Agent 突破口方向确认 2026-05-18 02:19:03 +08:00
iven
e149a61ce6 fix(auth): error 类型 + auth_service 小修复 2026-05-18 02:14:14 +08:00
iven
3aa71a94d2 docs(skills): design-handoff 设计稿 + spec + .gitignore 更新
- mp-11-doctor-core 设计交付包(截图 + tokens)
- mp-13/mp-14 新原型 HTML
- design-handoff skill 设计规格文档
- .gitignore 排除 dist-h5/test-results/uploads 等构建产物
2026-05-18 02:13:29 +08:00
iven
ded37830fe feat(mp): 新增 AvatarCircle/ShortcutButton/TodoAlert 组件 + 商品详情页
- AvatarCircle: 头像圆形组件
- ShortcutButton: 快捷操作按钮
- TodoAlert: 待办提醒组件
- pkg-mall/product: 积分商品详情页
2026-05-18 02:12:58 +08:00
iven
e555496528 feat(mp): design-handoff 产出的页面样式和组件优化
- 首页/商城/医生端/积分/家庭档案等页面 SCSS + TSX 更新
- TabFilter 组件样式优化
- points service 接口调整
- app.config 路由注册更新
2026-05-18 02:12:41 +08:00
iven
2698c98888 docs(ai): Phase 0 实施计划 — 12 Tasks,修复 Review R1 问题
- 复用已有 TokenUsage(修复编译冲突)
- AppointmentSummaryDto.id 改为 Uuid + scheduled_at 改为 DateTime
- Orchestrator 达到上限时用 User role(而非 Assistant)
- 添加路由说明(Phase 0 复用 /ai/chat,Phase 2 变更)
- 添加模型选择说明(Phase 0 硬编码 auto)
2026-05-18 02:10:59 +08:00
iven
31771168dd docs(ai): Spec Review R2 修复 — 复用 HealthDataProvider + 新增 generate_with_tools
CRITICAL:
- 移除重复的 HealthDataQuery trait,扩展现有 HealthDataProvider(新增 2 方法)
- Provider 适配改为新增 generate_with_tools 方法,不破坏现有 generate 路径

IMPORTANT:
- 修复章节编号(全文重排为连续编号)
- ai_tool_call_logs 补充 created_by + 说明省略原因(append-only)
- ai_user_profiles 说明省略 created_by/updated_by 原因(Agent 自动维护)
- ToolContext 改为持有 Arc<dyn HealthDataProvider> 而非裸 db
- SSE 语义明确:仅流式输出最终回复
- 5 轮上限强制终止逻辑:追加总结指令让 LLM 正常结束
- GenerateRequest 不再破坏性修改,新旧路径并行
2026-05-18 01:57:16 +08:00
iven
b0892706c8 fix(skills): Step 7 改为 design-handoff 自行实施,不再调用 huashu-design
原因: huashu-design 是纯设计 skill,加实施模式会导致人格冲突、
SKILL.md 膨胀、约束倒灌。design-handoff 已有 SPEC+截图+Token 映射,
直接在当前会话实施代码更自然。
2026-05-18 01:54:17 +08:00
iven
530262590e feat(skills): design-handoff 升级为全流程编排器
- 新增 Step 0: 调用 huashu-design 设计 HTML 原型
- 新增 Step 7: 调用 huashu-design 根据 SPEC 实施代码
- 支持两种输入模式: 从需求开始(完整7步) / 从原型开始(6步)
- 简化文档: 移除冗余的降级代码块,保留核心流程
2026-05-18 01:51:40 +08:00
iven
6759723731 docs(ai): 修复 Spec Review 发现的 9 个问题
CRITICAL 修复:
- 新增跨 crate 数据访问架构(erp-core HealthDataQuery trait)
- 新增 4 个权限码声明(session.list/manage/history + chat.send)
- 明确 Provider Function Calling 需中等程度重构,补充适配方案

IMPORTANT 修复:
- 说明与 copilot_chat_logs 表的关系(并存不迁移)
- 新增 ai_user_profiles 表(长期记忆/用户画像)
- 定义 DisplayHint 枚举(5 种富消息类型)
- Phase 0 验证标准修正为新端点
- 补充 Taro SSE 兼容层 + Web 从零构建,工期上调
- 新增 PII 脱敏规范(6 类字段处理规则)
- 新增故障处理与降级章节
- Phase 0/2 工作量估算上调,总工期 16-23→20-27 天
2026-05-18 01:51:03 +08:00
iven
1c8319fb4d docs(ai): AI Agent 突破口设计规格 — ReAct Agent + Function Calling
erp-ai 客服从简单问答升级为多策略 ReAct Agent:
- Agent Orchestrator 实现 ReAct 循环(最多 5 轮 Tool Call)
- 4 类 12 个 Tool 覆盖数据查询/AI 分析/知识服务/行动操作
- 多策略 System Prompt(安抚/科普/推荐/预警/引导到院)
- 3 张新表(sessions/messages/tool_logs)+ 会话管理 API
- 小程序 + Web 富消息渲染 + SSE 流式输出
- 4 Phase 分阶段实施,总工期 16-23 天
2026-05-18 01:42:13 +08:00
iven
6151fde7c4 fix(skills): 修复 design-handoff 医生端原型输出质量
- match-tokens: alias 增加值条件映射,支持 doctor/patient variant 自动检测
- parse-prototype: 支持 IosFrame label prop 提取(JSX 属性模式)
- interaction-rules: login-cta 增加 exclude_patterns 避免"立即关注"误报
- tokens.yml: T.pri/T.priL/T.priD alias 改为数组格式,按值匹配 variant

验证: 患者端 T.pri→--tk-pri 确认,医生端 T.pri→--tk-pri.doctor 确认
2026-05-18 01:00:20 +08:00
597 changed files with 66237 additions and 7119 deletions

View File

@@ -1,17 +1,146 @@
---
name: design-handoff
description: 将 HTML 原型转换为结构化设计交付包(截图 + SPEC.md + Token 映射),供新会话 LLM 高保真实现
description: 设计交付编排器 — 调用 huashu-design 设计原型、解析生成 SPEC再由本 skill 根据 SPEC 实施代码,覆盖从需求到实现的完整流程
---
# 设计交付 Skill (design-handoff)
# 设计交付编排器 (design-handoff)
将 huashu-design 生成的 HTML 原型转换为结构化设计交付包,使新会话中的 LLM 能够高保真还原 UI 实现。
编排从需求到代码实现的完整流程:
1. 调用 huashu-design 设计 HTML 原型
2. 解析原型生成 SPEC.md + 截图 + Token 映射
3. 根据 SPEC 直接实施代码(不调用 huashu-design
## 触发词
- `design-handoff`
- `设计交付`
- `handoff`
- `设计并实现`
- `从设计到代码`
---
## 输入模式
### 模式 A从需求开始完整流程
用户提供功能需求描述(无 HTML 文件skill 执行 7 步完整流程。
**输入示例:**
```
/design-handoff 患者端家庭档案管理页面,包含家庭成员列表、添加/编辑/删除操作
```
### 模式 B从原型开始仅解析+实施)
用户提供已有 HTML 原型文件路径,跳过 Step 0从 Step 1 开始。
**输入示例:**
```
/design-handoff docs/design/mp-13-family-profile.html
```
---
## 核心流程7 步)
### Step 0: 设计原型(仅模式 A
调用 huashu-design 生成 HTML 原型:
```
Skill("huashu-design", "设计 {用户需求描述}。要求1) 使用 HMS 设计系统 Tokenconst T = {...}2) 包含 IosFrame 设备框 3) 每个页面用 .screen-wrap 包裹 4) 输出到 docs/design/ 目录")
```
**关键要求传递给 huashu-design**
- 使用项目 Token 系统(`const T = { pri: ..., bg: ..., r: ... }`
- 每个页面/状态用 `.screen-wrap` 包裹并配中文 label
- 使用 `IosFrame` 组件呈现
- 输出为单文件 HTML保存到 `docs/design/mp-{编号}-{名称}.html`
**完成后获得:** HTML 原型文件路径,进入 Step 1。
### Step 1: 解析 HTML 原型
调用 `scripts/parse-prototype.mjs` 解析 HTML 文件:
```bash
node .claude/skills/design-handoff/scripts/parse-prototype.mjs <html文件路径>
```
**输出stdout JSON**
- `tokens`: T 对象所有键值对(如 `{"T.pri": "#C4623A", ...}`
- `inlineStyles`: 内联样式值收集(按 CSS 属性分组)
- `screens`: 每个屏幕的标签和组件名
- `components`: 所有组件函数定义列表
- `variant`: `"patient"``"doctor"`(根据 T.pri 值自动检测)
**若脚本不存在:** LLM 自行解析 HTML提取 T 对象和 DOM 结构。
### Step 2: 截图
调用 `scripts/extract-screenshots.mjs` 生成截图:
```bash
node .claude/skills/design-handoff/scripts/extract-screenshots.mjs <html文件路径> <输出目录>
```
**输出:** PNG 文件到输出目录,文件名由中文标签自动翻译为英文。
**若脚本不存在:** 指导用户手动截图,保存到 `docs/design/{原型名}/screenshots/`
### Step 3: Token 匹配
调用 `scripts/match-tokens.mjs` 进行三层 Token 匹配:
```bash
node .claude/skills/design-handoff/scripts/parse-prototype.mjs <html> > /tmp/parse-result.json
node .claude/skills/design-handoff/scripts/match-tokens.mjs /tmp/parse-result.json .design/tokens.yml
```
**三层匹配算法:**
1. **别名直查** — 在 `aliases.prototype_keys` 中查找已确认映射(支持值条件匹配,区分 patient/doctor variant
2. **值精确匹配** — 按 CSS 属性上下文消歧borderRadius→radius, fontSize→typography
3. **色彩模糊匹配** — RGB 欧几里得距离 < 30 视为近似
**若脚本不存在:** LLM 自行读取 tokens.yml 执行匹配。
### Step 4: 组件映射推断
读取 `defaults/components.yml`,按 DOM 特征推断组件映射。
### Step 5: 交互推断
调用 `scripts/infer-interactions.mjs` 推断交互行为:
```bash
node .claude/skills/design-handoff/scripts/infer-interactions.mjs <html文件路径> .claude/skills/design-handoff/rules/interaction-rules.yml
```
### Step 6: 组装 SPEC.md
将前 5 步数据组装为 SPEC.md输出到 `docs/design/{原型名}/SPEC.md`
### Step 7: 实施代码
根据 Step 1-6 产出的 SPEC.md、截图和 Token 映射,**在当前会话中直接实施代码**。
**实施流程:**
1. **读取 SPEC.md** — 获取 Token 映射、组件清单、交互行为、未匹配项
2. **读取截图** — 对照截图确认视觉还原度
3. **创建/修改文件** — 在 `apps/miniprogram/src/pages/` 下实现 Taro 页面
4. **验证**`pnpm build` 编译通过 + 长者模式适配
**实施规则:**
- 样式使用 `var(--tk-*)` CSS 变量,不硬编码数值
- 使用组件库已有组件ContentCard, PrimaryButton, SectionTitle 等),参见 `defaults/components.yml`
- 交互行为按 SPEC.md 第 4 节实现
- 遵循 CLAUDE.md 中的小程序代码规范
- 长者模式:字号 ≥ 22px间距按 elder 值
- 未匹配的 Token 需与用户确认后再补充到 `.design/tokens.yml`
- 新建组件时遵循 `feedback-component-consistency` 原则:不适配则新建,不外部覆盖
---
@@ -21,265 +150,29 @@ description: 将 HTML 原型转换为结构化设计交付包(截图 + SPEC.md
### 1. 输入验证
确认输入文件为 huashu-design 产物,须同时满足:
- 文件为 `.html` 格式
- 包含 React + Babel 的 script 标签(`<script src="...babel...">``type="text/babel"`
- 包含 Token 定义块(`const T = {` 或等效全局样式常量)
**模式 A从需求开始** 无需输入文件验证,直接进入 Step 0。
若不满足,输出错误并终止
```
[design-handoff] 错误:输入文件不是 huashu-design 产物。
期望特征:
- React/Babel script 标签
- const T = { ... } 样式定义块
请确认输入文件来源。
```
**模式 B从原型开始** 确认输入文件为 huashu-design 产物
- 文件为 `.html` 格式
- 包含 React + Babel 的 script 标签
- 包含 Token 定义块(`const T = {`
### 2. Token 配置文件初始化
检查项目根目录下的 `.design/tokens.yml` 是否存在:
- **存在** → 直接使用,跳过初始化
- **不存在** → 从 `defaults/tokens.yml` 复制到 `.design/tokens.yml`
`defaults/tokens.yml` 也不存在,输出警告并使用内置最小 Token 集:
```yaml
# 内置最小 Token 集defaults/tokens.yml 缺失时的降级方案)
color:
primary: "#1890ff"
success: "#52c41a"
warning: "#faad14"
danger: "#ff4d4f"
text: "#333333"
text_secondary: "#666666"
bg: "#ffffff"
bg_secondary: "#f5f5f5"
border: "#d9d9d9"
spacing:
xs: 4
sm: 8
md: 16
lg: 24
xl: 32
fontSize:
h1: 28
h2: 22
body_lg: 18
body: 16
body_sm: 14
caption: 13
radius:
sm: 4
md: 8
lg: 16
pill: 999
```
检查 `.design/tokens.yml` 是否存在:
- **存在** → 直接使用
- **存在** → `defaults/tokens.yml` 复制
---
## 核心流程6 步)
### Step 1: 解析 HTML 原型
调用 `scripts/parse-prototype.mjs` 解析输入 HTML 文件:
```bash
node scripts/parse-prototype.mjs <html文件路径>
```
**输出stdout JSON**
- `tokens`: T 对象所有键值对(如 `{"T.pri": "#C4623A", ...}`
- `inlineStyles`: 内联样式值收集(按 CSS 属性分组,如 `{"fontSize": ["32","28",...], ...}`
- `screens`: 每个屏幕的标签和组件名(如 `[{"label": "访客首页", "component": "GuestHome"}]`
- `components`: 所有组件函数定义列表
**若脚本不存在:** LLM 自行解析 HTML提取 T 对象和 DOM 结构。
### Step 2: 截图
调用 `scripts/extract-screenshots.mjs` 生成截图:
```bash
node scripts/extract-screenshots.mjs <html文件路径> <输出目录>
```
**输出:** PNG 文件到输出目录,文件名由中文标签自动翻译为英文。
- 每个 `.screen-wrap` 生成一张截图(裁掉 IosFrame 设备框、状态栏、Home Indicator
-`.screen-wrap` 时降级为整页截图 `full-page.png`
**若脚本不存在:** 指导用户手动截图,保存到 `docs/design/{原型名}/screenshots/`
### Step 3: Token 匹配
调用 `scripts/match-tokens.mjs` 进行三层 Token 匹配:
```bash
# 先保存 Step 1 的输出到临时文件
node scripts/parse-prototype.mjs <html> > /tmp/parse-result.json
node scripts/match-tokens.mjs /tmp/parse-result.json .design/tokens.yml
```
**三层匹配算法:**
1. **别名直查** — 在 `aliases.prototype_keys` 中查找已确认映射
2. **值精确匹配** — 按 CSS 属性上下文消歧borderRadius→radius, fontSize→typography
3. **色彩模糊匹配** — RGB 欧几里得距离 < 30 视为近似
**输出stdout JSON**
- `matched`: 已匹配 Token含 confidence: confirmed/pending/approximate
- `unmatched`: 未匹配的 Token key 列表
- `inlineTokenMap`: 内联样式值的 Token 映射
- `summary`: 统计confirmed/pending/approximate/unmatched 数量)
**若脚本不存在:** LLM 自行读取 tokens.yml 执行匹配。
### Step 4: 组件映射推断
读取 `defaults/components.yml`,然后 LLM 按 DOM 特征推断组件映射。
**若 `defaults/components.yml` 不存在:** 使用以下内置组件映射规则:
#### 组件推断规则8 条)
按优先级从高到低依次匹配,首个命中的规则决定组件类型:
| # | DOM 特征 | 推断组件 | 关键判定信号 |
|---|----------|----------|-------------|
| 1 | 容器节点 + `borderRadius` + `boxShadow` + 内含标题元素 + 正文元素 | `ContentCard` | borderRadius > 0 + boxShadow 存在 + 子节点含标题层级文本 |
| 2 | 左侧竖线装饰(`borderLeft` / `::before` 伪元素宽度 3-6px+ 标题文本 + 可选右侧链接文字 | `SectionTitle` | 3-6px 左侧彩色边框 + 标题级字号 |
| 3 | 按钮节点 + 主色背景(匹配 `color.primary`+ 白色文字 + `height` 44-56px | `PrimaryButton` | 背景色 = primary + 文字色 = 白/接近白 + 按钮语义 |
| 4 | 按钮节点 + 边框存在 + 背景透明/白色 + 文字色 = 主色或中性色 | `SecondaryButton` | border 存在 + 背景无填充 + 按钮语义 |
| 5 | 页面最外层容器节点 + `padding` + 可滚动(`overflow: auto/scroll` 或页面 body 级) | `PageShell` | DOM 深度 = 1-2 + padding + 无固定高度 |
| 6 | 小型容器 + 接近正方形/圆形(宽高比 0.8-1.2+ 包含数字或短文字 + 有背景色 | `StatusTag` | 宽高比 ≈ 1 + 内容简短 + 背景色非白 |
| 7 | 列表行 + 左侧图标区 + 双行文字(主文字 + 副文字)+ 右侧箭头或 chevron 符号 | `ListItem` | 行级容器 + 左图标 + 双行文本 + 右箭头 |
| 8 | 未匹配上述任何规则 | `需新建组件` | 标记为 `Unknown__<描述>`,需在 SPEC.md 中描述视觉特征 |
**推断方法:**
1. 对解析后的 DOM 树做深度优先遍历
2. 对每个非叶子节点,按上述 8 条规则依次检测
3. 命中规则 → 标记组件类型 + 记录判定依据
4. 全部未命中 → 标记 `需新建组件` + 附带节点样式摘要
5. 输出组件映射表到交付包
### Step 5: 交互推断
调用 `scripts/infer-interactions.mjs` 推断交互行为:
```bash
node scripts/infer-interactions.mjs <html文件路径> rules/interaction-rules.yml
```
**输出stdout JSON**
- `interactions`: 每条规则的匹配结果matched/unmatched、命中的 pattern、location 定位)
- `summary`: matched/unmatched 统计
**若脚本不存在:** LLM 自行推断:
1. 检查 DOM 中的 `onClick` / `onChange` 等事件绑定
2. 检查 CSS 中的 `transition` / `animation` / `@keyframes`
3. 检查 `cursor: pointer` 元素(隐含可点击)
4. 检查 `overflow: scroll/auto` 容器(隐含滚动行为)
5. 按钮语义元素(`<button>``role="button"`)推断点击行为
### Step 6: 组装 SPEC.md
读取 `templates/spec-template.md`,将前 5 步收集的数据组装为 SPEC.md。
**若模板不存在:** 使用以下结构:
```markdown
# {原型名称} 设计规格
> 由 design-handoff 自动生成 | 源文件: {HTML文件名} | 生成时间: {ISO日期}
## 1. 概览
- **原型文件**: {文件名}
- **截图**: screenshots/full.png
- **设计 Token 数**: {matched数}/{总Token数} 已匹配
- **组件数**: {组件总数}
- **交互行为数**: {交互总数}
## 2. 设计 Token
### 2.1 颜色
| Token 名称 | 值 | 使用位置 |
|------------|-----|---------|
| ... | ... | ... |
### 2.2 字号
| Token 名称 | 值 (px) | 使用位置 |
|------------|---------|---------|
| ... | ... | ... |
### 2.3 间距
| Token 名称 | 值 (px) | 使用位置 |
|------------|---------|---------|
| ... | ... | ... |
### 2.4 圆角
| Token 名称 | 值 (px) | 使用位置 |
|------------|---------|---------|
| ... | ... | ... |
## 3. 组件清单
### 3.1 组件树
{组件层级树形结构}
### 3.2 组件详情
#### {组件名}
- **推断规则**: 命中规则 #{N}
- **Token 依赖**: [列出使用的 Token]
- **子组件**: [列出子组件]
- **截图**: screenshots/sections/{组件名}.png如有
- **样式属性**:
```
{原始 CSS 属性键值对}
```
## 4. 交互行为
| 元素 | 事件类型 | 行为描述 | 实现建议 |
|------|---------|---------|---------|
| ... | click/scroll/input/transition | ... | ... |
## 5. 未匹配项
### 5.1 未匹配 Token
| 值 | 类型 | 出现位置 | 建议 Token 名称 |
|----|------|---------|---------------|
| ... | color/fontSize/spacing/radius | ... | ... |
### 5.2 需新建组件
| 组件名建议 | 视觉特征摘要 | 出现位置 |
|-----------|------------|---------|
| ... | ... | ... |
## 6. 实施注意事项
{LLM 根据原型复杂度自动生成的实现提示}
```
---
## 输出流程
### 目录结构
## 输出目录结构
```
docs/design/
mp-00-visitor.html # 原始原型(不修改
mp-00-visitor/ # 交付包目录(与原型同名)
mp-00-visitor.html # 原始原型(Step 0 产物或已有文件
mp-00-visitor/ # 交付包目录
├── SPEC.md # 设计规格文档Step 6 产物)
├── screenshots/ # 截图目录
├── screenshots/ # 截图目录Step 2 产物)
│ ├── home.png
│ └── profile.png
├── tokens.json # Token 匹配结果Step 3 产物)
@@ -290,12 +183,12 @@ docs/design/
```yaml
prototype: {原型文件名}
source: {HTML文件绝对路径}
generated_at: {ISO 8601 时间戳}
source: {HTML文件路径}
variant: patient | doctor
generated_at: {ISO 8601}
tokens:
matched: {数}
unmatched: {数}
pending: {数}
components:
total: {数}
mapped: {数}
@@ -303,120 +196,49 @@ components:
interactions: {数}
```
### 终端输出
---
完成全部步骤后,在终端输出实施 Prompt 模板:
## 终端输出
完成全部步骤后:
```
============================================================
设计交付完成: {原型名}
输出目录: docs/design/{原型名}/
模式: {完整流程 | 仅解析}
Variant: {patient | doctor}
Token 匹配: {matched}/{total} ({unmatched} 未匹配)
组件: {total} ({new} 需新建)
交互: {数} 项
下一步: 根据 SPEC 实施代码Step 7
============================================================
下一步:在新会话中粘贴以下 Prompt 实现设计
--- BEGIN PROMPT ---
请根据设计规格实现 {原型名称} 页面。
设计规格文件: docs/design/{原型名}/SPEC.md
截图参考: docs/design/{原型名}/screenshots/full.png
Token 文件: docs/design/{原型名}/tokens.json
实现要求:
1. 严格按照 SPEC.md 中的 Token 值设置样式
2. 使用 SPEC.md 中映射的组件,若标记为"需新建"则先创建组件
3. 实现所有交互行为(见 SPEC.md 第 4 节)
4. 未匹配的 Token 需确认后补充到 .design/tokens.yml
--- END PROMPT ---
```
---
## 批处理模式
当输入为 glob 模式(如 `docs/design/*.html`)或多个文件路径时:
1. 展开为文件列表
2. 逐个执行 6 步流程(串行,避免截图冲突)
3. 每个文件输出到 `docs/design/{各自原型名}/`
4. 全部完成后生成索引文件:
### docs/design/INDEX.md
```markdown
# 设计交付索引
> 自动生成于 {ISO 日期}
| # | 原型名 | 截图 | SPEC | Token 匹配率 | 组件数 | 状态 |
|---|--------|------|------|------------|--------|------|
| 1 | {名称} | [截图](./{名称}/screenshots/full.png) | [SPEC](./{名称}/SPEC.md) | {N}/{M} | {数} | 待实施 |
| 2 | ... | ... | ... | ... | ... | ... |
```
---
## 待确认项交互
完成 6 步流程后,若有 `pending``unmatched` Token需与用户交互确认
### 输出确认清单
```
[design-handoff] 以下 Token 需要确认:
【待确认 (pending)】
1. 值 "#2b85e4" ≈ color.primary (#1890ff) — 是否作为别名?
2. 值 15px ≈ spacing.lg (24px) — 是否新增 Token spacing.md_lg
【未匹配 (unmatched)】
3. 值 "#722ed1" — 建议新增 Token: color.accent_purple
4. 值 10px — 建议新增 Token: spacing.ms
请回复序号+确认结果(如 "1:是, 2:新增, 3:color.purple, 4:忽略"
```
### 处理用户确认
用户确认后:
1.`pending` 项的别名关系写入 `.design/tokens.yml``aliases` 字段
2.`unmatched` 项中确认新增的 Token 写入 `.design/tokens.yml` 对应分类
3. 更新 `tokens.json``SPEC.md` 中的匹配状态
### aliases 格式示例
```yaml
# .design/tokens.yml
color:
primary: "#1890ff"
aliases:
blue_hover: "#2b85e4" # 用户确认: primary 的 hover 态别名
accent_purple: "#722ed1" # 用户确认: 新增 accent 色值
```
完成 Step 1-6 后,若有 `pending``unmatched` Token需与用户交互确认后再进入 Step 7。
---
## 脚本说明
本 Skill 引用以下脚本(位于 `scripts/` 目录):
| 脚本 | 用途 |
|------|------|
| `scripts/parse-prototype.mjs` | 解析 HTML 原型为结构化 JSON |
| `scripts/extract-screenshots.mjs` | Playwright 截图(裁掉设备框) |
| `scripts/match-tokens.mjs` | 三层 Token 匹配 |
| `scripts/infer-interactions.mjs` | 交互行为推断 |
| 脚本 | 用途 | 输入 | 输出 |
|------|------|------|------|
| `parse-prototype.mjs` | 解析 HTML 原型为结构化 JSON | HTML 文件路径 | JSON DOM 树 |
| `extract-screenshots.mjs` | 使用 Puppeteer/Playwright 截图 | HTML 文件路径 + 输出目录 | PNG 截图文件 |
| `match-tokens.mjs` | 样式值与 Token 库匹配 | 解析 JSON + tokens.yml | 匹配报告 JSON |
| `infer-interactions.mjs` | 推断交互行为 | 解析 JSON | 交互清单 JSON |
**降级策略:** 每个脚本缺失时LLM 自行执行对应步骤。
**降级策略:** 每个脚本缺失时LLM 自行执行对应步骤(详见各 Step 说明)。脚本仅为加速工具,不影响核心功能。
## 模板说明
## 模板与规则
| 文件 | 用途 |
|------|------|
| `templates/spec-template.md` | SPEC.md 的自定义模板(可选) |
| `defaults/tokens.yml` | Token 初始定义(新项目首次使用时复制到 `.design/` |
| `defaults/components.yml` | 组件映射规则的自定义扩展(可选,覆盖内置 8 条规则) |
## 规则文件
`rules/` 目录可放置额外的设计规范约束文件(如品牌色限制、无障碍要求等),在 Step 4 组件推断和 Step 6 组装 SPEC 时自动加载。
| `templates/spec-template.md` | SPEC.md 模板 |
| `defaults/tokens.yml` | Token 注册表 + 别名映射 |
| `defaults/components.yml` | 组件映射规则 |
| `rules/interaction-rules.yml` | 交互推断规则 |

View File

@@ -327,16 +327,34 @@ shadow_unmapped:
aliases:
prototype_keys:
T.pri:
token: --tk-pri
status: exact_match
- value: "#C4623A"
token: --tk-pri
status: exact_match
variant: patient
- value: "#3A6B8C"
token: --tk-pri.doctor
status: exact_match
variant: doctor
T.priL:
token: --tk-pri-l
status: exact_match
- value: "#F0DDD4"
token: --tk-pri-l
status: exact_match
variant: patient
- value: "#D4E5F0"
token: --tk-pri-l.doctor
status: exact_match
variant: doctor
T.priD:
token: --tk-pri-d
status: exact_match
- value: "#8B3E1F"
token: --tk-pri-d
status: exact_match
variant: patient
- value: "#2A4F6A"
token: --tk-pri-d.doctor
status: exact_match
variant: doctor
T.bg:
token: null

View File

@@ -9,7 +9,8 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"js-yaml": "^4.1.1"
"js-yaml": "^4.1.1",
"playwright": "^1.58.0"
}
},
"node_modules/argparse": {
@@ -18,6 +19,20 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -29,6 +44,36 @@
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/playwright": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

View File

@@ -11,6 +11,7 @@
"license": "ISC",
"type": "commonjs",
"dependencies": {
"js-yaml": "^4.1.1"
"js-yaml": "^4.1.1",
"playwright": "^1.58.0"
}
}

View File

@@ -84,8 +84,14 @@ rules:
name: "登录/注册触发"
patterns:
- "登录"
- "立即"
- "注册"
- "微信登录"
- "立即登录"
exclude_patterns:
- "立即关注"
- "立即处理"
- "立即查看"
- "需要立即"
require_all: false
infer:
component: "PrimaryButton"

View File

@@ -121,6 +121,23 @@ function matchRule(rule, source, funcRanges) {
? matchedPatterns.length === rule.patterns.length
: matchedPatterns.length > 0;
// 排除模式检查:如果任一 exclude_pattern 命中,该规则不匹配
if (allMatched && Array.isArray(rule.exclude_patterns)) {
for (const exclPattern of rule.exclude_patterns) {
try {
const exclRegex = new RegExp(exclPattern);
if (exclRegex.test(source)) {
return {
matched: false,
matchedPatterns: [],
locations: [],
excluded_by: exclPattern,
};
}
} catch (_e) { /* ignore regex errors */ }
}
}
return {
matched: allMatched,
matchedPatterns: allMatched ? matchedPatterns : [],

View File

@@ -268,18 +268,53 @@ function buildTokenRegistry(tokensConfig) {
// ---------------------------------------------------------------------------
/**
* Layer 1: 别名直查
* @returns {object|null} 匹配结果或 null
* Layer 1: 别名直查(支持值条件映射)
*
* alias 格式有两种:
* 单条: { token: "...", status: "exact_match" }
* 数组: [{ value: "#C4623A", token: "--tk-pri", variant: "patient" },
* { value: "#3A6B8C", token: "--tk-pri.doctor", variant: "doctor" }]
*
* 数组格式按值匹配,单条格式直接匹配(兼容旧格式)。
*/
function matchByAlias(key, aliases) {
function matchByAlias(key, prototypeValue, aliases) {
if (!aliases || !aliases.prototype_keys) return null;
const alias = aliases.prototype_keys[key];
if (!alias) return null;
const result = {
method: 'alias',
confidence: null,
};
// 数组格式:按值条件匹配
if (Array.isArray(alias)) {
const normalizedInput = normalizeColor(prototypeValue) || normalizeNumericValue(prototypeValue) || String(prototypeValue);
for (const entry of alias) {
const normalizedAlias = normalizeColor(entry.value) || normalizeNumericValue(entry.value) || String(entry.value);
if (normalizedInput === normalizedAlias) {
const result = {
method: 'alias',
confidence: entry.status === 'exact_match' ? 'confirmed' : 'pending',
token: entry.token || null,
scssVar: entry.scss_var || null,
};
if (entry.variant) result.variant = entry.variant;
if (entry.note) result.note = entry.note;
return result;
}
}
// 数组中无值匹配 → 降级到 Layer 2
return null;
}
// 单条格式(兼容旧格式)
const result = { method: 'alias', confidence: null };
// 值校验:如果 alias 有 value 字段,比较值
if (alias.value && prototypeValue != null) {
const normAlias = normalizeColor(alias.value) || normalizeNumericValue(alias.value) || String(alias.value);
const normProto = normalizeColor(prototypeValue) || normalizeNumericValue(prototypeValue) || String(prototypeValue);
if (normAlias !== normProto) {
// 值不匹配 → 降级到 Layer 2
return null;
}
}
if (alias.status === 'exact_match') {
result.token = alias.token || null;
@@ -549,7 +584,7 @@ function main() {
const prototypeTokens = parseResult.tokens || {};
for (const [key, value] of Object.entries(prototypeTokens)) {
// Layer 1: 别名直查
const aliasResult = matchByAlias(key, aliases);
const aliasResult = matchByAlias(key, value, aliases);
if (aliasResult) {
matched[key] = {
...aliasResult,
@@ -617,9 +652,14 @@ function main() {
const totalAlias = Object.keys(prototypeTokens).length;
const totalInline = Object.values(inlineStyles).reduce((sum, arr) => sum + arr.length, 0);
// 检测 variant
const variantEntries = Object.values(matched).filter(e => e.variant);
const variant = variantEntries.length > 0 ? variantEntries[0].variant : null;
// ---- 输出 ----
const output = {
source: parseResult.source || null,
variant,
matched,
unmatched,
inlineTokenMap,

View File

@@ -263,18 +263,7 @@ function extractInlineStyles(source) {
function extractScreens(source) {
const screens = [];
// 策略:找所有 screen-wrap 块,从中提取 screen-label 文本和组件名
// 两种形式:
// 1) <div className="screen-wrap"> ... <span className="screen-label">文本</span> ... <IosFrame> ... </div>
// 2) <div class="screen-wrap"> ... <span class="screen-label">文本</span> ... <IosFrame> ... </div>
// 匹配所有 screen-wrap 块
const wrapPattern = /<div\s+(?:className|class)="screen-wrap"[^>]*>([\s\S]*?)<\/div>\s*(?=<div\s+(?:className|class)="screen-wrap"|<\/div>\s*<\/div>|\s*$)/g;
// 更可靠的方式:先找所有 screen-label再在上下文中找组件
// 因为 screen-wrap 的 HTML 嵌套可能很复杂
// 提取所有 screen-label 文本
// 策略 A: 找静态 screen-label 文本(外部 span/div 标签)
const labelPattern = /<(?:span|div)\s+(?:className|class)="screen-label"[^>]*>([^<]*)<\/(?:span|div)>/g;
const labels = [];
let labelMatch;
@@ -282,25 +271,35 @@ function extractScreens(source) {
labels.push(labelMatch[1].trim());
}
// 提取所有 screen-wrap 内的 IosFrame children 组件
// 模式: <IosFrame ...>\n <ComponentName />\n</IosFrame>
// 或: <IosFrame ...><ComponentName /></IosFrame>
// 策略 B: 找 IosFrame 的 label propJSX 属性模式)
// 匹配: <IosFrame ... label="文本" ...>
const iosFrameLabelPattern = /<IosFrame[^>]*\blabel=["']([^"']+)["'][^>]*>/g;
const iosLabels = [];
let iosLabelMatch;
while ((iosLabelMatch = iosFrameLabelPattern.exec(source)) !== null) {
iosLabels.push(iosLabelMatch[1].trim());
}
// 提取 IosFrame children 组件
const iosFramePattern = /<IosFrame[^>]*>\s*(?:<React\.Fragment[^>]*>)?\s*<(\w+)\s*\/?>/g;
const components = [];
let compMatch;
while ((compMatch = iosFramePattern.exec(source)) !== null) {
const name = compMatch[1];
// 排除 HTML 标签和 React 内置
if (name !== 'div' && name !== 'span' && name[0] === name[0].toUpperCase()) {
components.push(name);
}
}
// labels 和 components 配对
const count = Math.max(labels.length, components.length);
// 选择 label 来源:优先静态标签,如果数量不匹配则尝试 JSX prop
const useIosLabels = labels.length === 0 || (iosLabels.length > 0 && iosLabels.length === components.length && labels.length !== components.length);
const selectedLabels = useIosLabels ? iosLabels : labels;
// 配对
const count = Math.max(selectedLabels.length, components.length);
for (let i = 0; i < count; i++) {
screens.push({
label: labels[i] || '',
label: selectedLabels[i] || '',
component: components[i] || '',
});
}

View File

@@ -327,16 +327,34 @@ shadow_unmapped:
aliases:
prototype_keys:
T.pri:
token: --tk-pri
status: exact_match
- value: "#C4623A"
token: --tk-pri
status: exact_match
variant: patient
- value: "#3A6B8C"
token: --tk-pri.doctor
status: exact_match
variant: doctor
T.priL:
token: --tk-pri-l
status: exact_match
- value: "#F0DDD4"
token: --tk-pri-l
status: exact_match
variant: patient
- value: "#D4E5F0"
token: --tk-pri-l.doctor
status: exact_match
variant: doctor
T.priD:
token: --tk-pri-d
status: exact_match
- value: "#8B3E1F"
token: --tk-pri-d
status: exact_match
variant: patient
- value: "#2A4F6A"
token: --tk-pri-d.doctor
status: exact_match
variant: doctor
T.bg:
token: null

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

32
.gitignore vendored
View File

@@ -28,6 +28,16 @@ docker/redis_data/
# Test artifacts
.test_token
test-results/
# Build outputs
apps/miniprogram/dist-h5/
# Runtime uploads
uploads/
# Temp logs
_server_out.txt
*.heapsnapshot
perf-trace-*.json
docs/debug-*.png
@@ -72,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 文件),后续增量更新秒级完成

9
Cargo.lock generated
View File

@@ -1429,6 +1429,7 @@ dependencies = [
"handlebars",
"hex",
"redis",
"regex-lite",
"reqwest",
"sea-orm",
"serde",
@@ -1453,6 +1454,7 @@ dependencies = [
"base64 0.22.1",
"cbc",
"chrono",
"dashmap",
"erp-core",
"hex",
"jsonwebtoken",
@@ -1613,6 +1615,7 @@ dependencies = [
"tracing",
"utoipa",
"uuid",
"validator",
"wasmtime",
"wasmtime-wasi",
]
@@ -3978,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',
@@ -38,7 +39,7 @@ export default defineAppConfig({
},
{
root: 'pages/pkg-mall',
pages: ['exchange/index', 'orders/index', 'detail/index'],
pages: ['exchange/index', 'orders/index', 'detail/index', 'product/index'],
},
{
root: 'pages/pkg-profile',
@@ -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

@@ -0,0 +1,14 @@
@import '../../../styles/variables.scss';
.avatar-circle {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&__text {
font-family: 'Georgia', 'Times New Roman', serif;
font-weight: 700;
line-height: 1;
}
}

View File

@@ -0,0 +1,52 @@
import React, { useMemo } from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
type AvatarColor = 'pri' | 'acc' | 'wrn' | 'dan';
interface AvatarCircleProps {
name: string;
size?: number;
color?: AvatarColor;
className?: string;
}
const COLOR_MAP: Record<AvatarColor, { bg: string; fg: string }> = {
pri: { bg: '#D4E5F0', fg: '#3A6B8C' },
acc: { bg: '#E8F0E8', fg: '#5B7A5E' },
wrn: { bg: '#FFF3E0', fg: '#C4873A' },
dan: { bg: '#FDEAEA', fg: '#B54A4A' },
};
const AvatarCircle: React.FC<AvatarCircleProps> = ({
name,
size = 44,
color = 'pri',
className = '',
}) => {
const initial = useMemo(() => name?.charAt(0) || '?', [name]);
const colorStyle = COLOR_MAP[color];
const cls = ['avatar-circle', className].filter(Boolean).join(' ');
return (
<View
className={cls}
style={{
width: `${size}px`,
height: `${size}px`,
borderRadius: `${size / 2}px`,
background: colorStyle.bg,
}}
>
<Text
className="avatar-circle__text"
style={{ color: colorStyle.fg, fontSize: `${Math.round(size * 0.4)}px` }}
>
{initial}
</Text>
</View>
);
};
export default React.memo(AvatarCircle);

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

@@ -0,0 +1,79 @@
@import '../../../styles/variables.scss';
.shortcut-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
&__icon-wrap {
position: relative;
width: 52px;
height: 52px;
border-radius: 26px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s;
}
&__icon {
font-size: 22px;
line-height: 1;
}
&__badge {
position: absolute;
top: -4px;
right: -8px;
min-width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
background: $dan;
color: $white;
font-size: var(--tk-font-micro);
font-weight: 700;
border-radius: $r-pill;
padding: 0 4px;
}
&__label {
font-size: var(--tk-font-cap);
color: $tx2;
}
// ── 色彩变体(对齐 SPEC T.priL/T.accL/T.wrnL/T.danL──
&--pri .shortcut-btn__icon-wrap {
background: #D4E5F0;
}
&--pri .shortcut-btn__icon {
color: #3A6B8C;
}
&--acc .shortcut-btn__icon-wrap {
background: #E8F0E8;
}
&--acc .shortcut-btn__icon {
color: #5B7A5E;
}
&--wrn .shortcut-btn__icon-wrap {
background: #FFF3E0;
}
&--wrn .shortcut-btn__icon {
color: #C4873A;
}
&--dan .shortcut-btn__icon-wrap {
background: #FDEAEA;
}
&--dan .shortcut-btn__icon {
color: #B54A4A;
}
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
type ButtonColor = 'pri' | 'acc' | 'wrn' | 'dan';
interface ShortcutButtonProps {
icon: string;
label: string;
color?: ButtonColor;
onPress?: () => void;
badge?: number;
}
const ShortcutButton: React.FC<ShortcutButtonProps> = ({
icon,
label,
color = 'pri',
onPress,
badge,
}) => {
const cls = ['shortcut-btn', `shortcut-btn--${color}`].join(' ');
return (
<View className={cls} onClick={onPress}>
<View className="shortcut-btn__icon-wrap">
<Text className="shortcut-btn__icon">{icon}</Text>
{badge != null && badge > 0 && (
<Text className="shortcut-btn__badge">
{badge > 99 ? '99+' : badge}
</Text>
)}
</View>
<Text className="shortcut-btn__label">{label}</Text>
</View>
);
};
export default React.memo(ShortcutButton);

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

@@ -28,22 +28,26 @@
}
}
// Pill型 — 文章分类
// Pill型 — 筛选标签(医生端 ActionInbox / FollowUpList
&--pill {
flex-wrap: wrap;
gap: var(--tk-gap-xs);
.tab-filter__item {
height: 32px;
padding: 0 var(--tk-gap-lg);
border-radius: $r-pill;
background: $surface-alt;
padding: 0 16px;
border-radius: $r-lg;
background: $card;
border: 1px solid $bd;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&--active {
background: var(--tk-pri);
border-color: var(--tk-pri);
box-shadow: var(--tk-shadow-tab);
.tab-filter__text {
color: $white;

View File

@@ -0,0 +1,87 @@
@import '../../../styles/variables.scss';
.todo-alert {
display: flex;
align-items: center;
gap: 10px;
border-radius: $r;
padding: 14px 16px;
margin-bottom: 10px;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
&__icon-wrap {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__icon {
font-size: 18px;
line-height: 1;
}
&__body {
flex: 1;
min-width: 0;
}
&__title {
display: block;
font-size: 15px;
font-weight: 600;
color: $tx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__subtitle {
display: block;
font-size: var(--tk-font-cap);
color: $tx3;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__arrow {
flex-shrink: 0;
font-size: 20px;
color: $tx3;
margin-left: 4px;
}
// ── 靛蓝变体 ──
&--pri {
background: #D4E5F0;
border-left: 4px solid #3A6B8C;
.todo-alert__icon-wrap {
background: rgba(58, 107, 140, 0.15);
}
.todo-alert__icon {
color: #3A6B8C;
}
}
// ── 警告变体 ──
&--wrn {
background: #FFF3E0;
border-left: 4px solid #C4873A;
.todo-alert__icon-wrap {
background: rgba(196, 135, 58, 0.15);
}
.todo-alert__icon {
color: #C4873A;
}
}
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
type AlertColor = 'pri' | 'wrn';
interface TodoAlertProps {
icon?: string;
title: string;
subtitle?: string;
color?: AlertColor;
onPress?: () => void;
}
const TodoAlert: React.FC<TodoAlertProps> = ({
icon,
title,
subtitle,
color = 'pri',
onPress,
}) => {
const cls = ['todo-alert', `todo-alert--${color}`].join(' ');
return (
<View className={cls} onClick={onPress}>
{icon && (
<View className="todo-alert__icon-wrap">
<Text className="todo-alert__icon">{icon}</Text>
</View>
)}
<View className="todo-alert__body">
<Text className="todo-alert__title">{title}</Text>
{subtitle && <Text className="todo-alert__subtitle">{subtitle}</Text>}
</View>
<Text className="todo-alert__arrow"></Text>
</View>
);
};
export default React.memo(TodoAlert);

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;
}

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