Compare commits

...

258 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
iven
c26ca9088b feat(skills): E2E 验证通过 + 首个交付包(mp-00-visitor) + .design/tokens.yml
全管线验证结果:
- parse: 22 tokens, 5 screens, 7 components
- screenshots: 5/5 PNG 截图(2x 高清,裁掉设备框)
- token match: 20/22 matched (19 confirmed, 14 pending, 2 unmatched)
- interactions: 5/8 rules matched

交付包: docs/design/mp-00-visitor/
- screenshots/ (5 PNG)
- tokens.json (三层匹配结果)
- META.yml (元数据)

配置: .design/tokens.yml (项目级 Token 配置)
2026-05-18 00:08:05 +08:00
iven
63a9dac9d3 docs(skills): 更新 SKILL.md 脚本调用参数对齐实际接口
- parse-prototype.mjs: 输出 tokens/inlineStyles/screens/components
- extract-screenshots.mjs: 参数改为 <html> <output-dir>
- match-tokens.mjs: 参数改为 <parse-result.json> <tokens.yml>
- infer-interactions.mjs: 参数改为 <html> <rules.yml>
- 输出目录结构对齐 spec: docs/design/mp-00-visitor/
2026-05-18 00:05:59 +08:00
iven
a4732cd2d4 feat(skills): 添加截图提取 + 交互推断脚本 + SPEC 模板
- extract-screenshots.mjs: Playwright 截取 IosFrame 内容区域,裁掉设备框
- infer-interactions.mjs: 8 条规则正则匹配源码推断交互行为
- 重写 interaction-rules.yml: patterns 改为代码级正则(非自然语言描述)
- templates/spec-template.md: SPEC.md 六章节模板
2026-05-18 00:04:31 +08:00
iven
35bd60af5b feat(skills): 添加 HTML 原型解析 + Token 三层匹配脚本
- parse-prototype.mjs: 括号深度计数法提取 T 对象、内联样式值、screen/组件信息
- match-tokens.mjs: 别名直查→值精确匹配(带 CSS 属性消歧)→色彩模糊匹配(RGB Δ<30)
- 修复 tokens.yml radius 段 YAML 格式(unmapped 提升为顶级段)
2026-05-17 23:37:59 +08:00
iven
b8dce8a42a feat(skills): 创建 design-handoff skill 骨架 + 配置文件
- SKILL.md: skill 入口,含 6 步核心流程 + 组件映射推断规则
- defaults/tokens.yml: 从 tokens.scss 提取的完整 Token 注册表
- defaults/components.yml: 16 个 UI 组件映射 + 5 个框架组件
- rules/interaction-rules.yml: 8 条交互推断规则
- package.json + js-yaml 依赖
2026-05-17 23:17:37 +08:00
iven
d26ea64ab2 docs(wiki): 全面重写小程序质量规范清单 — 12类44条规则,覆盖164条git历史
从 164 条小程序 git 提交中全量抽象问题模式:
- 新增 4 类规则:前后端接口契约(字段对齐/API路径/必填字段)、安全(XSS/输入验证/敏感数据/权限)、定时器与副作用清理(setTimeout)、开发环境(DevTools优化)
- 补充已有类别:请求缓存去重(1.4)、统一组件库(3.5-3.6)、认证恢复(5.3-5.4)、对齐原型(7.4-7.5)、ErrorBoundary(9.3)
- 溯源表从 13 条扩至 38 条,每条对应具体修复提交
- 自查脚本从 6 项扩至 10 项
- 统计概览:44 规则 / 66+ fix 提交 / 20 CRITICAL+HIGH
2026-05-17 20:33:25 +08:00
iven
676a6c0e13 docs(wiki): 新增小程序质量规范清单 — 8类规则+提交前检查+PR Review依据
抽象两轮审计中的 20+ 问题为可复用规范:
- 并发与请求层:限流器/长轮询独立通道/reLaunch去重
- 导航与路由:页栈保护/分包预加载/生命周期防重入
- 组件与渲染:禁止render body组件/双重ScrollView/图片懒加载
- 数据与内存:数组上限/去重索引一致/Storage清理
- 认证与会话:模块缓存清理/Token刷新安全
- 审计与交付:提交前必检/PR Review检查点/里程碑审计
2026-05-17 20:12:39 +08:00
iven
fcce2f5c51 fix(mp): 二轮审计修复 — ScrollView嵌套/InputField重建/markdown分组/BLE上限/缓存清理
CRITICAL: ai-report/list PageShell scroll=false 修复双重滚动冲突
HIGH: dialysis/create InputField 提取为独立组件避免 render 销毁重建
MEDIUM: markdownToHtml 连续<li>合并到单个<ul>
MEDIUM: 咨询详情页图片添加 lazyLoad
MEDIUM: BLEManager readings 添加 MAX_LIVE_READINGS=200 上限
MEDIUM: DataBuffer trimToMax 时重建 seenKeys 保持一致性
MEDIUM: auth.ts logout 清理模块级缓存变量
LOW: request.ts safeReLaunch 添加 console.warn + doRefresh 死锁警告注释
2026-05-17 18:54:27 +08:00
iven
66aef532fa docs(wiki): 更新小程序并发安全相关内容 — 并发限制器/长轮询/导航保护 2026-05-17 18:37:55 +08:00
iven
3c98aaedbd docs(wiki): 更新关键数字日期 — 小程序 P0-P2 修复 2026-05-17 17:13:54 +08:00
iven
59dd5ef38e fix(mp): P1+P2 稳定性加固 — 导航安全+生产日志+分包预加载+logout清理
P1:
- 全局 23 个页面 Taro.navigateTo → safeNavigateTo,防止页栈超10层
- 生产构建保留 console.warn/error,便于线上问题排查
- 添加 preloadRule 分包预加载(首页预加载健康/医生/文章分包)

P2:
- logout 时清理 ai_chat_history + BLE DataBuffer 缓存
- restore() 移除冗余的双重 Storage 读取(secureGet 已包含 getStorageSync)
- 首页文章图片添加 lazyLoad
2026-05-17 17:13:35 +08:00
iven
1576709342 docs(wiki): 新增症状导航 — 开发者工具卡死(并发饥饿)修复记录 2026-05-17 17:02:08 +08:00
iven
9d50ef7847 fix(mp): 修复并发请求饥饿导致开发者工具卡死
- 长轮询走独立通道(requestUnlimited),不再占用 ConcurrencyLimiter 槽位
- ConcurrencyLimiter 上限 8→12,缓解 TabBar 切换请求风暴
- 新增 safeReLaunch 去重,防止并发 401 多次触发页面跳转
- maxFailures 50→10,后端不可用时快速止损而非持续 18 分钟重试

根因:咨询页长轮询每次占用槽位 25-30s,8 个槽位被占满后
所有新请求排队等待,叠加 401 场景形成死锁。
2026-05-17 17:01:24 +08:00
iven
b84becfbea fix(mp): 文章详情页对齐 mp-04 原型 — 流式布局+底部浮动栏
- 标题 22px serif bold(原型值),元信息 13px tx3
- 正文 15px tx2 lineHeight:1.8 首行缩进(原型值)
- 去掉分段白卡片,改为统一背景流式布局
- 新增底部浮动栏:收藏+分享按钮
- 分隔线 1px bdL
2026-05-17 15:53:03 +08:00
iven
d5ec250184 feat(docker): 云端部署配置 — host 网络模式 + 环境变量模板
- Dockerfile: Rust 版本升级为 latest stable, 添加 curl (healthcheck),
  前端产物 VOLUME 暴露供 OpenResty 挂载
- docker-compose.cloud.yml: 仅 app 容器, host 网络直连宿主机 PG/Redis
- .env.production.example: 环境变量模板含必填/可选项注释
2026-05-17 15:06:53 +08:00
iven
b8ce19f5dc fix(mp): 文章列表页对齐 mp-04 原型 — 分类Tab+卡片布局+字号
- 分类Tab: 选中态 pri 白字+阴影,未选中 surface-alt 圆角药丸
- 文章卡片: 80×80 缩略图+标题 16px serif+摘要 13px+元信息 12px
- ContentCard padding=sm margin=none,PageShell padding=none
- 缩略图 80×80(原型 80×80),封面 r-xs=8(原型 rXs=8)
2026-05-17 14:55:56 +08:00
iven
29d77e8c3d fix(mp): SegmentTabs pill 增加 margin-bottom 间距 2026-05-17 14:38:29 +08:00
iven
6c42d541fc fix(mp): SegmentTabs pill 变体对齐原型 — 等分圆角矩形 + 阴影
pill 变体改为 flex:1 等分宽度、height:44、borderRadius:12px 圆角矩形
(原型 T.rSm=12),选中态加 var(--tk-shadow-tab) 阴影,
字号 15px fontWeight:600 对齐原型。
2026-05-17 14:34:50 +08:00
iven
c631d364b3 fix(core): 消除乐观锁 version.unwrap() 潜在 panic
20 处 ActiveValue::unwrap() + 1 乐观锁递增改为 take().unwrap_or(0) + 1,
避免数据库记录缺少 version 字段时 panic。覆盖 erp-auth/erp-config/
erp-workflow/erp-health/erp-ai/erp-server 7 个 crate。
DTO 层 Option<i32> 字段保持原有 unwrap_or(0) 不变。
2026-05-17 13:05:40 +08:00
iven
7b2c03309c fix(mp): Profile 积分卡片 flex:1 等分宽度 2026-05-17 13:04:10 +08:00
iven
e8bbc36364 perf(auth): JWT 权限缓存 RwLock 替换为 DashMap
USER_SCOPE_CACHE 从 LazyLock<RwLock<HashMap>> 改为 LazyLock<DashMap>,
消除读写锁竞争,提升高并发场景下的认证中间件吞吐量。
过期条目淘汰逻辑改用 DashMap::retain,无需手动获取 write lock。
2026-05-17 12:54:34 +08:00
iven
c2c7f2d967 docs(wiki): 更新 Design Token 字号统计 + ContentCard margin prop 2026-05-17 12:53:28 +08:00
iven
227d81ddd6 ci(security): 新增 cargo audit + npm audit 安全扫描步骤
后端 CI 添加 cargo audit 依赖漏洞扫描,前端添加 npm audit。
在每次 PR 和 main push 时自动检测已知安全漏洞。
2026-05-17 12:50:27 +08:00
iven
551d19d921 fix(mp): 修正 design token 字号对齐原型 + ContentCard margin prop
- tokens.scss: 修正字号 token 对齐 18 份原型稿 fontSize 统计
  --tk-font-h1: 26→28px, --tk-font-h2: 24→22px
  --tk-font-body-lg: 28→18px, --tk-font-body: 22→16px
  --tk-font-body-sm: 16→14px
  elder-mode 同步重新计算比例系数
- ContentCard: 新增 margin prop ('none'|'md'),margin-bottom
  从 CSS 类移至 inline style,支持列表容器内无间距模式
- Profile 页: 用户卡片添加 profile-user-card flex 布局
  菜单组/积分卡片使用 margin="none",修复布局对齐
- Login 页: SCSS 全部改为 design token 引用
2026-05-17 12:48:38 +08:00
iven
6841c45846 fix(security): 文件上传 MIME 白名单 + OAuth JWT 密钥路径统一
P0 #1: 媒体文件上传增加 MIME 类型白名单校验(jpeg/png/gif/webp/svg/mp4/webm/pdf)
       和文件大小限制(10MB),扩展名使用白名单清理防止路径遍历攻击。
P0 #2: OAuth JWT 密钥从环境变量改为 State 注入,消除运行时 env::var 依赖,
       FHIR 路由中间件使用闭包捕获 jwt_secret 保持类型安全。
2026-05-17 12:40:02 +08:00
iven
8d3c5915c9 docs(analysis): 六维度全面均衡分析 + wiki 关键数字校正
- 6 专家组并行分析:架构 8.5 / 安全 7.5 / 测试 5.5 / 前端 7.2 / DevOps 3.8 / 产品 8.0
- 综合评分 6.8/10 (B),分析报告 + 讨论记录
- wiki 关键数字校正:源文件 652、迁移 147、权限码 128、Web 307 TS/TSX 等
2026-05-17 12:01:15 +08:00
iven
c38967a36e fix(mp): 修复小程序角色路由 + 前后端字段对齐 + E2E 测试报告
- 修复 stores/auth.ts 三种登录方式从错误路径提取 roles(resp.roles → resp.user.roles)
- 首页添加医护人员自动跳转医生端(useDidShow + isMedicalStaff)
- services/auth.ts credentialLogin 返回类型补全 roles 字段
- Web 前端 healthData.ts 字段对齐后端 DTO(indicators→items, content→overall_assessment)
- Web 前端 medicationReminders.ts 字段对齐(time_slots→reminder_times)
- 小程序 report.ts / reports 页面字段对齐后端(indicators→items, doctor_interpretation→doctor_notes)
- 小程序 patient.ts / followup.ts / alert.ts 补全缺失字段
- 后端 stats_handler.rs 权限码修正(health.patient.list→health.dashboard.manage)
- 新增 V1 E2E 测试报告和五专家组评审报告
2026-05-17 01:51:02 +08:00
iven
aa27c5174c docs(mp): 新增小程序全页面 HTML 原型 + UI 优化指南
- 新增 12 个核心页面原型(登录/首页/咨询/预约/商城/健康等)
- 新增医生端分包原型(核心 + 临床两个分包)
- 新增 AI 客服对话页原型
- 新增 MP UI 优化指南文档
- 更新 wiki 基础设施和小程序文档
2026-05-17 00:51:07 +08:00
iven
710b2e2423 feat(ai): 新增 AI 客服聊天功能 + 消息页重构为小华助手
- 新增 POST /ai/chat 端点,由 LLM(Ollama qwen3)担任 24h 健康客服"小华"
- 新增 ai.chat.send 权限,绑定管理员/患者/医生/护士/健康管理师角色
- 消息页从咨询列表重构为单窗口 AI 对话(欢迎态 + 聊天态 + 快捷问诊)
- 通知功能迁移到"我的"页面菜单项(带未读角标),独立通知列表页
- 修复气泡文字截断:改用百分比 max-width + block Text + pre-wrap 换行
- 修复权限绑定:迁移 SQL 角色名从英文改为中文(admin→管理员,patient→患者)
2026-05-17 00:49:41 +08:00
iven
4be28de3ce fix(health): 修复患者端咨询权限+聊天页UI+SVG模板警告
- consultation_handler: create_message/mark_session_read 从 .manage 降为 .list,
  患者端只有 list 权限,导致发送消息和标记已读 403
- consultation.ts: 同步后端 DTO doctor_name/patient_name 等缺失字段
- messages/index.tsx: 咨询卡片显示医生姓名替代 consultation_type
- consultation/index.tsx: 同步显示 doctor_name
- pkg-consultation/detail: 按原型重写聊天页(医生头像+在线状态+非对称气泡+药丸输入栏)
- ProgressRing: SVG 替换为 conic-gradient 纯 CSS,消除 tmpl_0_svg 模板警告
- usePageData: stopPullDownRefresh 加 try-catch 防止 DevTools fd race
2026-05-16 22:38:21 +08:00
iven
95e219ad5a refactor(mp): CSS 变量主题 + 登录页改造 — UI 优化 Phase 0-2
Phase 0: 建立 design token 体系
- tokens.scss 新增 --tk-pri/--tk-pri-l/--tk-pri-d/--tk-shadow-btn/--tk-shadow-tab
- .doctor-mode 覆盖为靛蓝色系,.elder-mode 非线性放大字号
- variables.scss 新增医生端色彩 + 阴影变量

Phase 1: 组件库 + 页面全局替换
- 75 个页面 SCSS $pri → var(--tk-pri) 全量替换
- 11 个新 UI 组件(PrimaryButton/TabFilter/FormInput/ProgressRing 等)
- 8 个现有组件 SCSS 更新
- 18 个医生端页面 useElderClass → useDoctorClass
- PageHeader 匹配原型 NavBar 规格

Phase 2: 登录页重写
- Logo: 方形+ → 圆形渐变 H
- 登录方式: 纯微信 → 账号密码 + 微信一键登录
- 新增 credentialLogin API + store action
- 字号/间距严格匹配原型 mp-01-login.html
2026-05-16 21:29:13 +08:00
iven
1786f0d707 fix(mp): 修复组件库运行时错误 — React 未导入 + SCSS 路径
- 所有原子/组合组件添加 import React from 'react'(修复 React is not defined)
- 修复 SCSS import 路径:components/ui 和 patterns 需要 ../../../ 而非 ../../
- 修复 action-inbox 页面 SCSS import 层级
2026-05-16 07:38:26 +08:00
iven
f8d0b41d61 docs(wiki): 更新关键数字 — 统一组件库迁移完成 2026-05-16 01:56:22 +08:00
iven
184bd0ea03 refactor(mp): 迁移剩余 8 特殊页面到统一组件库
- 首页/健康/我的/商城/消息 TabBar 页面使用 PageShell 替代手写容器
- 登录/法律条款/关怀模式设置页使用 PageShell 替代手写容器
- 各页面卡片统一使用 ContentCard 组件
- 清理页面 SCSS 中的 min-height/background/padding 样板代码
- 66 个小程序页面全部完成统一组件迁移
2026-05-16 01:55:28 +08:00
iven
c6bffd4019 refactor(mp): 迁移个人中心 12 个页面 — 统一组件库
线下活动、健康档案、报告列表、随访列表、透析记录、
透析处方、诊断列表、用药列表、家庭成员、添加家庭成员、
设置、知情同意共 12 个页面迁移:
- 最外层容器 → PageShell
- SCSS 删除 min-height/background 通用样式
2026-05-16 01:34:05 +08:00
iven
466b6567d1 refactor(mp): 迁移商城+AI报告+预约列表+文章详情页 — 统一组件库
商城订单/积分兑换/积分明细、AI 报告列表、预约列表、
文章详情共 6 个页面迁移:
- 最外层容器 → PageShell
- SCSS 删除 min-height/background 通用样式
2026-05-16 01:33:42 +08:00
iven
37327a4da4 refactor(mp): 迁移医护+健康页面 — 使用 PageShell + ContentCard 统一组件库
行动收件箱、医护工作台、健康趋势、患者告警、体征录入、
日常监测、设备同步共 7 个页面迁移:
- 最外层容器 → PageShell
- 卡片元素 → ContentCard
- SCSS 删除 min-height/background/box-shadow 通用样式
2026-05-16 01:33:24 +08:00
iven
4dd5a1b4d9 refactor(mp): 迁移创建页 — 使用 PageShell + ContentCard 统一组件库
处方创建、透析记录创建、预约创建三个表单页迁移:
- 最外层容器 ScrollView/View → PageShell
- 表单分组 section View → ContentCard
- SCSS 删除 min-height/background/box-shadow 通用样式
2026-05-16 01:33:06 +08:00
iven
900c9babc3 refactor(mp): 迁移预约+AI报告详情页 — 使用统一组件库 (12/12)
预约详情页:
- View.detail-page → PageShell 替代手写 page 容器
- 手写 detail-header → PageHeader 组件
- .status-card / .info-section / .tips-card → ContentCard
- 删除通用 page 容器、header 和 card 样式

AI 报告详情页:
- View.detail-page → PageShell 替代手写 page 容器
- .detail-card / .content-card → ContentCard
- 删除通用 page 容器和 card 样式,保留 RichText 内部样式
2026-05-16 01:17:04 +08:00
iven
61f1061092 refactor(mp): 迁移患者端详情页 — 使用统一组件库 (10/12)
报告详情、随访详情、透析记录详情、透析处方详情页:
- View.detail-page → PageShell 替代手写 page 容器
- .detail-card → ContentCard 替代手写卡片样式
- 删除通用 page 容器和 card 样式,保留业务布局样式
2026-05-16 01:16:49 +08:00
iven
85701ddeb2 refactor(mp): 迁移医生核心详情页 — 使用统一组件库 (6/12)
随访详情、患者详情页:
- ScrollView → PageShell 替代手写 page 容器
- .section → ContentCard 替代手写卡片样式
- 删除通用 page 容器和 card 样式,保留业务布局样式
2026-05-16 01:16:32 +08:00
iven
5e230ba1b5 refactor(mp): 迁移医生临床详情页 — 使用统一组件库 (4/12)
告警详情、报告详情、处方详情、透析详情页:
- ScrollView → PageShell 替代手写 min-height/bg/padding
- .section / .alert-detail-card → ContentCard 替代手写卡片样式
- 删除通用 page 容器和 card 样式,保留业务布局样式
2026-05-16 01:16:16 +08:00
iven
8d41d5a167 refactor(mp): 迁移随访列表页 — 使用统一组件库 PageShell/ContentCard/StatusTag/LoadingCard/SearchSection 2026-05-16 00:56:51 +08:00
iven
40b88c566d refactor(mp): 迁移医护咨询列表页 — 使用统一组件库 PageShell/ContentCard/StatusTag/LoadingCard/SearchSection/PaginationBar 2026-05-16 00:56:38 +08:00
iven
483342a1d8 refactor(mp): 迁移告警列表页 — 使用统一组件库 PageShell/ContentCard/StatusTag/LoadingCard/SearchSection/PaginationBar 2026-05-16 00:56:26 +08:00
iven
ae23baeece refactor(mp): 迁移报告列表页 — 使用统一组件库
- PageShell 替代手写 ScrollView + min-height/bg/padding
- SearchSection 替代搜索栏
- ContentCard 替代 report-card 手写样式
- StatusTag 替代 report-card__reviewed 手写标签
- LoadingCard 替代 Loading 组件
- 精简 SCSS:删除 page/search-bar/card 通用样式,保留业务特有样式
2026-05-16 00:56:18 +08:00
iven
3e88dcaba5 refactor(mp): 迁移处方列表页 — 使用统一组件库
- PageShell 替代手写 ScrollView + min-height/bg/padding
- SearchSection 替代搜索栏 + SegmentTabs 替代 tabs(预设患者时)
- ContentCard 替代 prescription-card 手写样式
- StatusTag 替代 status-tag 手写样式
- LoadingCard 替代 Loading 组件
- PaginationBar 替代手写分页
- 精简 SCSS:删除 page/search-bar/card/pagination 通用样式,保留业务特有样式
2026-05-16 00:56:02 +08:00
iven
9415807a40 refactor(mp): 迁移透析列表页 — 使用统一组件库
- PageShell 替代手写 ScrollView + min-height/bg/padding
- SearchSection 替代搜索栏 + SegmentTabs 替代 tabs(预设患者时)
- ContentCard 替代 record-card 手写样式
- StatusTag 替代 status-tag 手写样式
- LoadingCard 替代 Loading 组件
- PaginationBar 替代手写分页
- 精简 SCSS:删除 page/search-bar/card/pagination 通用样式,保留业务特有样式
2026-05-16 00:55:50 +08:00
iven
1579f35ff5 refactor(mp): 迁移患者咨询列表页 — 使用统一组件库
- PageShell 替代手写 min-height/bg/padding
- ContentCard 替代手写 session-card 卡片样式
- StatusTag 替代手写 session-tag 状态标签
- LoadingCard 替代初始加载态
- EmptyState 替代手写空状态
- ErrorState 替代手写错误状态
- 精简 SCSS 删除已接管样式,保留按钮/头像/角标等页面特有样式

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 00:55:40 +08:00
iven
9728afbc1b refactor(mp): 迁移文章列表页 — 使用统一组件库
- PageShell 替代手写 min-height/bg/padding
- ContentCard 替代手写 article-card 卡片样式
- LoadingCard 替代初始加载态
- 精简 SCSS 删除已接管样式,保留分类筛选/卡片内容布局

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 00:55:24 +08:00
iven
80794c9547 refactor(mp): 试点迁移患者列表页 — 使用统一组件库
替换手写 UI 为:
- PageShell 替代手动 min-height/bg/padding
- SearchSection 替代手写搜索栏 + 标签筛选
- ContentCard 替代手写卡片样式(背景/圆角/阴影/触摸反馈)
- StatusTag 替代 @include tag() mixin
- LoadingCard 替代初始加载的 Loading 组件

SCSS 从 151 行精简到 65 行,保留页面特有业务样式。
数据加载逻辑(无限滚动 + usePageData)保持不变。
2026-05-16 00:50:27 +08:00
iven
d758563a13 feat(mp): 小程序统一组件库 Phase 1 — Token 扩展 + 10 组件 + useListPage Hook
三层架构组件库:
- 第 1 层原子组件:PageShell/ContentCard/StatusTag/SectionTitle/LoadingCard
- 第 2 层组合模式:PageHeader/SearchSection/CardList/PaginationBar
- 第 3 层 Hook:useListPage(列表页通用逻辑抽象)

Token 扩展:新增 --tk-card-*/--tk-gap-*/--tk-page-* 等结构化 CSS 变量,
关怀模式通过变量覆写自动生效,新组件零额外代码即获关怀支持。

设计规格:docs/superpowers/specs/2026-05-16-miniprogram-unified-components-design.md
2026-05-16 00:47:39 +08:00
iven
3fb5a77ac0 refactor(mp): 统一空状态为 EmptyState 组件 + 清理旧 Tab CSS
- 4 个页面的内联空状态替换为共享 EmptyState 组件
  (messages / action-inbox / pkg-health/alerts / mall)
- 清理 10 个页面的旧 Tab CSS(迁移到 SegmentTabs 后不再需要)
- 清理 elder-mode 中已删除的 mall-empty-state 引用
2026-05-15 23:11:34 +08:00
811 changed files with 92216 additions and 10398 deletions

View File

@@ -0,0 +1,244 @@
---
name: design-handoff
description: 设计交付编排器 — 调用 huashu-design 设计原型、解析生成 SPEC再由本 skill 根据 SPEC 实施代码,覆盖从需求到实现的完整流程
---
# 设计交付编排器 (design-handoff)
编排从需求到代码实现的完整流程:
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` 原则:不适配则新建,不外部覆盖
---
## 前置检查
执行任何步骤前,必须完成以下检查:
### 1. 输入验证
**模式 A从需求开始** 无需输入文件验证,直接进入 Step 0。
**模式 B从原型开始** 确认输入文件为 huashu-design 产物:
- 文件为 `.html` 格式
- 包含 React + Babel 的 script 标签
- 包含 Token 定义块(`const T = {`
### 2. Token 配置文件初始化
检查 `.design/tokens.yml` 是否存在:
- **存在** → 直接使用
- **不存在** → 从 `defaults/tokens.yml` 复制
---
## 输出目录结构
```
docs/design/
mp-00-visitor.html # 原始原型Step 0 产物或已有文件)
mp-00-visitor/ # 交付包目录
├── SPEC.md # 设计规格文档Step 6 产物)
├── screenshots/ # 截图目录Step 2 产物)
│ ├── home.png
│ └── profile.png
├── tokens.json # Token 匹配结果Step 3 产物)
└── META.yml # 元数据
```
### META.yml 格式
```yaml
prototype: {原型文件名}
source: {HTML文件路径}
variant: patient | doctor
generated_at: {ISO 8601}
tokens:
matched: {数}
unmatched: {数}
components:
total: {数}
mapped: {数}
new: {数}
interactions: {数}
```
---
## 终端输出
完成全部步骤后:
```
============================================================
设计交付完成: {原型名}
输出目录: docs/design/{原型名}/
模式: {完整流程 | 仅解析}
Variant: {patient | doctor}
Token 匹配: {matched}/{total} ({unmatched} 未匹配)
组件: {total} ({new} 需新建)
交互: {数} 项
下一步: 根据 SPEC 实施代码Step 7
============================================================
```
---
## 待确认项交互
完成 Step 1-6 后,若有 `pending``unmatched` Token需与用户交互确认后再进入 Step 7。
---
## 脚本说明
| 脚本 | 用途 |
|------|------|
| `scripts/parse-prototype.mjs` | 解析 HTML 原型为结构化 JSON |
| `scripts/extract-screenshots.mjs` | Playwright 截图(裁掉设备框) |
| `scripts/match-tokens.mjs` | 三层 Token 匹配 |
| `scripts/infer-interactions.mjs` | 交互行为推断 |
**降级策略:** 每个脚本缺失时LLM 自行执行对应步骤。
## 模板与规则
| 文件 | 用途 |
|------|------|
| `templates/spec-template.md` | SPEC.md 模板 |
| `defaults/tokens.yml` | Token 注册表 + 别名映射 |
| `defaults/components.yml` | 组件映射规则 |
| `rules/interaction-rules.yml` | 交互推断规则 |

View File

@@ -0,0 +1,139 @@
version: 1
updated: "2026-05-17"
# ============================================================================
# 小程序 UI 组件映射注册表
# 数据源: apps/miniprogram/src/components/ui/
# 每个组件记录 import 路径和实际 props从源码接口定义提取
# ============================================================================
components:
# --- 数据展示 ---
ContentCard:
miniprogram:
import: "@components/ui/ContentCard"
props: "variant('default'|'outlined'|'elevated'), padding('none'|'sm'|'md'|'lg'), margin('none'|'md'), activeFeedback('bg'|'opacity'|'scale'|'none'), onPress, className, style, children"
notes: "通用卡片容器padding 映射到 --tk-card-padding-* token"
AlertCard:
miniprogram:
import: "@components/ui/AlertCard"
props: "variant('gradient'|'left-border'|'bordered'), title, subtitle, children, className"
notes: "告警/提示卡片,默认 left-border 变体"
VitalCard:
miniprogram:
import: "@components/ui/VitalCard"
props: "label, value, unit, status, onPress, className"
notes: "体征数据卡片,内嵌 StatusTag 显示状态"
ListItem:
miniprogram:
import: "@components/ui/ListItem"
props: "title, subtitle, extra(ReactNode), leftIcon(ReactNode), onPress, showArrow, unread, className"
notes: "列表行组件,支持图标/箭头/未读标记"
InfoRow:
miniprogram:
import: "@components/ui/InfoRow"
props: "label, value, valueNode(ReactNode), last, className"
notes: "键值对信息行last 控制底部分隔线"
ChatBubble:
miniprogram:
import: "@components/ui/ChatBubble"
props: "content, isMine, time, className"
notes: "聊天气泡isMine 控制左右侧和配色"
# --- 导航与标题 ---
SectionTitle:
miniprogram:
import: "@components/ui/SectionTitle"
props: "title, subtitle, action({text, onPress}), icon(ReactNode)"
notes: "区块标题,左侧竖线装饰 + 可选右侧操作链接"
TabFilter:
miniprogram:
import: "@components/ui/TabFilter"
props: "tabs(string[]), activeIndex, onChange(index), variant('fill'|'pill'|'segment'), className"
notes: "标签页筛选器,三种视觉变体"
GradientHeader:
miniprogram:
import: "@components/ui/GradientHeader"
props: "children, className"
notes: "渐变头部容器,承载页面顶部标题区域"
# --- 输入控件 ---
FormInput:
miniprogram:
import: "@components/ui/FormInput"
props: "label, placeholder, value, onInput(value), type('text'|'number'|'idcard'|'digit'), maxLength, disabled, error, className"
notes: "表单输入框,支持错误提示和多种输入类型"
# --- 按钮 ---
PrimaryButton:
miniprogram:
import: "@components/ui/PrimaryButton"
props: "children, onClick, disabled, loading, size('default'|'large'), className"
notes: "主色按钮,高度映射 --tk-btn-primary-h"
SecondaryButton:
miniprogram:
import: "@components/ui/SecondaryButton"
props: "children, onClick, disabled, className"
notes: "次要/描边按钮,与 PrimaryButton 配套使用"
# --- 状态指示 ---
StatusTag:
miniprogram:
import: "@components/ui/StatusTag"
props: "status, colorMap(Record<string, TagColor>), size('sm'|'md'), className, children"
notes: "状态标签,内置 status→color 映射success/warning/error/info/default"
ProgressRing:
miniprogram:
import: "@components/ui/ProgressRing"
props: "progress(0-1), size('sm'|'lg'), label, className"
notes: "环形进度条,使用 conic-gradient + --tk-pri 色"
LoadingCard:
miniprogram:
import: "@components/ui/LoadingCard"
props: "count, layout('card'|'list'|'detail')"
notes: "骨架屏加载占位,三种布局形态"
PageShell:
miniprogram:
import: "@components/ui/PageShell"
props: "padding('none'|'sm'|'md'|'lg'), safeBottom, scroll, className, children"
notes: "页面外壳统一页面内边距和底部安全区scroll 控制是否 ScrollView 包裹"
# ============================================================================
# 框架/平台组件非自研Taro/微信原生)
# ============================================================================
framework_components:
Swiper:
import: "@tarojs/components"
props: "autoplay, interval, duration, circular, indicatorDots, indicatorColor, indicatorActiveColor, onChange, children"
notes: "Taro Swiper 组件,用于轮播图场景"
TabBar:
import: "framework-config"
props: "N/Aapp.config.ts 中声明 tabBar 配置)"
notes: "微信小程序原生 TabBar在 app.config.ts 的 tabBar 字段配置"
ScrollView:
import: "@tarojs/components"
props: "scrollY, scrollX, scrollTop, onScroll, onScrollToLower, onScrollToUpper, children"
notes: "Taro ScrollViewPageShell 内部使用"
Input:
import: "@tarojs/components"
props: "value, placeholder, type, maxlength, disabled, onInput, onFocus, onBlur"
notes: "Taro InputFormInput 内部使用"
Picker:
import: "@tarojs/components"
props: "mode('date'|'time'|'selector'), value, range, onChange, children"
notes: "Taro Picker用于日期/时间/选项选择"

View File

@@ -0,0 +1,423 @@
version: 1
updated: "2026-05-17"
# ============================================================================
# Design Token 注册表
# 数据源: apps/miniprogram/src/styles/tokens.scss + variables.scss
# ============================================================================
colors:
# --- 主色系(赤土橙) ---
- token: --tk-pri
value: "#C4623A"
scss_var: "$pri"
role: 主色/赤土橙 accent
- token: --tk-pri-l
value: "#F0DDD4"
scss_var: "$pri-l"
role: 主色浅/赤土浅
- token: --tk-pri-d
value: "#8B3E1F"
scss_var: "$pri-d"
role: 主色深/赤土深
# --- 阴影色(含透明度) ---
- token: --tk-shadow-btn
value: "0 4px 16px rgba(196, 98, 58, 0.3)"
scss_var: "$shadow-btn"
role: 主按钮阴影
- token: --tk-shadow-tab
value: "0 2px 8px rgba(196, 98, 58, 0.25)"
scss_var: "$shadow-tab"
role: 选中Tab阴影
# --- 文字色 ---
- token: --tk-text-secondary
value: "#78716C"
scss_var: "$tx3"
role: 淡文字/辅助文字
# --- 卡片背景 ---
- token: --tk-card-bg
value: "#FFFFFF"
scss_var: "$card"
role: 卡片白底
# --- 医生端覆盖色(.doctor-mode 下自动替换 --tk-pri* ---
- token: --tk-pri.doctor
value: "#3A6B8C"
scss_var: "$doc-pri"
role: 医生端主色/靛蓝
note: 仅在 .doctor-mode 下覆盖 --tk-pri
- token: --tk-pri-l.doctor
value: "#D4E5F0"
scss_var: "$doc-pri-l"
role: 医生端浅色
- token: --tk-pri-d.doctor
value: "#2A4F6A"
scss_var: "$doc-pri-d"
role: 医生端深色
# --- 未映射到 Token 的 SCSS 变量(原型中有但 tokens.scss 未声明为 CSS 变量) ---
unmapped_scss_variables:
- scss_var: "$bg"
value: "#F5F0EB"
role: 页面主背景/温润米底
note: "原型 T.bg 映射目标tokens.scss 未声明为 CSS 变量"
- scss_var: "$tx"
value: "#2D2A26"
role: 主文字色/暖黑
note: "原型 T.tx 映射目标tokens.scss 未声明为 CSS 变量"
- scss_var: "$tx2"
value: "#5A554F"
role: 次文字色/暖灰
note: "原型 T.tx2 近似映射elder-mode 下 --tk-text-secondary 覆盖为此值"
- scss_var: "$bd"
value: "#E8E2DC"
role: 边框色
note: "原型 T.bd 映射目标tokens.scss 未声明为 CSS 变量"
- scss_var: "$bd-l"
value: "#F0EBE5"
role: 浅边框色
- scss_var: "$acc"
value: "#5B7A5E"
role: 鼠尾草绿/成功色
note: "原型中成功色tokens.scss 未声明为 CSS 变量"
- scss_var: "$acc-l"
value: "#E8F0E8"
role: 成功浅色
- scss_var: "$dan"
value: "#B54A4A"
role: 危险色/柔红
note: "原型中危险色tokens.scss 未声明为 CSS 变量"
- scss_var: "$dan-l"
value: "#FDEAEA"
role: 危险浅色
- scss_var: "$wrn"
value: "#C4873A"
role: 警告色/暖琥珀
note: "原型中警告色tokens.scss 未声明为 CSS 变量"
- scss_var: "$wrn-l"
value: "#FFF3E0"
role: 警告浅色
- scss_var: "$surface-alt"
value: "#EDE8E2"
role: 辅助底色
- scss_var: "$wechat"
value: "#07C160"
role: 微信绿
typography:
- token: --tk-font-display
value: "72px"
note: 大屏展示
elder: "80px"
- token: --tk-font-hero
value: "48px"
note: 启动页标题
elder: "56px"
- token: --tk-font-h1
value: "28px"
note: 页面标题 serif bold
elder: "32px"
- token: --tk-font-h2
value: "22px"
note: 副标题、用户名 serif bold
elder: "25px"
- token: --tk-font-body-lg
value: "18px"
note: 按钮文字、section 标题 fontWeight:600
elder: "22px"
- token: --tk-font-body
value: "16px"
note: 正文、输入框、icon 文字(最常用 UI 字号)
elder: "22px"
- token: --tk-font-body-sm
value: "14px"
note: 副文本、描述
elder: "19px"
- token: --tk-font-num
value: "30px"
note: 数值 serif bold
elder: "34px"
- token: --tk-font-num-lg
value: "34px"
note: 大数值
elder: "40px"
- token: --tk-font-cap
value: "13px"
note: 说明文字(第一高频字号)
elder: "18px"
- token: --tk-font-nav
value: "18px"
note: 导航栏标题 serif bold
elder: "22px"
- token: --tk-font-micro
value: "11px"
note: 角标、tag
elder: "17px"
structure:
- token: --tk-line-height
value: "1.5"
elder: "1.7"
spacing:
- token: --tk-gap-2xs
value: "4px"
scss_var: "$sp-2xs"
elder: "6px"
- token: --tk-gap-xs
value: "8px"
scss_var: "$sp-xs"
elder: "12px"
- token: --tk-gap-sm
value: "12px"
scss_var: "$sp-sm"
elder: "16px"
- token: --tk-gap-md
value: "16px"
scss_var: "$sp-md"
elder: "20px"
- token: --tk-section-gap
value: "20px"
scss_var: "$sp-section"
elder: "28px"
- token: --tk-gap-lg
value: "24px"
scss_var: "$sp-lg"
elder: "32px"
- token: --tk-gap-xl
value: "32px"
scss_var: "$sp-xl"
elder: "40px"
- token: --tk-gap-2xl
value: "48px"
scss_var: "$sp-2xl"
elder: "56px"
- token: --tk-page-padding
value: "20px"
elder: "28px"
- token: --tk-card-padding
value: "20px"
elder: "28px"
- token: --tk-card-padding-sm
value: "16px"
elder: "20px"
- token: --tk-card-padding-lg
value: "28px"
elder: "36px"
radius:
- token: --tk-card-radius
value: "16px"
scss_var: "$r"
elder: "20px"
radius_unmapped:
- scss_var: "$r-sm"
value: "12px"
note: "原型 T.rSm 映射目标"
- scss_var: "$r-xs"
value: "8px"
note: "原型 T.rXs 映射目标"
- scss_var: "$r-lg"
value: "20px"
- scss_var: "$r-pill"
value: "999px"
sizing:
- token: --tk-touch-min
value: "48px"
role: 最小触控区
elder: "56px"
- token: --tk-btn-primary-h
value: "52px"
role: 主按钮高度
elder: "60px"
- token: --tk-input-height
value: "56px"
role: 输入框高度
elder: "64px"
- token: --tk-tabbar-space
value: "100px"
role: TabBar 底部安全区
elder: "120px"
feedback:
- token: --tk-touch-feedback-opacity
value: "0.85"
role: 触控反馈透明度
elder: "0.8"
tag:
- token: --tk-tag-font-size
value: "11px"
elder: "13px"
- token: --tk-tag-padding-v
value: "3px"
elder: "5px"
- token: --tk-tag-padding-h
value: "8px"
elder: "12px"
shadow_unmapped:
# tokens.scss 中的 --tk-shadow-btn/tab 是复合值(含偏移+模糊+颜色)
# 以下为 variables.scss 中的其他阴影,未声明为 CSS Token
- scss_var: "$shadow-sm"
value: "0 1px 4px rgba(45, 42, 38, 0.06)"
role: 小阴影
- scss_var: "$shadow-md"
value: "0 2px 12px rgba(45, 42, 38, 0.10)"
role: 中阴影
- scss_var: "$shadow-lg"
value: "0 8px 32px rgba(45, 42, 38, 0.15)"
role: 大阴影
# ============================================================================
# 原型 Key → Token 映射aliases
# 用于设计移交时自动匹配原型属性到实际 Token
# ============================================================================
aliases:
prototype_keys:
T.pri:
- value: "#C4623A"
token: --tk-pri
status: exact_match
variant: patient
- value: "#3A6B8C"
token: --tk-pri.doctor
status: exact_match
variant: doctor
T.priL:
- 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:
- 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
nearest: --tk-card-bg
scss_var: "$bg"
value: "#F5F0EB"
status: unmatched
note: "原型页面背景色tokens.scss 未声明为 CSS 变量,直接用 $bg SCSS 变量"
T.card:
token: --tk-card-bg
status: exact_match
T.surface:
token: --tk-card-bg
status: approximate
note: "原型中 surface ≈ 卡片白底"
T.tx:
token: null
nearest: --tk-text-secondary
scss_var: "$tx"
value: "#2D2A26"
status: unmatched
note: "主文字色tokens.scss 未声明为 CSS 变量,直接用 $tx SCSS 变量"
T.tx2:
token: null
nearest: --tk-text-secondary
scss_var: "$tx2"
value: "#5A554F"
status: unmatched
note: "次文字色tokens.scss 未声明elder-mode 下 --tk-text-secondary 覆盖为此值"
T.tx3:
token: --tk-text-secondary
scss_var: "$tx3"
value: "#78716C"
status: exact_match
T.bd:
token: null
scss_var: "$bd"
value: "#E8E2DC"
status: unmatched
note: "边框色不是圆角tokens.scss 未声明为 CSS 变量"
T.r:
token: --tk-card-radius
scss_var: "$r"
value: "16px"
status: exact_match
T.rSm:
token: null
scss_var: "$r-sm"
value: "12px"
status: unmatched
note: "tokens.scss 未声明,需添加 --tk-radius-sm 或直接用 $r-sm SCSS 变量"
T.rXs:
token: null
scss_var: "$r-xs"
value: "8px"
status: unmatched
note: "tokens.scss 未声明,需添加 --tk-radius-xs 或直接用 $r-xs SCSS 变量"

View File

@@ -0,0 +1,79 @@
{
"name": "design-handoff",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "design-handoff",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"js-yaml": "^4.1.1",
"playwright": "^1.58.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"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",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"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

@@ -0,0 +1,17 @@
{
"name": "design-handoff",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"js-yaml": "^4.1.1",
"playwright": "^1.58.0"
}
}

View File

@@ -0,0 +1,114 @@
version: 1
updated: "2026-05-17"
# ============================================================================
# 交互推断规则
# patterns 使用正则表达式,匹配 HTML/JS 源码中的实际代码模式
# require_all: true 表示所有 pattern 必须同时匹配(默认 false任一匹配即可
# ============================================================================
rules:
- id: swiper-autoplay
name: "自动轮播 + 手动滑动"
patterns:
- "linear-gradient"
- "width.*24.*width.*8"
require_all: false
infer:
component: "Swiper"
props: "autoplay circular indicatorDots"
behavior: "自动轮播3-5秒切换"
confidence: high
- id: card-tap
name: "卡片点击跳转"
patterns:
- "\\.map\\("
require_all: false
infer:
component: "ContentCard"
props: "activeFeedback onPress"
behavior: "卡片可点击,带触控反馈"
confidence: medium
- id: form-submit
name: "表单提交"
patterns:
- "<input"
- "<button"
require_all: false
infer:
component: "FormInput + PrimaryButton"
props: ""
behavior: "表单输入+提交按钮"
confidence: high
- id: list-scroll
name: "列表滚动"
patterns:
- "overflow.*auto|scroll"
- "\\.map\\(.*\\.map\\(.*\\.map\\("
require_all: false
infer:
component: "ScrollView"
props: "scrollY onScrollToLower"
behavior: "可滚动列表,支持上拉加载"
confidence: medium
- id: tab-switch
name: "标签页切换"
patterns:
- "tab"
- "segment"
- "filter"
require_all: false
infer:
component: "TabFilter"
props: "tabs onChange"
behavior: "标签页切换筛选"
confidence: medium
- id: static-decoration
name: "纯装饰无交互"
patterns:
- "position.*absolute"
- "opacity.*0\\."
require_all: true
infer:
component: null
props: ""
behavior: "纯装饰性元素,无交互"
confidence: high
- id: login-cta
name: "登录/注册触发"
patterns:
- "登录"
- "注册"
- "微信登录"
- "立即登录"
exclude_patterns:
- "立即关注"
- "立即处理"
- "立即查看"
- "需要立即"
require_all: false
infer:
component: "PrimaryButton"
props: "onClick"
behavior: "登录/注册引导按钮"
confidence: high
- id: empty-fallback
name: "空数据降级"
patterns:
- "\\.length > 0"
- "\\.length === 0"
- "暂无"
- "没有"
require_all: false
infer:
component: null
props: ""
behavior: "条件渲染:有数据显示列表,无数据显示空状态"
confidence: medium

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env node
/**
* extract-screenshots.mjs — 从 huashu-design HTML 原型截取 IosFrame 屏幕内容
*
* 用法: node extract-screenshots.mjs <html-file> <output-dir>
*/
import { chromium } from 'playwright';
import { resolve, basename } from 'node:path';
import { mkdirSync, existsSync } from 'node:fs';
const IOS_FRAME_TRIM = { statusBar: 54, homeIndicator: 34, padding: 12 };
const RENDER_WAIT_TIMEOUT = 10_000;
const RENDER_POLL_INTERVAL = 200;
const ZH_EN_MAP = {
'完整页': '', '—': '-', ' ': '-',
'首页': 'home', '我的': 'profile', '登录': 'login', '健康': 'health',
'消息': 'messages', '咨询': 'consultation', '预约': 'appointment',
'商城': 'mall', '设置': 'settings', '访客': 'guest', '轮播': 'slide',
'体检': 'checkup', '报告': 'report', '医生': 'doctor', '患者': 'patient',
'记录': 'record', '详情': 'detail', '列表': 'list', '管理': 'manage',
'编辑': 'edit', '新增': 'add', '搜索': 'search', '筛选': 'filter',
'结果': 'result', '历史': 'history', '数据': 'data', '体征': 'vitals',
'用药': 'medication', '随访': 'followup', '透析': 'dialysis',
'日常': 'daily', '监测': 'monitor', '告警': 'alert', '家庭': 'family',
'成员': 'member', '档案': 'profile', '商品': 'product', '兑换': 'exchange',
'订单': 'order', '积分': 'points', '活动': 'activity',
};
function translateLabel(label, fallbackIndex) {
let result = label;
const sortedKeys = Object.keys(ZH_EN_MAP).sort((a, b) => b.length - a.length);
for (const zh of sortedKeys) {
result = result.split(zh).join(ZH_EN_MAP[zh]);
}
result = result.replace(/[^\w\-.]/g, '-');
result = result.replace(/^-+|-+$/g, '').replace(/-+/g, '-');
return result || `screen-${fallbackIndex}`;
}
function fail(msg) {
process.stderr.write(`ERROR: ${msg}\n`);
process.exit(1);
}
async function main() {
const args = process.argv.slice(2);
if (args.length < 2) fail('用法: node extract-screenshots.mjs <html-file> <output-dir>');
const htmlFile = resolve(args[0]);
const outputDir = resolve(args[1]);
if (!existsSync(htmlFile)) fail(`HTML 文件不存在: ${htmlFile}`);
mkdirSync(outputDir, { recursive: true });
let browser;
try {
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 2400, height: 1400 },
deviceScaleFactor: 2,
});
const page = await context.newPage();
const fileUrl = `file:///${htmlFile.replace(/\\/g, '/')}`;
await page.goto(fileUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
// 等待 React 渲染
const renderStart = Date.now();
let rendered = false;
while (Date.now() - renderStart < RENDER_WAIT_TIMEOUT) {
rendered = await page.evaluate(() => {
const root = document.getElementById('root');
return root ? root.children.length > 0 : false;
});
if (rendered) break;
await page.waitForTimeout(RENDER_POLL_INTERVAL);
}
if (!rendered) fail(`React 渲染超时: ${basename(htmlFile)}`);
await page.waitForTimeout(500);
// 查找 .screen-wrap 容器
const screenCount = await page.locator('.screen-wrap').count();
if (screenCount === 0) {
// 降级:整页截图
await page.screenshot({ path: resolve(outputDir, 'full-page.png'), fullPage: true });
process.stdout.write(JSON.stringify({
source: basename(htmlFile), totalScreens: 0, fallback: true,
files: [{ label: 'full-page', file: 'full-page.png' }],
}, null, 2) + '\n');
await browser.close();
return;
}
// 使用 page.evaluate 定位并截取每个 screen
const screenData = await page.evaluate(() => {
const wraps = document.querySelectorAll('.screen-wrap');
return Array.from(wraps).map((wrap, i) => {
// 获取标签
const labelEl = wrap.querySelector('.screen-label');
const label = labelEl ? labelEl.textContent.trim() : '';
// 找 IosFrame wrapper: screen-wrap 下有 borderRadius:60 + background:#000 的 div
// 或者用策略:找第一个有 style 含 borderRadius 的 div
const allDivs = wrap.querySelectorAll(':scope > div');
let wrapperDiv = null;
for (const div of allDivs) {
const style = div.getAttribute('style') || '';
// IosFrame wrapper 特征: background:#000 或 background: black
if (style.includes('background:')) {
wrapperDiv = div;
break;
}
}
// 如果没找到 background 的,取第一个 div
if (!wrapperDiv && allDivs.length > 0) wrapperDiv = allDivs[0];
if (!wrapperDiv) return { label, index: i, found: false };
const wrapperBox = wrapperDiv.getBoundingClientRect();
return {
label,
index: i,
found: true,
wrapperBox: {
x: wrapperBox.x,
y: wrapperBox.y,
width: wrapperBox.width,
height: wrapperBox.height,
},
};
});
});
const { statusBar, homeIndicator, padding } = IOS_FRAME_TRIM;
const files = [];
const usedNames = new Set();
for (const screen of screenData) {
if (!screen.found || !screen.wrapperBox) {
process.stderr.write(`WARNING: screen ${screen.index + 1} 未找到 IosFrame跳过\n`);
continue;
}
const box = screen.wrapperBox;
const clip = {
x: box.x + padding,
y: box.y + padding + statusBar,
width: box.width - padding * 2,
height: box.height - padding * 2 - statusBar - homeIndicator,
};
if (clip.width <= 0 || clip.height <= 0) {
process.stderr.write(`WARNING: screen ${screen.index + 1} clip 无效 (${clip.width}x${clip.height})\n`);
continue;
}
let safeName = translateLabel(screen.label || '', screen.index + 1);
if (usedNames.has(safeName)) {
let s = 2;
while (usedNames.has(`${safeName}-${s}`)) s++;
safeName = `${safeName}-${s}`;
}
usedNames.add(safeName);
const filename = `${safeName}.png`;
await page.screenshot({ path: resolve(outputDir, filename), clip });
files.push({
label: screen.label || `screen-${screen.index + 1}`,
file: filename,
clip: {
x: Math.round(clip.x), y: Math.round(clip.y),
width: Math.round(clip.width), height: Math.round(clip.height),
},
});
}
process.stdout.write(JSON.stringify({
source: basename(htmlFile),
totalScreens: screenCount,
extracted: files.length,
files,
}, null, 2) + '\n');
} catch (err) {
fail(`Playwright 执行失败: ${err.message}`);
} finally {
if (browser) await browser.close();
}
}
main();

View File

@@ -0,0 +1,222 @@
#!/usr/bin/env node
/**
* infer-interactions.mjs -- 对 HTML 原型源码进行静态模式匹配,推断页面交互行为
*
* 读取 HTML 文件和 interaction-rules.yml用正则匹配源码中的模式
* 输出推断的交互组件、props 和行为描述。
*
* 用法: node infer-interactions.mjs <html-file> <interaction-rules.yml>
*/
import { readFileSync } from 'node:fs';
import { resolve, basename } from 'node:path';
import { createRequire } from 'node:module';
// js-yaml 是 CJS 包,需要用 createRequire 加载
const require = createRequire(import.meta.url);
const yaml = require('js-yaml');
// -- 工具函数 ----------------------------------------------------------------
/**
* 输出错误到 stderr 并以 code 1 退出
*/
function fail(message) {
process.stderr.write(JSON.stringify({ error: message }, null, 2) + '\n');
process.exit(1);
}
/**
* 读取文件内容,失败时调用 fail
*/
function readFileOrFail(filePath, description) {
try {
return readFileSync(filePath, 'utf-8');
} catch (err) {
fail(`无法读取${description}: ${filePath} -- ${err.message}`);
}
}
// -- 组件函数位置追踪 ---------------------------------------------------------
/**
* 提取源码中所有 function/const 组件声明的位置和名称。
* 返回 [{ name, start, end }],按 start 排序。
*
* end 的计算采用简化策略:找到下一个同级 function/const 声明或源码末尾。
* 对于 locations 追踪来说已经足够精确——只要知道 pattern 落在哪个函数内即可。
*/
function extractFunctionRanges(source) {
const ranges = [];
// 匹配 function 声明: function XxxName(
const funcPattern = /function\s+([A-Z]\w*)\s*\(/g;
let match;
while ((match = funcPattern.exec(source)) !== null) {
ranges.push({ name: match[1], start: match.index });
}
// 匹配 const 箭头函数: const XxxName = (...
const arrowPattern = /const\s+([A-Z]\w*)\s*=\s*(?:\([^)]*\)|\w+)\s*=>/g;
while ((match = arrowPattern.exec(source)) !== null) {
ranges.push({ name: match[1], start: match.index });
}
// 按 start 排序,计算 end下一个函数的 start 或源码末尾)
ranges.sort((a, b) => a.start - b.start);
for (let i = 0; i < ranges.length; i++) {
const nextStart = i + 1 < ranges.length ? ranges[i + 1].start : source.length;
ranges[i].end = nextStart;
}
return ranges;
}
/**
* 给定 pattern 在 source 中的匹配位置,找到它所属的函数名。
* 如果不在任何函数内,返回 '全局'。
*/
function findEnclosingFunction(ranges, matchIndex) {
for (const range of ranges) {
if (matchIndex >= range.start && matchIndex < range.end) {
return range.name;
}
}
return '全局';
}
// -- 核心匹配逻辑 ------------------------------------------------------------
/**
* 对单条 rule 执行模式匹配。
* 返回 { matched, matchedPatterns, locations }。
*/
function matchRule(rule, source, funcRanges) {
const requireAll = rule.require_all === true;
const matchedPatterns = [];
const locationSet = new Set();
for (const patternStr of rule.patterns) {
try {
const regex = new RegExp(patternStr, 'g');
let match;
let patternMatched = false;
while ((match = regex.exec(source)) !== null) {
patternMatched = true;
const enclosing = findEnclosingFunction(funcRanges, match.index);
locationSet.add(enclosing);
}
if (patternMatched) {
matchedPatterns.push(patternStr);
}
} catch (err) {
// 正则语法错误,跳过此 pattern 并报告到 stderr
process.stderr.write(`警告: rule "${rule.id}" 的 pattern "${patternStr}" 正则语法错误: ${err.message}\n`);
}
}
const allMatched = requireAll
? 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 : [],
locations: allMatched ? [...locationSet] : [],
};
}
// -- 主流程 ------------------------------------------------------------------
function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
fail('用法: node infer-interactions.mjs <html-file> <interaction-rules.yml>');
}
const htmlPath = resolve(args[0]);
const rulesPath = resolve(args[1]);
// 读取源文件
const source = readFileOrFail(htmlPath, 'HTML 文件');
// 读取并解析 rules YAML
const rulesContent = readFileOrFail(rulesPath, 'interaction-rules.yml');
let rulesDoc;
try {
rulesDoc = yaml.load(rulesContent);
} catch (err) {
fail(`YAML 解析失败: ${err.message}`);
}
if (!rulesDoc || !Array.isArray(rulesDoc.rules)) {
fail('interaction-rules.yml 格式错误: 缺少顶层的 rules 数组');
}
const rules = rulesDoc.rules;
// 提取函数位置范围表(用于 locations 追踪)
const funcRanges = extractFunctionRanges(source);
// 逐条匹配
const interactions = [];
let matchedCount = 0;
for (const rule of rules) {
const { matched, matchedPatterns, locations } = matchRule(rule, source, funcRanges);
const entry = {
id: rule.id,
name: rule.name,
matched,
};
if (matched) {
matchedCount++;
entry.matchedPatterns = matchedPatterns;
entry.component = rule.infer?.component ?? null;
entry.props = rule.infer?.props ?? '';
entry.behavior = rule.infer?.behavior ?? '';
entry.confidence = rule.confidence ?? 'medium';
if (locations.length > 0) {
entry.locations = locations;
}
}
interactions.push(entry);
}
// 组装输出
const result = {
source: basename(htmlPath),
interactions,
summary: {
total: rules.length,
matched: matchedCount,
unmatched: rules.length - matchedCount,
},
};
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
main();

View File

@@ -0,0 +1,706 @@
#!/usr/bin/env node
/**
* match-tokens.mjs — 三层 Token 匹配
*
* 接收 parse-prototype.mjs 的输出 JSON 和 tokens.yml 配置,
* 对每个原型 Token别名 / 内联样式值)执行三层匹配,
* 输出映射关系到 stdout。
*
* 三层匹配算法:
* Layer 1: 别名直查aliases.prototype_keys
* Layer 2: 值精确匹配(带 CSS 属性上下文消歧)
* Layer 3: 色彩模糊匹配RGB 欧几里得距离 < 30
*
* 用法:
* node match-tokens.mjs <parse-result.json> <tokens.yml>
*/
import yaml from 'js-yaml';
import { readFileSync } from 'fs';
import { resolve } from 'path';
// ---------------------------------------------------------------------------
// 工具函数
// ---------------------------------------------------------------------------
/**
* 解析 hex 颜色字符串为 {r, g, b}
* 支持 #RGB / #RRGGBB / RRGGBB
*/
function hexToRgb(hex) {
if (!hex || typeof hex !== 'string') return null;
const cleaned = hex.replace(/^#/, '').trim();
if (cleaned.length === 3) {
const r = parseInt(cleaned[0] + cleaned[0], 16);
const g = parseInt(cleaned[1] + cleaned[1], 16);
const b = parseInt(cleaned[2] + cleaned[2], 16);
return { r, g, b };
}
if (cleaned.length === 6) {
const r = parseInt(cleaned.substring(0, 2), 16);
const g = parseInt(cleaned.substring(2, 4), 16);
const b = parseInt(cleaned.substring(4, 6), 16);
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
return { r, g, b };
}
return null;
}
/**
* RGB 欧几里得距离
*/
function colorDistance(rgb1, rgb2) {
return Math.sqrt(
(rgb1.r - rgb2.r) ** 2 +
(rgb1.g - rgb2.g) ** 2 +
(rgb1.b - rgb2.b) ** 2,
);
}
/**
* 判断字符串是否为颜色值hex 格式)
*/
function isHexColor(value) {
if (!value || typeof value !== 'string') return false;
return /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(value.trim());
}
/**
* 归一化数值:去掉 px 后缀,返回数值字符串(保留小数)
*/
function normalizeNumericValue(value) {
if (value == null) return null;
const str = String(value).trim();
if (str.endsWith('px')) {
return str.slice(0, -2).trim();
}
if (str.endsWith('rem') || str.endsWith('em')) {
return null; // rem/em 标记为 pending不参与数值匹配
}
return str;
}
/**
* 归一化颜色值:统一为小写 #rrggbb
*/
function normalizeColor(value) {
if (!value) return null;
const str = String(value).trim();
const hex = str.startsWith('#') ? str : `#${str}`;
const rgb = hexToRgb(hex);
if (!rgb) return null;
return `#${rgb.r.toString(16).padStart(2, '0')}${rgb.g.toString(16).padStart(2, '0')}${rgb.b.toString(16).padStart(2, '0')}`;
}
/**
* CSS 属性到 token 类别的映射
*/
const CSS_PROPERTY_CATEGORIES = {
// 颜色类
color: 'color',
backgroundColor: 'color',
'background-color': 'color',
background: 'color',
borderColor: 'color',
'border-color': 'color',
borderTopColor: 'color',
borderBottomColor: 'color',
borderLeftColor: 'color',
borderRightColor: 'color',
outlineColor: 'color',
textColor: 'color',
fill: 'color',
// 间距类
padding: 'spacing',
paddingTop: 'spacing',
paddingBottom: 'spacing',
paddingLeft: 'spacing',
paddingRight: 'spacing',
paddingVertical: 'spacing',
paddingHorizontal: 'spacing',
margin: 'spacing',
marginTop: 'spacing',
marginBottom: 'spacing',
marginLeft: 'spacing',
marginRight: 'spacing',
gap: 'spacing',
rowGap: 'spacing',
columnGap: 'spacing',
top: 'spacing',
bottom: 'spacing',
left: 'spacing',
right: 'spacing',
// 排版类
fontSize: 'typography',
lineHeight: 'typography',
fontWeight: 'typography',
letterSpacing: 'typography',
// 圆角类
borderRadius: 'radius',
borderTopLeftRadius: 'radius',
borderTopRightRadius: 'radius',
borderBottomLeftRadius: 'radius',
borderBottomRightRadius: 'radius',
// 尺寸类
width: 'sizing',
height: 'sizing',
minWidth: 'sizing',
minHeight: 'sizing',
maxWidth: 'sizing',
maxHeight: 'sizing',
};
// ---------------------------------------------------------------------------
// Token 注册表构建
// ---------------------------------------------------------------------------
/**
* 从 tokens.yml 构建可查找的 Token 列表
* 返回 { all: [...], byCategory: { color: [...], spacing: [...], ... } }
*/
function buildTokenRegistry(tokensConfig) {
const all = [];
const byCategory = {};
function addToken(entry, category) {
const record = {
token: entry.token || null,
scssVar: entry.scss_var || null,
value: entry.value || null,
category,
role: entry.role || null,
note: entry.note || null,
};
all.push(record);
if (!byCategory[category]) byCategory[category] = [];
byCategory[category].push(record);
}
// colors
if (Array.isArray(tokensConfig.colors)) {
for (const c of tokensConfig.colors) {
addToken(c, 'color');
}
}
// unmapped_scss_variables颜色类
if (Array.isArray(tokensConfig.unmapped_scss_variables)) {
for (const v of tokensConfig.unmapped_scss_variables) {
addToken(v, 'color');
}
}
// typography
if (Array.isArray(tokensConfig.typography)) {
for (const t of tokensConfig.typography) {
addToken(t, 'typography');
}
}
// structure (line-height 归入 typography)
if (Array.isArray(tokensConfig.structure)) {
for (const s of tokensConfig.structure) {
addToken(s, 'typography');
}
}
// spacing
if (Array.isArray(tokensConfig.spacing)) {
for (const s of tokensConfig.spacing) {
addToken(s, 'spacing');
}
}
// radius
if (Array.isArray(tokensConfig.radius)) {
for (const r of tokensConfig.radius) {
if (r && typeof r === 'object') {
addToken(r, 'radius');
}
}
}
if (Array.isArray(tokensConfig.radius_unmapped)) {
for (const r of tokensConfig.radius_unmapped) {
if (r && typeof r === 'object') {
addToken(r, 'radius');
}
}
}
// sizing
if (Array.isArray(tokensConfig.sizing)) {
for (const s of tokensConfig.sizing) {
addToken(s, 'sizing');
}
}
// feedback归入 sizing 或单独)
if (Array.isArray(tokensConfig.feedback)) {
for (const f of tokensConfig.feedback) {
addToken(f, 'feedback');
}
}
// tag
if (Array.isArray(tokensConfig.tag)) {
for (const t of tokensConfig.tag) {
addToken(t, 'tag');
}
}
// shadow_unmapped
if (Array.isArray(tokensConfig.shadow_unmapped)) {
for (const s of tokensConfig.shadow_unmapped) {
addToken(s, 'shadow');
}
}
return { all, byCategory };
}
// ---------------------------------------------------------------------------
// 三层匹配
// ---------------------------------------------------------------------------
/**
* 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, prototypeValue, aliases) {
if (!aliases || !aliases.prototype_keys) return null;
const alias = aliases.prototype_keys[key];
if (!alias) return 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;
result.scssVar = alias.scss_var || null;
result.confidence = 'confirmed';
} else if (alias.status === 'unmatched') {
result.token = alias.token || null;
result.scssVar = alias.scss_var || null;
result.tokenValue = alias.value || null;
result.confidence = 'pending';
if (alias.note) result.note = alias.note;
if (!result.token && alias.scssVar) {
result.note = result.note || `tokens.scss 未声明为 CSS 变量,直接使用 SCSS 变量 ${alias.scssVar}`;
}
} else if (alias.status === 'approximate') {
result.token = alias.token || null;
result.scssVar = alias.scss_var || null;
result.confidence = 'approximate';
if (alias.note) result.note = alias.note;
}
return result;
}
/**
* 获取用于值匹配的候选 token 列表
* 根据 CSS 属性确定查找类别
*/
function getCandidatesByProperty(propertyName, registry) {
const category = CSS_PROPERTY_CATEGORIES[propertyName];
if (!category) {
// 未知属性,在所有 token 中搜索
return registry.all;
}
if (category === 'color') {
// color 类同时搜索 colors + unmapped_scss_variables
return [
...(registry.byCategory.color || []),
...(registry.byCategory.shadow || []), // 阴影也含颜色信息
];
}
if (category === 'spacing') {
return [
...(registry.byCategory.spacing || []),
...(registry.byCategory.tag || []), // tag 也有 padding
];
}
if (category === 'typography') {
return registry.byCategory.typography || [];
}
if (category === 'radius') {
return registry.byCategory.radius || [];
}
if (category === 'sizing') {
return [
...(registry.byCategory.sizing || []),
...(registry.byCategory.feedback || []),
];
}
return registry.byCategory[category] || registry.all;
}
/**
* Layer 2: 值精确匹配(带 CSS 属性上下文消歧)
* @param {string} value - 原型中的值
* @param {string|null} cssProperty - 关联的 CSS 属性名(用于消歧)
* @param {object} registry - Token 注册表
* @returns {object|null} 匹配结果
*/
function matchByValue(value, cssProperty, registry) {
if (value == null) return null;
const candidates = getCandidatesByProperty(cssProperty, registry);
if (candidates.length === 0) return null;
// 颜色匹配
if (isHexColor(value)) {
const normalizedInput = normalizeColor(value);
if (!normalizedInput) return null;
for (const candidate of candidates) {
if (!candidate.value) continue;
if (!isHexColor(candidate.value)) continue;
const normalizedCandidate = normalizeColor(candidate.value);
if (normalizedCandidate === normalizedInput) {
return buildValueMatchResult(candidate, value);
}
}
return null;
}
// 数值匹配(含 px 后缀)
const normalizedInput = normalizeNumericValue(value);
if (normalizedInput === null) {
// rem/em — 标记为 pending
return {
token: null,
prototypeValue: value,
method: 'value_exact',
confidence: 'pending',
note: 'rem/em 值无法自动匹配,需手动确认',
};
}
for (const candidate of candidates) {
if (!candidate.value) continue;
const normalizedCandidate = normalizeNumericValue(candidate.value);
if (normalizedCandidate === null) continue;
if (normalizedCandidate === normalizedInput) {
return buildValueMatchResult(candidate, value);
}
}
// 无单位数值直接比较(如 lineHeight: 1.5
for (const candidate of candidates) {
if (!candidate.value) continue;
if (String(candidate.value).trim() === String(value).trim()) {
return buildValueMatchResult(candidate, value);
}
}
return null;
}
/**
* 构建值匹配结果
*/
function buildValueMatchResult(candidate, prototypeValue) {
const result = {
token: candidate.token || null,
scssVar: candidate.scssVar || null,
prototypeValue,
tokenValue: candidate.value,
method: 'value_exact',
confidence: candidate.token ? 'confirmed' : 'pending',
};
if (candidate.role) result.role = candidate.role;
if (!candidate.token && candidate.scssVar) {
result.note = `匹配到 SCSS 变量 ${candidate.scssVar},但无对应 CSS Token`;
}
return result;
}
/**
* Layer 3: 色彩模糊匹配RGB 欧几里得距离)
* @param {string} value - hex 颜色值
* @param {object} registry - Token 注册表
* @param {number} threshold - 距离阈值,默认 30
* @returns {object|null} 最佳近似匹配
*/
function matchByColorFuzzy(value, registry, threshold = 30) {
const inputRgb = hexToRgb(value);
if (!inputRgb) return null;
// 在所有颜色类 token 中搜索
const colorCandidates = [
...(registry.byCategory.color || []),
];
let bestMatch = null;
let bestDistance = Infinity;
for (const candidate of colorCandidates) {
if (!candidate.value) continue;
const candidateRgb = hexToRgb(candidate.value);
if (!candidateRgb) continue;
const dist = colorDistance(inputRgb, candidateRgb);
if (dist < bestDistance) {
bestDistance = dist;
bestMatch = candidate;
}
}
if (bestMatch && bestDistance < threshold) {
return {
token: bestMatch.token || null,
scssVar: bestMatch.scssVar || null,
prototypeValue: value,
tokenValue: bestMatch.value,
method: 'color_fuzzy',
confidence: 'approximate',
distance: Math.round(bestDistance * 100) / 100,
note: `颜色近似匹配RGB 距离 ${Math.round(bestDistance * 100) / 100}`,
};
}
return null;
}
/**
* 对单个值执行完整三层匹配
* @param {string} value - 要匹配的值
* @param {string|null} cssProperty - CSS 属性名(消歧用)
* @param {object} registry - Token 注册表
* @returns {object} 匹配结果
*/
function fullMatchValue(value, cssProperty, registry) {
// Layer 2: 值精确匹配
const exact = matchByValue(value, cssProperty, registry);
if (exact) return exact;
// Layer 3: 颜色模糊匹配(仅对颜色值)
if (isHexColor(value)) {
const fuzzy = matchByColorFuzzy(value, registry);
if (fuzzy) return fuzzy;
}
// 未匹配
return {
token: null,
scssVar: null,
prototypeValue: value,
method: 'none',
confidence: 'unmatched',
};
}
// ---------------------------------------------------------------------------
// 主流程
// ---------------------------------------------------------------------------
function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
process.stderr.write('用法: node match-tokens.mjs <parse-result.json> <tokens.yml>\n');
process.exit(1);
}
const [parseResultPath, tokensYmlPath] = args;
// 读取输入文件
let parseResult;
try {
const raw = readFileSync(resolve(parseResultPath), 'utf-8');
parseResult = JSON.parse(raw);
} catch (err) {
process.stderr.write(`无法读取/解析 parse-result JSON: ${err.message}\n`);
process.exit(1);
}
let tokensConfig;
try {
const raw = readFileSync(resolve(tokensYmlPath), 'utf-8');
tokensConfig = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
} catch (err) {
process.stderr.write(`无法读取/解析 tokens.yml: ${err.message}\n`);
process.exit(1);
}
// 构建 Token 注册表
const registry = buildTokenRegistry(tokensConfig);
const aliases = tokensConfig.aliases || null;
// ---- 匹配原型 Token 别名 ----
const matched = {};
const unmatched = [];
const prototypeTokens = parseResult.tokens || {};
for (const [key, value] of Object.entries(prototypeTokens)) {
// Layer 1: 别名直查
const aliasResult = matchByAlias(key, value, aliases);
if (aliasResult) {
matched[key] = {
...aliasResult,
prototypeValue: value,
// 如果 alias 没有覆盖 tokenValue从 token 注册表中查找
tokenValue: aliasResult.tokenValue || null,
};
// 补充 tokenValue
if (aliasResult.token && !matched[key].tokenValue) {
const found = registry.all.find(t => t.token === aliasResult.token);
if (found) matched[key].tokenValue = found.value;
}
continue;
}
// 无别名,尝试值匹配(需要推断 CSS 属性上下文)
const inferredProperty = inferPropertyFromTokenKey(key);
const valueResult = fullMatchValue(value, inferredProperty, registry);
if (valueResult.confidence !== 'unmatched') {
matched[key] = valueResult;
} else {
unmatched.push(key);
}
}
// ---- 匹配内联样式值 ----
const inlineTokenMap = {};
const inlineStyles = parseResult.inlineStyles || {};
for (const [cssProperty, values] of Object.entries(inlineStyles)) {
for (const rawValue of values) {
const mapKey = `${cssProperty}:${rawValue}`;
const result = fullMatchValue(rawValue, cssProperty, registry);
if (result.confidence !== 'unmatched') {
inlineTokenMap[mapKey] = {
token: result.token,
scssVar: result.scssVar || undefined,
tokenValue: result.tokenValue || undefined,
confidence: result.confidence,
method: result.method,
};
if (result.note) inlineTokenMap[mapKey].note = result.note;
if (result.distance != null) inlineTokenMap[mapKey].distance = result.distance;
}
}
}
// ---- 汇总统计 ----
let confirmed = 0;
let pending = 0;
let approximate = 0;
for (const entry of Object.values(matched)) {
if (entry.confidence === 'confirmed') confirmed++;
else if (entry.confidence === 'pending') pending++;
else if (entry.confidence === 'approximate') approximate++;
}
for (const entry of Object.values(inlineTokenMap)) {
if (entry.confidence === 'confirmed') confirmed++;
else if (entry.confidence === 'pending') pending++;
else if (entry.confidence === 'approximate') approximate++;
}
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,
summary: {
total: totalAlias + totalInline,
aliasTokens: totalAlias,
inlineValues: totalInline,
confirmed,
pending,
approximate,
unmatched: unmatched.length,
},
};
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
}
/**
* 从 Token 别名 key 推断 CSS 属性(用于无别名时的消歧)
*/
function inferPropertyFromTokenKey(key) {
const lower = key.toLowerCase();
if (lower.includes('pri') || lower.includes('color') || lower.includes('bg') ||
lower.includes('tx') || lower.includes('bd') || lower.includes('card') ||
lower.includes('surface') || lower.includes('acc') || lower.includes('dan') ||
lower.includes('wrn') || lower.includes('wechat')) {
return 'color';
}
if (lower.includes('font') || lower.includes('text') && !lower.includes('color')) {
return 'fontSize';
}
if (lower.includes('r') && (lower.includes('sm') || lower.includes('xs') || lower.length <= 3)) {
return 'borderRadius';
}
if (lower.includes('gap') || lower.includes('pad') || lower.includes('margin')) {
return 'padding';
}
return null; // 未知,不限制类别
}
// 启动
main();

View File

@@ -0,0 +1,384 @@
#!/usr/bin/env node
/**
* parse-prototype.mjs — 解析 huashu-design 产出的 HTML 原型文件
*
* 提取设计 Token (T 对象)、内联样式硬编码值、屏幕信息和组件定义。
* 输出 JSON 到 stdout。
*
* 用法: node parse-prototype.mjs <html-file-path>
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// ─── 工具函数 ────────────────────────────────────────────────
/**
* 输出错误 JSON 到 stderr 并以 code 1 退出
*/
function fail(message) {
process.stderr.write(JSON.stringify({ valid: false, error: message }, null, 2) + '\n');
process.exit(1);
}
/**
* 括号深度计数法:从 source[startPos] 开始startPos 应指向 '{'
* 提取完整的平衡花括号内容。
*
* 正确处理:
* - 单引号/双引号内的花括号不计数
* - 模板字符串(`)内的花括号不计数
* - 转义字符(\" \' \\ \x \u跳过
*
* 返回包含外层花括号的完整字符串,或在无法平衡时返回 null。
*/
function extractBalancedBraces(source, startPos) {
if (source[startPos] !== '{') return null;
let depth = 0;
let i = startPos;
const len = source.length;
while (i < len) {
const ch = source[i];
if (ch === "'" || ch === '"') {
// 字符串字面量:跳到配对的结束引号
const quote = ch;
i++;
while (i < len) {
if (source[i] === '\\') {
i += 2; // 跳过转义字符
continue;
}
if (source[i] === quote) {
i++;
break;
}
i++;
}
continue;
}
if (ch === '`') {
// 模板字符串:处理 ${...} 内的嵌套,以及转义
i++;
while (i < len) {
if (source[i] === '\\') {
i += 2;
continue;
}
if (source[i] === '`') {
i++;
break;
}
if (source[i] === '$' && i + 1 < len && source[i + 1] === '{') {
// 模板表达式 ${...},需要单独平衡
i += 2; // 跳过 ${
let tmplDepth = 1;
while (i < len && tmplDepth > 0) {
const tc = source[i];
if (tc === '{') tmplDepth++;
else if (tc === '}') tmplDepth--;
// 简化:模板表达式内也可以有字符串,但这里不递归处理
// 因为 T 对象的值不太可能有如此复杂的嵌套
if (tc === "'" || tc === '"') {
const tq = tc;
i++;
while (i < len) {
if (source[i] === '\\') { i += 2; continue; }
if (source[i] === tq) { i++; break; }
i++;
}
continue;
}
if (tc === '`') {
// 嵌套模板字符串
i++;
let nestedTmplDepth = 0;
while (i < len) {
if (source[i] === '\\') { i += 2; continue; }
if (source[i] === '`') { i++; break; }
if (source[i] === '$' && i + 1 < len && source[i + 1] === '{') {
// 这里简化处理,不递归
i += 2;
nestedTmplDepth++;
continue;
}
if (source[i] === '{') nestedTmplDepth++;
if (source[i] === '}') {
if (nestedTmplDepth > 0) nestedTmplDepth--;
else { i++; break; }
}
i++;
}
continue;
}
i++;
}
continue;
}
i++;
}
continue;
}
if (ch === '/' && i + 1 < len) {
// 注释处理
if (source[i + 1] === '/') {
// 单行注释
while (i < len && source[i] !== '\n') i++;
continue;
}
if (source[i + 1] === '*') {
// 多行注释
i += 2;
while (i + 1 < len && !(source[i] === '*' && source[i + 1] === '/')) i++;
i += 2;
continue;
}
}
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) {
return source.slice(startPos, i + 1);
}
}
i++;
}
return null; // 未平衡
}
// ─── Step 0: 格式校验 ────────────────────────────────────────
function validateSource(source) {
const hasReact = source.includes('react@') || source.includes('react.production');
const hasBabel = source.includes('@babel/standalone') || source.includes('babel.min');
const hasToken = /const\s+T\s*=\s*\{/.test(source);
if (!hasReact) return '文件不包含 React 引用';
if (!hasBabel) return '文件不包含 Babel 引用';
if (!hasToken) return '文件不包含 T 对象定义 (const T = {)';
return null;
}
// ─── Step 1: 提取 T 对象 ─────────────────────────────────────
function extractTokens(source) {
// 匹配 const T = { / const T ={ / const T={ 等各种空格变体
const markerPattern = /const\s+T\s*=\s*\{/;
const markerMatch = markerPattern.exec(source);
if (!markerMatch) return {};
const braceStart = markerMatch.index + markerMatch[0].length - 1; // 指向 '{'
const rawObject = extractBalancedBraces(source, braceStart);
if (!rawObject) return {};
// 将 JS 对象字面量转为可求值字符串,用 Function 构造器执行
try {
const fn = new Function('return (' + rawObject + ')');
const tObj = fn();
// 展平为 "T.key": "value" 格式
const flat = {};
for (const [key, value] of Object.entries(tObj)) {
flat[`T.${key}`] = String(value);
}
return flat;
} catch {
// 如果 Function 执行失败,尝试用正则提取简单键值对作为降级方案
const fallback = {};
const kvPattern = /(\w+)\s*:\s*'([^']*)'/g;
const kvPattern2 = /(\w+)\s*:\s*"([^"]*)"/g;
const kvPatternNum = /(\w+)\s*:\s*(\d+(?:\.\d+)?)/g;
let match;
const innerContent = rawObject.slice(1, -1);
while ((match = kvPattern.exec(innerContent)) !== null) {
fallback[`T.${match[1]}`] = match[2];
}
while ((match = kvPattern2.exec(innerContent)) !== null) {
fallback[`T.${match[1]}`] = match[2];
}
while ((match = kvPatternNum.exec(innerContent)) !== null) {
fallback[`T.${match[1]}`] = match[2];
}
return fallback;
}
}
// ─── Step 2: 提取内联样式硬编码值 ─────────────────────────────
const STYLE_PROPERTIES = ['fontSize', 'padding', 'borderRadius', 'gap', 'margin', 'fontWeight', 'lineHeight', 'width', 'height', 'letterSpacing'];
function extractInlineStyles(source) {
const result = {};
for (const prop of STYLE_PROPERTIES) {
const values = new Set();
// 匹配 camelCase 属性: fontSize: 16, fontSize: '16px', fontSize: "16px"
// 也匹配 object shorthand: { fontSize: 16 }
const pattern = new RegExp(
prop + '\\s*:\\s*([\'"`]?)([\\w\\s%.,()-]+?)\\1\\s*[,}]',
'g'
);
let match;
while ((match = pattern.exec(source)) !== null) {
let val = match[2].trim();
// 过滤掉变量引用(如 T.pri, T.r 等)
if (val.startsWith('T.') || val.startsWith('${')) continue;
// 过滤模板字符串插值
if (val.includes('${')) continue;
// 过滤掉 CSS 变量引用
if (val.startsWith('var(')) continue;
values.add(val);
}
if (values.size > 0) {
// 去重并排序:数值优先(降序),字符串按字母序
const sorted = [...values].sort((a, b) => {
const numA = parseFloat(a);
const numB = parseFloat(b);
if (!isNaN(numA) && !isNaN(numB)) return numB - numA;
if (!isNaN(numA)) return -1;
if (!isNaN(numB)) return 1;
return a.localeCompare(b);
});
result[prop] = sorted;
}
}
return result;
}
// ─── Step 3: 提取 screen 信息 ────────────────────────────────
function extractScreens(source) {
const screens = [];
// 策略 A: 找静态 screen-label 文本(外部 span/div 标签)
const labelPattern = /<(?:span|div)\s+(?:className|class)="screen-label"[^>]*>([^<]*)<\/(?:span|div)>/g;
const labels = [];
let labelMatch;
while ((labelMatch = labelPattern.exec(source)) !== null) {
labels.push(labelMatch[1].trim());
}
// 策略 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];
if (name !== 'div' && name !== 'span' && name[0] === name[0].toUpperCase()) {
components.push(name);
}
}
// 选择 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: selectedLabels[i] || '',
component: components[i] || '',
});
}
return screens;
}
// ─── Step 4: 识别组件函数 ─────────────────────────────────────
function extractComponents(source) {
const components = new Set();
// 匹配 function 声明: function XxxPage() / function Xxx()
const funcPattern = /function\s+([A-Z]\w*)\s*\(/g;
let match;
while ((match = funcPattern.exec(source)) !== null) {
components.add(match[1]);
}
// 匹配 const Xxx = () => / const Xxx = () =>
const arrowPattern = /const\s+([A-Z]\w*)\s*=\s*(?:\([^)]*\)|[^=])\s*=>/g;
while ((match = arrowPattern.exec(source)) !== null) {
components.add(match[1]);
}
return [...components].sort((a, b) => {
// IosFrame 排到最前面(基础设施组件)
if (a === 'IosFrame') return -1;
if (b === 'IosFrame') return 1;
return a.localeCompare(b);
});
}
// ─── 主流程 ──────────────────────────────────────────────────
function main() {
const args = process.argv.slice(2);
if (args.length < 1) {
fail('用法: node parse-prototype.mjs <html-file-path>');
}
const filePath = resolve(args[0]);
let source;
try {
source = readFileSync(filePath, 'utf-8');
} catch (err) {
fail(`无法读取文件: ${filePath}${err.message}`);
}
// Step 0: 格式校验
const validationError = validateSource(source);
if (validationError) {
fail(`格式校验失败: ${validationError}`);
}
// Step 1: 提取 T 对象
const tokens = extractTokens(source);
// Step 2: 提取内联样式
const inlineStyles = extractInlineStyles(source);
// Step 3: 提取 screen 信息
const screens = extractScreens(source);
// Step 4: 识别组件函数
const components = extractComponents(source);
// 组装输出
const result = {
valid: true,
filePath: filePath.replace(/\\/g, '/'),
tokens,
inlineStyles,
screens,
components,
};
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
main();

View File

@@ -0,0 +1,73 @@
# {{pageTitle}} 设计规格
> 来源: {{sourceFile}} | 平台: {{platform}} | 页面数: {{screenCount}} | 生成: {{date}}
## 页面索引
| 页面 | 截图 | 路由 |
|------|------|------|
{{#each screens}}
| {{label}} | ![{{file}}](./screenshots/{{file}}) | {{route}} |
{{/each}}
## 一、Token 映射
| 原型值 | 项目 Token | 状态 |
|--------|-----------|------|
{{#each tokenMap}}
| {{prototypeValue}} ({{key}}) | {{tokenRef}} | {{statusIcon}} |
{{/each}}
> 状态标记: ✅ confirmed 直接使用 | ⚠️ pending 需人工复核 | ❌ unmatched 需硬编码或新建 Token
## 二、页面结构
{{#each screens}}
### {{ordinal}}. {{label}}
![{{label}}](./screenshots/{{file}})
布局层级(从上到下):
{{layoutDescription}}
{{/each}}
## 三、组件映射
| 原型元素 | 推荐组件 | 来源 | 备注 |
|----------|---------|------|------|
{{#each componentMap}}
| {{prototypeElement}} | {{component}} | {{source}} | {{notes}} |
{{/each}}
{{#each newComponents}}
> ⚠️ **需新建**: {{name}} — {{reason}}
{{/each}}
## 四、交互规格
| 元素 | 交互 | 触发 | 反馈 | 备注 |
|------|------|------|------|------|
{{#each interactions}}
| {{element}} | {{type}} | {{trigger}} | {{feedback}} | {{notes}} |
{{/each}}
## 五、状态变体
{{#each stateVariants}}
- **{{name}}**: {{description}}
{{/each}}
## 六、样式清单
{{styleSummary}}
---
> 此规格由 design-handoff skill 自动生成。LLM 实施时请:
> 1. 先阅读截图建立视觉印象
> 2. 按 Token 映射表使用项目 Token✅ 标记的直接用)
> 3. 优先使用"组件映射"中列出的已有组件
> 4. 参考"交互规格"实现对应的交互逻辑
> 5. "需新建"的组件参考截图和布局描述从头实现

423
.design/tokens.yml Normal file
View File

@@ -0,0 +1,423 @@
version: 1
updated: "2026-05-17"
# ============================================================================
# Design Token 注册表
# 数据源: apps/miniprogram/src/styles/tokens.scss + variables.scss
# ============================================================================
colors:
# --- 主色系(赤土橙) ---
- token: --tk-pri
value: "#C4623A"
scss_var: "$pri"
role: 主色/赤土橙 accent
- token: --tk-pri-l
value: "#F0DDD4"
scss_var: "$pri-l"
role: 主色浅/赤土浅
- token: --tk-pri-d
value: "#8B3E1F"
scss_var: "$pri-d"
role: 主色深/赤土深
# --- 阴影色(含透明度) ---
- token: --tk-shadow-btn
value: "0 4px 16px rgba(196, 98, 58, 0.3)"
scss_var: "$shadow-btn"
role: 主按钮阴影
- token: --tk-shadow-tab
value: "0 2px 8px rgba(196, 98, 58, 0.25)"
scss_var: "$shadow-tab"
role: 选中Tab阴影
# --- 文字色 ---
- token: --tk-text-secondary
value: "#78716C"
scss_var: "$tx3"
role: 淡文字/辅助文字
# --- 卡片背景 ---
- token: --tk-card-bg
value: "#FFFFFF"
scss_var: "$card"
role: 卡片白底
# --- 医生端覆盖色(.doctor-mode 下自动替换 --tk-pri* ---
- token: --tk-pri.doctor
value: "#3A6B8C"
scss_var: "$doc-pri"
role: 医生端主色/靛蓝
note: 仅在 .doctor-mode 下覆盖 --tk-pri
- token: --tk-pri-l.doctor
value: "#D4E5F0"
scss_var: "$doc-pri-l"
role: 医生端浅色
- token: --tk-pri-d.doctor
value: "#2A4F6A"
scss_var: "$doc-pri-d"
role: 医生端深色
# --- 未映射到 Token 的 SCSS 变量(原型中有但 tokens.scss 未声明为 CSS 变量) ---
unmapped_scss_variables:
- scss_var: "$bg"
value: "#F5F0EB"
role: 页面主背景/温润米底
note: "原型 T.bg 映射目标tokens.scss 未声明为 CSS 变量"
- scss_var: "$tx"
value: "#2D2A26"
role: 主文字色/暖黑
note: "原型 T.tx 映射目标tokens.scss 未声明为 CSS 变量"
- scss_var: "$tx2"
value: "#5A554F"
role: 次文字色/暖灰
note: "原型 T.tx2 近似映射elder-mode 下 --tk-text-secondary 覆盖为此值"
- scss_var: "$bd"
value: "#E8E2DC"
role: 边框色
note: "原型 T.bd 映射目标tokens.scss 未声明为 CSS 变量"
- scss_var: "$bd-l"
value: "#F0EBE5"
role: 浅边框色
- scss_var: "$acc"
value: "#5B7A5E"
role: 鼠尾草绿/成功色
note: "原型中成功色tokens.scss 未声明为 CSS 变量"
- scss_var: "$acc-l"
value: "#E8F0E8"
role: 成功浅色
- scss_var: "$dan"
value: "#B54A4A"
role: 危险色/柔红
note: "原型中危险色tokens.scss 未声明为 CSS 变量"
- scss_var: "$dan-l"
value: "#FDEAEA"
role: 危险浅色
- scss_var: "$wrn"
value: "#C4873A"
role: 警告色/暖琥珀
note: "原型中警告色tokens.scss 未声明为 CSS 变量"
- scss_var: "$wrn-l"
value: "#FFF3E0"
role: 警告浅色
- scss_var: "$surface-alt"
value: "#EDE8E2"
role: 辅助底色
- scss_var: "$wechat"
value: "#07C160"
role: 微信绿
typography:
- token: --tk-font-display
value: "72px"
note: 大屏展示
elder: "80px"
- token: --tk-font-hero
value: "48px"
note: 启动页标题
elder: "56px"
- token: --tk-font-h1
value: "28px"
note: 页面标题 serif bold
elder: "32px"
- token: --tk-font-h2
value: "22px"
note: 副标题、用户名 serif bold
elder: "25px"
- token: --tk-font-body-lg
value: "18px"
note: 按钮文字、section 标题 fontWeight:600
elder: "22px"
- token: --tk-font-body
value: "16px"
note: 正文、输入框、icon 文字(最常用 UI 字号)
elder: "22px"
- token: --tk-font-body-sm
value: "14px"
note: 副文本、描述
elder: "19px"
- token: --tk-font-num
value: "30px"
note: 数值 serif bold
elder: "34px"
- token: --tk-font-num-lg
value: "34px"
note: 大数值
elder: "40px"
- token: --tk-font-cap
value: "13px"
note: 说明文字(第一高频字号)
elder: "18px"
- token: --tk-font-nav
value: "18px"
note: 导航栏标题 serif bold
elder: "22px"
- token: --tk-font-micro
value: "11px"
note: 角标、tag
elder: "17px"
structure:
- token: --tk-line-height
value: "1.5"
elder: "1.7"
spacing:
- token: --tk-gap-2xs
value: "4px"
scss_var: "$sp-2xs"
elder: "6px"
- token: --tk-gap-xs
value: "8px"
scss_var: "$sp-xs"
elder: "12px"
- token: --tk-gap-sm
value: "12px"
scss_var: "$sp-sm"
elder: "16px"
- token: --tk-gap-md
value: "16px"
scss_var: "$sp-md"
elder: "20px"
- token: --tk-section-gap
value: "20px"
scss_var: "$sp-section"
elder: "28px"
- token: --tk-gap-lg
value: "24px"
scss_var: "$sp-lg"
elder: "32px"
- token: --tk-gap-xl
value: "32px"
scss_var: "$sp-xl"
elder: "40px"
- token: --tk-gap-2xl
value: "48px"
scss_var: "$sp-2xl"
elder: "56px"
- token: --tk-page-padding
value: "20px"
elder: "28px"
- token: --tk-card-padding
value: "20px"
elder: "28px"
- token: --tk-card-padding-sm
value: "16px"
elder: "20px"
- token: --tk-card-padding-lg
value: "28px"
elder: "36px"
radius:
- token: --tk-card-radius
value: "16px"
scss_var: "$r"
elder: "20px"
radius_unmapped:
- scss_var: "$r-sm"
value: "12px"
note: "原型 T.rSm 映射目标"
- scss_var: "$r-xs"
value: "8px"
note: "原型 T.rXs 映射目标"
- scss_var: "$r-lg"
value: "20px"
- scss_var: "$r-pill"
value: "999px"
sizing:
- token: --tk-touch-min
value: "48px"
role: 最小触控区
elder: "56px"
- token: --tk-btn-primary-h
value: "52px"
role: 主按钮高度
elder: "60px"
- token: --tk-input-height
value: "56px"
role: 输入框高度
elder: "64px"
- token: --tk-tabbar-space
value: "100px"
role: TabBar 底部安全区
elder: "120px"
feedback:
- token: --tk-touch-feedback-opacity
value: "0.85"
role: 触控反馈透明度
elder: "0.8"
tag:
- token: --tk-tag-font-size
value: "11px"
elder: "13px"
- token: --tk-tag-padding-v
value: "3px"
elder: "5px"
- token: --tk-tag-padding-h
value: "8px"
elder: "12px"
shadow_unmapped:
# tokens.scss 中的 --tk-shadow-btn/tab 是复合值(含偏移+模糊+颜色)
# 以下为 variables.scss 中的其他阴影,未声明为 CSS Token
- scss_var: "$shadow-sm"
value: "0 1px 4px rgba(45, 42, 38, 0.06)"
role: 小阴影
- scss_var: "$shadow-md"
value: "0 2px 12px rgba(45, 42, 38, 0.10)"
role: 中阴影
- scss_var: "$shadow-lg"
value: "0 8px 32px rgba(45, 42, 38, 0.15)"
role: 大阴影
# ============================================================================
# 原型 Key → Token 映射aliases
# 用于设计移交时自动匹配原型属性到实际 Token
# ============================================================================
aliases:
prototype_keys:
T.pri:
- value: "#C4623A"
token: --tk-pri
status: exact_match
variant: patient
- value: "#3A6B8C"
token: --tk-pri.doctor
status: exact_match
variant: doctor
T.priL:
- 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:
- 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
nearest: --tk-card-bg
scss_var: "$bg"
value: "#F5F0EB"
status: unmatched
note: "原型页面背景色tokens.scss 未声明为 CSS 变量,直接用 $bg SCSS 变量"
T.card:
token: --tk-card-bg
status: exact_match
T.surface:
token: --tk-card-bg
status: approximate
note: "原型中 surface ≈ 卡片白底"
T.tx:
token: null
nearest: --tk-text-secondary
scss_var: "$tx"
value: "#2D2A26"
status: unmatched
note: "主文字色tokens.scss 未声明为 CSS 变量,直接用 $tx SCSS 变量"
T.tx2:
token: null
nearest: --tk-text-secondary
scss_var: "$tx2"
value: "#5A554F"
status: unmatched
note: "次文字色tokens.scss 未声明elder-mode 下 --tk-text-secondary 覆盖为此值"
T.tx3:
token: --tk-text-secondary
scss_var: "$tx3"
value: "#78716C"
status: exact_match
T.bd:
token: null
scss_var: "$bd"
value: "#E8E2DC"
status: unmatched
note: "边框色不是圆角tokens.scss 未声明为 CSS 变量"
T.r:
token: --tk-card-radius
scss_var: "$r"
value: "16px"
status: exact_match
T.rSm:
token: null
scss_var: "$r-sm"
value: "12px"
status: unmatched
note: "tokens.scss 未声明,需添加 --tk-radius-sm 或直接用 $r-sm SCSS 变量"
T.rXs:
token: null
scss_var: "$r-xs"
value: "8px"
status: unmatched
note: "tokens.scss 未声明,需添加 --tk-radius-xs 或直接用 $r-xs SCSS 变量"

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
@@ -49,6 +49,9 @@ jobs:
- name: Clippy
run: cargo clippy --workspace -- -D warnings
- name: Security audit (Rust)
run: cargo audit
frontend-test:
runs-on: ubuntu-latest
defaults:
@@ -76,3 +79,31 @@ jobs:
- name: Build
run: pnpm build
- name: Security audit (npm)
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

36
.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,4 +82,28 @@ tmp/
screenshots/
server-log.txt
snapshot_*.txt
uploads/
_*.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

@@ -1,7 +1,7 @@
# ==============================
# Stage 1: Build Rust backend
# ==============================
FROM rust:1.85-bookworm AS rust-builder
FROM rust:1-bookworm AS rust-builder
WORKDIR /app
@@ -75,6 +75,7 @@ FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -85,7 +86,7 @@ COPY --from=rust-builder /app/target/release/erp-server /app/erp-server
# 复制配置文件
COPY config/ /app/config/
# 复制前端构建产物
# 复制前端构建产物(可通过 volume 暴露给 OpenResty
COPY --from=frontend-builder /app/apps/web/dist/ /app/static/
# 创建上传目录
@@ -96,7 +97,7 @@ RUN useradd -r -s /bin/false appuser \
&& chown -R appuser:appuser /app
USER appuser
# 环境变量(运行时通过 docker-compose 覆盖)
# 环境变量(运行时通过 docker-compose / .env 覆盖)
ENV ERP__SERVER__HOST=0.0.0.0
ENV ERP__SERVER__PORT=3000
ENV ERP__SERVER__METRICS_PORT=9090
@@ -104,7 +105,7 @@ ENV ERP__STORAGE__UPLOAD_DIR=/app/uploads
EXPOSE 3000 9090
VOLUME ["/app/uploads"]
VOLUME ["/app/uploads", "/app/static"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -f http://localhost:3000/api/v1/health || exit 1

View File

@@ -1,12 +1,8 @@
import { api } from './request'
export interface IndicatorDetail {
value: number; unit?: string; reference_min?: number; reference_max?: number; status?: string
}
export interface LabReport {
id: string; report_date: string; report_type: string
indicators: Record<string, IndicatorDetail>; doctor_interpretation?: string
items?: unknown; doctor_notes?: string
image_urls?: string[]; version: number
}

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

@@ -20,5 +20,23 @@ export default {
});
},
},
h5: {},
h5: {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
client: {
overlay: {
errors: true,
warnings: false,
},
},
},
webpackChain(chain) {
chain.resolve.alias.set('react-dom$', require.resolve('react-dom'));
},
},
} satisfies UserConfigExport;

View File

@@ -2,13 +2,19 @@ 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',
designWidth: 375,
deviceRatio: { 640: 2.34 / 2, 750: 1, 375: 2, 828: 1.81 / 2 },
sourceRoot: 'src',
outputRoot: 'dist',
outputRoot: process.env.TARO_ENV === 'h5' ? 'dist-h5' : 'dist',
plugins: [],
defineConstants: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
@@ -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,17 +8,23 @@ export default {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.info', 'console.debug', 'console.warn', 'console.error'],
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: {
miniCssExtractPluginOption: {
ignoreOrder: true,
cssLoaderOption: { importLoaders: 1, esModule: false },
},
},
} satisfies UserConfigExport;

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,9 +4,19 @@
"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",
"test:e2e": "vitest run --config e2e/vitest.config.ts"
"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"
},
"browserslist": [
"last 3 versions",
@@ -14,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",
@@ -22,7 +34,9 @@
"@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"
},
"devDependencies": {
@@ -30,15 +44,28 @@
"@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",
"vite": "^8.0.10",
"vitest": "^4.1.5",
"webpack": "~5.95.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',
@@ -48,7 +49,7 @@ export default defineAppConfig({
'dialysis-records/index', 'dialysis-records/detail/index',
'dialysis-prescriptions/index', 'dialysis-prescriptions/detail/index',
'consents/index', 'health-records/index', 'diagnoses/index',
'elder-mode/index', 'events/index',
'elder-mode/index', 'events/index', 'notifications/index',
],
},
{
@@ -59,6 +60,10 @@ export default defineAppConfig({
root: 'pages/article',
pages: ['index', 'detail/index'],
},
{
root: 'pages/appointment',
pages: ['index', 'create/index', 'detail/index'],
},
{
root: 'pages/pkg-consultation',
pages: ['detail/index'],
@@ -72,10 +77,29 @@ 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/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'],
},
},
window: {
backgroundTextStyle: 'dark',
navigationBarBackgroundColor: '#FFFFFF',

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

@@ -5,18 +5,18 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120px 40px;
padding: var(--tk-gap-2xl) var(--tk-gap-xl);
}
.empty-state-icon-wrap {
width: 120px;
height: 120px;
width: var(--tk-gap-2xl);
height: var(--tk-gap-2xl);
border-radius: 50%;
background: $surface-alt;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
margin-bottom: var(--tk-gap-lg);
}
.empty-state-icon-char {
@@ -27,21 +27,21 @@
}
.empty-state-text {
font-size: var(--tk-font-num);
font-size: var(--tk-font-body-lg);
color: $tx2;
margin-bottom: 8px;
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: 32px;
margin-bottom: var(--tk-gap-xl);
}
.empty-state-action {
background: $pri;
background: var(--tk-pri);
border-radius: 40px;
padding: 16px 48px;
padding: var(--tk-gap-md) var(--tk-gap-2xl);
}
.empty-state-action-text {

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

@@ -13,7 +13,7 @@
width: 64px;
height: 64px;
border-radius: 32px;
background: $pri-l;
background: var(--tk-pri-l);
display: flex;
align-items: center;
justify-content: center;
@@ -24,7 +24,7 @@
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-h1);
font-weight: 600;
color: $pri-d;
color: var(--tk-pri-d);
}
.error-title {
@@ -41,7 +41,7 @@
}
.error-retry-btn {
background: $pri;
background: var(--tk-pri);
border-radius: $r-sm;
padding: 14px 48px;
}

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

@@ -5,25 +5,25 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120px 40px;
padding: var(--tk-gap-2xl) var(--tk-gap-xl);
}
.error-state-icon {
font-size: var(--tk-font-display);
margin-bottom: 24px;
margin-bottom: var(--tk-gap-lg);
}
.error-state-text {
font-size: var(--tk-font-body-lg);
color: $tx2;
margin-bottom: 32px;
margin-bottom: var(--tk-gap-xl);
text-align: center;
}
.error-state-retry {
background: $pri;
background: var(--tk-pri);
border-radius: 40px;
padding: 16px 48px;
padding: var(--tk-gap-md) var(--tk-gap-2xl);
}
.error-state-retry-text {

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

@@ -48,7 +48,7 @@
display: inline-block;
height: 48px;
padding: 0 32px;
background: $pri;
background: var(--tk-pri);
border-radius: $r-pill;
@include flex-center;

View File

@@ -12,7 +12,7 @@
width: 48px;
height: 48px;
border: 4px solid $bd;
border-top-color: $pri;
border-top-color: var(--tk-pri);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 20px;
@@ -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,9 +15,11 @@
justify-content: center;
position: relative;
@include focus-ring;
&--active {
.seg-tab__text {
color: $pri;
color: var(--tk-pri);
font-weight: bold;
}
@@ -28,7 +30,7 @@
left: 30%;
right: 30%;
height: 4px;
background: $pri;
background: var(--tk-pri);
border-radius: $r-xs;
}
}
@@ -41,26 +43,35 @@
}
&--pill {
gap: 12px;
flex-wrap: wrap;
gap: 8px;
margin-bottom: var(--tk-section-gap);
.seg-tab {
padding: 8px 24px;
border-radius: $r-pill;
flex: 1;
height: 44px;
border-radius: $r-sm;
background: $surface-alt;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
@include focus-ring;
&--active {
background: $pri;
background: var(--tk-pri);
box-shadow: var(--tk-shadow-tab);
.seg-tab__text {
color: $card;
font-weight: bold;
color: $white;
font-weight: 600;
}
}
}
.seg-tab__text {
font-size: var(--tk-font-body-lg);
font-size: 15px;
font-weight: 600;
color: $tx2;
}
}

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

@@ -44,7 +44,7 @@
z-index: 1;
&.step-current {
background: $pri;
background: var(--tk-pri);
color: white;
}
@@ -61,7 +61,7 @@
text-align: center;
&.step-current {
color: $pri;
color: var(--tk-pri);
font-weight: bold;
}

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

@@ -15,7 +15,7 @@
.week-arrow {
font-size: var(--tk-font-body-lg);
color: $pri;
color: var(--tk-pri);
padding: 0 16px;
}
@@ -52,7 +52,7 @@
}
.cell-today {
color: $pri;
color: var(--tk-pri);
font-weight: bold;
}
@@ -68,7 +68,7 @@
}
.cell-selected {
background: $pri;
background: var(--tk-pri);
border-radius: $r-sm;
.cell-date { color: white; }

View File

@@ -0,0 +1,12 @@
.card-list {
display: flex;
flex-direction: column;
&--sm {
gap: var(--tk-gap-sm);
}
&--md {
gap: var(--tk-gap-md);
}
}

View File

@@ -0,0 +1,62 @@
import { View } from '@tarojs/components';
import { ReactNode } from 'react';
import EmptyState from '../../EmptyState';
import ErrorState from '../../ErrorState';
import LoadingCard from '../../ui/LoadingCard';
import './index.scss';
interface CardListProps<T> {
items: T[];
renderItem: (item: T, index: number) => ReactNode;
keyExtractor: (item: T) => string;
loading?: boolean;
error?: string | null;
emptyText?: string;
emptyAction?: { text: string; onPress: () => void };
gap?: 'sm' | 'md';
}
function CardList<T>({
items,
renderItem,
keyExtractor,
loading = false,
error = null,
emptyText = '暂无数据',
emptyAction,
gap = 'md',
}: CardListProps<T>) {
if (loading) {
return <LoadingCard count={3} layout="card" />;
}
if (error) {
return (
<ErrorState
text={error}
/>
);
}
if (items.length === 0) {
return (
<EmptyState
text={emptyText}
actionText={emptyAction?.text}
onAction={emptyAction?.onPress}
/>
);
}
return (
<View className={`card-list card-list--${gap}`}>
{items.map((item, index) => (
<View key={keyExtractor(item)} className="card-list__item">
{renderItem(item, index)}
</View>
))}
</View>
);
}
export default CardList;

View File

@@ -0,0 +1,56 @@
@import '../../../styles/variables.scss';
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 var(--tk-page-padding);
background: $bg;
border-bottom: 1px solid $bd-l;
z-index: 10;
&--sticky {
position: sticky;
top: 0;
}
&__left {
display: flex;
align-items: center;
gap: var(--tk-gap-xs);
min-width: 0;
flex: 1;
}
&__back {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 44px;
}
&__back-icon {
font-size: var(--tk-font-h2);
color: var(--tk-pri);
line-height: 1;
}
&__title {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-nav);
font-weight: 700;
color: $tx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__right {
display: flex;
align-items: center;
gap: var(--tk-gap-sm);
flex-shrink: 0;
}
}

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { ReactNode } from 'react';
import './index.scss';
interface PageHeaderProps {
title: string;
showBack?: boolean;
onBack?: () => void;
rightActions?: ReactNode;
sticky?: boolean;
}
const PageHeader: React.FC<PageHeaderProps> = ({
title,
showBack = true,
onBack,
rightActions,
sticky = true,
}) => {
const handleBack = () => {
if (onBack) {
onBack();
} else {
Taro.navigateBack({ delta: 1 }).catch(() => {
Taro.switchTab({ url: '/pages/index/index' });
});
}
};
const cls = [
'page-header',
sticky && 'page-header--sticky',
].filter(Boolean).join(' ');
return (
<View className={cls}>
<View className="page-header__left">
{showBack && (
<View className="page-header__back" onClick={handleBack}>
<Text className="page-header__back-icon"></Text>
</View>
)}
<Text className="page-header__title">{title}</Text>
</View>
{rightActions && (
<View className="page-header__right">{rightActions}</View>
)}
</View>
);
};
export default React.memo(PageHeader);

View File

@@ -0,0 +1,38 @@
@import '../../../styles/variables.scss';
.pagination-bar {
display: flex;
align-items: center;
justify-content: center;
gap: var(--tk-gap-md);
padding: var(--tk-gap-md) 0;
&__btn {
display: flex;
align-items: center;
justify-content: center;
padding: var(--tk-gap-xs) var(--tk-section-gap);
border-radius: $r-sm;
background: var(--tk-card-bg);
border: 1px solid $bd;
font-size: var(--tk-font-body-sm);
color: $tx;
min-height: var(--tk-touch-min);
&:active:not(&--disabled) {
background: $bd-l;
}
&--disabled {
opacity: 0.4;
pointer-events: none;
}
}
&__info {
font-size: var(--tk-font-body-sm);
color: $tx2;
min-width: 60px;
text-align: center;
}
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
interface PaginationBarProps {
current: number;
total: number;
pageSize: number;
onChange: (page: number) => void;
}
const PaginationBar: React.FC<PaginationBarProps> = ({
current,
total,
pageSize,
onChange,
}) => {
const totalPages = Math.ceil(total / pageSize);
if (totalPages <= 1) return null;
const hasPrev = current > 1;
const hasNext = current < totalPages;
return (
<View className="pagination-bar">
<View
className={`pagination-bar__btn ${!hasPrev ? 'pagination-bar__btn--disabled' : ''}`}
onClick={() => hasPrev && onChange(current - 1)}
>
<Text></Text>
</View>
<Text className="pagination-bar__info">
{current} / {totalPages}
</Text>
<View
className={`pagination-bar__btn ${!hasNext ? 'pagination-bar__btn--disabled' : ''}`}
onClick={() => hasNext && onChange(current + 1)}
>
<Text></Text>
</View>
</View>
);
};
export default React.memo(PaginationBar);

View File

@@ -0,0 +1,37 @@
@import '../../../styles/variables.scss';
.search-section {
margin-bottom: var(--tk-gap-md);
&__input-wrap {
display: flex;
align-items: center;
gap: var(--tk-gap-xs);
background: var(--tk-card-bg);
border-radius: var(--tk-card-radius);
padding: 0 var(--tk-gap-md);
height: var(--tk-input-height);
box-shadow: $shadow-sm;
}
&__icon {
font-size: var(--tk-font-body-sm);
flex-shrink: 0;
}
&__input {
flex: 1;
font-size: var(--tk-font-body);
color: $tx;
height: 100%;
}
&__placeholder {
color: $tx3;
font-size: var(--tk-font-body);
}
&__filters {
margin-top: var(--tk-gap-sm);
}
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { View, Input, Text } from '@tarojs/components';
import SegmentTabs from '../../SegmentTabs';
import './index.scss';
interface SearchSectionProps {
value: string;
onChange: (v: string) => void;
onSearch?: () => void;
placeholder?: string;
filters?: { key: string; label: string }[];
activeFilter?: string;
onFilterChange?: (key: string) => void;
}
const SearchSection: React.FC<SearchSectionProps> = ({
value,
onChange,
onSearch,
placeholder = '搜索...',
filters,
activeFilter,
onFilterChange,
}) => {
return (
<View className="search-section">
<View className="search-section__input-wrap">
<Text className="search-section__icon">🔍</Text>
<Input
className="search-section__input"
value={value}
onInput={(e) => onChange(e.detail.value)}
onConfirm={onSearch}
placeholder={placeholder}
placeholderClass="search-section__placeholder"
confirmType="search"
/>
</View>
{filters && filters.length > 0 && (
<View className="search-section__filters">
<SegmentTabs
tabs={filters.map((f) => ({ key: f.key, label: f.label }))}
activeKey={activeFilter ?? filters[0]?.key ?? ''}
onChange={onFilterChange ?? (() => {})}
variant="pill"
/>
</View>
)}
</View>
);
};
export default React.memo(SearchSection);

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,52 @@
@import '../../../styles/variables.scss';
.alert-card {
border-radius: $r;
padding: var(--tk-gap-lg);
margin-bottom: var(--tk-gap-md);
// 渐变型 — 智能提醒
&--gradient {
background: linear-gradient(135deg, var(--tk-pri) 0%, var(--tk-pri-d) 100%);
color: $white;
}
// 左边框型 — AI 建议
&--left-border {
background: $acc-l;
border-left: 4px solid $acc;
}
// 全边框型 — 温馨提示
&--bordered {
background: $wrn-l;
border-radius: $r-sm;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--tk-gap-xs);
}
&__title {
font-size: var(--tk-font-body);
font-weight: 600;
}
&--left-border &__title {
color: $acc;
}
&__subtitle {
font-size: var(--tk-font-micro);
opacity: 0.7;
}
&__body {
font-size: var(--tk-font-cap);
color: $tx2;
line-height: 1.6;
}
}

View File

@@ -0,0 +1,41 @@
import React, { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
type AlertVariant = 'gradient' | 'left-border' | 'bordered';
interface AlertCardProps {
variant?: AlertVariant;
title?: string;
subtitle?: string;
children?: ReactNode;
className?: string;
}
const AlertCard: React.FC<AlertCardProps> = ({
variant = 'left-border',
title,
subtitle,
children,
className = '',
}) => {
const cls = [
'alert-card',
`alert-card--${variant}`,
className,
].filter(Boolean).join(' ');
return (
<View className={cls}>
{title && (
<View className='alert-card__header'>
<Text className='alert-card__title'>{title}</Text>
{subtitle && <Text className='alert-card__subtitle'>{subtitle}</Text>}
</View>
)}
{children ?? <Text className='alert-card__body'>{subtitle}</Text>}
</View>
);
};
export default React.memo(AlertCard);

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

@@ -0,0 +1,37 @@
@import '../../../styles/variables.scss';
.chat-bubble-wrap {
display: flex;
flex-direction: column;
margin-bottom: var(--tk-gap-xs);
}
.chat-bubble {
max-width: 75%;
padding: var(--tk-gap-md) var(--tk-gap-lg);
font-size: var(--tk-font-body);
line-height: 1.5;
&--other {
align-self: flex-start;
background: $card;
border-radius: $r $r $r $r-xs;
}
&--mine {
align-self: flex-end;
background: var(--tk-pri-l);
border-radius: $r $r $r-xs $r;
}
&__text {
color: $tx;
}
&__time {
font-size: var(--tk-font-micro);
color: $tx3;
margin-top: 4px;
text-align: center;
}
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
interface ChatBubbleProps {
content: string;
isMine?: boolean;
time?: string;
className?: string;
}
const ChatBubble: React.FC<ChatBubbleProps> = ({
content,
isMine = false,
time,
className = '',
}) => {
const cls = [
'chat-bubble',
isMine ? 'chat-bubble--mine' : 'chat-bubble--other',
className,
].filter(Boolean).join(' ');
return (
<View className='chat-bubble-wrap'>
<View className={cls}>
<Text className='chat-bubble__text'>{content}</Text>
</View>
{time && <Text className='chat-bubble__time'>{time}</Text>}
</View>
);
};
export default React.memo(ChatBubble);

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,33 @@
@import '../../../styles/variables.scss';
.content-card {
background: var(--tk-card-bg);
border-radius: var(--tk-card-radius);
box-shadow: $shadow-sm;
transition: background 0.15s, opacity 0.15s, transform 0.15s;
&--outlined {
box-shadow: none;
border: 1px solid $bd;
}
&--elevated {
box-shadow: $shadow-md;
}
&--pressable {
cursor: pointer;
}
&--feedback-bg.content-card--pressable:active {
background: $bd-l;
}
&--feedback-opacity.content-card--pressable:active {
opacity: var(--tk-touch-feedback-opacity);
}
&--feedback-scale.content-card--pressable:active {
transform: scale(0.98);
}
}

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { View } from '@tarojs/components';
import { CSSProperties, ReactNode, useMemo } from 'react';
import './index.scss';
interface ContentCardProps {
variant?: 'default' | 'outlined' | 'elevated';
onPress?: () => void;
padding?: 'none' | 'sm' | 'md' | 'lg';
margin?: 'none' | 'md';
activeFeedback?: 'bg' | 'opacity' | 'scale' | 'none';
className?: string;
style?: CSSProperties;
children: ReactNode;
}
const PADDING_MAP = {
none: '0',
sm: 'var(--tk-card-padding-sm)',
md: 'var(--tk-card-padding)',
lg: 'var(--tk-card-padding-lg)',
} as const;
const MARGIN_MAP = {
none: '0',
md: 'var(--tk-gap-md)',
} as const;
const ContentCard: React.FC<ContentCardProps> = ({
variant = 'default',
onPress,
padding = 'md',
margin = 'md',
activeFeedback = 'bg',
className,
style,
children,
}) => {
const innerStyle = useMemo(() => ({
padding: PADDING_MAP[padding],
marginBottom: MARGIN_MAP[margin],
...style,
}), [padding, margin, style]);
const hasPress = !!onPress;
const cls = [
'content-card',
`content-card--${variant}`,
hasPress && 'content-card--pressable',
hasPress && activeFeedback !== 'none' && `content-card--feedback-${activeFeedback}`,
className,
].filter(Boolean).join(' ');
return (
<View className={cls} style={innerStyle} onClick={onPress}>
{children}
</View>
);
};
export default React.memo(ContentCard);

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

@@ -0,0 +1,57 @@
@import '../../../styles/variables.scss';
.form-input {
&__label {
display: block;
font-size: var(--tk-font-cap);
color: $tx3;
margin-bottom: 6px;
}
&__field {
height: var(--tk-input-height);
background: $card;
border: 1.5px solid $bd;
border-radius: $r;
padding: 0 var(--tk-gap-lg);
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 {
width: 100%;
height: 100%;
font-size: var(--tk-font-body);
color: $tx;
}
&__placeholder {
color: $tx3;
}
&--error &__field {
border-color: $dan;
}
&--disabled &__field {
opacity: 0.5;
}
&--focus &__field {
border-color: var(--tk-pri);
}
&__error {
display: block;
font-size: var(--tk-font-cap);
color: $dan;
margin-top: 4px;
}
}

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { View, Text, Input } from '@tarojs/components';
import './index.scss';
interface FormInputProps {
label?: string;
placeholder?: string;
value?: string;
onInput?: (value: string) => void;
type?: 'text' | 'number' | 'idcard' | 'digit';
maxLength?: number;
disabled?: boolean;
error?: string;
className?: string;
}
const FormInput: React.FC<FormInputProps> = ({
label,
placeholder,
value,
onInput,
type = 'text',
maxLength,
disabled = false,
error,
className = '',
}) => {
const cls = [
'form-input',
error && 'form-input--error',
disabled && 'form-input--disabled',
className,
].filter(Boolean).join(' ');
return (
<View className={cls}>
{label && <Text className='form-input__label'>{label}</Text>}
<View className='form-input__field'>
<Input
className='form-input__control'
placeholder={placeholder}
placeholderClass='form-input__placeholder'
value={value}
onInput={e => onInput?.(e.detail.value)}
type={type}
maxlength={maxLength}
disabled={disabled}
aria-invalid={!!error}
aria-label={label || placeholder}
/>
</View>
{error && <Text className='form-input__error'>{error}</Text>}
</View>
);
};
export default React.memo(FormInput);

View File

@@ -0,0 +1,8 @@
@import '../../../styles/variables.scss';
.gradient-header {
background: linear-gradient(135deg, var(--tk-pri) 0%, var(--tk-pri-d) 100%);
border-radius: $r;
padding: 18px;
color: $white;
}

View File

@@ -0,0 +1,21 @@
import React, { ReactNode } from 'react';
import { View } from '@tarojs/components';
import './index.scss';
interface GradientHeaderProps {
children: ReactNode;
className?: string;
}
const GradientHeader: React.FC<GradientHeaderProps> = ({
children,
className = '',
}) => {
return (
<View className={`gradient-header ${className}`}>
{children}
</View>
);
};
export default React.memo(GradientHeader);

View File

@@ -0,0 +1,27 @@
@import '../../../styles/variables.scss';
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--tk-gap-md) 0;
border-bottom: 1px solid $bd-l;
&--last {
border-bottom: none;
}
&__label {
font-size: var(--tk-font-body);
color: $tx2;
flex-shrink: 0;
}
&__value {
font-size: var(--tk-font-body-lg);
color: $tx;
text-align: right;
flex: 1;
margin-left: var(--tk-gap-md);
}
}

View File

@@ -0,0 +1,34 @@
import React, { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
interface InfoRowProps {
label: string;
value?: string;
valueNode?: ReactNode;
last?: boolean;
className?: string;
}
const InfoRow: React.FC<InfoRowProps> = ({
label,
value,
valueNode,
last = false,
className = '',
}) => {
const cls = [
'info-row',
last && 'info-row--last',
className,
].filter(Boolean).join(' ');
return (
<View className={cls}>
<Text className='info-row__label'>{label}</Text>
{valueNode ?? <Text className='info-row__value'>{value}</Text>}
</View>
);
};
export default React.memo(InfoRow);

View File

@@ -0,0 +1,67 @@
@import '../../../styles/variables.scss';
.list-item {
display: flex;
align-items: center;
background: $card;
border-radius: $r;
padding: var(--tk-gap-lg);
box-shadow: $shadow-sm;
gap: var(--tk-gap-md);
&--pressable {
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
&--read {
opacity: 0.7;
}
&__icon {
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: 22px;
display: flex;
align-items: center;
justify-content: center;
}
&__body {
flex: 1;
min-width: 0;
}
&__title {
display: block;
font-size: var(--tk-font-body);
font-weight: 500;
color: $tx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__subtitle {
display: block;
font-size: var(--tk-font-cap);
color: $tx3;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__extra {
flex-shrink: 0;
}
&__arrow {
flex-shrink: 0;
font-size: var(--tk-font-body-lg);
color: $tx3;
margin-left: var(--tk-gap-2xs);
}
}

View File

@@ -0,0 +1,46 @@
import React, { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';
interface ListItemProps {
title: string;
subtitle?: string;
extra?: ReactNode;
leftIcon?: ReactNode;
onPress?: () => void;
showArrow?: boolean;
unread?: boolean;
className?: string;
}
const ListItem: React.FC<ListItemProps> = ({
title,
subtitle,
extra,
leftIcon,
onPress,
showArrow = false,
unread = false,
className = '',
}) => {
const cls = [
'list-item',
onPress && 'list-item--pressable',
!unread && 'list-item--read',
className,
].filter(Boolean).join(' ');
return (
<View className={cls} onClick={onPress}>
{leftIcon && <View className='list-item__icon'>{leftIcon}</View>}
<View className='list-item__body'>
<Text className='list-item__title'>{title}</Text>
{subtitle && <Text className='list-item__subtitle'>{subtitle}</Text>}
</View>
{extra && <View className='list-item__extra'>{extra}</View>}
{showArrow && <Text className='list-item__arrow'></Text>}
</View>
);
};
export default React.memo(ListItem);

View File

@@ -0,0 +1,75 @@
@import '../../../styles/variables.scss';
@keyframes skeleton-pulse {
0% { opacity: 1; }
50% { opacity: 0.4; }
100% { opacity: 1; }
}
.loading-card-group {
&--card {
display: flex;
flex-direction: column;
gap: var(--tk-gap-md);
}
&--list {
display: flex;
flex-direction: column;
gap: var(--tk-gap-sm);
}
&--detail {
display: flex;
flex-direction: column;
gap: var(--tk-gap-md);
}
}
.loading-card {
background: var(--tk-card-bg);
border-radius: var(--tk-card-radius);
padding: var(--tk-card-padding);
animation: skeleton-pulse 1.5s ease-in-out infinite;
&__row {
display: flex;
align-items: center;
gap: var(--tk-gap-sm);
}
&__circle {
width: 40px;
height: 40px;
border-radius: 50%;
background: $bd-l;
flex-shrink: 0;
}
&__lines {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--tk-gap-xs);
}
&__line {
border-radius: 4px;
background: $bd-l;
&--title {
width: 60%;
height: 16px;
}
&--text {
width: 100%;
height: 12px;
}
&--short {
width: 40%;
height: 12px;
}
}
}

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { View } from '@tarojs/components';
import './index.scss';
interface LoadingCardProps {
count?: number;
layout?: 'card' | 'list' | 'detail';
}
const LoadingCard: React.FC<LoadingCardProps> = ({
count = 3,
layout = 'card',
}) => {
return (
<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 className="loading-card__line loading-card__line--title" />
<View className="loading-card__line loading-card__line--text" />
<View className="loading-card__line loading-card__line--short" />
</>
)}
{layout === 'list' && (
<View className="loading-card__row">
<View className="loading-card__circle" />
<View className="loading-card__lines">
<View className="loading-card__line loading-card__line--title" />
<View className="loading-card__line loading-card__line--short" />
</View>
</View>
)}
{layout === 'detail' && (
<>
<View className="loading-card__line loading-card__line--title" />
<View className="loading-card__line loading-card__line--text" />
<View className="loading-card__line loading-card__line--text" />
<View className="loading-card__line loading-card__line--short" />
</>
)}
</View>
))}
</View>
);
};
export default React.memo(LoadingCard);

View File

@@ -0,0 +1,11 @@
@import '../../../styles/variables.scss';
.page-shell {
min-height: 100vh;
background: $bg;
box-sizing: border-box;
&--safe-bottom {
padding-bottom: calc(var(--tk-page-padding) + env(safe-area-inset-bottom, 0px));
}
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { View, ScrollView } from '@tarojs/components';
import { ReactNode, useMemo } from 'react';
import './index.scss';
interface PageShellProps {
padding?: 'none' | 'sm' | 'md' | 'lg';
safeBottom?: boolean;
scroll?: boolean;
className?: string;
children: ReactNode;
}
const PADDING_MAP = {
none: '0',
sm: 'var(--tk-gap-md)',
md: 'var(--tk-page-padding)',
lg: 'var(--tk-gap-xl)',
} as const;
const PageShell: React.FC<PageShellProps> = ({
padding = 'md',
safeBottom = true,
scroll = true,
className,
children,
}) => {
const style = useMemo(() => ({
paddingLeft: PADDING_MAP[padding],
paddingRight: PADDING_MAP[padding],
paddingTop: PADDING_MAP[padding],
}), [padding]);
const cls = [
'page-shell',
safeBottom && 'page-shell--safe-bottom',
className,
].filter(Boolean).join(' ');
if (scroll) {
return (
<ScrollView scrollY className={cls} style={style}>
{children}
</ScrollView>
);
}
return (
<View className={cls} style={style}>
{children}
</View>
);
};
export default React.memo(PageShell);

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

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