From 4507083f3fedb3b91aaee8586aa593374e909512 Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Sun, 28 Jun 2026 22:53:35 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E8=93=9D?= =?UTF-8?q?=E7=89=99=E8=AE=BE=E5=A4=87=E9=A1=B5=E4=B8=8E=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E9=A1=B5=E7=9A=84=E6=89=AB=E6=8F=8F-?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5-=E5=BD=95=E5=85=A5=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通用 BLE 健康设备识别(血压计/血糖仪/体重秤/血氧仪 标准 service UUID) - 蓝牙设备页静默自动同步:仅匹配已绑定的设备名+类型,收到数据后变绿 - 新增设备页:扫描所有支持类型设备 → 选连接 → 绑定 + 同步首次数据 - 修复 read 缓存导致的重复录入循环(删除主动 read,依赖 indicate 推送) - 修复 isConnected 缓存状态导致连接异常(强制先 disconnect 再 connect) - 录入弹窗:血压心率同等级展示、3 秒自动关闭/手动确认 - 顺手更新本机开发 IP 与清理过时设计文档 --- .../Endpoints/notification_endpoints.cs | 44 + docs/BUG_REVIEW.md | 438 -------- docs/backend_architecture_evolution.md | 283 ----- docs/color_design_system.md | 819 --------------- docs/doctor_app_merge_design.md | 313 ------ docs/omron_bp_implementation_plan.md | 170 --- docs/omron_bp_integration.md | 825 --------------- docs/project_deep_review.md | 570 ---------- health_app/lib/core/api_client.dart | 2 +- health_app/lib/models/ble_device.dart | 187 ++++ health_app/lib/models/bp_reading.dart | 2 + health_app/lib/pages/chart/trend_page.dart | 4 +- .../pages/device/device_management_page.dart | 992 ++++++++++++------ .../lib/pages/device/device_scan_page.dart | 724 ++++++------- health_app/lib/pages/home/home_page.dart | 1 + .../lib/providers/omron_device_provider.dart | 262 +++-- .../lib/services/health_ble_service.dart | 316 ++++++ .../lib/services/omron_ble_service.dart | 244 ----- health_app/lib/widgets/ble_sync_dialog.dart | 160 +++ health_app/lib/widgets/health_drawer.dart | 8 + 20 files changed, 1936 insertions(+), 4428 deletions(-) delete mode 100644 docs/BUG_REVIEW.md delete mode 100644 docs/backend_architecture_evolution.md delete mode 100644 docs/color_design_system.md delete mode 100644 docs/doctor_app_merge_design.md delete mode 100644 docs/omron_bp_implementation_plan.md delete mode 100644 docs/omron_bp_integration.md delete mode 100644 docs/project_deep_review.md create mode 100644 health_app/lib/models/ble_device.dart create mode 100644 health_app/lib/services/health_ble_service.dart delete mode 100644 health_app/lib/services/omron_ble_service.dart create mode 100644 health_app/lib/widgets/ble_sync_dialog.dart diff --git a/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs b/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs index 13a1c06..9ed4aa2 100644 --- a/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs @@ -1,5 +1,7 @@ using System.Security.Claims; +using System.Text.Json; using Health.Application.Notifications; +using Health.Domain.Enums; namespace Health.WebApi.Endpoints; @@ -96,6 +98,48 @@ public static class NotificationEndpoints if (body.HealthRecordReminderWeight.HasValue) pref.HealthRecordReminderWeight = body.HealthRecordReminderWeight.Value; pref.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(ct); + + // 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00 + if (body.HealthRecordReminder is true && pref.HealthRecordReminder) + { + var nowCst = DateTime.UtcNow.AddHours(8); + var todayStart = nowCst.Date.AddHours(-8); + var todayEnd = todayStart.AddDays(1); + + var recordedTypes = await db.HealthRecords + .Where(r => r.UserId == userId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd) + .Select(r => r.MetricType) + .Distinct() + .ToListAsync(ct); + + var missing = new List<(HealthMetricType type, bool enabled, string label)> + { + (HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"), + (HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"), + (HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"), + (HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"), + (HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"), + }; + + var missingLabels = missing + .Where(m => m.enabled && !recordedTypes.Contains(m.type)) + .Select(m => m.label) + .ToList(); + + if (missingLabels.Count > 0) + { + var producer = http.RequestServices.GetRequiredService(); + var sourceId = Guid.NewGuid(); + await producer.EnqueueAsync(userId, sourceId, new NotificationMessage( + Type: "health_record_reminder", + Title: "记录今日健康指标", + Message: $"建议录入:{string.Join("、", missingLabels)}", + Severity: "info", + ActionType: "health", + ActionTargetId: null), ct); + } + } + return Results.Ok(new { code = 0, data = ToDto(pref), message = (string?)null }); }); } diff --git a/docs/BUG_REVIEW.md b/docs/BUG_REVIEW.md deleted file mode 100644 index 1642726..0000000 --- a/docs/BUG_REVIEW.md +++ /dev/null @@ -1,438 +0,0 @@ -# 健康管家 — 全面代码审查与Bug文档 - -> 审查日期:2026-06-10 | 范围:全栈(后端 .NET 10 + 前端 Flutter) - ---- - -## 测试结果汇总 - -| 测试集 | 通过 | 失败 | 说明 | -|--------|------|------|------| -| 后端单元测试 (entity_tests) | 10 | 0 | 实体/数据库操作全通过 | -| 后端单元测试 (auth_tests) | 4 | 0 | 认证流程全通过 | -| 后端单元测试 (ai_agent_tests - PromptManager) | 8 | 0 | AI提示词全通过 | -| 后端集成测试 (ai_agent_tests - AI对话) | 0 | 5 | 需要后端运行中 | -| Flutter 测试 (widget_test) | - | - | sqlite3 原生库下载失败,无法运行 | - -**总计:22/27 通过(5个集成测试需要后端在线)** - ---- - -## 一、后端 Bug - -### B1. 缺少 consultation DELETE 端点 -- **位置**:`consultation_endpoints.cs` -- **问题**:问诊创建后无删除/取消接口。患者无法取消发起的问诊 -- **影响**:患者发起错误问诊后无法撤销 -- **建议**:添加 `MapDelete("/consultations/{id:guid}", ...)` - -### B2. ai_chat_endpoints 中 unified agent 缺少部分工具 -- **位置**:`ai_chat_endpoints.cs:340-347` -- **问题**:unified agent 虽聚合了 7 种工具,但缺少 `manage_archive` 和 `request_doctor` -- **影响**:用户在 unified 模式无法通过对话修改档案或请求医生 - -### B3. CompressImage 未释放 GDI 资源 -- **位置**:`ai_chat_endpoints.cs:484-503` -- **问题**:`Image.FromFile`、`Bitmap`、`Graphics` 均 `using` 包裹,但 `EncoderParameters` 未释放 -- **影响**:每次食物识别可能泄漏少量非托管内存 -- **修复**:`using var parameters = new EncoderParameters(1);` - -### B4. 用药提醒服务可能定时查询过于频繁 -- **位置**:`BackgroundServices/medication_reminder_service.cs` -- **问题**:需要检查轮询间隔,每分钟查一次可能对数据库压力大 - -### B5. 开发数据种子硬编码 API Key 检查 -- **位置**:`dev_data_seeder.cs` -- **问题**:`DEVDATA_ENABLED=true` 创建测试用户,生产环境可能误开启 - ---- - -## 二、前端 Bug - -### C1. ~~聊天列表从顶部跳到底部~~ ✅ 已修复 -- **修复**:`chat_messages_view.dart` 改为 `reverse:true` -- **修复**:`home_page.dart` 滚动逻辑简化 - -### C2. ~~SwipeDeleteTile 内层手势冲突~~ ✅ 已修复 -- **修复**:`common_widgets.dart` 滑动状态下用 `AbsorbPointer` 屏蔽内部按钮 - -### C3. ~~SwipeDeleteTile 红色溢出~~ ✅ 已修复 -- **修复**:margin 参数从 Stack 内部移到外部 Padding - -### C4. ~~运动打卡 dayOfWeek 索引偏差~~ ✅ 已修复 -- **修复**:`remaining_pages.dart` 改为 `weekday % 7` 与 C# DayOfWeek 对齐 - -### C5. ~~用药管理 Dismissible 一步删除~~ ✅ 已修复 -- **修复**:统一使用 `SwipeDeleteTile` - -### C6. Flutter 测试断言错误 -- **位置**:`health_app/test/widget_test.dart:9` -- **代码**:`expect(AppTheme.primary, AppTheme.primaryLight);` -- **问题**:`primary` (0xFF6366F1) ≠ `primaryLight` (0xFFEEF2FF),这个断言必然失败 -- **修复**:改为 `expect(AppTheme.primary, const Color(0xFF6366F1));` - -### C7. home_page.dart onTap 手误写了 `?.()` -- **位置**:`home_page.dart:115` -- **代码**:`onTap: () => pushRoute(ref, 'notificationPrefs')` 实际没有 `?.()` 问题(之前看错了),此处无误 - -### C8. 报告详情返回按钮:popRoute 在 setState 后 -- **位置**:`report_pages.dart:449-454` -- **问题**:`clearAnalysis()` 调用 `state.copyWith(...)` 然后立即 `popRoute(ref)`,在 pop 过程中可能访问已 dispose 的 provider -- **风险**:中等 - -### C9. DietCapturePage TextField 内存泄漏 -- **位置**:`diet_capture_page.dart:465,479,495` -- **问题**:每个食物项创建 `TextEditingController(text: food.name)` 但从不 dispose -- **修复**:用 `TextFormField` + `initialValue` 或缓存 controller - -### C10. 报告上传错误吞没 -- **位置**:`report_pages.dart:199` -- **代码**:`} catch (_) {}` — 上传失败无任何反馈 -- **修复**:至少显示 snackbar 提示 - ---- - -## 三、前端缺失功能 - -### M1. 饮食记录无编辑/修改功能 -- 当前只能查看(`DietRecordDetailPage`)和删除,无法修改已有记录 - -### M2. 运动计划详情页缺失 -- `ExercisePlanPage` 的 `onTap: () {}` 为空,点卡片无反应 -- 没有类似 `ExercisePlanDetailPage` 的页面 - -### M3. 问诊列表无前端入口 -- 后端有 `/api/consultations` GET,但前端没有问诊历史列表页 - -### M4. 对话无删除/清空功能 -- 后端有 `DELETE /api/ai/conversations/{id}`,但前端无对应UI - -### M5. 无数据导出功能 -- 用户无法导出健康数据(血压记录、饮食记录等) - ---- - -## 四、安全与代码质量 - -### S1. JWT Secret 开发默认值 -- **位置**:`Program.cs:46` -- **问题**:`jwtSecret ??= "dev-secret-key-change-in-production-min-32-chars!!";` -- **风险**:若忘记设环境变量,生产环境用弱密钥 - -### S2. token 通过 query string 传输(SSE) -- **位置**:`ai_chat_endpoints.cs:25`, `sse_handler.dart:17` -- **问题**:`token` 放在 URL query string,会被服务器日志、代理缓存 -- **风险**:中等(含过期时间的 token,但仍有泄露风险) - -### S3. CORS 全开 -- **位置**:`Program.cs:96-98` -- **代码**:`policy.SetIsOriginAllowed(_ => true)` -- **风险**:生产环境应限定具体 origin - -### S4. 全局异常中间件可能泄露内部错误 -- **位置**:`exception_middleware.cs` -- **需要检查**:是否在生产环境返回了调用栈 - -### S5. Flutter 硬编码后端 IP -- **位置**:`api_client.dart:6` -- **代码**:`const String baseUrl = 'http://10.4.164.158:5000';` -- **问题**:每次换网络都需改代码 - ---- - -## 五、未使用代码(可清理) - -- `chat_messages_view.dart`: `_cardFilledBtn`, `_cardOutlineBtn`, `_agentColors`, `_taskRow` 未使用 -- `chat_provider.dart`: `_parseAgent` 未使用 -- `remaining_pages.dart`: `shadcn_ui` 导入未使用 -- `report_pages.dart`: `shadcn_ui` 导入未使用,`reportId` 变量未使用 -- `service_package_detail_page.dart`: `navigation_provider` 导入未使用 - ---- - -## 六、Agent 自动审查新增发现 - -### A1. app_router.dart 无防御 null 断言(🔴严重) -- **位置**:`app_router.dart:41-73` -- **代码示例**:`ReportDetailPage(id: params['id']!)` 等多处 -- **问题**:`params['id']!` 若 params 中无 'id' 键,会抛出 null 断言异常导致崩溃 -- **影响**:任何路由参数拼写错误或缺少都会 crash -- **修复**:使用 `params['id'] ?? ''` 并提供 fallback - -### A2. device_scan_page.dart BLE 流订阅未取消(🔴严重) -- **位置**:`device_scan_page.dart` -- **问题**:BLE stream subscription 在 dispose 时未 cancel,导致蓝牙连接泄漏 -- **影响**:多次进出扫描页会积累未释放的 BLE 连接 - -### A3. 静默吞错误(🟡中) -- **范围**:全前端 28 处 `catch (_) {}` -- **问题**:所有 API 错误、解析错误均无日志或用户提示 -- **修复**:至少加上 `debugPrint('Error: $e')` 或显示 snackbar - -### A4. 删除后未刷新 Provider(🟡中) -- **范围**:多个页面 -- **问题**:删除操作后调用了 `_load()` 或 `_refresh()`(setState),但未调用 `ref.invalidate(someProvider)`,导致 Riverpod 缓存未更新 -- **影响**:切换到其他页面再回来,旧数据可能仍显示 - -### A5. FutureProvider + setState 双重模式(🟢低) -- **范围**:`DietRecordListPage`, `ExercisePlanPage` 等 -- **问题**:同时使用 Riverpod FutureProvider 和本地 setState,导致 provider 失效无效 - ---- - -## 七、全部问题汇总(含Agent发现) - -| # | 优先级 | 位置 | 问题描述 | -|---|--------|------|----------| -| 1 | 🔴 | `app_router.dart` | null 断言无防御,参数缺失即崩溃 | -| 2 | 🔴 | `device_scan_page.dart` | BLE 流订阅未取消 | -| 3 | 🔴 | `B1` consultation | 缺少 DELETE 端点 | -| 4 | 🔴 | `S1` Program.cs | JWT 开发默认弱密钥 | -| 5 | 🔴 | `C9` diet_capture | TextEditingController 泄漏 | -| 6 | 🟡 | 全局 28处 | catch(_){} 静默吞错 | -| 7 | 🟡 | 多页面 | 删除后无 ref.invalidate | -| 8 | 🟡 | `C6` widget_test | 断言 primary == primaryLight 错误 | -| 9 | 🟡 | `C10` report | 上传失败无提示 | -| 10 | 🟡 | `M2` exercise | 运动详情页缺失,onTap 空 | -| 11 | 🟡 | `C8` report | pop 时序问题 | -| 12 | 🟡 | `A5` 多页面 | FutureProvider+setState 双重模式 | -| 13 | 🟢 | `B3` ai_chat | EncoderParameters 未释放 | -| 14 | 🟢 | `B4` bg_service | 用药提醒轮询频率需审视 | -| 15 | 🟢 | `B5` dev_data | 生产环境可能误开启测试数据 | -| 16 | 🟢 | `S2` SSE | token 走 query string | -| 17 | 🟢 | `S3` CORS | 全开 AllowCredentials | -| 18 | 🟢 | `S5` api_client | 硬编码 IP | -| 19 | 🟢 | 多处 | 未使用代码可清理 | -| 20 | 🟢 | `B2` unified | unified agent 缺少 manage_archive 工具 | - ---- - -## 八、后端 Agent 审查新增发现(关键) - -### D1. 运动计划 DayOfWeek 跨层不一致(🔴严重) -- **位置**:`prompt_manager.cs` vs `exercise_plan.cs` 实体注释 -- **问题**:Prompt 告诉 AI `day_of_week: 0-6(周日=0)`,但实体注释 `// 0=周一, 6=周日`。AI 按周日=0 生成数据,后端按周一=0 解析,运动计划星期全偏一天 -- **修复**:统一为 C# DayOfWeek 枚举(0=周日) - -### D2. SMS 验证码使用伪随机数(🔴安全漏洞) -- **位置**:`sms_service.cs` -- **问题**:`Random.Shared.Next(100000, 1000000)` 使用 PRNG,攻击者可预测验证码 -- **修复**:改用 `RandomNumberGenerator.GetInt32(100000, 999999)` - -### D3. checkin 无所有权验证(🔴越权漏洞) -- **位置**:`medication_agent_handler.cs:99`、`exercise_agent_handler.cs:69` -- **问题**:`confirm_medication` 和 `exercise checkin` 直接通过 itemId 操作,不验证是否属于当前用户。任何认证用户可操作他人数据 -- **修复**:添加 `&& item.Plan.UserId == userId` 检查 - -### D4. VisionAsync content 序列化错误(🔴严重) -- **位置**:`open_ai_compatible_client.cs:136` -- **问题**:将图片 contentParts 先序列化为 JSON 字符串再赋值给 Content,但 OpenAI 兼容 API 期望 Content 为数组格式 -- **影响**:食物识别 VLM 调用可能失败 - -### D5. diet/consultation agent 工具声明但未实现(🔴严重) -- **位置**:`diet_agent_handler.cs`、`consultation_agent_handler.cs` -- **问题**:`EstimateFoodTool` 和 `RequestDoctorTool` 在 Tools 列表中声明,但 Execute 方法无对应 case,调用返回"未知工具" -- **影响**:饮食识别和请求医生功能不可用 - -### D6. CleanupService 删除未级联(🔴严重) -- **位置**:`cleanup_service.cs:28` -- **问题**:`db.Conversations.RemoveRange(oldConversations)` 未先删除关联的 ConversationMessage,可能因 FK 约束抛异常 -- **修复**:先删除 Messages 再删 Conversations,或使用 ExecuteDeleteAsync - -### D7. 用药提醒时区计算 bug(🔴严重) -- **位置**:`medication_reminder_service.cs:37` -- **问题**:`DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc)` — 北京时间 00:00 被标记为 UTC 00:00,导致打卡检测有 8 小时偏差 -- **影响**:提醒时间错位、重复提醒或漏提醒 - -### D8. DbContext 未配置外键和级联删除(🟡中等) -- **位置**:`app_db_context.cs` -- **问题**:`OnModelCreating` 没有任何 `HasOne/WithMany/HasForeignKey/OnDelete` 配置,全凭约定 -- **影响**:数据库无 FK 约束、级联行为不明确、RefreshToken 全表扫描 - -### D9. 缺少多个数据库索引(🟡中等) -- RefreshToken: 无索引,认证查询全表扫描 -- FollowUp: 无 (UserId, ScheduledAt) 索引 -- DeviceToken: 无 UserId 索引 -- Report: 无 (UserId, CreatedAt) 索引 - -### D10. ExceptionMiddleware 统一返回500(🟡中等) -- **位置**:`exception_middleware.cs` -- **问题**:所有异常都返回 500,不区分 400/401/404 -- **影响**:客户端无法根据状态码处理不同类型的错误 - -### D11. 用药提醒未实际推送(🟡中等) -- **位置**:`medication_reminder_service.cs` -- **问题**:TODO 注释表明推送尚未实现,只记录日志 - -### D12. Prompt 文本硬编码(🟢低) -- 不支持热更新,修改需重新编译 - ---- - -## 九、最终汇总(所有发现) - -| # | 优先级 | 类别 | 位置 | 问题 | -|---|--------|------|------|------| -| 1 | 🔴 | 安全 | sms_service | 验证码用 PRNG 可预测 | -| 2 | 🔴 | 安全 | agent handlers | checkin 越权漏洞 | -| 3 | 🔴 | 逻辑 | prompt/entity | DayOfWeek 跨层不一致 | -| 4 | 🔴 | 功能 | diet/consult agent | 工具声明但未实现 | -| 5 | 🔴 | 功能 | open_ai_client | Vision content 序列化错误 | -| 6 | 🔴 | 稳定性 | cleanup_service | 删除未级联 FK 冲突 | -| 7 | 🔴 | 功能 | reminder_service | 时区计算 8h 偏差 | -| 8 | 🔴 | 崩溃 | app_router.dart | null 断言无防御 | -| 9 | 🔴 | 泄漏 | device_scan_page | BLE 流未取消 | -| 10 | 🔴 | 安全 | Program.cs | CORS 全开+AllowCredentials | -| 11 | 🔴 | 安全 | Program.cs | JWT 默认弱密钥 | -| 12 | 🔴 | 泄漏 | diet_capture | TextEditingController 未释放 | -| 13 | 🟡 | 体验 | 28处 | catch(_){} 静默吞错 | -| 14 | 🟡 | 逻辑 | 多页面 | 删除后未 invalidate provider | -| 15 | 🟡 | 配置 | app_db_context | 无 FK/级联/索引 | -| 16 | 🟡 | 体验 | exception_middleware | 统一返回 500 | -| 17 | 🟡 | 测试 | widget_test | 断言永远失败 | -| 18 | 🟡 | 功能 | 用药提醒 | 推送未实现 | -| 19 | 🟡 | 架构 | 多页面 | FutureProvider+setState 混用 | -| 20 | 🟡 | 缺失 | exercise | 计划详情页缺失 | -| 21 | 🟢 | 代码 | 多处 | 未使用代码/本地时间/冗余 | - ---- - -## 十、后端端点 Agent 审查新增发现(关键) - -### E1. 医生端点零授权(🔴阻断级) -- **位置**:`doctor_endpoints.cs:19` -- **问题**:`MapGroup("/api/doctor")` **没有 `.RequireAuthorization()`**,所有医生端点(患者详情、健康数据、问诊、报告、随访)对公网开放 -- **影响**:任何人可查看所有患者隐私数据、修改报告、创建/删除随访 - -### E2. consultation POST 消息无所有权检查(🔴阻断级) -- **位置**:`consultation_endpoints.cs:48-75` -- **问题**:发消息只检查 consultation 存在,不验证是否属于当前用户。用户A可向用户B的问诊发消息 - -### E3. exercise checkin 无所有权检查(🔴阻断级) -- **位置**:`exercise_endpoints.cs:119` -- **问题**:`FindAsync([itemId])` 只按ID查,不验证 `item.Plan.UserId == userId`。用户可操作他人运动计划 - -### E4. SMS验证码在响应中暴露(🔴严重) -- **位置**:`auth_endpoints.cs:34` -- **问题**:`devCode = code` 将6位验证码直接返回在JSON中,无 `#if DEBUG` 守卫 - -### E5. Task.Run 火后不理模式(🔴严重) -- **位置**:`report_endpoints.cs:67` -- **问题**:`_ = Task.Run(async () => { ... }, CancellationToken.None)` 在请求结束后 scope 可能已释放,后台任务崩溃 - -### E6. System.Drawing 仅Windows(🔴严重) -- **位置**:`ai_chat_endpoints.cs:486-494` -- **问题**:`Image.FromFile`/`Bitmap`/`Graphics` 在Linux容器中崩溃 - -### E7. 健康数据 N+1 查询(🟡中等) -- **位置**:`health_endpoints.cs:78-89` -- **问题**:`/latest` 对5种指标类型分别发一次SQL查询 - -### E8. 日历用药事件显示错误(🟡中等) -- **位置**:`calendar_endpoints.cs:49` -- **问题**:用户有任意活跃用药就在每月每天标记"用药",不区分具体哪天该吃药 - -### E9. 手动JSON解析绕过模型绑定(🟡中等) -- **范围**:diet、medication、exercise、doctor endpoints -- **问题**:`JsonDocument.Parse` 手动解析导致 Swagger 无法文档化、无自动验证、拼写错误抛500 - -### E10. 缺失端点 -- health:缺 DELETE -- diet:缺 PUT -- exercise:缺 PUT -- report:缺 DELETE -- file:缺 GET/DELETE/list -- followup:缺 detail/confirm - ---- - -## 十一、完整问题排名 - -| # | 等级 | 文件 | 问题 | -|---|------|------|------| -| 1 | 🔴🔴 | doctor_endpoints | **零授权** — 所有患者数据公开 | -| 2 | 🔴🔴 | consultation | 发消息无所有权检查 | -| 3 | 🔴🔴 | exercise | checkin无所有权检查 | -| 4 | 🔴 | auth | SMS验证码响应暴露 | -| 5 | 🔴 | report | Task.Run火后不理 | -| 6 | 🔴 | ai_chat | System.Drawing仅Windows | -| 7 | 🔴 | sms_service | PRNG可预测验证码 | -| 8 | 🔴 | agent handlers | checkin越权 | -| 9 | 🔴 | prompt_manager | DayOfWeek不一致 | -| 10 | 🔴 | diet/consult agent | 工具声明未实现 | -| 11 | 🔴 | open_ai_client | Vision序列化错误 | -| 12 | 🔴 | cleanup_service | 删除未级联 | -| 13 | 🔴 | reminder_service | 时区8h偏差 | -| 14 | 🔴 | app_router | null断言崩溃 | -| 15 | 🔴 | device_scan | BLE泄漏 | -| 16 | 🔴 | Program.cs | CORS全开 | -| 17 | 🔴 | Program.cs | JWT弱密钥 | -| 18 | 🔴 | diet_capture | Controller泄漏 | -| 19 | 🟡 | 28处 | catch(_){}吞错 | -| 20 | 🟡 | 多页面 | 删除未invalidate | -| 21 | 🟡 | health | N+1查询 | -| 22 | 🟡 | calendar | 用药事件逻辑错误 | -| 23 | 🟡 | app_db_context | 无FK/索引配置 | -| 24 | 🟡 | exception_mw | 统一500 | -| 25 | 🟡 | 提醒服务 | 推送未实现 | -| 26 | 🟡 | 多端点 | 缺PUT/DELETE | -| 27 | 🟡 | 多端点 | 手动JSON无验证 | - ---- - -## 优先级排序 - -| 优先级 | Bug | 影响范围 | -|--------|-----|----------| -| 🔴 高 | C9: TextEditingController 内存泄漏 | 饮食识别页面 | -| 🔴 高 | B1: 缺少 consultation DELETE | 问诊功能 | -| 🔴 高 | S1: JWT 弱密钥默认值 | 安全 | -| 🟡 中 | C6: Flutter 测试断言错误 | CI/CD | -| 🟡 中 | C10: 报告上传错误吞没 | 用户体验 | -| 🟡 中 | M2: 运动计划详情页缺失 | 用户体验 | -| 🟡 中 | C8: report pop 时序问题 | 潜在崩溃 | -| 🟢 低 | B3: EncoderParameters 未释放 | 极少量泄漏 | -| 🟢 低 | S5: 硬编码 IP | 开发体验 | -| 🟢 低 | 未使用代码 | 代码质量 | - ---- - -*共发现 15 个问题,5 个缺失功能。其中 5 个 Bug 已在本轮修复。* - ---- - -## 第二轮换角度审查(新增 10 个问题) - -### F1. uploadFile 响应解析崩溃 🔴 -`api_client.dart:60-69` — 后端返回 `data: [{id, name, size}]`(List),前端做 `data['data']?['url']` 对List用字符串索引,运行时异常。聊天图片上传全部无声失败。 - -### F2. consultationChatProvider SignalR 永停不了 🔴 -`consultation_provider.dart` — `stop()` 定义了但无 Widget dispose 调用。离开问诊页后 SignalR 连接和 5秒轮询永续运行。 - -### F3. chatProvider 流订阅无 dispose 🔴 -`chat_provider.dart` — `_subscription`/`_streamTimer` 无 dispose,provider 销毁即泄漏。 - -### F4. ChatMessage 原地修改破坏不可变性 🔴 -`msg.confirmed = true` 直接修改 state 中的对象 → 违反 Riverpod 不可变契约。 - -### F5. 所有 FutureProvider 无 autoDispose 🟡 -6 个 FutureProvider 缓存永不过期,页面级数据不释放。 - -### F6. authProvider Token 刷新窗口期 id/phone 为空 🟡 -`auth_provider.dart:59` — `UserInfo(id: '', phone: '')` 然后等 `_loadProfile()` 异步补全。 - -### F7. 医生 Web 零认证 🔴🔴 -`doctor_web/src/services/api-client.ts` — 不发送 Authorization 头。叠加后端 doctor_endpoints 零授权(E1),任何人可访问所有患者数据。 - -### F8. 医生 Web SignalR handler 泄漏 🔴 -`ChatPage.tsx:28-65` — 组件卸载时若在 Reconnecting 状态,跳过 `off('ReceiveMessage')`,重复挂载累积 handler。 - -### F9. 6个后端端点前端从未调用 🟢 -`GET/DELETE /api/ai/conversations`、`GET /api/consultations`、`PUT /api/health-records/{id}`、全部 `/api/doctor/*` - -### F10. 37 对 API 契约中 1 对崩溃 + 多端手动JSON脆弱 🟡 -`uploadFile` 是唯一崩溃的。其余 36 对匹配但 `TryGetProperty` 区分大小写,依赖前端恰好用 camelCase。 - ---- - -*两轮共发现 37 个问题,其中 5 个阻断级、19 个严重、10 个中等、3 个低。* diff --git a/docs/backend_architecture_evolution.md b/docs/backend_architecture_evolution.md deleted file mode 100644 index 2290daa..0000000 --- a/docs/backend_architecture_evolution.md +++ /dev/null @@ -1,283 +0,0 @@ -# 后端架构演进方案 - -日期:2026-06-18 - -## 1. 背景 - -当前项目已经具备 `Health.Domain`、`Health.Application`、`Health.Infrastructure`、`Health.WebApi` 的分层目录,但实际业务逻辑主要仍写在 `Health.WebApi/Endpoints` 中。多数接口直接注入 `AppDbContext`,在 Endpoint 内完成查询、权限判断、状态流转和 `SaveChanges`。 - -这种方式适合早期快速验证,但随着患者端健康管理、AI 分析、报告、饮食、用药、运动、医生端等业务增长,会带来几个问题: - -- 业务规则分散在 Endpoint、AI Agent Handler、后台服务中。 -- 同一个业务动作可能被普通接口和 AI 工具重复实现。 -- 权限和资源归属校验容易遗漏。 -- 异步任务目前存在 `Task.Run` 形式,不便于限流、重试和统一管理。 -- Application 层没有真正承接业务用例,后续测试和维护成本会升高。 - -技术目标是按 DDD 思路逐步演进:让 Endpoint 成为接口适配层,业务流程进入 Application Service,外部能力和数据访问由 Infrastructure 支撑,耗时任务通过生产者-消费者管道处理。 - -## 2. 当前业务边界 - -### 2.1 当前核心患者端闭环 - -以下业务都需要继续做扎实: - -1. AI 健康管家聊天 -2. 健康指标记录与趋势:血压、心率、血糖、血氧、体重 -3. 报告上传与 AI 预解读 -4. 饮食拍照分析与保存 -5. 用药管理与打卡 -6. 运动计划 - -### 2.2 医生端当前策略 - -医生端可以做代码整理和权限边界收拢,但不作为当前核心业务闭环: - -- 医患实时聊天:暂时搁置,后续接入互联网医院后再完善。 -- 医生审核报告:暂时不是当前真实流程,患者端报告以 AI 预解读为主;界面可显示“医生审核中/待审核”一类状态。 -- 医生工作台:保留现有页面和基础接口,不主动扩大功能范围,重构时以不影响当前项目运行为目标。 - -### 2.3 AI 写入规则 - -统一业务规则: - -- AI 纯查询、解释、建议可以直接回复。 -- AI 只要要写入用户健康相关数据,必须先让用户确认。 -- 需要确认的写入包括但不限于:健康指标、用药计划、运动计划、健康档案修改。 -- 饮食记录当前在饮食分析结果页由用户主动保存,保留该方式。 - -## 3. 目标架构 - -目标不是创建一个巨大的全能 Service,而是按业务模块拆分 Application Service: - -```text -Health.WebApi - Endpoints - Hubs - BackgroundServices - -Health.Application - Auth - Users - HealthRecords - Medications - Diet - Reports - Exercise - Consultations - Doctors - Ai - Common - -Health.Domain - Entities - Enums - DomainRules - -Health.Infrastructure - Data - AI - Services - Storage -``` - -职责边界: - -- `WebApi`:接收 HTTP/SignalR 请求,解析参数,返回响应。 -- `Application`:承接业务用例、状态流转、权限校验、任务入队。 -- `Domain`:保存核心实体、枚举和稳定业务规则。 -- `Infrastructure`:EF Core、AI 客户端、短信、文件存储、推送、未来互联网医院适配。 - -## 4. 数据库读写收拢方式 - -短期先不强制引入 Repository 抽象,避免过度设计。第一阶段采用: - -```text -Endpoint -> Application Service -> AppDbContext -``` - -也就是说,数据库读写先统一进入对应业务 Service,而不是继续散落在 Endpoint 中。 - -后续如果业务复杂度继续提升,再考虑: - -```text -Application Service -> Repository / UnitOfWork -> AppDbContext -``` - -第一阶段优先收拢这些模块: - -1. `ReportService` -2. `DietService` -3. `MedicationService` -4. `ExerciseService` -5. `HealthRecordService` -6. `ConsultationService` - -## 5. 生产者-消费者管道 - -不是所有业务都需要管道。同步、快速、必须立即返回的操作仍走普通 Service。 - -适合管道的任务: - -- 报告 AI 分析 -- 饮食图片识别 -- 处方图片识别 -- 用药提醒推送 -- 健康周报生成 -- 后期互联网医院数据同步 - -当前阶段采用 .NET 内置 `Channel` + `BackgroundService`: - -```text -业务 Service = 生产者 -Channel = 内存任务队列 -BackgroundService = 消费者 -AI/推送/外部服务 = 实际执行器 -``` - -单服务器和当前用户规模下,内存队列足够作为第一阶段方案。后续如果出现多实例部署、任务不能丢、重试次数和死信队列等要求,再迁移到 Redis Stream、RabbitMQ 或 Hangfire。 - -## 6. 第一阶段落地范围 - -先从报告模块落地,因为它同时具备上传、AI、异步、状态流转、失败重试等典型场景。 - -### 6.1 报告模块目标 - -从当前: - -```text -ReportEndpoints -> AppDbContext + Task.Run + AI 调用 -``` - -演进为: - -```text -ReportEndpoints - -> ReportService - -> 保存文件 - -> 创建报告 - -> 标记状态 - -> 入队 ReportAnalysisJob - -ReportAnalysisWorker - -> 消费 ReportAnalysisQueue - -> 调用 ReportAnalysisService / AI Analyzer - -> 更新报告状态 -``` - -### 6.2 报告模块要保持的业务行为 - -- 上传只支持报告图片。 -- 上传成功后状态为 `Analyzing`。 -- AI 成功后进入 `PendingDoctor`,患者端展示 AI 预解读。 -- AI 失败后进入 `AnalysisFailed`,不生成假摘要、假指标。 -- 患者端可以查看原始报告图片。 -- 医生审核不是当前核心闭环,状态可保留但不扩大功能。 - -## 7. 当前已落地进展 - -截至 2026-06-18,第一轮服务化已经完成以下模块: - -1. `ReportService`:报告上传、图片校验、报告创建、重新分析、删除、AI 分析状态流转进入服务层;报告分析通过 `ReportAnalysisQueue` + `ReportAnalysisWorker` 异步消费。 -2. `HealthRecordService`:健康指标列表、创建、更新、删除、最新值、趋势查询、异常判断进入服务层;AI 记数据工具复用同一套创建逻辑。 -3. `ExerciseService`:运动计划创建、列表、详情、删除、打卡、AI 创建/查询/打卡进入服务层。 -4. `MedicationService`:用药列表、创建、更新、删除、今日服药确认、按剂量打卡、提醒查询、AI 创建/查询/确认进入服务层。 -5. `DietService`:饮食记录列表、保存、删除、热量/评分更新进入服务层。 - -其中以下模块已经继续演进为更严格的 Application Service + Repository 结构: - -```text -Endpoint - -> Health.Application.*Service - -> Health.Application.I*Repository - -> Health.Infrastructure.Ef*Repository - -> AppDbContext -``` - -已完成: - -1. `HealthRecordService` + `IHealthRecordRepository` + `EfHealthRecordRepository` -2. `ExerciseService` + `IExerciseRepository` + `EfExerciseRepository` -3. `DietService` + `IDietRepository` + `EfDietRepository` -4. `MedicationService` + `IMedicationRepository` + `EfMedicationRepository` -5. `ReportService` + `IReportRepository` + `EfReportRepository` + `IReportFileStorage` + `LocalReportFileStorage` - -报告模块中,`ReportAnalysisQueue` 和 `ReportAnalysisService` 仍属于 Infrastructure:前者是内存队列适配,后者需要调用 AI 客户端并通过 `IReportRepository` 更新报告状态,不再直接依赖 `AppDbContext`。 - -AI 写入确认机制已完成第一阶段落地: - -```text -AI 写入工具调用 - -> 创建 10 分钟有效的 PendingAiWriteCommand - -> 前端展示确认卡片,此时不写数据库 - -> 用户点击确认 - -> POST /api/ai/confirm-write/{commandId} - -> 校验当前用户和一次性命令 - -> 执行对应 Application Service 写入 -``` - -- 健康指标、创建/确认用药、创建/打卡运动、AI 修改健康档案均进入待确认流程。 -- 纯查询工具继续直接执行。 -- 命令只能由所属用户执行一次;执行失败时会恢复命令供用户重试,过期或服务重启后自动失效。 -- AI 待确认命令已迁移到数据库表 `AiWriteCommands`,领取命令、业务写入和完成状态在同一事务内执行,避免重复写入。 -- 报告分析任务已迁移到数据库表 `ReportAnalysisTasks`,支持服务重启恢复、原子领取、失败重试和最终失败状态。 -- 项目当前仍使用 `EnsureCreated` 管理原有表;新增 `DatabaseSchemaMigrator` 和 `__AppSchemaMigrations` 版本表,为已有本地数据库安全补充基础设施表。 - -患者端其他业务收拢进展: - -1. `HealthArchiveService` + `IHealthArchiveRepository`:健康档案页面、AI 查询和确认写入统一执行。 -2. `AiConversationService` + `IAiConversationRepository`:会话创建、消息保存、历史列表和删除统一执行。 -3. `PatientContextService`:统一组合健康档案、近期指标和当前用药。 -4. `UserService` + `IUserRepository`:个人资料和账号注销统一执行;账号数据清理使用事务。 -5. `CalendarService` + `ICalendarRepository`:聚合用药、运动、随访日历。 - -生产者/消费者管道现状: - -- 报告分析:数据库持久化任务 + `ReportAnalysisWorker`。 -- 饮食图片识别:任务持久化到 `DietImageAnalysisTasks`,由 `DietImageAnalysisWorker` 原子领取、失败重试和恢复处理中断任务;前端接口协议保持不变。 -- 用药提醒:`MedicationReminderService` 负责扫描生产任务,任务持久化到 `MedicationReminderTasks` 并按药品/日期/时间唯一去重;`MedicationReminderWorker` 消费后写入 `NotificationOutbox`。真正的手机推送需在选定推送服务后消费 Outbox,目前不会把未发送通知标记为已推送。 -- 报告、饮食和用药任务消费者均采用 1 到 5 秒的自适应空闲轮询,过期 Processing 任务每分钟恢复一次,避免每秒重复执行恢复更新。 -- App 内用药提醒通过 `NotificationOutbox` + `/api/notifications/pending` 提供,患者端前台每 30 秒获取并展示,展示后回执去重;暂不接入系统级或厂商推送。 -- `MaintenanceService` 每小时执行一次维护,自动删除超过 30 天的已完成/失败后台任务、通知 Outbox、过期 AI 写入命令和旧 AI 会话,不删除用户健康记录、饮食记录或用药计划。 -- 登录、注册、验证码、Token 刷新和退出已收拢到 `IAuthService`;管理员医生管理和患者列表已收拢到 `IAdminService`,Endpoint 不再直接读写数据库。开发环境 `send-sms` 仍返回 `devCode` 供 Flutter 自动填入,非开发环境不返回验证码。 -- AI 工具查询、确认写入和事务边界已收拢到 `IAiToolExecutionService`,AI Endpoint 不再直接持有 `AppDbContext`。 -- 运动计划已从 `WeekStartDate + DayOfWeek` 周模板改为 `StartDate + EndDate + ReminderTime`,每日条目使用唯一的 `ScheduledDate`。连续 7 天、10 天或更长计划按真实日期生成,首页今日任务、健康日历、医生端只读详情和 AI 创建均使用同一日期模型。 -- App 内运动提醒按每日条目的 `ScheduledDate` 和计划 `ReminderTime` 生成到 `NotificationOutbox`;已打卡和休息日不会提醒,同一每日条目只生成一次。 - -当前仍保留在 Endpoint 或旧 Handler 中的业务,需要后续逐步收拢: - -- `ConsultationService`:医生聊天暂不作为当前核心业务,但基础权限和数据读写仍可继续服务化。 -- `DoctorService` / `AdminDoctorService`:医生信息、患者列表、报告查看等目前不作为真实互联网医院流程,后续按老板和互联网医院接入方案调整。 -- `CalendarService`:健康日历目前聚合运动、用药、饮食等多模块数据,适合在核心模块稳定后单独收拢。 -- `User/ProfileService`:个人信息、健康档案、账号清理等可继续整理,尤其是 AI 修改健康档案前的确认规则。 - -## 8. 后续推广顺序 - -1. 报告:Application Service + 分析队列 + Worker -2. 饮食:图片识别任务队列,用户修正后保存 -3. 用药:提醒扫描和推送任务解耦 -4. 运动:计划创建、打卡规则收拢到 Service -5. 健康指标:记录、异常判断、趋势查询收拢到 Service -6. 问诊:互联网医院接入前只收拢基础接口,不扩大聊天功能 -7. 医生端:保留基础接口,后续根据互联网医院和老板决策再完善审核/聊天工作流 - -## 9. 不做的事 - -当前阶段暂不做: - -- 不一次性重构全项目。 -- 不立即引入 RabbitMQ/Kafka 等重型组件。 -- 不强制引入复杂 Repository 层。 -- 不改变当前患者端主要交互。 -- 不把医生聊天/医生审核作为当前核心业务流。 - -## 10. 验收标准 - -第一阶段完成后应满足: - -- 报告 Endpoint 不再直接承载主要业务流程。 -- 报告 AI 分析不再使用 `Task.Run`。 -- 报告分析任务通过队列进入后台 Worker。 -- 报告状态流转集中在 Application Service。 -- 上传、列表、详情、删除、查看原图、分析失败状态保持可用。 -- 后端编译通过,前端报告页分析通过。 diff --git a/docs/color_design_system.md b/docs/color_design_system.md deleted file mode 100644 index 3753b0a..0000000 --- a/docs/color_design_system.md +++ /dev/null @@ -1,819 +0,0 @@ -# 健康管家 — 配色设计文档 v3.0 - -> 2026-06-12 更新:清理未使用色,新增医生端色系,全面页面配色拆解 - ---- - -## 0. AppColors 色彩表(定义在 `app_colors.dart`) - -### 主色 - -| 常量 | 色号 | 色块 | 用途 | -|------|------|------|------| -| `primary` | `#8B5CF6` | 🟣🟣🟣🟣🟣 | 用户端主紫 | -| `primaryLight` | `#A78BFA` | 🟣 | 浅紫/渐变 | -| `primaryDark` | `#7C3AED` | 🟣 | 深紫/按下 | -| `doctorBlue` | `#0891B2` | 🔵 | **医生端主色** | - -### 背景 - -| 常量 | 色号 | 色块 | 用途 | -|------|------|------|------| -| `background` | `#F0ECFF` | ⬜ | 患者端页面底 | -| `cardBackground` | `#FFFFFF` | ⬜ | 卡片白 | -| `cardInner` | `#F5F2FF` | ⬜ | 卡片内底 | -| `pageGrey` | `#F5F5F5` | ⬜ | 医生端页面底 | -| `iconBg` | `#E8E0FA` | ⬜ | 图标底 | -| `avatarBg` | `#F0F0FF` | ⬜ | 头像底 | - -### 文字 - -| 常量 | 色号 | 色块 | 用途 | -|------|------|------|------| -| `textPrimary` | `#1F2937` | ⬛⬛⬛ | 主文字 | -| `textSecondary` | `#6B7280` | ⬛⬛ | 副文字 | -| `textHint` | `#9CA3AF` | ⬛ | 提示 | -| `textOnGradient` | `#FFFFFF` | ⬜ | 渐变上白色文字 | - -### 边框 - -| 常量 | 色号 | 色块 | -|------|------|------| -| `border` | `#E5E7EB` | ⬜ | -| `borderLight` | `#F3F4F6` | ⬜ | - -### 功能色 - -| 常量 | 色号 | 色块 | 含义 | -|------|------|------|------| -| `success` | `#10B981` | 🟢 | 成功/正常/绿色 | -| `successLight` | `#D1FAE5` | 🟢 | 成功浅底 | -| `error` | `#EF4444` | 🔴 | 错误/异常/红色 | -| `errorLight` | `#FEE2E2` | 🔴 | 错误浅底 | -| `warning` | `#F59E0B` | 🟡 | 警告/待定 | -| `warningLight` | `#FEF3C7` | 🟡 | 警告浅底 | -| `blueMeasure` | `#3B82F6` | 🔵 | 指标蓝色 | - -### 渐变 - -| 常量 | 方向 | 色值 | -|------|------|------| -| `primaryGradient` | 上→下 | `#8B5CF6` → `#A78BFA` | -| `bgGradient` | 左→右 | `#F0ECFF` → `#EBF4FF` (4:6比例) | - ---- - -## 1. 登录页 (`login_page.dart`) - -``` -┌──────────────────────────────────┐ -│ │ -│ [❤] 图标 │ color: _accent (用户=primary, 医生=doctorBlue) -│ 健康管家 │ color: textPrimary -│ 登录你的账号 │ color: textSecondary -│ │ -│ ┌──────────────────────────┐ │ -│ │ [用户] │ [医生] │ │ 选中: 对应_accent背景+白字 -│ │ 用户卡 │ 医生卡 │ │ 未选中: 白底+textPrimary字+border边框 -│ └──────────────────────────┘ │ -│ │ -│ ┌──────────────────────────┐ │ 背景: #F5F5F5 -│ │ 手机号 │ │ 文字: textPrimary -│ └──────────────────────────┘ │ 提示: textHint -│ │ -│ ┌─────────────────┐ ┌──────┐ │ -│ │ 验证码 │ │获取 │ │ 左侧同手机号 -│ └─────────────────┘ │验证码│ │ 按钮: _accent底+白字 -│ └──────┘ │ 倒计时: #F5F5F5底+_accent字 -│ │ -│ ┌──────────────────────────┐ │ 仅注册+医生时显示 -│ │ 医生审核码 │ │ 同手机号样式 -│ └──────────────────────────┘ │ -│ │ -│ ☑ 已阅读并同意《服务协议》 │ 未勾选: 白+border边框 -│ 和《隐私政策》 │ 已勾选: _accent底+白勾 -│ │ -│ ┌──────────────────────────┐ │ -│ │ 登 录 / 注 册 │ │ 按钮: _accent底+白字 -│ └──────────────────────────┘ │ -│ │ -│ 没有账号?去注册 / 已有账号? │ color: _accent -│ │ -└──────────────────────────────────┘ -大背景: #FFFFFF (白色) -``` - -| 区域 | 背景色 | 文字色 | 边框色 | -|------|--------|--------|--------| -| 页面底 | `#FFFFFF` | — | — | -| 输入框 | `#F5F5F5` | `textPrimary` | 无 | -| 角色卡片(选中) | `primary`或`doctorBlue` | `#FFFFFF` | 同背景 | -| 角色卡片(未选中) | `#FFFFFF` | `textPrimary` | `border (#E5E7EB)` | -| 获取验证码按钮 | `_accent` | `#FFFFFF` | 无 | -| 主按钮 | `_accent` | `#FFFFFF` | 无 | -| 协议链接 | 透明 | `_accent` | — | - ---- - -## 2. 患者端首页 (`home_page.dart`) - -``` -┌──────────────────────────────────┐ -│ ☰ 健康管家 ⚙ │ AppBar: 紫底渐变(navGradient?) -│ │ -│ [AI对话区域] │ -│ ┌──────────────────────────┐ │ -│ │ 患者消息(右) │ │ 气泡: primary底+白字 -│ │ AI消息(左) │ │ 气泡: cardBackground+textPrimary -│ │ 医生消息(左) │ │ 气泡: #FEFEFF+ #D8DCFD边框 -│ └──────────────────────────┘ │ -│ │ -│ ┌──────────────────────────┐ │ -│ │ 输入框 [发送]│ │ 输入: background底+textPrimary -│ └──────────────────────────┘ │ 发送: blueMeasure(可用时) -│ │ -└──────────────────────────────────┘ -大背景: bgGradient (紫→蓝横向渐变) -``` - -| 区域 | 背景色 | 文字色 | 边框色 | -|------|--------|--------|--------| -| 页面底 | `bgGradient` 渐变 | — | — | -| 用户气泡 | `primary` | `#FFFFFF` | 无 | -| AI气泡 | `cardBackground` | `textPrimary` | `#E2E8F0` | -| 医生气泡 | `#FEFEFF` | `textPrimary` | `#D8DCFD` | -| 输入栏 | `background` | `textPrimary` | 无 | - ---- - -## 3. 侧边抽屉 — 患者端 (`health_drawer.dart`) - -``` -┌──────────────────────────┐ -│ [头像] 用户名 │ bg: transparent(融入bgGradient) -│ 手机号 [设置] │ -│ │ -│ ┌────────┐ ┌────────┐ │ -│ │🔵蓝牙 │ │📁档案 │ │ 白底 + #C8DDFD 边框 -│ └────────┘ └────────┘ │ -│ │ -│ ┌ 健康仪表盘 ────────┐ │ -│ │ 血压 心率 血糖 血氧 体重│ 白底 + #C8DDFD 边框 -│ └────────────────────┘ │ -│ │ -│ ┌ 功能 ──────────────┐ │ -│ │ 报告 日历 饮食 随访 │ │ 白底 + #C8DDFD 边框 -│ └────────────────────┘ │ -│ │ -│ ┌ VIP服务 ──────────┐ │ -│ └────────────────────┘ │ 白底 + #C8DDFD 边框 -│ │ -│ ┌ 保险 ────────────┐ │ -│ └────────────────────┘ │ 白底 + #C8DDFD 边框 -└──────────────────────────┘ -大背景: bgGradient -``` - -| 区域 | 背景色 | 文字色 | 边框 | -|------|--------|--------|------| -| 抽屉底 | `bgGradient` | — | — | -| 信息区 | transparent | `textPrimary`/`textSecondary` | 无 | -| 分类卡片 | `cardBackground` (#FFF) | — | `#C8DDFD` (1.5px) | -| 分类标题 | `primaryGradient` 紫渐变胶囊 | `#FFFFFF` | — | - ---- - -## 4. 侧边抽屉 — 医生端 (`doctor_drawer.dart`) - -``` -┌──────────────────────────┐ -│ ┌──────────────────────┐ │ -│ │ [头像] │ │ bg: doctorBlue (#0891B2) -│ │ 医生姓名 │ │ 文字: #FFFFFF -│ │ 点击完善信息 │ │ -│ └──────────────────────┘ │ -│ │ -│ 📊 工作台 │ 选中: doctorBlue字+doctorBlue淡底 -│ 👥 患者管理 │ 未选中: textPrimary字+无色 -│ 💬 问诊列表 │ -│ 📋 报告审核 │ -│ 📅 复查随访 │ -│ ──────────────────────── │ -│ ⚙ 设置 │ -│ 🚪 退出登录 │ -└──────────────────────────┘ -大背景: #FFFFFF -``` - -| 区域 | 背景色 | 文字色 | 选中态 | -|------|--------|--------|--------| -| 抽屉底 | `#FFFFFF` | — | — | -| 头部 | `doctorBlue` | `#FFFFFF` | — | -| 菜单项(选中) | `doctorBlue` 8%透明 | `doctorBlue` | — | -| 菜单项(未选中) | transparent | `textPrimary` | — | -| 头像 | `#FFFFFF` 24%透明 | `#FFFFFF` | — | - ---- - -## 5. 医生工作台 (`doctor_dashboard_page.dart`) - -``` -┌──────────────────────────────────┐ -│ ┌ 请完善个人信息 ───────────┐ │ bg: #E0F2FE, 字: doctorBlue -│ └────────────────────────────┘ │ -│ │ -│ ┌──────────┐ ┌──────────┐ │ -│ │ 👥 患者 │ │ 💬 问诊 │ │ 统计卡片: 白底 -│ │ 总数 N │ │ 进行中N │ │ 图标底: 各色10%透明 -│ └──────────┘ └──────────┘ │ -│ ┌──────────┐ ┌──────────┐ │ -│ │ 📋 报告 │ │ 📅 随访 │ │ -│ │ 待审核N │ │ 今日 N │ │ -│ └──────────┘ └──────────┘ │ -│ │ -│ ┌ 待回复问诊 (N项) ────────┐ │ 白底, 绿色图标 -│ │ 患者A 待回复 > │ │ -│ │ 患者B 待回复 > │ │ -│ └──────────────────────────┘ │ -│ │ -│ ┌ 待审核报告 (N项) ────────┐ │ 白底, 黄色图标 -│ └──────────────────────────┘ │ -│ │ -│ ┌ 今日随访 (N项) ──────────┐ │ 白底, 红色图标 -│ └──────────────────────────┘ │ -└──────────────────────────────────┘ -大背景: pageGrey (#F5F5F5) -``` - -| 卡片 | 图标色 | 卡底色 | 文字色 | -|------|--------|--------|--------| -| 患者总数 | `blueMeasure` (#3B82F6) | `#FFFFFF` | `textPrimary` | -| 进行中问诊 | `success` (#10B981) | `#FFFFFF` | `textPrimary` | -| 待审核报告 | `warning` (#F59E0B) | `#FFFFFF` | `textPrimary` | -| 今日随访 | `error` (#EF4444) | `#FFFFFF` | `textPrimary` | -| 待回复问诊 | `success` | `#FFFFFF` | `textPrimary` | -| 待审核报告 | `warning` | `#FFFFFF` | `textPrimary` | -| 今日随访 | `error` | `#FFFFFF` | `textPrimary` | - ---- - -## 6. 患者列表 (`doctor_patients_page.dart`) - -``` -┌──────────────────────────────────┐ -│ ┌──────────────────────────────┐ │ -│ │ 🔍 搜索患者姓名或手机号 │ │ 白底, 无边框, textPrimary -│ └──────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────┐ │ -│ │ [头像] 张三 > │ │ 白底卡片, 14px圆角 -│ │ 138xxxx │ │ 头像底: avatarBg (#F0F0FF) -│ └──────────────────────────────┘ │ 名字: textPrimary (16px 粗) -│ ┌──────────────────────────────┐ │ 手机: textHint (13px) -│ │ [头像] 李四 > │ │ 箭头: textHint -│ └──────────────────────────────┘ │ -│ ... │ -└──────────────────────────────────┘ -大背景: pageGrey (#F5F5F5) -``` - -| 区域 | 背景色 | 文字色 | 边框 | -|------|--------|--------|------| -| 搜索栏 | `cardBackground` (#FFF) | `textPrimary` | 无 | -| 患者卡片 | `cardBackground` (#FFF) | — | 无 | -| 头像 | `avatarBg` (#F0F0FF) | `primary` | 无 | -| 名字 | — | `textPrimary` | — | -| 副信息 | — | `textHint` | — | - ---- - -## 7. 患者详情 (`doctor_patient_detail_page.dart`) - -``` -┌──────────────────────────────────┐ -│ ← 患者详情 │ 白底AppBar -│ │ -│ ┌──────────────────────────────┐ │ -│ │ [头像] 张三 │ │ 白底卡片 -│ │ 138xxxx · 男 · 1990 │ │ 头像底: avatarBg (#F0F0FF) -│ └──────────────────────────────┘ │ -│ │ -│ ┌ 健康档案 ──────────────────┐ │ -│ │ 诊断 高血压 │ │ 白底卡片 -│ │ 手术史 心脏搭桥 2023-01 │ │ 标签: textHint (13px) -│ │ 过敏史 青霉素、头孢 │ │ 值: 14px -│ │ 慢病 糖尿病 │ │ -│ │ 家族史 父亲高血压 │ │ -│ └──────────────────────────────┘ │ -│ │ -│ ┌ 健康指标 ──────────────────┐ │ -│ │ 血压 122/80 mmHg │ │ 白底卡片 -│ │ 心率 72 次/分 │ │ 异常值: error 色 -│ │ 血糖 5.6 mmol/L │ │ -│ └──────────────────────────────┘ │ -│ │ -│ ┌ 当前用药 ──────────────────┐ │ -│ │ 阿司匹林 100mg 每日 │ │ 白底卡片 -│ └──────────────────────────────┘ │ -│ │ -│ ┌ 报告(N) ────┐ ┌ 随访(N) ────┐│ 入口卡片 -│ └──────────────┘ └──────────────┘│ -└──────────────────────────────────┘ -大背景: pageGrey (#F5F5F5) -``` - ---- - -## 8. 问诊列表 (`doctor_consultations_page.dart`) - -``` -┌──────────────────────────────────┐ -│ ┌──────────────────────────────┐ │ -│ │ [头像] 患者A [AI中] │ │ 白底卡片, 圆角14 -│ │ 最新消息预览... │ │ 头像: avatarBg -│ └──────────────────────────────┘ │ 状态标签: 各色10%底+对应色字 -│ ┌──────────────────────────────┐ │ -│ │ [头像] 患者B [等待医生] │ │ AI中: #6366F1 -│ └──────────────────────────────┘ │ 等待医生: warning -│ ┌──────────────────────────────┐ │ 已回复: success -│ │ [头像] 患者C [已回复] │ │ 已关闭: textHint -│ └──────────────────────────────┘ │ -└──────────────────────────────────┘ -大背景: pageGrey (#F5F5F5) -``` - ---- - -## 9. 报告审核列表 (`doctor_reports_page.dart`) - -``` -┌──────────────────────────────────┐ -│ [全部] [待审核] [已审核] │ 筛选标签: 选中=primary底+白字 -│ │ 未选中=白底+border边框 -│ ┌──────────────────────────────┐ │ -│ │ 📋 患者A [待审核] │ │ 白底卡片, 圆角14 -│ │ 血常规 · 2026-06-11 │ │ 图标: primary -│ └──────────────────────────────┘ │ 状态: warning/ success -│ ┌──────────────────────────────┐ │ -│ │ 📋 患者B [已审核] │ │ -│ └──────────────────────────────┘ │ -└──────────────────────────────────┘ -大背景: pageGrey (#F5F5F5) -``` - ---- - -## 10. 报告审核详情 (`doctor_report_detail_page.dart`) - -``` -┌──────────────────────────────────┐ -│ ← 报告审核 │ -│ │ -│ ┌──────────────────────────────┐ │ -│ │ [头像] 患者A [待审核] │ │ 白底卡片 -│ │ 血常规 · Image │ │ 头像: avatarBg -│ └──────────────────────────────┘ │ -│ │ -│ ┌ AI 预分析 ─────────────────┐ │ -│ │ 摘要文字... │ │ 白底卡片, 左侧紫色竖条 -│ └──────────────────────────────┘ │ -│ │ -│ ┌ 指标检测 ─────────────────┐ │ -│ │ 白细胞 7.5 正常 │ │ 白底卡片 -│ │ 红细胞 4.1 偏低 │ │ 正常: success 绿 -│ │ 血红蛋白 125 偏低 │ │ 偏高: error 红 -│ └──────────────────────────────┘ │ 偏低: warning 黄 -│ │ -│ [查看原始报告] │ 白底+primary字+border边框 -│ │ -│ ─────── 医生审核 ─────── │ -│ │ -│ 严重程度: │ -│ [正常] [异常] [严重] [危急] │ 选中: 对应色底+白字 -│ │ 正常=success, 异常=warning -│ 建议模板: │ 严重=error, 危急=#991B1B -│ [药量调整] [复查建议] [生活方式] │ 选中: #EFF6FF底+#0891B2字+#0891B2边框 -│ [进一步检查] [专科转诊] [无需] │ 未选中: 白底+border边框+textSecondary字 -│ │ -│ 评语: │ -│ ┌──────────────────────────────┐ │ -│ │ (多行输入) │ │ 白底, 无边框 -│ └──────────────────────────────┘ │ -│ │ -│ ┌ 提交审核 ──────────────────┐ │ -│ └──────────────────────────────┘ │ doctorBlue底+白字 -│ │ -│ (已审核时) │ -│ ┌ ✅ 审核已完成 ─────────────┐ │ 白底+#D1FAE5边框 -│ │ 严重程度: 异常 │ │ 标签: textHint -│ │ 建议: 复查建议、生活方式 │ │ -│ │ 评语: ... │ │ -│ └──────────────────────────────┘ │ -└──────────────────────────────────┘ -大背景: pageGrey (#F5F5F5) -``` - ---- - -## 11. 随访列表 (`doctor_followups_page.dart`) - -``` -┌──────────────────────────────────┐ -│ [全部] [待完成] [已完成] [+] │ 筛选: 同报告页 -│ │ 添加: primary图标 -│ ┌──────────────────────────────┐ │ -│ │ 术后一个月复查 [待完成] │ │ 白底卡片 -│ │ 患者A · 2026-06-18 09:00 │ │ 待完成: warning标签 -│ │ 备注: ... │ │ 已完成: success标签 -│ │ [编辑] [完成] [删除] │ │ 按钮: textHint/success/error -│ └──────────────────────────────┘ │ -└──────────────────────────────────┘ -大背景: pageGrey (#F5F5F5) -``` - ---- - -## 12. 随访编辑 (`doctor_followup_edit_page.dart`) - -``` -┌──────────────────────────────────┐ -│ ← 新建随访 / 编辑随访 │ -│ │ -│ 患者 │ -│ ┌──────────────────────────────┐ │ -│ │ ▼ 选择患者 │ │ 白底, 下拉选择器 -│ └──────────────────────────────┘ │ -│ │ -│ 随访标题 │ -│ ┌──────────────────────────────┐ │ -│ │ 例:术后一个月复查 │ │ 白底, 无边框 -│ └──────────────────────────────┘ │ -│ │ -│ 随访时间 │ -│ ┌──────────────┐ ┌────────────┐ │ -│ │ 2026-06-18 │ │ 09:00 │ │ 白底卡片, 点击弹出选择器 -│ └──────────────┘ └────────────┘ │ -│ │ -│ 备注 │ -│ ┌──────────────────────────────┐ │ -│ │ (多行) │ │ 白底, 无边框 -│ └──────────────────────────────┘ │ -│ │ -│ ┌ 创建随访 / 保存修改 ────────┐ │ -│ └──────────────────────────────┘ │ doctorBlue底+白字 -└──────────────────────────────────┘ -大背景: pageGrey (#F5F5F5) -``` - ---- - -## 13. 医生设置 (`doctor_settings_page.dart`) - -``` -┌──────────────────────────────────┐ -│ ← 设置 │ -│ │ -│ ┌──────────────────────────────┐ │ -│ │ 🔔 推送通知 [开关] │ │ 白底, 圆角14 -│ └──────────────────────────────┘ │ -│ ┌──────────────────────────────┐ │ -│ │ 📄 隐私政策 > │ │ -│ └──────────────────────────────┘ │ -│ ┌──────────────────────────────┐ │ -│ │ 📃 用户协议 > │ │ -│ └──────────────────────────────┘ │ -│ ┌──────────────────────────────┐ │ -│ │ 🗑 删除账号 > │ │ 红色文字 -│ └──────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────┐ │ -│ │ 退出登录 │ │ error字+#FECACA边框 -│ └──────────────────────────────┘ │ -└──────────────────────────────────┘ -大背景: pageGrey (#F5F5F5) -``` - ---- - -## 14. 医生个人信息 (`doctor_profile_page.dart`) - -``` -┌──────────────────────────────────┐ -│ ← 个人信息 │ -│ │ -│ ┌──────────────────────────────┐ │ -│ │ 姓名 │ │ 白底输入框 -│ └──────────────────────────────┘ │ -│ ┌──────────────────────────────┐ │ -│ │ 职称 │ │ 白底输入框 -│ └──────────────────────────────┘ │ -│ ┌──────────────────────────────┐ │ -│ │ 科室 │ │ 白底输入框 -│ └──────────────────────────────┘ │ -│ ┌──────────────────────────────┐ │ -│ │ 医院 │ │ 白底输入框 -│ └──────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────┐ │ -│ │ 保存 │ │ doctorBlue底+白字 -│ └──────────────────────────────┘ │ -└──────────────────────────────────┘ -大背景: pageGrey (#F5F5F5) -``` - ---- - -## 15. 健康概览趋势页 (`trend_page.dart`) - -``` -┌──────────────────────────────────┐ -│ [血压] [心率] [血糖] [血氧] [体重] 指标chip切换 -│ 🔴 🟡 🔵 🟢 🟣 颜色来自硬编码 -│ │ -│ ┌ 趋势图 (fl_chart) ──────────┐ │ -│ │ │ │ -│ │ 📈 折线图 30天趋势 │ │ -│ │ │ │ -│ └──────────────────────────────┘ │ -│ │ -│ 历史记录 (N条) │ -│ ┌──────────────────────────────┐ │ -│ │ 🫀 6月12日 18:30 │ │ 白底卡片(左滑删除) -│ │ 122/80 mmHg │ │ 异常值: error红 -│ └──────────────────────────────┘ │ 正常值: #1A1A1A -│ ┌──────────────────────────────┐ │ 删除背景: error红 -│ │ 💓 6月12日 08:00 │ │ -│ │ 72 次/分 │ │ -│ └──────────────────────────────┘ │ -└──────────────────────────────────┘ -大背景: bgGradient渐变 -``` - -| 指标 | 色号 | 色块 | -|------|------|------| -| 血压 | `#EF4444` (error) | 🔴 | -| 心率 | `#F59E0B` (warning) | 🟡 | -| 血糖 | `#4F6EF7` | 🔵 | -| 血氧 | `#20C997` | 🟢 | -| 体重 | `#845EF7` | 🟣 | - ---- - -## 16. 蓝牙设备管理 (`device_management_page.dart`) - -``` -┌──────────────────────────────────┐ -│ 蓝牙设备 [+] │ AppBar: #FFFFFF -│ │ -│ ┌──────────────────────────────┐ │ -│ │ [蓝牙] 血压计 🟢 已连接 │ │ 已连接: success绿边框+绿点 -│ │ CB:1C:DF:93:7F:7A │ │ 未连接: border灰边框+灰点 -│ │ 上次同步: 6/12 18:30 │ │ 点击重连(未连接时) -│ └──────────────────────────────┘ │ 头像底: successLight/ #F5F5F5 -│ │ -│ ┌ 最近测量 ──────────────────┐ │ -│ │ 122/80 mmHg │ │ 白底卡片 -│ └──────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────┐ │ -│ │ 解绑设备 │ │ error字+#FECACA边框 -│ └──────────────────────────────┘ │ -│ │ -│ ┌ 使用说明 ──────────────────┐ │ -│ │ 1. 血压计装好电池... │ │ -│ │ 2. 按开始键测量... │ │ -│ │ 3. 点击设备栏自动连接... │ │ -│ └──────────────────────────────┘ │ -└──────────────────────────────────┘ -大背景: pageGrey (#F5F5F5) - -空状态: - [蓝牙图标] 灰色 #BBBBBB - 暂无设备 - 点击右上角 + 添加血压计 -``` - ---- - -## 17. 蓝牙扫描/连接 (`device_scan_page.dart`) - -``` -┌──────────────────────────────────┐ -│ ← BLESmart_xxx │ -│ │ -│ 扫描中/已发现 N 台设备 │ 状态栏: iconBg底 -│ │ -│ ┌──────────────────────────────┐ │ -│ │ [蓝牙] BLESmart... [连接] │ │ 白底卡片, #EEEEEE边框 -│ │ CB:1C:DF... · 信号强 │ │ 连接按钮: primary底+白字 -│ └──────────────────────────────┘ │ -│ │ -│ [重新扫描] │ primary字+border边框 -│ │ -│ (无设备时居中显示) │ -│ [脉动动画] │ #F0F0FF底, primary图标 -│ 正在搜索蓝牙设备... │ textHint -│ 请确保血压计处于通信模式 │ #CCCCCC -│ │ -│ (已连接时居中显示) │ -│ [✓ 图标] │ #D1FAE5底/#F0F0FF底 -│ 测量完成 / 设备已连接 │ textPrimary -│ 122/80 mmHg │ textPrimary 大字 -│ 脉搏 78 bpm │ #EFF6FF底, primary字 -│ 数据已自动同步 │ textHint -└──────────────────────────────────┘ -大背景: #FFFFFF -``` - ---- - -## 18. 健康档案页 (`HealthArchivePage` in remaining_pages.dart) - -``` -┌──────────────────────────────────┐ -│ ← 健康档案 │ -│ │ -│ 基本信息: 姓名/性别/出生日期 │ 输入框: backgroundSoft -│ 既往病史/手术史/过敏/慢病/饮食 │ -│ │ -│ [保存] │ primary底+白字 -└──────────────────────────────────┘ -大背景: bgGradient -``` - ---- - -## 19. 复查随访页-患者端 (`FollowUpListPage` in remaining_pages.dart) - -``` -┌──────────────────────────────────┐ -│ ← 复查随访 │ -│ │ -│ (空状态) │ -│ [📅图标] 暂无随访安排 │ textHint色 -│ 医生创建随访后将显示在这里 │ -│ │ -│ (有数据) │ -│ ┌──────────────────────────────┐ │ -│ │ 随访标题 [即将到来] │ │ 白底卡片 -│ │ 医生名 · 科室 │ │ 状态: primary/success/error -│ │ 时间 │ │ -│ └──────────────────────────────┘ │ -└──────────────────────────────────┘ -大背景: bgGradient -``` - ---- - -## 20. 启动闪屏 (`app.dart` _RootNavigator) - -``` -┌──────────────────────────────────┐ -│ │ -│ │ -│ ❤ 心形图标 │ primary色 -│ │ -│ 健康管家 │ textPrimary -│ │ -│ │ -└──────────────────────────────────┘ -大背景: #FFFFFF -``` - ---- - -## 颜色使用频率排名 - -| 颜色 | 使用文件数 | 最常用途 | -|------|-----------|---------| -| `textPrimary` #1F2937 | 28 | 全项目主文字 | -| `textHint` #9CA3AF | 28 | 全项目提示文字 | -| `primary` #8B5CF6 | 26 | 用户端主色/按钮/选中 | -| `error` #EF4444 | 21 | 错误/删除 | -| `textSecondary` #6B7280 | 19 | 副文字 | -| `border` #E5E7EB | 13 | 通用边框 | -| `cardInner` #F5F2FF | 11 | 卡片内底 | -| `success` #10B981 | 10 | 成功/绿色状态 | -| `warning` #F59E0B | 7 | 警告/黄色状态 | -| `pageGrey` #F5F5F5 | 7 | 医生端页面底 | -| `doctorBlue` #0891B2 | 7 | 医生端主色 | -| `avatarBg` #F0F0FF | 6 | 头像底 | ---- - -## 21. 图标与底色对照表 - -### 全局图标 - -| 位置 | 图标 | 图标色 | 底色 | 底色号 | -|------|------|--------|------|--------| -| 登录页心形 | favorite | `_accent` (用户紫/医生蓝) | 无 | — | -| 登录页成功 | check_circle | `#059669` | `#D1FAE5` | successLight | -| 抽屉菜单项 | 各种 | `primary` | 无 | — | -| 抽屉蓝牙入口 | bluetooth_rounded | `primary` | 无 | — | -| 空状态图标 | inbox_outlined | `textHint` | 无 | — | -| 空状态图标 | bluetooth_disabled | `#BBBBBB` | `#F0F0F0` | — | -| 药瓶确认 | check_circle_outline | `textHint` | 无 | — | -| 添加按钮(FAB) | add | `#FFFFFF` | `primary` | — | -| 侧边栏AI图标 | smart_toy | `primaryLight` | 无 | — | -| 侧边栏头像 | person | `textHint` | `#FFFFFF` | — | -| AI消息头像 | health_and_safety | `primary` | 无 | — | - -### 医生端 - -| 位置 | 图标 | 图标色 | 底色 | 底色号 | -|------|------|--------|------|--------| -| 抽屉头部 | person | `#FFFFFF` | `#FFFFFF` 24%透明 | — | -| 抽屉菜单(选中) | 各种 | `doctorBlue` | `doctorBlue` 8%透明 | — | -| 抽屉菜单(未选中) | 各种 | `textHint` | 无 | — | -| 统计卡片-患者 | people | `blueMeasure` | `blueMeasure` 10%透明 | — | -| 统计卡片-问诊 | chat | `success` | `success` 10%透明 | — | -| 统计卡片-报告 | description | `warning` | `warning` 10%透明 | — | -| 统计卡片-随访 | event_note | `error` | `error` 10%透明 | — | -| 待办图标 | chat_outlined 等 | `success`/`warning`/`error` | 无 | — | -| 完善信息提示 | info_outline | `doctorBlue` | `#E0F2FE` | — | -| 患者列表头像 | person/male/female | `primary` | `avatarBg` #F0F0FF | — | -| 患者详情头像 | person文字 | `primary` | `avatarBg` #F0F0FF | — | -| 问诊列表头像 | person文字 | `primary` | `avatarBg` #F0F0FF | — | -| 报告列表图标 | description | `primary` | 无 | — | -| 报告详情头像 | person文字 | `primary` | `avatarBg` #F0F0FF | — | -| 审核完成图标 | check_circle | `success` | 无 | — | -| AI预分析竖条 | (Container) | — | `#6366F1` | — | -| 设置列表图标 | notifications/description/article/delete | `textPrimary` (删除为红) | 无 | — | -| 返回箭头 | arrow_back | `textPrimary` | 无 | — | -| 抽屉汉堡菜单 | menu | `textPrimary` | 无 | — | - -### 蓝牙设备页 - -| 位置 | 图标 | 图标色 | 底色 | 底色号 | -|------|------|--------|------|--------| -| 设备卡片(已连接) | bluetooth | `success` | `successLight` | — | -| 设备卡片(未连接) | bluetooth | `#BBBBBB` | `#F5F5F5` | — | -| 连接状态点(已连接) | (Container圆) | — | `success`+shadow | — | -| 连接状态点(未连接) | (Container圆) | — | `#BBBBBB` | — | -| 扫描中动画 | bluetooth_searching | `primary` | `#F0F0FF` | — | -| 扫描脉动环 | (Container) | — | `primary` 8%透明 | — | -| 已连接图标 | bluetooth_connected | `primary` | `#F0F0FF` | — | -| 测量完成图标 | check_rounded | `success` | `successLight` | — | - -### 健康概览趋势页 - -| 位置 | 图标/色 | 图标色 | 底色 | -|------|---------|--------|------| -| 血压chip | favorite_rounded (🫀) | `error` (#EF4444) | `error` 20%透明 | -| 心率chip | monitor_heart (💓) | `warning` (#F59E0B) | `warning` 20%透明 | -| 血糖chip | bloodtype (🩸) | `#4F6EF7` | `#4F6EF7` 20%透明 | -| 血氧chip | air (🫁) | `#20C997` | `#20C997` 20%透明 | -| 体重chip | monitor_weight (⚖️) | `#845EF7` | `#845EF7` 20%透明 | -| 历史记录项图标 | emoji文字 | `primary` | `各色20%透明` | -| 删除背景 | delete_outline | `#FFFFFF` | `error` | - -### 饮食页 - -| 位置 | 图标 | 图标色 | 底色 | -|------|------|--------|------| -| 添加食物 | add_circle_outline | `_dietAccent` (#F0A060) | 无 | -| AI按钮 | auto_awesome | `_dietAccent` (#F0A060) | 无 | -| 食物列表项 | — | — | `#FFFBF5` | - -### 套餐卡片 - -| 位置 | 图标 | 图标色 | 底色 | -|------|------|--------|------| -| 功能列表项图标 | — | `#F5A623` | `#FFF8EE` | -| 套餐头 | workspace_premium | `#FFFFFF` | `primaryGradient` | -| 前进箭头 | chevron_right | `textHint` | 无 | - -### 问诊聊天 - -| 位置 | 图标 | 图标色 | 底色 | -|------|------|--------|------| -| AI头像 | smart_toy | `primaryLight` | — | -| 在线指示点 | (Container圆) | — | `#F0F2FF` | -| 健康图标 | — | `blueMeasure` | `#DBEAFE` | -| 运动图标 | — | `warning` | `warningLight` | -| 用药图标 | — | `#EC4899` | `#FCE7F3` | -| 建议灯泡 | lightbulb_outline | `primary` | 无 | -| Agent卡片图标 | 各种 | 各Agent accent色 | 各Agent iconBg色 | - -### 设置/通知页 - -| 位置 | 图标 | 图标色 | 底色 | -|------|------|--------|------| -| 通知项图标 | 各种 | `iconColor` (#9B8AF7) | `iconBg` (#E8E0FA) | - -### 统一规则 - -| 场景 | 图标色 | 底色 | -|------|--------|------| -| **可点击图标** | `primary` | 无/透明 | -| **不可点击图标** | `textHint` | 无/透明 | -| **成功/正常状态** | `success` (#10B981) | `successLight` (#D1FAE5) | -| **警告/待处理** | `warning` (#F59E0B) | `warningLight` (#FEF3C7) | -| **错误/删除** | `error` (#EF4444) | `errorLight` (#FEE2E2) | -| **医生端强调** | `doctorBlue` (#0891B2) | `doctorBlue` 淡底 | -| **禁用/空状态** | `#BBBBBB` | `#F0F0F0` | -| **头像/人员底** | `primary` | `avatarBg` (#F0F0FF) | - ---- - -> 文档中标注的色号均为定义在 AppColors 中的常量名。要修改任何地方的颜色,告诉我"XX页面的XX区域换成XX色"即可。 diff --git a/docs/doctor_app_merge_design.md b/docs/doctor_app_merge_design.md deleted file mode 100644 index 00df4a1..0000000 --- a/docs/doctor_app_merge_design.md +++ /dev/null @@ -1,313 +0,0 @@ -# 医生端合并至App — 详细设计文档(已确认版) - -> 目标:将 `doctor_web` (React SPA) 的功能完整迁移到 `health_app` (Flutter),通过注册时选择"用户/医生"身份实现双端合一。 -> -> 本文档所有决策均已经过逐一讨论和确认。 - ---- - -## 一、已确认设计决策总表 - -| # | 决策项 | 结论 | -|---|--------|------| -| 1 | 角色体系 | **方案A** — User表加Role字段 + 新建DoctorProfile子表,废弃旧Doctor表 | -| 2 | 手机号角色 | **一个手机号只能一个角色** | -| 3 | 种子医生 | **全部删除**(王建国/李芳/张明),重新注册测试 | -| 4 | 审核码 | 硬编码 `6666`,注册医生时校验 | -| 5 | 代码位置 | 放在 `health_app` 同一项目,同一安装包 | -| 6 | 开发节奏 | **一次性全量做完**,不分期 | -| 7 | 患者端 | 开发期间保持可用,患者问诊聊天保留 | -| 8 | 医生主色调 | **医疗蓝 `#0891B2`** + 白色 | -| 9 | 深色模式 | 不做,仅浅色 | -| 10 | 导航结构 | **侧边抽屉**,与患者端一致 | -| 11 | 报告文件预览 | 图片直接内嵌查看,PDF调用系统查看器 | -| 12 | 医生头像 | 支持从相册上传 | -| 13 | 所有医生端点 | 加JWT鉴权 | - ---- - -## 二、注册流程 - -``` -注册页 - ├─ ① 选择身份:[用户] [医生] ← 第一步,先选 - ├─ ② 输入手机号 → 获取短信验证码 ← 通用 - ├─ ③ 输入验证码 ← 通用 - ├─ ④ (仅医生) 输入审核码 [6666] ← 选医生才弹出 - ├─ ⑤ 勾选同意《隐私政策》《用户协议》 ← 可点击查看详情页 - └─ ⑥ 点击注册 -``` - -- 文案统一用"用户"而非"患者" -- 医生注册时不填姓名/职称/科室/医院,登录后在设置里补全 -- 登录时不需要选身份,自动进入注册时选择的身份端 - ---- - -## 三、多端登录体系(新增) - -### 3.1 账号核心原则 - -- **手机号 = 主标识**,微信/Apple 都是绑定到手机号上 -- **注册时定身份,永久不可改**(除非删除账号重来) -- 一个手机号只能绑定一个微信 + 一个 Apple ID -- 登录方式可选(手机号/微信/Apple),但身份由注册时的选择决定 - -### 3.2 注册流程(手机号注册 + 立刻绑定) - -``` -注册页 - ├─ ① 选择身份:[用户] [医生] - ├─ ② 输入手机号 → 短信验证码 - ├─ ③ (仅医生) 审核码 6666 - ├─ ④ 勾选同意《隐私政策》《用户协议》 - ├─ ⑤ 点击注册 - └─ ⑥ 注册成功后弹绑定页: - ├─ [绑定微信] - ├─ [绑定Apple] - └─ [跳过,以后再绑] -``` - -### 3.3 后续登录方式 - -注册后,登录页直接显示三个按钮: - -``` -登录页 - ├─ [微信登录] → 查手机号 → 自动进入对应身份端 - ├─ [Apple登录] → 查手机号 → 自动进入对应身份端 - └─ [手机号登录] → 短信验证 → 自动进入对应身份端 -``` - -### 3.4 删除账号(≠ 退出登录) - -- 在设置页独立入口,不与退出登录混淆 -- 确认后彻底删除:用户数据、健康记录、问诊记录、绑定关系 -- Apple 用户额外调用 Apple revocation API -- 删除后手机号释放,可重新注册(可换身份) - -### 3.5 微信/Apple 平台准备 - -| 微信开放平台 | 需要新的 AppID,绑定 `com.datalumina.YYA` | -|-------------|------------------------------------------| -| Apple Sign In | Apple Developer 账号开启 Capability | -| Flutter包 | 微信用 `fluwx`,Apple 用 `sign_in_with_apple` | - -> AppID 暂时空着,等申请下来再填。 - ---- - -## 四、登录后路由(不变) - -``` -登录成功 - ├─ Role = 'User' → 现有患者端(AI对话首页 + 侧边抽屉) - └─ Role = 'Doctor' → 医生端(工作台Dashboard + 侧边抽屉) -``` - ---- - -## 五、医生职责与功能 - -### 4.1 患者范围(初期) - -所有注册用户都是所有医生的患者。后期VIP体系再做患者分配。 - -### 4.2 工作台 Dashboard - -- 统计卡片:患者总数 / 进行中问诊 / 待审核报告 / 今日随访 -- 待办事项列表(全部可点击快捷进入): - - 未回复问诊消息数 - - 待审核报告数 - - 今日随访对象 -- 每个待办项都是快捷入口,点击直接进入对应工作页 - -### 4.3 患者管理 - -- 搜索框(姓名/手机号)+ 分页加载(上拉更多) -- 点击患者进入详情页 -- **详情页结构**: - - 默认展示:基本信息(姓名/性别/年龄/电话)、健康档案(病史/手术史/过敏/慢病/家族史)、当前用药 - - 按钮"更多信息" → 展开:趋势图/饮食记录/运动计划/报告列表/随访记录 - -### 4.4 问诊聊天 - -- 保持现有流程:患者发起 → AI先接待 → 医生后续介入 -- AI行为暂不改动(后续优化) -- 电话功能:留UI入口,功能暂不做 -- 问诊列表显示:患者头像+姓名、最后消息预览、状态、时间 -- 聊天页面复用现有 `DoctorChatPage`,但需要新建医生端Provider(使用 `/api/doctor/consultations/*` 端点,senderType='Doctor') -- SignalR 实时通信保留(已有实现) - -### 4.5 报告审核 - -- 全功能迁移,包含: - 1. AI预分析摘要 - 2. 指标表格(正常/异常颜色标记) - 3. 严重程度4级评定(正常/异常/严重/危急) - 4. 建议模板多选(药量调整/复诊建议/生活建议/其他) - 5. 自定义评语(可选) - 6. 原始报告查看(图片内嵌/PDF系统查看器) - 7. 提交审核 -- 所有医生都能看到所有患者的报告 - -### 4.6 复查随访 - -- 医生创建随访计划(标题+时间+备注) -- 到期只提醒医生 → 医生手动联系患者 -- 随访列表 + 新建/编辑/标记完成/删除 - ---- - -## 六、导航与页面结构 - -### 5.1 医生端侧边抽屉 - -``` -┌─────────────────────┐ -│ [头像] 在线/离线 🔵 │ ← 点击头像→个人信息编辑页 -│ 姓名 职称 科室 │ ← 在线状态可切换 -│ │ -│ ━━━━━━━━━━━━━━━━━━━ │ -│ 📊 工作台 │ ← 默认首页 -│ 👥 患者管理 │ -│ 💬 问诊列表 │ -│ 📋 报告审核 │ -│ 📅 复查随访 │ -│ ⚙️ 设置 │ -│ ━━━━━━━━━━━━━━━━━━━ │ -│ 🚪 退出登录 │ -└─────────────────────┘ -``` - -- 点击抽屉项 → 主页面切换到对应页面 -- 在线/离线状态在抽屉顶部头像区快速切换 - -### 5.2 "设置"页面内容 - -- 推送通知开关 -- 隐私政策查看 -- 用户协议查看 -- 退出登录 - -### 5.3 "个人信息"页面(点击头像进入) - -- 姓名 / 职称 / 科室 / 医院 -- 头像上传(相册选图) - -### 5.4 医生首次登录 - -直接进工作台,但顶部显示提示条"请完善个人信息",点击跳转到个人信息编辑页。 - ---- - -## 七、路由表 - -| 路由名 | 页面 | 用户端 | 医生端 | -|--------|------|--------|--------| -| `login` | 登录/注册 | ✅ | ✅ | -| `home` | 首页(AI对话/工作台) | ✅ | ✅ (根据Role) | -| `doctorPatients` | 患者列表 | — | ✅ | -| `doctorPatientDetail` | 患者详情 | — | ✅ | -| `doctorChat` | 问诊聊天 | ✅(复用) | ✅(新Provider) | -| `doctorConsultations` | 问诊列表 | — | ✅ | -| `doctorReports` | 报告列表 | — | ✅ | -| `doctorReportDetail` | 报告审核 | — | ✅ | -| `doctorFollowUps` | 随访列表 | — | ✅ | -| `doctorFollowUpEdit` | 随访编辑 | — | ✅ | -| `doctorSettings` | 医生设置 | — | ✅ | -| `doctorProfile` | 个人信息编辑 | — | ✅ | - ---- - -## 八、数据库变更 - -### 7.1 Users 表加字段 - -```sql -ALTER TABLE "Users" ADD COLUMN "Role" TEXT NOT NULL DEFAULT 'User'; -- 'User' | 'Doctor' -``` - -### 7.2 新建 DoctorProfiles 表 - -```sql -CREATE TABLE "DoctorProfiles" ( - "Id" UUID PRIMARY KEY, - "UserId" UUID NOT NULL REFERENCES "Users"("Id"), - "Name" TEXT, - "Title" TEXT, - "Department" TEXT, - "Hospital" TEXT, - "AvatarUrl" TEXT, - "IsOnline" BOOLEAN DEFAULT false, - "IsActive" BOOLEAN DEFAULT true, - "CreatedAt" TIMESTAMP DEFAULT now(), - "UpdatedAt" TIMESTAMP DEFAULT now() -); -``` - -### 7.3 清理 - -- 删除旧 `Doctors` 表种子数据 -- 迁移 Consultation 表的 DoctorId 外键关联 - ---- - -## 九、后端变更 - -### 8.1 JWT 增加 Role Claim - -```csharp -new Claim("Role", user.Role) -``` - -### 8.2 所有 `/api/doctor/*` 端点 - -- 添加 `.RequireAuthorization()` -- 添加 Role 校验(Role = "Doctor") -- 从 JWT 获取医生信息替代硬编码"王建国" - -### 8.3 注册接口改造 - -- 支持 Role 参数 -- 医生注册时校验审核码 6666 -- 医生注册创建 DoctorProfile 空记录 - -### 8.4 审核码配置 - -```csharp -// appsettings.json 或环境变量 -const string DoctorInviteCode = "6666"; -``` - ---- - -## 十、UI设计规范 - -### 9.1 颜色 - -| 用途 | 颜色 | -|------|------| -| 主色 | `#0891B2` (医疗蓝) | -| 背景 | `#FFFFFF` / `#F5F5F5` | -| 卡片 | 白色 + 浅阴影 | -| 成功/已连接 | `#10B981` | -| 状态标签 | 待处理(橙) / 已处理(绿) / 异常(红) | -| 文字 | `#1A1A1A` / `#999999` | - -### 9.2 与患者端的视觉区分 - -- 患者端:紫色渐变 `#5B8DEF` -- 医生端:医疗蓝 `#0891B2` -- 抽屉结构和布局保持一致 - ---- - -## 十一、实施参考 - -- 新增约 12 条路由 -- 新增约 10 个页面组件 -- 新增约 4 个 Provider -- 修改约 15 个后端端点 -- 修改注册/登录流程 -- 总计预估 6-8 天 diff --git a/docs/omron_bp_implementation_plan.md b/docs/omron_bp_implementation_plan.md deleted file mode 100644 index cd80715..0000000 --- a/docs/omron_bp_implementation_plan.md +++ /dev/null @@ -1,170 +0,0 @@ -# 欧姆龙 J735 蓝牙血压计 — 实施计划 - ---- - -## 一、当前状态 - -- 设备已购:欧姆龙 J735 -- 前端占位:`DeviceManagementPage` 目前是空壳(显示"暂无绑定设备"),路由 `devices` 已注册,入口在 ProfilePage → 设备管理 -- 项目风格:淡紫清新风(`AppTheme`),Material 3 + shadcn_ui + Riverpod -- 本地存储:SQLite(`LocalDatabase`,kv_store 表) -- 后端:已有 `POST /api/health-records` 支持 BloodPressure 类型,Source 枚举需加 `Device` - ---- - -## 二、实施范围 - -### 2.1 新增依赖 - -```yaml -# pubspec.yaml -flutter_blue_plus: ^1.34.0 # BLE 核心 -permission_handler: ^11.3.0 # 蓝牙权限 -``` - -### 2.2 新增文件(6 个) - -``` -health_app/lib/ -├── services/ -│ └── omron_ble_service.dart # BLE 连接 + SFLOAT 协议解析 -├── models/ -│ └── bp_reading.dart # 血压数据模型 -├── providers/ -│ └── omron_device_provider.dart # 设备绑定状态 + 实时读数 -├── pages/ -│ └── device/ -│ ├── device_scan_page.dart # 扫描设备页 -│ └── device_bind_page.dart # 已绑定设备管理页(替换空壳) -``` - -### 2.3 修改文件(3 个) - -| 文件 | 改动内容 | -|------|---------| -| `pages/remaining_pages.dart` | 替换 `DeviceManagementPage` 空壳为 `DeviceBindPage` | -| `core/app_router.dart` | `devices` 路由指向新页面 | -| `providers/data_providers.dart` | 新增 `omronBleServiceProvider`、`bpReadingsProvider` | -| 后端 `health_enums.cs` | `HealthRecordSource` 加 `Device` | - ---- - -## 三、分步实施(3 天) - -### Day 1 — BLE 服务层 - -**目标**:手机能搜到 J735、连接上、收到蓝牙数据并正确解析 - -1. 添加 `flutter_blue_plus` + `permission_handler` 依赖 -2. 配置 Android 蓝牙权限(`AndroidManifest.xml`)+ iOS 权限(`Info.plist`) -3. 实现 `BpReading` 数据模型(systolic/diastolic/pulse/timestamp/status) -4. 实现 `OmronBleService`: - - `scan()` — 过滤蓝牙名含 `OMRON`/`HEM`/`BLEsmart` 且有 `0x1810` 服务的设备 - - `connect()` — 连接 → discoverServices → 订阅 `0x2A35` Indicate - - `_parseReading()` — SFLOAT 解码 + Flags 解析(kPa 转换、体动、心律等) - - `_syncTime()` — 写入 `0x2A08` 同步时间 - - `disconnect()` / `dispose()` -5. 用 nRF Connect 抓包验证原始 HEX → SFLOAT 解码结果与屏幕一致 - -**产出**:`OmronBleService` 可独立运行,单元测试验证 SFLOAT 解码 - -### Day 2 — UI 页面 - -**目标**:实现扫描绑定页 + 已绑定管理页,风格对齐项目淡紫主题 - -#### 扫描绑定页 (`DeviceScanPage`) -- AppBar 标题"添加血压计",返回按钮 -- 顶部:扫描状态指示(转圈 + "正在扫描..." / "扫描完成,发现 N 台设备") -- 空状态:未发现设备时显示使用说明卡片(装电池 → 长按蓝牙键 → 手机靠近 → 检查权限) -- 设备列表:每行显示 BLE 设备名 + 信号强度 + MAC + "连接"按钮 -- 连接中:按钮变 loading -- 连接成功:SnackBar "已连接 OMRON xxx",返回上一页并传递设备信息 -- 底部:重新扫描按钮 - -#### 已绑定管理页 (`DeviceBindPage`,替换空壳) -- **未绑定状态**:大蓝牙图标 + "未绑定设备" + 说明文字 + "添加设备"按钮,点击跳扫描页 -- **已绑定状态**:白底圆角卡片 - - 设备图标(紫色渐变圆)+ 设备名 - - MAC 地址(灰色小字) - - 上次同步时间 - - "解绑设备"按钮(红色文字) - -**风格要点**(保证不出现"全黑"): -- 背景色:`AppTheme.bg`(淡紫白 `#FAF9FF`) -- 卡片:白色 `Colors.white`,圆角 `AppTheme.rLg`(20),`AppTheme.shadowCard` 阴影 -- 按钮:紫色实心(`AppTheme.primary`)+ 圆角 `AppTheme.rPill` -- 空状态图标:`Colors.grey[300]`,文字 `AppTheme.textHint` -- 使用 shadcn_ui 图标(`LucideIcons`) -- 与 SettingsPage、ProfilePage 保持一致的 AppBar 风格 - -**产出**:两个 UI 页面,风格与项目统一,从 ProfilePage → 设备管理可进入 - -### Day 3 — 数据同步 + 联调 - -1. **Provider 层**:`omronDeviceProvider` 管理绑定状态(MAC/name/lastSync 存入 SQLite) -2. **自动同步**:收到 BLE 读数后自动 `POST /api/health-records` - ```dart - { - 'type': 'BloodPressure', - 'systolic': reading.systolic, - 'diastolic': reading.diastolic, - 'source': 'Device', - 'unit': 'mmHg', - 'recordedAt': reading.timestamp.toUtc().toIso8601String(), - } - ``` -3. **去重逻辑**:同一时间戳(精确到秒)+ 同一值 → 跳过 -4. **健康概览刷新**:同步后 `ref.invalidate(latestHealthProvider)` -5. **真机 + J735 联调**: - - 扫描 → 连接 → 测量 → 数据上传 → 健康概览显示新数据 - - 断连 → 重新打开 App → 再次连接 - - iOS 和 Android 双平台测试 - ---- - -## 四、交互流程 - -``` -用户操作 App 行为 -──────── ──────── -设置 → 蓝牙血压计 打开 DeviceBindPage(已绑定/未绑定) - │ - ├─ 未绑定 - │ └─ 点"添加设备" 打开 DeviceScanPage - │ └─ 扫到 J735 点击"连接" - │ └─ 连接成功 返回 DeviceBindPage(已绑定状态) - │ - └─ 已绑定 - └─ 血压计测量 血压计按开始键 → 测量完成 - │ BLE Indicate 推送数据 - │ App 收到 120/80 - │ SnackBar: "已同步: 120/80 mmHg" - │ 自动写入 HealthRecords - │ 健康概览刷新 - └─ 查看 点"健康概览"可看到新数据 -``` - ---- - -## 五、预期效果 - -- 从 ProfilePage → 设备管理,不再是全黑空壳,而是漂亮的设备管理页面 -- 点击"添加设备"打开扫描页,15 秒内搜索到 J735 -- 点击"连接" 2 秒内完成绑定 -- J735 测量完毕后,App 自动收到数据(无需手动操作) -- 数据自动写入健康记录,健康概览实时刷新 -- 同一数据不重复录入 -- 重新打开 App 后已绑定设备状态保留 -- iOS + Android 双平台均可使用 - ---- - -## 六、风险点 - -| 风险 | 应对 | -|------|------| -| J735 蓝牙名称与预期不符 | 不硬编码名称,用 `0x1810` 服务 UUID 过滤;首次连接后打印实际名称 | -| Android 12+ 蓝牙权限弹窗被拒 | `permission_handler` 检测权限状态,引导用户去设置开启 | -| 设备已被系统蓝牙占用 | 扫描页提示"如搜不到请先取消系统蓝牙配对" | -| SFLOAT 解码与屏幕值有偏差 | 用 nRF Connect 抓 HEX 对比,误差 > 1 则调整解码 | -| iOS BLE 后台断连 | 前台连接同步,完成后断开;不做后台持久连接 | diff --git a/docs/omron_bp_integration.md b/docs/omron_bp_integration.md deleted file mode 100644 index 2985f64..0000000 --- a/docs/omron_bp_integration.md +++ /dev/null @@ -1,825 +0,0 @@ -# 欧姆龙蓝牙血压计接入方案(iOS + Android 双平台) - - -## 一、选购建议 - -### 选用型号:J735 - -| 项目 | 详情 | -|------|------| -| **型号** | 欧姆龙 J735 | -| **价格** | ¥265-389(百亿补贴约 ¥265,日常 ¥350) | -| **蓝牙** | ✅ BLE 4.0,标准协议 `0x1810` | -| **精度** | ±3mmHg,AAMI 认证 | -| **特点** | 双人模式(各60组)、360°袖带、心律检测、体动检测、高血压红屏预警 | -| **产地** | 日本原装进口 | -| **购买** | 京东/天猫搜索「欧姆龙 J735」 | - -### 为什么不需要官方 SDK - -J735 的蓝牙通信走的是 **Bluetooth SIG 国际标准协议**(Blood Pressure Service `0x1810`),不是欧姆龙私有协议。任何支持 BLE GATT 的蓝牙库都能直连,跟品牌无关。 - -``` -┌──────────────────────────────────────────┐ -│ flutter_blue_plus (开源) │ -│ ↓ BLE GATT 标准协议 │ -│ ↓ Service: 0x1810 (Blood Pressure) │ -│ ↓ Char: 0x2A35 (Measurement) │ -│ ↓ │ -│ 欧姆龙 J735 ←── 标准蓝牙芯片 ──→ 任何 BLE 血压计 │ -└──────────────────────────────────────────┘ -``` - -欧姆龙官方 SDK 做的是封装这些标准协议 + 提供云端API,不是必需的。我们的方案直接走标准 BLE,零依赖、零费用、全平台兼容。 - ---- - -## 二、通信协议(实测可靠) - -欧姆龙蓝牙血压计严格遵循 **Bluetooth SIG Blood Pressure Service 1.1.1**,这是国际标准协议,不是私有协议。 - -### 2.1 GATT 服务结构 - -``` -Blood Pressure Service (0x1810) -├── Blood Pressure Measurement (0x2A35) [Indicate] ← 测量数据在这里 -├── Blood Pressure Feature (0x2A49) [Read] -├── Intermediate Cuff Pressure (0x2A36) [Notify] ← 充气过程中的实时压力(可选) -└── Date Time (0x2A08) [Write] ← 同步时间 -``` - -128-bit UUID 格式(Android/iOS 都用这个): -``` -Blood Pressure Service: 00001810-0000-1000-8000-00805F9B34FB -Blood Pressure Measurement: 00002A35-0000-1000-8000-00805F9B34FB -``` - -### 2.2 测量数据格式(`0x2A35`) - -BLE 推送的原始字节数组,设备每次测量完成后自动 Indicate(需客户端确认): - -``` -┌────────┬──────────┬───────────┬────────┬──────────┬─────────┬─────────┬──────────────┐ -│ Byte 0 │ Byte 1-2 │ Byte 3-4 │Byte 5-6│Byte 7-13 │Byte14-15│ Byte 16 │ Byte 17-18 │ -│ Flags │ 收缩压 │ 舒张压 │ 平均压 │ 时间戳 │ 脉搏 │ 用户ID │ 测量状态 │ -│ │ SFLOAT │ SFLOAT │ SFLOAT │ (可选) │ (可选) │ (可选) │ (可选) │ -└────────┴──────────┴───────────┴────────┴──────────┴─────────┴─────────┴──────────────┘ - -Flags byte 各位含义: - bit 0 = 1 → kPa,0 → mmHg - bit 1 = 1 → 含时间戳 - bit 2 = 1 → 含脉搏 - bit 3 = 1 → 含用户 ID - bit 4 = 1 → 含测量状态(体动/袖带/心律等) -``` - -### 2.3 SFLOAT 解码(IEEE-11073 16-bit) - -```dart -// 通用解码函数(iOS/Android 一致) -double decodeSFloat(int raw) { - int mantissa = raw & 0x0FFF; // 低 12 位 - if (mantissa & 0x0800 != 0) { // 符号扩展 - mantissa = mantissa | 0xFFFFF000; - } - int exponent = (raw >> 12) & 0x0F; // 高 4 位 - if (exponent & 0x08 != 0) { - exponent = exponent | 0xFFFFFFF0; - } - return mantissa * pow(10, exponent); -} -``` - -实测例子(HEM-7600T 实测数据): -``` -原始: [0xDE, 0x88, 0xF4, 0x20, 0xF3, 0xFF, 0x07, 0xE4, 0x07, 0x01, 0x13, 0x16, 0x37, 0x00, 0xBC, 0xF2, 0x01, 0x44, 0x01] -解析: - Flags = 0xDE → mmHg, 含时间戳, 含脉搏, 含用户ID, 含测量状态 - 收缩压 = decodeSFloat(0xF488) = 116.0 mmHg - 舒张压 = decodeSFloat(0xF320) = 80.0 mmHg - 脉搏 = decodeSFloat(0xF2BC) = 70 bpm - 时间 = 2020-01-19 22:55:00 - 状态 = 心律不齐检测到 -``` - -### 2.4 官方协议出处 - -- Bluetooth SIG Blood Pressure Service 1.1.1: [bluetooth.com/specifications/specs/blood-pressure-service-1-1-1](https://www.bluetooth.com/specifications/specs/blood-pressure-service-1-1-1/) -- 欧姆龙 B2B SDK 开发者门户: [public.omronhealthcare.com.cn/b2bsdk](https://public.omronhealthcare.com.cn/b2bsdk/) -- 欧姆龙全球开发者 API: [omronhealthcare.com/api](https://omronhealthcare.com/api) - ---- - -## 三、Flutter 实现(iOS + Android 双平台) - -### 3.1 依赖 - -```yaml -# pubspec.yaml -dependencies: - flutter_blue_plus: ^1.34.0 # BLE 核心库(iOS + Android) - permission_handler: ^11.3.0 # 权限管理 -``` - -### 3.2 目录结构 - -``` -lib/ -├── services/ -│ └── omron_ble_service.dart # 欧姆龙 BLE 连接 + 协议解析 -├── providers/ -│ └── omron_device_provider.dart -├── pages/ -│ └── device/ -│ ├── device_scan_page.dart -│ └── device_bind_page.dart -└── models/ - └── bp_reading.dart # 血压数据模型 -``` - -### 3.3 核心代码 - -#### 3.3.1 数据模型 - -```dart -class BpReading { - final int systolic; // 收缩压 - final int diastolic; // 舒张压 - final int? pulse; // 脉搏(可选) - final DateTime timestamp; - final String? userId; // 设备用户ID(J760 双人模式) - final bool isKpa; // 是否 kPa 单位 - final bool hasBodyMovement; // 体动 - final bool hasIrregularPulse; // 心律不齐 - - // ... 构造函数 -} -``` - -#### 3.3.2 BLE 服务 - -```dart -import 'dart:async'; -import 'dart:math'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; - -class OmronBleService { - static const bpServiceUuid = '00001810-0000-1000-8000-00805F9B34FB'; - static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB'; - static const bpFeatureUuid = '00002A49-0000-1000-8000-00805F9B34FB'; - static const dateTimeUuid = '00002A08-0000-1000-8000-00805F9B34FB'; - - BluetoothDevice? _device; - StreamSubscription>? _subscription; - final _readingsController = StreamController.broadcast(); - Stream get readings => _readingsController.stream; - - // ── 扫描 ── - Stream scan({Duration timeout = const Duration(seconds: 15)}) { - return FlutterBluePlus.scan( - timeout: timeout, - withServices: [Guid(bpServiceUuid)], // 只扫描有血压服务的设备 - ).where((r) { - final name = r.device.platformName.toUpperCase(); - return name.contains('OMRON') || name.contains('HEM') || name.contains('BLEsmart'); - }); - } - - // ── 连接 ── - Future connect(BluetoothDevice device) async { - _device = device; - await device.connect(autoConnect: false); - await device.discoverServices(); - - final services = await device.discoverServices(); - final bpService = services.firstWhere( - (s) => s.uuid.str128.toUpperCase() == bpServiceUuid.toUpperCase(), - ); - - final measurementChar = bpService.characteristics.firstWhere( - (c) => c.uuid.str128.toUpperCase() == bpMeasurementUuid.toUpperCase(), - ); - - // 欧姆龙用 Indicate(不是 Notify),flutter_blue_plus 自动处理 - await measurementChar.setNotifyValue(true); - - _subscription = measurementChar.lastValueStream.listen(_parseReading); - - // 连接后同步当前时间到设备(让测量时间戳准确) - await _syncTime(bpService); - } - - // ── 解析测量数据 ── - void _parseReading(List raw) { - if (raw.length < 7) return; - - final flags = raw[0]; - final isKpa = (flags & 0x01) != 0; - final hasTimestamp = (flags & 0x02) != 0; - final hasPulse = (flags & 0x04) != 0; - final hasUserId = (flags & 0x08) != 0; - final hasStatus = (flags & 0x10) != 0; - - int systolic = _decodeSFloat(raw[1] | (raw[2] << 8)).round(); - int diastolic = _decodeSFloat(raw[3] | (raw[4] << 8)).round(); - // byte 5-6: MAP (舍去) - - if (isKpa) { - systolic = (systolic * 7.50062).round(); - diastolic = (diastolic * 7.50062).round(); - } - - int offset = 7; - DateTime timestamp = DateTime.now(); - - if (hasTimestamp && raw.length >= offset + 7) { - int year = raw[offset] | (raw[offset + 1] << 8); - int month = raw[offset + 2]; - int day = raw[offset + 3]; - int hour = raw[offset + 4]; - int minute = raw[offset + 5]; - int second = raw[offset + 6]; - timestamp = DateTime(year, month, day, hour, minute, second); - offset += 7; - } - - int? pulse; - if (hasPulse && raw.length >= offset + 2) { - pulse = _decodeSFloat(raw[offset] | (raw[offset + 1] << 8)).round(); - offset += 2; - } - - String? userId; - if (hasUserId && raw.length > offset) { - userId = raw[offset].toString(); - offset += 1; - } - - bool bodyMovement = false, irregularPulse = false; - if (hasStatus && raw.length >= offset + 2) { - int status = raw[offset] | (raw[offset + 1] << 8); - bodyMovement = (status & 0x0001) != 0; - irregularPulse = (status & 0x0800) != 0; - } - - _readingsController.add(BpReading( - systolic: systolic, diastolic: diastolic, pulse: pulse, - timestamp: timestamp, userId: userId, isKpa: isKpa, - hasBodyMovement: bodyMovement, hasIrregularPulse: irregularPulse, - )); - } - - // ── SFLOAT 解码 ── - static double _decodeSFloat(int raw) { - int mantissa = raw & 0x0FFF; - if (mantissa & 0x0800 != 0) mantissa |= 0xFFFFF000; - int exponent = (raw >> 12) & 0x0F; - if (exponent & 0x08 != 0) exponent |= 0xFFFFFFF0; - return mantissa * pow(10, exponent); - } - - double pow(double x, int exp) { - if (exp == 0) return 1; - double r = 1; - for (int i = 0; i < exp.abs(); i++) r *= x; - return exp > 0 ? r : 1 / r; - } - - // ── 同步时间 ── - Future _syncTime(BluetoothService service) async { - try { - final dtChar = service.characteristics.firstWhere( - (c) => c.uuid.str128.toUpperCase() == dateTimeUuid.toUpperCase(), - ); - final now = DateTime.now(); - await dtChar.write([ - now.year & 0xFF, now.year >> 8, - now.month, now.day, - now.hour, now.minute, now.second, - ]); - } catch (_) { - // 部分型号不支持写入时间,忽略 - } - } - - // ── 断开 ── - Future disconnect() async { - _subscription?.cancel(); - await _device?.disconnect(); - _device = null; - } - - void dispose() { - disconnect(); - _readingsController.close(); - } -} -``` - -#### 3.3.3 数据同步到后端 - -```dart -// 在 Provider 中监听 -final omronReadingsProvider = StreamProvider((ref) { - return ref.read(omronBleServiceProvider).readings; -}); - -// 监听并自动同步 -ref.listen(omronReadingsProvider, (_, next) { - next.whenData((reading) async { - final api = ref.read(apiClientProvider); - await api.post('/api/health-records', data: { - 'type': 'BloodPressure', - 'systolic': reading.systolic, - 'diastolic': reading.diastolic, - 'source': 'Device', - 'unit': 'mmHg', - 'recordedAt': reading.timestamp.toIso8601String(), - }); - ref.invalidate(latestHealthProvider); // 刷新健康概览 - }); -}); -``` - -### 3.4 平台权限配置 - -#### Android (`AndroidManifest.xml`) - -```xml - - - - - - - -``` - -#### iOS (`Info.plist`) - -```xml -NSBluetoothAlwaysUsageDescription -需要使用蓝牙连接欧姆龙血压计以同步测量数据 -NSBluetoothPeripheralUsageDescription -需要使用蓝牙连接血压计 -``` - ---- - -## 四、交互流程设计 - -### 4.1 用户操作流程 - -``` -1. 打开 App → 设置 → "蓝牙血压计" -2. 点击"扫描设备" -3. 看到列表:OMRON HEM-7600T / BLEsmart_xxxx -4. 点击连接 → 自动绑定(存储 MAC 地址) -5. 之后每次打开 App 自动连接已绑定设备 -6. 血压计测量完成 → App 自动收到数据 → 弹窗确认 → 存入健康概览 -``` - -### 4.2 前端页面代码 - -#### 扫描绑定页 (`device_scan_page.dart`) - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import '../../services/omron_ble_service.dart'; - -class DeviceScanPage extends ConsumerStatefulWidget { - const DeviceScanPage({super.key}); - @override ConsumerState createState() => _DeviceScanPageState(); -} - -class _DeviceScanPageState extends ConsumerState { - final _ble = OmronBleService(); - final _results = []; - bool _scanning = false; - String? _connectingId; - - @override void initState() { - super.initState(); - _startScan(); - } - - Future _startScan() async { - setState(() { _scanning = true; _results.clear(); }); - - // Android 12+ 需要先请求权限 - await FlutterBluePlus.turnOn(); - - _ble.scan().listen((result) { - if (!_results.any((r) => r.device.remoteId == result.device.remoteId)) { - setState(() => _results.add(result)); - } - }).onDone(() { - if (mounted) setState(() => _scanning = false); - }); - - // 15 秒后自动停止扫描 - Future.delayed(const Duration(seconds: 15), () { - FlutterBluePlus.stopScan(); - if (mounted) setState(() => _scanning = false); - }); - } - - Future _connect(ScanResult result) async { - setState(() => _connectingId = result.device.remoteId.toString()); - try { - await _ble.connect(result.device); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('已连接 ${result.device.platformName}')), - ); - Navigator.pop(context, result.device); // 返回设备信息 - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('连接失败: $e'), backgroundColor: Colors.red), - ); - } - } finally { - if (mounted) setState(() => _connectingId = null); - } - } - - @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('添加血压计')), - body: Column(children: [ - // 扫描状态 - Container( - width: double.infinity, padding: const EdgeInsets.all(16), - color: const Color(0xFFF0F2FF), - child: Row(children: [ - if (_scanning) ...[ - const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), - const SizedBox(width: 12), - ], - Text(_scanning ? '正在扫描...' : '扫描完成,发现 ${_results.length} 台设备', - style: const TextStyle(fontSize: 14)), - ]), - ), - - // 使用说明(没有设备时) - if (!_scanning && _results.isEmpty) - Padding( - padding: const EdgeInsets.all(32), - child: Column(children: [ - Icon(Icons.bluetooth_disabled, size: 64, color: Colors.grey[300]), - const SizedBox(height: 16), - const Text('未发现设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), - const SizedBox(height: 24), - _tipCard('1', '请确保血压计已装电池'), - _tipCard('2', '长按血压计蓝牙键 3 秒,直到图标闪烁'), - _tipCard('3', '手机靠近血压计(1 米内)'), - _tipCard('4', '关闭其他已连接血压计的手机'), - _tipCard('5', 'Android 用户请确保已授予蓝牙和定位权限'), - ]), - ), - - // 设备列表 - Expanded( - child: ListView.builder( - itemCount: _results.length, - itemBuilder: (ctx, i) { - final r = _results[i]; - final isConnecting = _connectingId == r.device.remoteId.toString(); - final name = r.device.platformName.isNotEmpty - ? r.device.platformName : '未知设备'; - final signal = r.rssi; - - return ListTile( - leading: CircleAvatar( - backgroundColor: const Color(0xFFEDF2FF), - child: Icon(Icons.bluetooth, color: signal > -60 ? const Color(0xFF4F6EF7) : Colors.grey), - ), - title: Text(name, style: const TextStyle(fontWeight: FontWeight.w500)), - subtitle: Text('信号: ${_rssiLabel(signal)} · MAC: ${r.device.remoteId}'), - trailing: isConnecting - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) - : ElevatedButton.icon( - onPressed: () => _connect(r), - icon: const Icon(Icons.link, size: 16), - label: const Text('连接'), - ), - ); - }, - ), - ), - - // 重新扫描按钮 - if (!_scanning && _results.isNotEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: SizedBox(width: double.infinity, child: OutlinedButton.icon( - onPressed: _startScan, - icon: const Icon(Icons.refresh), - label: const Text('重新扫描'), - )), - ), - ]), - ); - } - - Widget _tipCard(String num, String text) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container(width: 20, height: 20, alignment: Alignment.center, - decoration: BoxDecoration(color: const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(10)), - child: Text(num, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600))), - const SizedBox(width: 10), - Expanded(child: Text(text, style: TextStyle(fontSize: 13, color: Colors.grey[600]))), - ]), - ); - - String _rssiLabel(int rssi) { - if (rssi > -55) return '强'; - if (rssi > -70) return '中'; - return '弱'; - } -} -``` - -#### 已绑定设备管理页 (`device_manager_page.dart`) - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../services/local_database.dart'; - -class DeviceManagerPage extends ConsumerStatefulWidget { - const DeviceManagerPage({super.key}); - @override ConsumerState createState() => _DeviceManagerPageState(); -} - -class _DeviceManagerPageState extends ConsumerState { - String? _boundMac; - String? _boundName; - String? _lastSync; - bool _loading = true; - - @override void initState() { - super.initState(); - _loadBoundDevice(); - } - - Future _loadBoundDevice() async { - final db = ref.read(localDatabaseProvider); - _boundMac = await db.read('bp_device_mac'); - _boundName = await db.read('bp_device_name'); - _lastSync = await db.read('bp_last_sync'); - if (mounted) setState(() => _loading = false); - } - - Future _unbind() async { - final ok = await showDialog(context: context, builder: (ctx) => AlertDialog( - title: const Text('解绑设备'), - content: const Text('解绑后需重新扫描连接,确定吗?'), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), - TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定解绑')), - ], - )); - if (ok != true) return; - final db = ref.read(localDatabaseProvider); - await db.delete('bp_device_mac'); - await db.delete('bp_device_name'); - await db.delete('bp_last_sync'); - setState(() { _boundMac = null; _boundName = null; _lastSync = null; }); - } - - Future _goScan() async { - final device = await Navigator.push(context, MaterialPageRoute(builder: (_) => const DeviceScanPage())); - if (device != null) { - final db = ref.read(localDatabaseProvider); - await db.write('bp_device_mac', device.remoteId.toString()); - await db.write('bp_device_name', device.platformName); - _loadBoundDevice(); - } - } - - @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('蓝牙血压计')), - body: _loading - ? const Center(child: CircularProgressIndicator()) - : _boundMac == null - ? _emptyState() - : _boundCard(), - ); - } - - Widget _emptyState() => Center( - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.bluetooth_disabled, size: 64, color: Colors.grey[300]), - const SizedBox(height: 16), - const Text('未绑定设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), - const SizedBox(height: 8), - const Text('连接欧姆龙血压计,自动同步测量数据', style: TextStyle(color: Color(0xFF999999))), - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: _goScan, - icon: const Icon(Icons.add), - label: const Text('添加设备'), - style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white), - ), - ]), - ); - - Widget _boundCard() => Padding( - padding: const EdgeInsets.all(16), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8)]), - child: Column(children: [ - CircleAvatar(radius: 30, backgroundColor: const Color(0xFFEDF2FF), - child: const Icon(Icons.bluetooth_connected, color: Color(0xFF4F6EF7))), - const SizedBox(height: 12), - Text(_boundName ?? '血压计', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), - const SizedBox(height: 4), - Text('MAC: $_boundMac', style: const TextStyle(fontSize: 13, color: Color(0xFF999999))), - if (_lastSync != null) ...[ - const SizedBox(height: 4), - Text('上次同步: $_lastSync', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))), - ], - const SizedBox(height: 20), - TextButton.icon( - onPressed: _unbind, - icon: const Icon(Icons.delete_outline, color: Colors.red), - label: const Text('解绑设备', style: TextStyle(color: Colors.red)), - ), - ]), - ), - ); -} -``` - -### 4.3 交互细节 - -- **测量中提示**:设备开始充气时 `0x2A36`(Intermediate Cuff Pressure)会推送实时压力,可显示充气动画 -- **测量完成**:收到 `0x2A35` Indicate 后,弹 SnackBar 显示 `"已同步: 120/80 mmHg"` -- **自动记录**:无需用户手动确认,直接写入 HealthRecords 表 -- **防重复**:同一时间戳(精确到秒)+ 同一值 → 不重复录入 -- **多用户设备(J760)**:如果 Flags 含 User ID bit,弹出选择"这是谁的数据?" - ---- - -## 五、欧姆龙官方 SDK vs 自行解析 - -| 对比 | 官方 B2B SDK | 自行解析 BLE GATT | -|------|-------------|-------------------| -| 复杂度 | 低(已封装) | 中(需理解协议) | -| 费用 | 需申请/可能收费 | 免费 | -| 支持范围 | 仅欧姆龙 | 所有 BLE 血压计 | -| 维护 | SDK 更新需适配 | 标准协议不变 | -| iOS 支持 | 有 | ✅ flutter_blue_plus | -| Android 支持 | 有 | ✅ flutter_blue_plus | -| 获取方式 | [public.omronhealthcare.com.cn/b2bsdk](https://public.omronhealthcare.com.cn/b2bsdk/) | — | - -**推荐路线**: -1. **先用自行解析方案**(本文档方案),因为协议是标准 BLE,不依赖第三方 -2. 如果遇到兼容性问题(特定型号数据解析异常),再申请欧姆龙官方 SDK 作为补充 - ---- - -## 六、后端改动 - -### 6.1 DB 新增表(可选) - -```sql -CREATE TABLE IF NOT EXISTS "DeviceBindings" ( - "Id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), - "UserId" UUID NOT NULL REFERENCES "Users"("Id"), - "DeviceType" TEXT DEFAULT 'BloodPressure', - "DeviceModel" TEXT, -- 'OMRON J732' - "DeviceMac" TEXT, -- 'AA:BB:CC:DD:EE:FF' - "DeviceSerial" TEXT, - "LastSyncAt" TIMESTAMPTZ, - "CreatedAt" TIMESTAMPTZ DEFAULT NOW() -); -``` - -### 6.2 后端几乎无需改动 - -现有 `POST /api/health-records` 已支持 `BloodPressure` + `systolic/diastolic`,只需确保 `Source` 枚举有 `Device`: - -```csharp -public enum HealthRecordSource { - Manual, - AiEntry, - Device // ← 新增 -} -``` - ---- - -## 七、实施计划(5 天) - -| 天 | 任务 | 产出 | -|----|------|------| -| **Day 1** | flutter_blue_plus 集成 + 权限配置 + SFLOAT 单元测试 | BLE 服务骨架 | -| **Day 2** | 扫描/连接/解析/断连完整实现 | OmronBleService | -| **Day 3** | 设备扫描页 + 绑定管理页 + 设置入口 | UI 完成 | -| **Day 4** | 数据同步到后端 + 去重逻辑 + 健康概览刷新 | 端到端打通 | -| **Day 5** | 真机 + J735 联调测试、异常处理 | 上线就绪 | - ---- - -## 八、J735 到货后首次调试指南 - -### 8.1 开箱检查 - -收到货后先确认设备正常: - -1. 装电池(4 节 AA)或插电源适配器 -2. 按「开始/停止」键,袖带充气 → 屏幕显示数值 → 确认测量功能正常 -3. 长按蓝牙键(屏幕出现蓝牙图标闪烁)→ 进入配对模式 - -### 8.2 用官方 App 验证蓝牙 - -这一步是验证蓝牙硬件没问题: - -1. 手机下载 **OMRON Connect**(iOS App Store / Android 应用市场) -2. 注册账号 → 添加设备 → 选择 J735 -3. 血压计长按蓝牙键进入配对模式 -4. App 扫描连接 → 测量一次 → 确认数据同步到 App - -如果官方 App 能正常同步,说明硬件没问题,接下来就是我们的代码对接。 - -### 8.3 用 nRF Connect 抓包验证 - -nRF Connect 是 Nordic 官方的免费 BLE 调试工具,可以直接看到设备广播的原始数据。**这是开发前最重要的验证步骤**: - -1. 手机安装 **nRF Connect**(iOS App Store / Android Google Play) -2. 打开 J735 蓝牙配对模式 -3. nRF Connect 扫描 → 找到 `OMRON` 或 `BLEsmart` 开头的设备 → 点击连接 -4. 找到 `Blood Pressure` 服务(UUID `0x1810`) -5. 订阅 `Blood Pressure Measurement` Characteristic(UUID `0x2A35`) -6. 在血压计上测量一次 -7. nRF Connect 会收到 Indicate 推送 → 截图保存原始 HEX 数据 - -这个 HEX 数据就是我们要解析的原始字节。用它来验证我们的 SFLOAT 解码是否正确。 - -### 8.4 BLE 名称确认 - -不同批次 J735 的蓝牙名称可能不同,常见的有: - -- `OMRON HEM-xxxx` -- `BLEsmart_xxxx` -- `OMRON-BPM` - -首次连接后用 `device.platformName` 打印出来,后续扫描过滤就用这个。 - -### 8.5 常见坑 - -| 坑 | 现象 | 解决 | -|----|------|------| -| **设备已被手机系统蓝牙连接** | flutter_blue_plus 搜不到 | 系统设置里取消配对 | -| **未订阅 Characteristic** | 收不到数据 | 必须 `setNotifyValue(true)` | -| **忘记请求权限** | Android 扫描返回空列表 | 检查蓝牙+定位权限 | -| **SFLOAT 计算错误** | 数据差很多 | 用 nRF Connect 的 HEX 对比解码结果 | -| **iOS BLE 后台限制** | App 切后台后断连 | 前台测量时连接,测量完断开 | -| **J735 是 Indicate 不是 Notify** | 数据只来一次 | flutter_blue_plus 对 Indicate 自动处理,但每次数据后需设备收到确认才会发下一条 | - -### 8.6 测试清单 - -``` -□ 扫描能看到 J735 -□ 点击连接成功 -□ discoverServices 能找到 0x1810 服务 -□ setNotifyValue(true) 后收到测量数据 -□ SFLOAT 解码结果与屏幕显示一致(允许 ±1 误差) -□ 数据成功 POST 到 /api/health-records -□ 健康概览刷新后显示新数据 -□ 断连后重新打开 App 能再次连接 -□ 同一测量不重复录入(去重逻辑) -□ iOS 和 Android 双平台都测试通过 -``` - ---- - -## 九、风险与备选 - -| 风险 | 应对 | -|------|------| -| 公司 WiFi 环境蓝牙干扰大 | 手机靠近血压计(< 5 米) | -| Android 后台被杀 | 前台 Service 保持 BLE 连接 | -| iOS BLE 限制(App 后台) | 前台连接时同步历史数据 | -| J735 协议与标准有偏差 | 用 nRF Connect 抓包对比,微调解码 | -| 多个血压计混淆 | 按 MAC 地址绑定,UI 显示设备名称 | - ---- - -## 参考资源 - -- Bluetooth SIG Blood Pressure Service 1.1: https://www.bluetooth.com/specifications/specs/blood-pressure-service-1-1-1/ -- 欧姆龙 B2B SDK 中国: https://public.omronhealthcare.com.cn/b2bsdk/ -- 欧姆龙全球开发者 API: https://omronhealthcare.com/api -- flutter_blue_plus: https://pub.dev/packages/flutter_blue_plus diff --git a/docs/project_deep_review.md b/docs/project_deep_review.md deleted file mode 100644 index 52224ce..0000000 --- a/docs/project_deep_review.md +++ /dev/null @@ -1,570 +0,0 @@ -# 健康项目深度分析报告 - -日期:2026-06-17 - -## 1. 分析范围与结论概览 - -本次分析覆盖了 Flutter 客户端、ASP.NET Core 后端、AI 问诊/饮食识别链路、路由、数据存储、UI 结构、测试与工程配置。执行过的主要检查包括: - -- `flutter analyze`:当前客户端有 162 条 analyzer/lint 问题,其中包含错误导入、未使用代码、异步上下文风险、废弃 API、调试输出等。 -- `dotnet test`:后端测试未能真正跑完,原因是正在运行的 `Health.WebApi` 进程锁住了 `bin/Debug/net10.0` 下的 DLL,导致构建复制失败。 -- 重点阅读:`api_client.dart`、`chat_provider.dart`、`app_router.dart`、`data_providers.dart`、`local_database.dart`、`Program.cs`、`auth_endpoints.cs`、`ai_chat_endpoints.cs`、`file_endpoints.cs`、测试目录等。 - -整体判断:项目功能面铺得很广,已经具备“患者端健康管理 + AI 助手 + 饮食识别 + 用药/报告/日历/随访 + 医生/管理员”的雏形。但目前最大问题不是单个页面丑,而是功能深度、数据安全、接口契约、状态管理、UI 体系和测试可信度还没有收拢。现在已经到了需要“先稳核心链路,再美化体验”的阶段。 - -## 2. 最需要优先处理的问题 - -| 优先级 | 问题 | 影响 | 建议 | -| --- | --- | --- | --- | -| P0 | 短信验证码接口返回 `devCode`,登录页还会自动填充 | 真实环境下等于绕过验证码安全 | 仅开发环境返回,生产环境绝不返回;前端删除自动填充逻辑 | -| P0 | SSE token 放在 query 中,后端用 `ReadJwtToken` 读取但没有验证签名 | 可被伪造用户 ID,存在严重认证风险 | SSE 也必须走标准 JWT 验证,或使用一次性短票据 | -| P0 | AI 会话按 `conversationId` 查询后未确认归属用户 | 可能跨用户写入/读取会话 | 所有 conversation 查询都加 `UserId == currentUserId` | -| P1 | 文件上传前后端契约不一致 | 图片上传后前端拿不到 URL,AI 图片消息可能只保存本地路径 | 后端返回可访问 URL,或前端按后端返回结构处理 | -| P1 | token 和健康数据存在本地 SQLite,未加密 | 手机丢失或被调试时有泄露风险 | 使用 secure storage 保存 token,敏感健康数据考虑加密 | -| P1 | CORS 允许任意 Origin 且允许 Credentials | Web 场景下有跨站风险 | 按环境配置白名单 | -| P1 | 后端用 `EnsureCreatedAsync` 而不是 migrations | 数据库升级不可控 | 建立 EF migrations 流程 | -| P1 | UI 设计体系不统一 | 页面之间像不同产品拼接,后期维护难 | 建立颜色、间距、字体、组件规范 | -| P2 | Flutter 存在大量未使用代码、旧页面、调试输出 | 维护成本高,也容易藏 bug | 分模块清理,保留真实业务入口 | -| P2 | 测试依赖运行中的本地服务和不安全 dev 行为 | CI 不可靠,安全问题被测试固化 | 单元/集成测试分层,测试环境用隔离配置 | - -## 3. 功能层面分析 - -### 3.1 功能广度够,但关键流程深度不够 - -目前功能入口很多:AI 问诊、记数据、饮食拍照、用药、报告、运动、健康档案、复查随访、医生端、管理员端等。这个方向是对的,但现在许多功能更像“有入口、有页面、有部分数据”,还没有形成足够扎实的闭环。 - -建议优先把以下闭环做深: - -1. 健康数据闭环:记录数据、展示趋势、异常提示、医生/AI 解读、复查建议。 -2. 饮食闭环:图片识别、用户修正、营养统计、长期趋势、与血糖/体重关联。 -3. 用药闭环:药品计划、提醒、服药打卡、漏服记录、医生可见。 -4. 报告闭环:上传报告、AI 解析、结构化指标、异常项追踪、历史对比。 - -现在的问题是入口比闭环多。用户第一次点进去可能觉得丰富,但长期使用时会发现很多地方缺少持续价值。 - -### 3.2 健康仪表盘需要从“展示数值”升级到“解释状态” - -血压、心率、血糖、血氧、体重这些指标是同等级核心指标,UI 上横向平铺是合理的。但产品上还需要补足: - -- 每个指标显示采集时间,避免用户误以为是最新状态。 -- 显示单位和正常范围。 -- 显示趋势,例如较上次升高/下降。 -- 异常时给出明确状态,而不是只换颜色。 -- 区分数据来源:手动录入、蓝牙设备、报告解析、医生录入。 - -医疗健康类应用不能只好看,还要让用户理解“现在是否安全、下一步做什么”。 - -### 3.3 AI 功能需要更强的边界 - -AI 问诊、饮食分析、报告解读是项目亮点,但要注意: - -- AI 建议不能替代医生诊断,需要在关键场景给出边界提示。 -- AI 生成结果要允许用户修正,尤其是饮食热量、报告指标、药品信息。 -- AI 使用了用户健康上下文,应有授权说明、数据范围说明、删除机制。 -- AI 工具调用失败时,前端需要展示可理解的失败状态,而不是静默失败。 - -## 4. UI/UX 分析 - -### 4.1 当前最大 UI 问题:没有稳定设计系统 - -项目里已经有 `AppColors`、`AppTheme`,也有多处页面自己的渐变、阴影、圆角、卡片样式。最近的页面修改又加入了更多独立渐变。这样短期能调好某个页面,但长期会导致页面之间不统一。 - -建议建立一套稳定规则: - -- 主色:用于导航、主要按钮、健康仪表盘重点区域。 -- 功能色:饮食、用药、报告、运动、问诊等可以不同,但要同一明度和饱和度等级。 -- 状态色:正常、警告、危险、完成、禁用必须固定。 -- 卡片规则:圆角、阴影、边框、内边距统一。 -- 图标规则:同一模块内图标线宽、尺寸、背景形状统一。 - -现在用户已经多次指出“颜色太花”“图标不一致”“侧边栏不好看”,本质就是设计 token 没有统一。 - -### 4.2 侧边栏应该是高频操作中心,不是杂物入口 - -侧边栏现在承担了个人信息、仪表盘、功能入口、设置等很多内容。建议侧边栏只保留高频且有明确层级的内容: - -- 顶部:用户身份和健康状态摘要。 -- 中部:核心健康指标,横向一屏看完。 -- 功能入口:报告管理、饮食记录、用药管理、健康日历、复查随访、运动计划,两行三列。 -- 底部:健康档案、设置。 - -个人信息区域不一定要卡片包起来,可以用更轻的排版。健康仪表盘可以做成视觉重点,但功能入口不应该抢它的层级。 - -### 4.3 页面质感不均衡 - -部分页面已经开始有精致的渐变和卡片,但另一些页面仍像占位页或默认列表页。尤其是: - -- 个人信息页:信息架构需要重做,当前不够像一个正式健康档案/账号资料页。 -- 设置页:应按账号、安全、通知、设备、隐私、关于分组,而不是堆按钮。 -- 饮食分析页:餐次选择、识别结果、热量展示、AI 建议这几个区域需要更统一的卡片层级。 -- 医生端/管理员端:如果后续要真实使用,需要比患者端更重视密度、筛选、表格和状态。 - -### 4.4 可访问性风险 - -当前大量使用渐变、浅色图标、小号文字和颜色区分状态。健康类应用的用户可能包含中老年人,建议: - -- 核心数字字号足够大。 -- 不只靠颜色表达异常,还要有文字。 -- 保证按钮文字和图标对比度。 -- 支持系统字体缩放。 -- 重要按钮触控面积至少 44x44。 - -## 5. 前端工程分析 - -### 5.1 API 配置不适合真实移动端 - -> 2026-06-20 更新:已支持 `--dart-define=API_BASE_URL=...`,并保留 `http://localhost:5000` 作为 USB `adb reverse` 本地开发默认值。本节的环境配置问题已处理。 - -`health_app/lib/core/api_client.dart` 中 `baseUrl` 默认是 `http://localhost:5000`。这在 Android/iOS 真机上通常不可用,也不适合测试/生产环境切换。 - -建议: - -- 使用 `--dart-define=API_BASE_URL=...`。 -- 区分 dev/staging/prod。 -- 本地 Android 模拟器使用 `10.0.2.2` 或局域网地址。 - -### 5.2 Token 存储不安全 - -当前 token 存在本地 SQLite key-value 中,例如 `access_token`、`refresh_token`。这对健康类应用风险偏高。 - -建议: - -- token 改用 `flutter_secure_storage` 或平台安全存储。 -- SQLite 中的敏感健康数据考虑加密。 -- 退出登录时确认清理 token、用户缓存、会话缓存。 - -### 5.3 上传接口前后端不一致 - -前端 `uploadFile` 期望后端返回: - -- `url` -- 或 `data.url` - -但后端 `file_endpoints.cs` 返回的是文件列表,每项包含: - -- `id` -- `name` -- `size` - -这会导致 `ChatNotifier.sendImage` 中的 `uploadedUrl` 很可能是 null,最终消息只保存本地路径,跨设备、重启、后端 AI 处理都会出问题。 - -建议统一契约: - -- 后端返回 `{ id, name, size, url, contentType }`。 -- 前端保存 `remoteUrl`,本地路径只做临时预览。 -- 上传失败要有明确 UI 反馈。 - -### 5.4 路由可靠性不足 - -`app_router.dart` 使用字符串 switch 和 `params['id']!`。如果参数缺失会直接崩溃。 - -建议: - -- 至少对参数做空值保护。 -- 核心页面可以逐步迁移到 typed route。 -- 路由表按模块拆分,避免一个文件不断膨胀。 - -### 5.5 状态管理有隐患 - -`chat_provider.dart` 中有一些状态直接修改对象字段的写法,例如修改 `ChatMessage` 的 `content`、`type`、`metadata`、`confirmed`。这容易造成 Riverpod rebuild 不稳定、历史消息状态串联、难以调试。 - -建议: - -- 消息对象尽量不可变。 -- 更新消息时使用 copy/update list。 -- loading、streaming、error 状态显式建模。 - -### 5.6 错误处理过于安静 - -> 2026-06-20 更新:`ApiClient` 已统一识别 `{ code, message }` 业务错误及 HTTP/Dio 网络错误,并转换为可直接展示的 `ApiException`。后端历史 endpoint 的 HTTP 状态码仍需按模块逐步规范,避免一次性破坏现有前端协议。 - -一些 provider 会 catch 异常后返回 fallback 数据,例如医生列表、运动计划。这个方式能让页面不崩,但会掩盖真实后端错误。 - -建议: - -- demo 数据和真实数据明确分离。 -- 网络失败时展示“加载失败/重试”,不要假装成功。 -- 对关键接口增加日志和错误上报。 - -### 5.7 当前 Flutter analyzer 需要清理 - -本次 `flutter analyze` 返回 162 条问题。较重要的包括: - -- `settings_pages.dart` 从 `data_providers.dart` 导入 `apiClientProvider`,但 provider 实际不在该文件中,属于当前明显错误。 -- 多处 `use_build_context_synchronously`,异步后使用 `context` 前没有判断 mounted。 -- BLE 使用废弃的 `BluetoothDevice.localName`。 -- 大量 `avoid_print`,可能泄露请求、token、健康数据。 -- 多个未使用方法、变量、页面,说明旧代码堆积严重。 - -建议先定一个目标:把 analyzer 从 162 条降到 0 或只剩明确允许的少量规则。 - -## 6. 后端工程与安全分析 - -### 6.1 短信验证码存在严重安全问题 - -`auth_endpoints.cs` 中发送短信验证码后会返回 `devCode`,前端登录页还会自动填充。这个行为如果进入真实环境,会直接破坏验证码意义。 - -另外还存在: - -- 管理员手机号 `12345678910`。 -- 固定验证码 `000000`。 -- 缺少短信发送频率限制。 -- 缺少验证码尝试次数限制。 -- 验证码在部分失败流程中可能提前被标记已使用。 - -建议: - -- 只有开发环境允许返回验证码。 -- 生产环境必须接入真实短信服务。 -- 加入 IP/手机号频率限制。 -- 管理员登录改为独立安全流程。 - -### 6.2 SSE 认证方式有严重漏洞 - -AI SSE 接口允许从 query string 读取 token,而且后端使用 `ReadJwtToken` 读取用户 ID。`ReadJwtToken` 只解析 token,不验证签名、不验证过期、不验证 issuer/audience。 - -这意味着攻击者可能构造一个看似 JWT 的字符串,放入任意用户 ID claim,后端就可能当成有效用户。 - -建议: - -- 禁止直接信任 query token。 -- SSE 鉴权也必须经过 `JwtBearer` 验证。 -- 如果浏览器 EventSource 不方便加 header,可以先用标准授权接口换一个短期一次性 stream token,服务端存储并校验。 - -### 6.3 会话归属校验不足 - -AI 聊天接口中根据 `conversationId` 查询会话后,需要确认该会话属于当前用户。否则只要知道或猜到 conversationId,就有跨用户写入/读取风险。 - -建议: - -```csharp -var conversation = await db.Conversations - .FirstOrDefaultAsync(c => c.Id == convId && c.UserId == currentUserId); -``` - -所有报告、健康档案、饮食、聊天记录等用户资源都应遵循这个模式。 - -### 6.4 CORS 配置过宽 - -`Program.cs` 中 CORS 允许任意 origin,同时允许 credentials。这在 Web 客户端场景下风险很大。 - -建议: - -- dev 环境允许 localhost。 -- staging/prod 使用固定域名白名单。 -- 不要在任意 origin 下允许 credentials。 - -### 6.5 数据库初始化方式不适合长期维护 - -后端使用 `EnsureCreatedAsync()`。这适合原型阶段,不适合真实项目迭代。后续字段变更、索引变更、数据迁移都会困难。 - -建议: - -- 建立 EF Core migrations。 -- 启动时只在开发环境自动迁移,生产环境由部署流程控制。 -- 所有 schema 变更进入版本管理。 - -### 6.6 文件上传风险 - -`file_endpoints.cs` 当前只保存上传文件,没有看到严格的大小、类型、内容校验。 - -建议: - -- 限制文件大小。 -- 限制 MIME 类型和扩展名。 -- 图片重新编码或扫描。 -- 不直接信任用户文件名。 -- 返回可访问 URL,并控制访问权限。 - -### 6.7 System.Drawing 跨平台风险 - -后端 AI 图片压缩使用 `System.Drawing` 相关 API,analyzer 给出了 CA1416 警告。这些 API 在非 Windows 环境不可靠。如果部署到 Linux 容器,可能出问题。 - -建议换成: - -- ImageSharp -- SkiaSharp -- 或云端对象存储/图片处理服务 - -## 7. AI 与隐私合规分析 - -项目会把用户健康档案、健康记录、饮食图片、报告等数据送入 AI。健康数据属于高敏感数据,需要明确处理策略。 - -建议补齐: - -- 用户授权:哪些数据会发送给 AI。 -- 数据最小化:只传当前任务必要字段。 -- 日志脱敏:不要记录完整模型响应、token、报告原文、图片隐私信息。 -- 删除机制:用户删除账号后,AI 会话、上传文件、日志也要清理。 -- 医疗免责声明:AI 只做健康建议,不替代诊断。 -- 审计日志:医生/管理员访问患者数据需要留痕。 - -当前 `vlm_log_*.txt` 会记录模型响应,可能包含敏感结果,建议尽快调整为脱敏日志或仅开发环境启用。 - -## 8. 测试与工程流程 - -### 8.1 后端测试当前不可靠 - -`dotnet test` 未跑完,因为正在运行的 WebApi 进程锁住了构建输出 DLL。这说明本地开发/测试流程还不稳定。 - -建议: - -- 测试使用独立输出目录或先停止运行中的服务。 -- CI 中从干净环境执行。 -- 单元测试不要依赖手动启动的 localhost 服务。 - -### 8.2 测试内容还偏浅 - -目前测试更像验证部分服务逻辑和开发流程,没有覆盖高风险业务: - -- 验证码频控和错误次数。 -- JWT 过期、刷新、伪造 token。 -- 用户 A 不能访问用户 B 的会话/报告/健康数据。 -- 文件上传大小/类型限制。 -- AI SSE 中断、重试、失败展示。 -- 饮食识别后用户修正和保存。 - -建议先补安全边界测试,再补 UI golden/screenshot 测试。 - -## 9. 具体 bug 与风险点清单 - -| 文件 | 问题 | 建议 | -| --- | --- | --- | -| `health_app/lib/pages/settings/settings_pages.dart` | `apiClientProvider` 导入来源错误,analyzer 已报错 | 从正确 provider 文件导入,或统一 provider 暴露位置 | -| `health_app/lib/core/api_client.dart` | `baseUrl` 写死 localhost | 使用环境配置 | -| `health_app/lib/core/api_client.dart` | 上传返回结构与后端不匹配 | 统一上传响应 | -| `health_app/lib/core/api_client.dart` | `LogInterceptor` 打印请求/响应 body | 生产关闭,敏感字段脱敏 | -| `health_app/lib/core/local_database.dart` | token 存 SQLite | 改 secure storage | -| `health_app/lib/providers/chat_provider.dart` | 图片消息上传 URL 可能为空 | 修复上传契约和失败 UI | -| `health_app/lib/providers/chat_provider.dart` | 静默 catch | 展示错误状态并记录日志 | -| `health_app/lib/core/app_router.dart` | `params['id']!` 可能崩溃 | 参数校验和 fallback 页面 | -| `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs` | 返回 `devCode` | 仅 dev 环境启用 | -| `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs` | 固定管理员验证码 | 改正式认证流程 | -| `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | query token 未验证签名 | 改标准 JWT 鉴权 | -| `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | conversation 归属校验不足 | 查询时加入 UserId | -| `backend/src/Health.WebApi/Endpoints/file_endpoints.cs` | 文件类型/大小校验不足 | 加白名单和限制 | -| `backend/src/Health.WebApi/Program.cs` | CORS 过宽 | 按环境配置白名单 | -| `backend/src/Health.WebApi/Program.cs` | `EnsureCreatedAsync` | 改 migrations | - -## 10. 建议整改路线 - -### 10.1 0 到 3 天:先堵住安全和明显 bug - -1. 修复 SSE token 验证和 conversation 用户归属校验。 -2. 删除生产环境 `devCode` 返回和前端自动填充验证码。 -3. 修复文件上传返回结构。 -4. 修复 `settings_pages.dart` 的错误导入。 -5. 关闭生产环境请求/响应 body 日志。 -6. 把 Flutter analyzer 里的真正错误先清零。 - -### 10.2 1 到 2 周:收拢核心体验 - -1. 建立统一设计 token:颜色、渐变、按钮、卡片、图标。 -2. 重做侧边栏、个人信息页、设置页、饮食分析页的信息层级。 -3. 健康仪表盘增加趋势、时间、单位、状态解释。 -4. 清理 `remaining_pages.dart` 中旧页面和无入口页面。 -5. 前端 API baseUrl 改成环境配置。 -6. token 改安全存储。 - -### 10.3 1 个月:提升产品完整度 - -1. 建立 EF migrations 和 CI。 -2. 增加安全测试:验证码、JWT、跨用户访问、上传。 -3. 增加 AI 结果修正、置信度、失败重试。 -4. 补齐隐私授权、数据删除、访问审计。 -5. 医生端做成真正可用的工作台:患者筛选、风险排序、随访提醒。 -6. 报告、饮食、用药、运动做长期趋势关联。 - -## 11. 第二轮深挖补充 - -这一轮额外对已有 `docs/BUG_REVIEW.md`、后端端点、AI Agent handler、SignalR、Flutter provider 生命周期、饮食/蓝牙/问诊页面做了交叉检查。需要注意:旧 bug 文档中有一些问题已经被修复或情况已经变化,本节以当前代码为准。 - -### 11.1 已确认仍然存在的高风险问题 - -| 优先级 | 位置 | 当前问题 | 风险说明 | 修复方向 | -| --- | --- | --- | --- | --- | -| P0 | `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs` | `/api/auth/send-sms` 仍返回 `devCode` | 任何客户端都能拿到验证码,短信验证等同失效 | 只在 Development 返回;生产环境完全移除 | -| P0 | `health_app/lib/pages/auth/login_page.dart` | 前端拿到 `devCode` 后自动填入验证码 | 把后端安全问题固化成产品行为 | 删除自动填充;开发环境可用 debug banner 或日志 | -| P0 | `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | SSE token 走 query,且 fallback 用 `ReadJwtToken` 解析 | query token 会进日志;`ReadJwtToken` 不验证签名 | SSE 改标准鉴权,或先换一次性 stream ticket | -| P0 | `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | 创建/续写 AI 会话时 `FindAsync(conversationId)` 未校验 `UserId` | 可能跨用户写入/读取会话 | `FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId)` | -| P0 | `backend/src/Health.WebApi/Hubs/ConsultationHub.cs` | SignalR Hub 没有鉴权,入组只靠 `consultationId` | 任意连接可加入任意问诊房间 | `MapHub().RequireAuthorization()`,Join 前校验用户或医生权限 | -| P0 | `backend/src/Health.WebApi/Hubs/ConsultationHub.cs` | `SendMessage` 按传入 `senderType` 和 `consultationId` 写库 | 客户端可冒充 Doctor/User,向任意会话发消息 | senderType 从 Claims/角色推导,不信任客户端 | -| P0 | `backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs` | 患者发消息只查会话存在,未校验会话属于当前用户 | 用户 A 可向用户 B 的问诊发 HTTP 消息 | 查询加 `c.UserId == userId` | -| P0 | `backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs` | `/items/{itemId}/checkin` 只 `FindAsync(itemId)` | 用户可打卡/取消他人的运动计划项 | Include Plan 后校验 `Plan.UserId` | -| P0 | `backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs` | AI 运动 checkin 只按 itemId 操作 | 通过 AI 工具也可越权打卡 | 查询 item 时联表校验当前 userId | -| P0 | `backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs` | AI 用药 confirm 直接写 `MedicationLog`,不验证药品归属 | 可给他人药品写入服药记录 | 先查 `Medication.Id == medId && UserId == userId` | -| P1 | `backend/src/Health.WebApi/Endpoints/medication_endpoints.cs` | `/medications/{id}/confirm` 查 existing log 未限制 `UserId`,也未先确认药品归属 | 可能误删/写入不属于当前用户药品的日志 | 先查药品归属,再按 `MedicationId + UserId` 查日志 | -| P1 | `backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs` | 医生端已鉴权,但患者详情/报告/随访操作多处只按 id 查询 | 医生可能访问或修改非自己负责患者的数据 | 所有医生端资源都按医生关联患者过滤 | -| P1 | `backend/src/Health.WebApi/Endpoints/file_endpoints.cs` | 上传缺少大小、类型、内容校验,且返回结构与前端不匹配 | 恶意文件/超大文件风险,前端图片上传链路失败 | 加限制、白名单、URL 返回和访问控制 | -| P1 | `health_app/lib/pages/diet/diet_capture_page.dart` | `_fieldCtrls` 缓存 controller,但页面没有 dispose | 多次进入饮食分析页会泄漏 TextEditingController | 在 State `dispose()` 中遍历释放 | -| P1 | `health_app/lib/providers/consultation_provider.dart` | Hub/轮询 stop 需要手动调用,provider 自身没有自动释放 | 离开页面后可能继续 SignalR 或 5 秒轮询 | Notifier build 中注册 `ref.onDispose(stop)` | -| P1 | `health_app/lib/providers/chat_provider.dart` | SSE `_subscription` 和 timer 没看到 provider dispose 释放 | 聊天流中断/页面销毁后可能残留监听 | 注册 `ref.onDispose`,切换会话时取消旧流 | -| P1 | `backend/src/Health.WebApi/Program.cs` | `MapHub("/hubs/consultation")` 未 RequireAuthorization | 即使 API 鉴权,实时通道仍裸露 | Hub 映射处加鉴权并处理 token 传递 | - -### 11.2 旧问题中已经变化或需要修正的判断 - -这些点在旧 `BUG_REVIEW.md` 里出现过,但当前代码已经不是原始状态,后续不要按旧结论机械修: - -- `doctor_endpoints.cs` 不是“零授权”了:当前已有 `.RequireAuthorization()` 和医生角色过滤。真正问题是医生端数据授权粒度不够,部分详情/报告/随访接口没有限制到当前医生负责的患者。 -- `open_ai_compatible_client.cs` 的 Vision content 当前已经是 `Content = contentParts`,不是把多模态数组序列化成字符串。旧的 VLM 序列化 bug 看起来已修复。 -- `diet_agent_handler.cs` 和 `consultation_agent_handler.cs` 当前没有声明未实现工具,而是主动缩减为档案/记录查询。问题应描述为“AI Agent 能力和首页胶囊/欢迎卡片承诺不一致”,不是“声明工具但未实现”。 -- `cleanup_service.cs` 当前已经先删 ConversationMessages 再删 Conversations,旧的 FK 删除顺序问题已修。 -- `device_scan_page.dart` 当前 `dispose()` 已取消 scan/read/connection 订阅,不能继续作为确定泄漏 bug。仍建议检查 `OmronBleService` 的全局 provider 生命周期和断线重连边界。 -- `widget_test.dart` 中 `primary == primaryLight` 的错误断言已经改掉,目前测试更大的问题是覆盖面太浅。 - -### 11.3 后端授权边界需要系统性重查 - -项目里很多接口已经 `.RequireAuthorization()`,但“已登录”不等于“有权操作这个资源”。建议建立统一规则: - -| 资源 | 当前风险 | 应该怎么查 | -| --- | --- | --- | -| AI Conversation | 续写时未绑定 `UserId` | `Conversation.Id == id && Conversation.UserId == currentUserId` | -| Consultation HTTP | POST message 未绑定 `UserId` | `Consultation.Id == id && Consultation.UserId == currentUserId` | -| Consultation Hub | 入组/发消息无服务端权限判断 | Join 和 SendMessage 都查用户或医生是否有权进入该 consultation | -| ExercisePlanItem | checkin 只按 itemId | `ExercisePlanItem.Id == itemId && Item.Plan.UserId == currentUserId` | -| Medication confirm | 部分确认接口未先校验药品归属 | `Medication.Id == id && Medication.UserId == currentUserId` | -| Doctor patient detail | 医生按任意 patient id 查详情 | `User.Id == patientId && User.DoctorId == currentDoctorId` | -| Doctor report review | 医生按任意 report id 审阅 | `Report.User.DoctorId == currentDoctorId` | -| Doctor follow-up update/delete | 医生按任意 followUp id 操作 | `FollowUp.User.DoctorId == currentDoctorId` 或 `DoctorName/DoctorId` 绑定 | - -建议在后端加一层可复用 helper,例如: - -```csharp -static IQueryable ScopePatientsToDoctor(AppDbContext db, Guid doctorId) => - db.Users.Where(u => u.Role == "User" && u.DoctorId == doctorId); -``` - -所有医生端接口都从这个 scope 派生,不要每个 endpoint 手写判断。 - -### 11.4 API 语义和错误码问题 - -现在不少接口用 HTTP 200 包业务错误码,比如 401/403/404/400 都包成 `{ code, message }`。这种风格可以保留,但要注意两个问题: - -- 对认证授权失败,HTTP 状态码最好仍返回 401/403,方便客户端、网关、日志系统识别。 -- 业务错误码需要统一枚举,否则前端只能靠字符串判断。 - -建议定义统一错误码: - -- `0` 成功 -- `40001` 参数错误 -- `40002` 登录过期 -- `40003` 无权限 -- `40004` 资源不存在 -- `40005` 业务状态冲突 -- `50000` 服务端异常 - -并让 `ExceptionMiddleware` 只处理意外异常,业务错误由 endpoint 明确返回。 - -### 11.5 数据模型和索引补充 - -当前 `AppDbContext` 已经配置了不少索引和枚举转换,比旧报告里“完全没有 FK/索引”的描述更好。但仍建议补: - -- `RefreshToken(Token)` 唯一或普通索引:刷新 token 查询会频繁发生。 -- `RefreshToken(UserId, IsRevoked, ExpiresAt)`:便于清理和会话管理。 -- `Report(UserId, CreatedAt)`:报告列表按用户和时间查询。 -- `FollowUp(UserId, ScheduledAt)`:随访日历、医生待办会用到。 -- `DeviceToken(UserId)`:推送服务上线后需要。 -- `Consultation(UserId, CreatedAt)` 和 `Consultation(Status, CreatedAt)`:患者历史和医生待办都会用到。 - -另外,核心关系建议显式配置删除行为,尤其是 User 删除时关联 Consultation、Report、Conversation、MedicationLog 的级联或手动删除策略。 - -### 11.6 AI Agent 产品能力不一致 - -现在首页上有多个 agent/胶囊入口,但后端能力不完全一致: - -- 饮食 Agent 当前只保留健康档案查询,真正饮食识别在专门图片接口。 -- 问诊 Agent 当前只保留健康记录和档案查询,转医生走专门问诊流程。 -- 用药/运动 Agent 有创建、查询、确认能力,但确认工具存在所有权校验问题。 -- 通用 Agent 如果聚合多个工具,需要明确“哪些动作会写数据,哪些只是查询”。 - -建议 UI 文案和后端能力统一: - -- 欢迎卡片不要暗示当前 agent 能完成它实际上做不到的写操作。 -- 会写入健康数据、药品、运动计划、档案的 AI 动作,必须有确认卡片。 -- AI 工具调用结果应返回结构化状态,前端不要只靠自然语言判断成功。 - -### 11.7 前端状态和生命周期问题 - -> 2026-06-20 更新:健康最新值、用药列表、用药提醒、当前运动计划已改为 `autoDispose`;AI 确认写入后会同时刷新这些核心数据。报告和饮食使用各自 Notifier/页面加载流程,尚未强行合并成一个全局缓存。 - -前端现在能跑起来,但长期运行会有状态残留风险: - -- `ConsultationChatNotifier` 里 Hub 和轮询 timer 需要自动释放。建议 `build()` 中调用 `ref.onDispose(stop)`。 -- `ChatNotifier` 的 SSE 订阅、流式响应 timer 需要在 provider 销毁、切换 agent、重新发送时取消。 -- 饮食页 `_fieldCtrls` 需要 `dispose()`,否则每次识别食物后 controller 累积。 -- 多个 `FutureProvider` 没有 `autoDispose`,页面级数据会缓存很久。健康最新值、药品提醒、当前运动计划这类数据建议明确刷新策略。 -- 很多页面删除后只本地 `_load()`,没有 `ref.invalidate(...)`,跨页面缓存可能不同步。 - -### 11.8 UI 深层问题:不是再加渐变,而是建立层级 - -最近 UI 调整集中在侧边栏、欢迎卡片、饮食页、设置页、个人信息页等。颜色已经比最初丰富,但仍需要注意: - -- 颜色角色要固定:蓝色用于主行动/健康状态,橙色用于饮食,紫色用于报告,绿色用于记录/恢复,青色用于设备或运动,浅红用于提醒/风险。 -- 欢迎卡片和胶囊按钮的图标必须同语义、同线宽、同背景形状。用户已经多次指出“不只是颜色一样,图标内容也要一样”,这说明视觉一致性比单个渐变更重要。 -- 健康仪表盘应优先展示数字、单位、状态、更新时间。图标可以弱化,避免抢数字层级。 -- 设置页不应只是按钮列表,应分为账号、安全、通知、设备、隐私、关于。 -- 个人信息页应像正式档案:基础信息、医疗信息、健康偏好、绑定医生、账号安全分区展示。 -- 功能入口两行三列是合理的,但每个入口不要堆摘要,图标 + 名称 + 必要状态即可。 - -### 11.9 功能缺口再细化 - -| 模块 | 当前缺口 | 建议 | -| --- | --- | --- | -| 饮食记录 | 已有识别和保存,但历史记录编辑能力弱 | 支持编辑食物、份量、热量、餐次;支持从历史复制 | -| 报告管理 | 上传后异步 AI 分析,但失败/处理中状态不够细 | 增加 pending/analyzing/failed/retry 状态和轮询刷新 | -| 问诊 | 患者端创建即新会话,历史问诊入口弱 | 增加问诊历史、继续问诊、关闭问诊、评价医生 | -| 用药 | 提醒后台只记录日志,未实际推送 | 接入推送,支持漏服/补服/跳过原因 | -| 运动 | 有计划和打卡,但计划解释和详情不足 | 计划详情页、运动禁忌、完成趋势 | -| 健康日历 | 汇总用药/运动/随访,但和打卡状态联动有限 | 日历上直接展示已完成、未完成、逾期 | -| 医生端 | 已有工作台,但患者范围和流程需加强 | 风险患者排序、未读消息、待审报告、随访待办 | -| 管理员端 | 医生管理已有基础 | 增加操作审计、禁用账号、数据统计 | - -### 11.10 更细的整改顺序 - -第一批必须先修安全: - -1. 移除生产 `devCode` 和前端自动填充。 -2. 修 SSE 认证,不再解析未验证 JWT。 -3. AI conversation 按用户归属查询。 -4. Consultation Hub 加鉴权、入组校验、senderType 服务端判定。 -5. Consultation HTTP 发消息校验当前用户。 -6. Exercise/Medication 的普通接口和 AI 工具都补所有权校验。 -7. 医生端详情、报告、随访接口限制到当前医生负责患者。 - -第二批修接口契约和稳定性: - -1. 文件上传返回 `{ id, name, size, url, contentType }`。 -2. 前端 `uploadFile` 兼容后端 envelope 和 list 返回,失败要提示。 -3. 饮食页 controller dispose。 -4. Chat/Consultation provider 注册 `ref.onDispose`。 -5. 后端补上传大小/类型限制。 -6. 关闭生产 body 日志。 - -第三批做 UI 体系: - -1. 把颜色、渐变、圆角、阴影、图标背景抽成设计 token。 -2. 侧边栏、个人信息、设置、饮食分析页按同一设计语言重做。 -3. 首页欢迎卡片和胶囊入口统一图标语义。 -4. 健康仪表盘增加更新时间、单位、状态文字。 -5. 对中老年用户做字号、对比度、触控面积检查。 - -第四批补测试: - -1. 后端加越权测试:用户 A 不能操作用户 B 的 conversation/consultation/exercise/medication。 -2. 加短信验证码生产环境不返回测试。 -3. 加 SignalR Hub 入组权限测试。 -4. 加文件上传类型/大小测试。 -5. Flutter 加饮食保存、问诊连接释放、上传失败 UI 的 widget/provider 测试。 - -## 12. 总体建议 - -这个项目最有价值的方向是“围绕患者长期健康数据做 AI 辅助管理”,不是简单堆功能入口。接下来建议把项目重心从“页面多”转为“核心闭环扎实”: - -- 患者每天愿意记录。 -- 数据能被看懂。 -- 异常能被发现。 -- AI 建议能被修正和追踪。 -- 医生能看到有价值的摘要。 -- 安全和隐私经得起真实使用。 - -UI 上不要继续单页单独调色,应该先统一设计体系,再逐步替换页面。工程上先修安全和接口契约,再做大面积美化。这样项目会从“原型功能很多”变成“真正像一个可信赖的健康产品”。 diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 4855b54..66342c9 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -6,7 +6,7 @@ import 'local_database.dart'; /// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。 const String baseUrl = String.fromEnvironment( 'API_BASE_URL', - defaultValue: 'http://10.4.202.36:5000', + defaultValue: 'http://10.4.232.251:5000', ); class ApiException implements Exception { diff --git a/health_app/lib/models/ble_device.dart b/health_app/lib/models/ble_device.dart new file mode 100644 index 0000000..0b2dc1b --- /dev/null +++ b/health_app/lib/models/ble_device.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; + +import 'bp_reading.dart'; + +enum BleDeviceType { bloodPressure, glucose, weightScale, pulseOximeter } + +enum SupportedMetric { bloodPressure, heartRate, glucose, weight, spo2 } + +extension BleDeviceTypeX on BleDeviceType { + String get code => switch (this) { + BleDeviceType.bloodPressure => 'bloodPressure', + BleDeviceType.glucose => 'glucose', + BleDeviceType.weightScale => 'weightScale', + BleDeviceType.pulseOximeter => 'pulseOximeter', + }; + + String get label => switch (this) { + BleDeviceType.bloodPressure => '血压计', + BleDeviceType.glucose => '血糖仪', + BleDeviceType.weightScale => '体重秤', + BleDeviceType.pulseOximeter => '血氧仪', + }; + + String get serviceUuid => switch (this) { + BleDeviceType.bloodPressure => BleDeviceIdentifier.bloodPressureServiceUuid, + BleDeviceType.glucose => BleDeviceIdentifier.glucoseServiceUuid, + BleDeviceType.weightScale => BleDeviceIdentifier.weightScaleServiceUuid, + BleDeviceType.pulseOximeter => BleDeviceIdentifier.pulseOximeterServiceUuid, + }; + + List get metrics => switch (this) { + BleDeviceType.bloodPressure => [ + SupportedMetric.bloodPressure, + SupportedMetric.heartRate, + ], + BleDeviceType.glucose => [SupportedMetric.glucose], + BleDeviceType.weightScale => [SupportedMetric.weight], + BleDeviceType.pulseOximeter => [ + SupportedMetric.spo2, + SupportedMetric.heartRate, + ], + }; + + IconData get icon => switch (this) { + BleDeviceType.bloodPressure => Icons.speed_rounded, + BleDeviceType.glucose => Icons.water_drop_rounded, + BleDeviceType.weightScale => Icons.monitor_weight_outlined, + BleDeviceType.pulseOximeter => Icons.air_rounded, + }; + + static BleDeviceType? fromCode(String? code) { + for (final type in BleDeviceType.values) { + if (type.code == code) return type; + } + return null; + } +} + +extension SupportedMetricX on SupportedMetric { + String get code => switch (this) { + SupportedMetric.bloodPressure => 'BloodPressure', + SupportedMetric.heartRate => 'HeartRate', + SupportedMetric.glucose => 'Glucose', + SupportedMetric.weight => 'Weight', + SupportedMetric.spo2 => 'SpO2', + }; + + String get label => switch (this) { + SupportedMetric.bloodPressure => '血压', + SupportedMetric.heartRate => '心率', + SupportedMetric.glucose => '血糖', + SupportedMetric.weight => '体重', + SupportedMetric.spo2 => '血氧', + }; +} + +class BoundBleDevice { + final String id; + final String name; + final BleDeviceType type; + final String serviceUuid; + final DateTime? lastSyncAt; + + const BoundBleDevice({ + required this.id, + required this.name, + required this.type, + required this.serviceUuid, + this.lastSyncAt, + }); + + List get metrics => type.metrics; + + BoundBleDevice copyWith({ + String? id, + String? name, + BleDeviceType? type, + String? serviceUuid, + DateTime? lastSyncAt, + }) { + return BoundBleDevice( + id: id ?? this.id, + name: name ?? this.name, + type: type ?? this.type, + serviceUuid: serviceUuid ?? this.serviceUuid, + lastSyncAt: lastSyncAt ?? this.lastSyncAt, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'type': type.code, + 'serviceUuid': serviceUuid, + 'lastSyncAt': lastSyncAt?.toUtc().toIso8601String(), + }; + + static BoundBleDevice? fromJson(Map json) { + final type = BleDeviceTypeX.fromCode(json['type']?.toString()); + final id = json['id']?.toString(); + if (type == null || id == null || id.isEmpty) return null; + + final lastSyncRaw = json['lastSyncAt']?.toString(); + return BoundBleDevice( + id: id, + name: json['name']?.toString() ?? type.label, + type: type, + serviceUuid: json['serviceUuid']?.toString() ?? type.serviceUuid, + lastSyncAt: lastSyncRaw == null || lastSyncRaw.isEmpty + ? null + : DateTime.tryParse(lastSyncRaw)?.toLocal(), + ); + } +} + +class BleDeviceIdentifier { + static const glucoseServiceUuid = '00001808-0000-1000-8000-00805F9B34FB'; + static const bloodPressureServiceUuid = + '00001810-0000-1000-8000-00805F9B34FB'; + static const weightScaleServiceUuid = '0000181D-0000-1000-8000-00805F9B34FB'; + static const pulseOximeterServiceUuid = + '00001822-0000-1000-8000-00805F9B34FB'; + + static BleDeviceType? typeFromScan(ScanResult result) { + return typeFromUuidStrings( + result.advertisementData.serviceUuids.map((uuid) => uuid.str128), + ); + } + + static BleDeviceType? typeFromServices(List services) { + return typeFromUuidStrings(services.map((service) => service.uuid.str128)); + } + + static BleDeviceType? typeFromUuidStrings(Iterable uuids) { + final normalized = uuids.map((uuid) => uuid.toUpperCase()).toSet(); + if (normalized.contains(bloodPressureServiceUuid)) { + return BleDeviceType.bloodPressure; + } + if (normalized.contains(glucoseServiceUuid)) { + return BleDeviceType.glucose; + } + if (normalized.contains(weightScaleServiceUuid)) { + return BleDeviceType.weightScale; + } + if (normalized.contains(pulseOximeterServiceUuid)) { + return BleDeviceType.pulseOximeter; + } + return null; + } + + static bool isSupportedScanResult(ScanResult result) { + return typeFromScan(result) != null; + } +} + +sealed class HealthBleSyncResult { + const HealthBleSyncResult({required this.device}); + + final BoundBleDevice device; +} + +class BloodPressureSyncResult extends HealthBleSyncResult { + const BloodPressureSyncResult({required super.device, required this.reading}); + + final BpReading reading; +} diff --git a/health_app/lib/models/bp_reading.dart b/health_app/lib/models/bp_reading.dart index 65a460a..98be934 100644 --- a/health_app/lib/models/bp_reading.dart +++ b/health_app/lib/models/bp_reading.dart @@ -8,6 +8,7 @@ class BpReading { final bool isKpa; final bool hasBodyMovement; final bool hasIrregularPulse; + final bool hasDeviceTimestamp; const BpReading({ required this.systolic, @@ -18,6 +19,7 @@ class BpReading { this.isKpa = false, this.hasBodyMovement = false, this.hasIrregularPulse = false, + this.hasDeviceTimestamp = false, }); String get display => '$systolic/$diastolic'; diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index df1d668..a37690b 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -78,7 +78,9 @@ class _TrendPageState extends ConsumerState { 'id': r['id'], 'type': t, 'date': - DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? + DateTime.tryParse( + r['recordedAt']?.toString() ?? '', + )?.toLocal() ?? DateTime.now(), 'systolic': r['systolic'], 'diastolic': r['diastolic'], diff --git a/health_app/lib/pages/device/device_management_page.dart b/health_app/lib/pages/device/device_management_page.dart index 15cd8da..45a1392 100644 --- a/health_app/lib/pages/device/device_management_page.dart +++ b/health_app/lib/pages/device/device_management_page.dart @@ -1,93 +1,298 @@ +import 'dart:async'; +import 'dart:io' show Platform; + import 'package:flutter/material.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; + import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; +import '../../models/ble_device.dart'; +import '../../models/bp_reading.dart'; +import '../../providers/auth_provider.dart'; import '../../providers/omron_device_provider.dart'; +import '../../services/health_ble_service.dart'; +import '../../widgets/ble_sync_dialog.dart'; class DeviceManagementPage extends ConsumerStatefulWidget { const DeviceManagementPage({super.key}); + @override ConsumerState createState() => _DeviceManagementPageState(); } -class _DeviceManagementPageState extends ConsumerState { - bool _reconnecting = false; - OverlayEntry? _toast; +class _DeviceManagementPageState extends ConsumerState + with SingleTickerProviderStateMixin { + static const _freshScanWindow = Duration(seconds: 2); + static const _sameDeviceRetryWindow = Duration(seconds: 3); + + StreamSubscription>? _scanSub; + late final HealthBleService _bleService; + late final AnimationController _scanCtrl; + late final Animation _scanAnim; + final _recentAttempts = {}; + String? _busyDeviceId; + String? _connectedDeviceId; + DateTime? _scanStartedAt; + String _activeScanBoundKey = ''; + bool _scanning = false; + bool _disposed = false; + + @override + void initState() { + super.initState(); + _bleService = ref.read(healthBleServiceProvider); + _scanCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + )..repeat(reverse: true); + _scanAnim = Tween( + begin: 0.85, + end: 1.35, + ).animate(CurvedAnimation(parent: _scanCtrl, curve: Curves.easeInOut)); + } @override void dispose() { - _hideToast(); + _disposed = true; + _scanSub?.cancel(); + _scanCtrl.dispose(); + unawaited(FlutterBluePlus.stopScan()); + unawaited(_bleService.disconnect()); super.dispose(); } - void _showToast(String msg, {bool success = false}) { - _hideToast(); - _toast = OverlayEntry( - builder: (_) => Positioned( - top: MediaQuery.of(context).padding.top + 8, - left: 16, - right: 16, - child: TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 250), - builder: (context, v, child) => Opacity( - opacity: v, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: success ? AppColors.success : AppColors.error, - borderRadius: BorderRadius.circular(24), - boxShadow: AppColors.cardShadow, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - success ? Icons.check_circle : Icons.info_outline, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - msg, - style: const TextStyle(color: Colors.white, fontSize: 14), - ), - ), - ], - ), - ), + Future _startForegroundScan() async { + await [Permission.bluetoothScan, Permission.bluetoothConnect].request(); + if (_disposed || !mounted) return; + + if (Platform.isAndroid) { + final locStatus = await Permission.locationWhenInUse.serviceStatus; + if (_disposed || !mounted) return; + if (locStatus != ServiceStatus.enabled && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'), + backgroundColor: AppColors.warning, ), - ), - ), - ); - Overlay.of(context).insert(_toast!); - Future.delayed(const Duration(seconds: 2), _hideToast); - } - - void _hideToast() { - _toast?.remove(); - _toast = null; - } - - Future _reconnect(String mac) async { - if (_reconnecting) return; - setState(() => _reconnecting = true); - final ok = await ref.read(omronBleServiceProvider).reconnectByMac(mac); - if (!mounted) return; - setState(() => _reconnecting = false); - if (ok) { - _showToast('设备已连接', success: true); - } else { - _showToast('设备未在线,请确认血压计已开机并处于通信模式'); + ); + } } + + final adapterState = await FlutterBluePlus.adapterState.first; + if (_disposed || !mounted) return; + if (adapterState != BluetoothAdapterState.on) { + try { + await FlutterBluePlus.turnOn(); + } catch (_) {} + } + + try { + await FlutterBluePlus.stopScan(); + } catch (_) {} + if (_disposed || !mounted) return; + final boundIds = ref + .read(omronDeviceProvider) + .devices + .map((device) => device.id) + .toList(); + if (boundIds.isEmpty) { + _activeScanBoundKey = ''; + if (mounted) setState(() => _scanning = false); + return; + } + _activeScanBoundKey = _boundIdsKey(boundIds); + _scanStartedAt = DateTime.now(); + await _scanSub?.cancel(); + _scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults); + try { + await FlutterBluePlus.startScan( + withRemoteIds: boundIds, + androidScanMode: AndroidScanMode.lowLatency, + oneByOne: true, + ); + if (mounted) setState(() => _scanning = true); + } catch (e) { + if (mounted) setState(() => _scanning = false); + } + } + + void _onScanResults(List results) { + if (!mounted || _busyDeviceId != null) return; + final scanStartedAt = _scanStartedAt; + if (scanStartedAt == null) return; + final boundDevices = ref.read(omronDeviceProvider).devices; + if (boundDevices.isEmpty) return; + + final candidates = results.where((result) { + final id = result.device.remoteId.toString(); + if (result.timeStamp.isBefore(scanStartedAt)) return false; + if (!result.advertisementData.connectable) return false; + if (DateTime.now().difference(result.timeStamp) > _freshScanWindow) { + return false; + } + final boundDevice = ref.read(omronDeviceProvider).findById(id); + if (boundDevice == null) return false; + if (!_isSameBoundDeviceName(result, boundDevice)) return false; + final scanType = HealthBleService.deviceTypeFromScan(result); + if (scanType != null && scanType != boundDevice.type) return false; + final lastAttempt = _recentAttempts[id]; + if (lastAttempt == null) return true; + return DateTime.now().difference(lastAttempt) > _sameDeviceRetryWindow; + }).toList()..sort((a, b) => b.rssi.compareTo(a.rssi)); + + if (candidates.isEmpty) return; + final result = candidates.first; + final bound = ref + .read(omronDeviceProvider) + .findById(result.device.remoteId.toString()); + if (bound == null) return; + unawaited(_syncDevice(result, bound)); + } + + bool _isSameBoundDeviceName(ScanResult result, BoundBleDevice bound) { + final scannedName = _scanDeviceName(result); + if (scannedName.isEmpty) return false; + return _normalizeDeviceName(scannedName) == + _normalizeDeviceName(bound.name); + } + + String _scanDeviceName(ScanResult result) { + final advName = result.advertisementData.advName.trim(); + if (advName.isNotEmpty) return advName; + return result.device.platformName.trim(); + } + + String _normalizeDeviceName(String value) { + return value.trim().toUpperCase(); + } + + String _boundIdsKey(List ids) { + final sorted = [...ids]..sort(); + return sorted.join('|'); + } + + Future _syncDevice(ScanResult result, BoundBleDevice bound) async { + if (_disposed || !mounted || _busyDeviceId != null) return; + _recentAttempts[bound.id] = DateTime.now(); + setState(() { + _busyDeviceId = bound.id; + _connectedDeviceId = null; + }); + + await FlutterBluePlus.stopScan(); + if (_disposed || !mounted) return; + setState(() => _scanning = false); + + try { + final syncResult = await _bleService.syncBoundDevice( + result.device, + bound, + timeout: const Duration(seconds: 35), + onConnected: () { + if (!_disposed && mounted) { + setState(() => _connectedDeviceId = bound.id); + } + }, + onMeasurementReceived: () { + // Measurement has arrived; parsing and persistence happen below. + }, + ); + if (syncResult is BloodPressureSyncResult) { + if (_disposed || !mounted) return; + final saved = await _persistBloodPressure( + syncResult.device, + syncResult.reading, + ); + if (!_disposed && mounted && saved) { + await _showBloodPressureDialog(syncResult.reading); + } + } + } on TimeoutException { + if (!_disposed && mounted && _connectedDeviceId == bound.id) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('已连接设备,但本次未收到测量数据'), + backgroundColor: AppColors.warning, + ), + ); + } + } on Exception { + if (!_disposed && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('数据录入失败,请稍后重试'), + backgroundColor: AppColors.error, + ), + ); + } + } finally { + await _bleService.disconnect(); + if (!_disposed && mounted) { + setState(() { + _busyDeviceId = null; + _connectedDeviceId = null; + }); + } + if (!_disposed && mounted) unawaited(_startForegroundScan()); + } + } + + Future _persistBloodPressure( + BoundBleDevice device, + BpReading reading, + ) async { + final notifier = ref.read(omronDeviceProvider.notifier); + if (await notifier.isDuplicateReading(device, reading)) return false; + + final api = ref.read(apiClientProvider); + await api.post('/api/health-records', data: reading.toHealthRecord()); + final heartRateRecord = reading.toHeartRateRecord(); + if (heartRateRecord != null) { + await api.post('/api/health-records', data: heartRateRecord); + } + await notifier.recordSuccessfulSync(device: device, reading: reading); + return true; + } + + Future _showBloodPressureDialog(BpReading reading) { + return showBleSyncDialog(context, reading: reading); } @override Widget build(BuildContext context) { - final device = ref.watch(omronDeviceProvider); + final state = ref.watch(omronDeviceProvider); + final devices = state.devices; + final connected = _connectedDeviceId != null; + final boundKey = _boundIdsKey(devices.map((device) => device.id).toList()); + + if (devices.isNotEmpty && + !_scanning && + _busyDeviceId == null && + _activeScanBoundKey != boundKey) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_disposed && mounted) unawaited(_startForegroundScan()); + }); + } + + ref.listen(omronDeviceProvider, (prev, next) { + final nextIds = next.devices.map((device) => device.id).toList(); + final nextKey = _boundIdsKey(nextIds); + if (nextIds.isEmpty) { + _activeScanBoundKey = ''; + unawaited(FlutterBluePlus.stopScan()); + if (!_disposed && mounted) setState(() => _scanning = false); + return; + } + if (nextKey != _activeScanBoundKey && _busyDeviceId == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_disposed && mounted) unawaited(_startForegroundScan()); + }); + } + }); return GradientScaffold( appBar: AppBar( @@ -97,295 +302,456 @@ class _DeviceManagementPageState extends ConsumerState { onPressed: () => popRoute(ref), ), actions: [ - IconButton( - icon: const Icon(Icons.add, color: AppColors.primary), - onPressed: () => pushRoute(ref, 'deviceScan'), - tooltip: '添加设备', + Padding( + padding: const EdgeInsets.only(right: 10), + child: IconButton( + tooltip: '新增设备', + onPressed: () => pushRoute(ref, 'deviceScan'), + style: IconButton.styleFrom( + backgroundColor: const Color(0xFFEFF6FF), + foregroundColor: const Color(0xFF2563EB), + fixedSize: const Size(40, 40), + ), + icon: const Icon(Icons.add_rounded), + ), ), ], ), - body: device.isBound ? _buildDeviceList(device) : _buildEmpty(), + body: Stack( + children: [ + ListView( + padding: const EdgeInsets.fromLTRB(18, 10, 18, 120), + children: [ + _DevicesHeader(deviceCount: devices.length), + const SizedBox(height: 18), + if (devices.isEmpty) + const _EmptyDevices() + else + for (final type in BleDeviceType.values) + if (state.devicesOfType(type).isNotEmpty) ...[ + _DeviceSection( + type: type, + devices: state.devicesOfType(type), + connectedDeviceId: _connectedDeviceId, + busyDeviceId: _busyDeviceId, + onUnbind: _confirmUnbind, + ), + const SizedBox(height: 18), + ], + ], + ), + if (devices.isNotEmpty) + Positioned( + right: 18, + bottom: 20, + child: _ScanIndicator( + animation: _scanAnim, + scanning: _scanning, + syncing: connected, + ), + ), + ], + ), ); } - Widget _buildEmpty() => Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 82, - height: 82, - decoration: BoxDecoration( - gradient: AppColors.calmHealthGradient, - borderRadius: BorderRadius.circular(24), - border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, - ), - child: const Icon( - Icons.bluetooth_disabled, - size: 40, - color: Colors.white, - ), + Future _confirmUnbind(BoundBleDevice device) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('解绑设备'), + content: Text('确定解绑这台${device.type.label}吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), ), - const SizedBox(height: 16), - const Text( - '暂无设备', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 6), - const Text( - '点击右上角 + 添加血压计', - style: TextStyle(fontSize: 14, color: AppColors.textSecondary), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('解绑'), ), ], ), - ), - ); + ); + if (ok == true) { + await ref.read(omronDeviceProvider.notifier).unbind(device.id); + } + } +} - Widget _buildDeviceList(DeviceBindState d) => ListView( - padding: const EdgeInsets.all(16), - children: [ - GestureDetector( - onTap: d.isConnected ? null : () => _reconnect(d.mac ?? ''), - child: AnimatedContainer( - duration: const Duration(milliseconds: 250), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: AppColors.surfaceGradient, - borderRadius: BorderRadius.circular(18), - border: Border.all( - color: d.isConnected ? AppColors.success : AppColors.borderLight, +class _DevicesHeader extends StatelessWidget { + final int deviceCount; + + const _DevicesHeader({required this.deviceCount}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + boxShadow: AppColors.cardShadowLight, + ), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.bluetooth_audio_rounded, + color: Color(0xFF2563EB), + size: 23, ), - boxShadow: AppColors.cardShadowLight, ), - child: Row( - children: [ - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - gradient: d.isConnected - ? AppColors.calmHealthGradient - : AppColors.surfaceGradient, - borderRadius: BorderRadius.circular(15), - border: Border.all(color: AppColors.borderLight), - ), - child: Icon( - Icons.bluetooth, - size: 24, - color: d.isConnected ? Colors.white : AppColors.textHint, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - d.name ?? '血压计', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 2), - Text( - d.mac ?? '', - style: const TextStyle( - fontSize: 12, - color: AppColors.textHint, - ), - ), - if (d.lastSync != null) - Text( - '上次同步: ${d.lastSync}', - style: const TextStyle( - fontSize: 11, - color: AppColors.textHint, - ), - ), - ], - ), - ), - if (_reconnecting) - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else ...[ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: d.isConnected - ? AppColors.success - : AppColors.textHint, - shape: BoxShape.circle, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '已绑定设备', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w900, + color: AppColors.textPrimary, ), ), - const SizedBox(width: 8), + const SizedBox(height: 3), Text( - d.isConnected ? '已连接' : '未连接', - style: TextStyle( - fontSize: 13, + '$deviceCount 台设备', + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.w700, - color: d.isConnected - ? AppColors.success - : AppColors.textHint, + color: AppColors.textSecondary, ), ), ], - ], + ), ), - ), + ], ), - if (d.lastReading != null) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: AppColors.surfaceGradient, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, - ), + ); + } +} + +class _DeviceSection extends StatelessWidget { + final BleDeviceType type; + final List devices; + final String? connectedDeviceId; + final String? busyDeviceId; + final ValueChanged onUnbind; + + const _DeviceSection({ + required this.type, + required this.devices, + required this.connectedDeviceId, + required this.busyDeviceId, + required this.onUnbind, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 10), child: Row( children: [ - const Text( - '最近测量', - style: TextStyle(fontSize: 14, color: AppColors.textSecondary), + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.borderLight), + ), + child: Icon( + type.icon, + size: 17, + color: const Color(0xFF2563EB), + ), ), - const Spacer(), + const SizedBox(width: 9), Text( - d.lastReading!.display, + type.label, style: const TextStyle( - fontSize: 24, + fontSize: 15, fontWeight: FontWeight.w900, color: AppColors.textPrimary, ), ), - const SizedBox(width: 4), - const Text( - 'mmHg', - style: TextStyle(fontSize: 12, color: AppColors.textHint), - ), - if (d.lastReading!.pulse != null) ...[ - const SizedBox(width: 16), - Text( - '${d.lastReading!.pulse} bpm', - style: const TextStyle( - fontSize: 14, - color: AppColors.primary, - fontWeight: FontWeight.w700, - ), + const SizedBox(width: 8), + Text( + '${devices.length}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: AppColors.textHint, ), - ], + ), ], ), ), + for (final device in devices) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _BoundDeviceTile( + device: device, + connected: connectedDeviceId == device.id, + onUnbind: () => onUnbind(device), + ), + ), ], - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: () async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('解绑设备'), - content: const Text('解绑后需要重新扫描连接,确定吗?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('取消'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - style: TextButton.styleFrom( - foregroundColor: AppColors.error, - ), - child: const Text('确定'), - ), - ], - ), - ); - if (ok == true) { - await ref.read(omronDeviceProvider.notifier).unbind(); - } - }, - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.error, - side: const BorderSide(color: AppColors.errorLight), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: const Text('解绑设备'), - ), - ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: AppColors.surfaceGradient, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '使用说明', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 8), - _tip('1', '血压计装好电池,绑好袖带'), - _tip('2', '按开始键测量,结束后设备自动进入通信模式'), - _tip('3', '点击设备卡片即可自动连接并同步数据'), - ], - ), - ), - ], - ); + ); + } +} - Widget _tip(String n, String t) => Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '$n.', - style: const TextStyle( - fontSize: 13, - color: AppColors.primary, - fontWeight: FontWeight.w800, - ), +class _BoundDeviceTile extends StatelessWidget { + final BoundBleDevice device; + final bool connected; + final VoidCallback onUnbind; + + const _BoundDeviceTile({ + required this.device, + required this.connected, + required this.onUnbind, + }); + + @override + Widget build(BuildContext context) { + final accent = connected ? AppColors.success : AppColors.textHint; + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.fromLTRB(14, 12, 8, 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: connected ? AppColors.success : AppColors.border, + width: connected ? 1.4 : 1.1, ), - const SizedBox(width: 4), - Expanded( - child: Text( - t, - style: const TextStyle( + boxShadow: connected + ? [ + BoxShadow( + color: AppColors.success.withValues(alpha: 0.14), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ] + : AppColors.cardShadowLight, + ), + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: 42, + height: 42, + decoration: BoxDecoration( + color: connected + ? AppColors.successLight + : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(device.type.icon, color: accent, size: 22), + ), + const SizedBox(width: 13), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + device.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 3), + Text( + '最近同步:${_formatLastSync(device.lastSyncAt)}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textHint, + ), + ), + ], + ), + ), + if (connected) + Container( + margin: const EdgeInsets.only(right: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: AppColors.successLight, + borderRadius: BorderRadius.circular(999), + ), + child: const Text( + '已连接', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w900, + color: AppColors.success, + ), + ), + ), + IconButton( + tooltip: '解绑设备', + onPressed: connected ? null : onUnbind, + icon: const Icon(Icons.delete_outline_rounded), + color: connected ? AppColors.textHint : AppColors.error, + ), + ], + ), + ); + } + + String _formatLastSync(DateTime? value) { + if (value == null) return '暂无'; + final now = DateTime.now(); + final dayLabel = + value.year == now.year && + value.month == now.month && + value.day == now.day + ? '今天' + : '${value.month}/${value.day}'; + final minute = value.minute.toString().padLeft(2, '0'); + return '$dayLabel ${value.hour}:$minute'; + } +} + +class _EmptyDevices extends StatelessWidget { + const _EmptyDevices(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(24, 42, 24, 42), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.borderLight), + ), + child: Column( + children: [ + Container( + width: 62, + height: 62, + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.bluetooth_disabled_rounded, + color: Color(0xFF2563EB), + size: 31, + ), + ), + const SizedBox(height: 16), + const Text( + '还没有绑定设备', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + const Text( + '右上角添加设备', + textAlign: TextAlign.center, + style: TextStyle( fontSize: 13, + fontWeight: FontWeight.w700, color: AppColors.textSecondary, ), ), - ), - ], - ), - ); + ], + ), + ); + } +} + +class _ScanIndicator extends StatelessWidget { + final Animation animation; + final bool scanning; + final bool syncing; + + const _ScanIndicator({ + required this.animation, + required this.scanning, + required this.syncing, + }); + + @override + Widget build(BuildContext context) { + final active = scanning || syncing; + return SizedBox( + width: 88, + height: 88, + child: Stack( + alignment: Alignment.center, + children: [ + if (active) ...[ + AnimatedBuilder( + animation: animation, + builder: (context, child) => Container( + width: 78 * animation.value, + height: 78 * animation.value, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF2563EB).withValues(alpha: 0.10), + ), + ), + ), + AnimatedBuilder( + animation: animation, + builder: (context, child) => Container( + width: 64 * animation.value, + height: 64 * animation.value, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF2563EB).withValues(alpha: 0.18), + ), + ), + ), + ], + Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: const Color(0xFF2563EB), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: const Color(0xFF2563EB).withValues(alpha: 0.24), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Icon( + syncing + ? Icons.bluetooth_connected_rounded + : Icons.bluetooth_searching_rounded, + color: Colors.white, + size: 28, + ), + ), + ], + ), + ); + } } diff --git a/health_app/lib/pages/device/device_scan_page.dart b/health_app/lib/pages/device/device_scan_page.dart index be641ec..8222173 100644 --- a/health_app/lib/pages/device/device_scan_page.dart +++ b/health_app/lib/pages/device/device_scan_page.dart @@ -1,38 +1,42 @@ import 'dart:async'; import 'dart:io' show Platform; + import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; + import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; +import '../../models/ble_device.dart'; +import '../../models/bp_reading.dart'; import '../../providers/auth_provider.dart'; import '../../providers/omron_device_provider.dart'; -import '../../services/omron_ble_service.dart'; +import '../../services/health_ble_service.dart'; +import '../../widgets/ble_sync_dialog.dart'; class DeviceScanPage extends ConsumerStatefulWidget { const DeviceScanPage({super.key}); + @override ConsumerState createState() => _DeviceScanPageState(); } class _DeviceScanPageState extends ConsumerState with SingleTickerProviderStateMixin { + static const _visibleDeviceWindow = Duration(seconds: 12); + final _results = []; - bool _scanning = false; + StreamSubscription>? _scanSub; + Timer? _scanStopTimer; + Timer? _resultPruneTimer; String? _connectingId; - StreamSubscription? _scanSub; + DateTime? _scanStartedAt; + bool _scanning = false; - bool _connected = false; - String _deviceName = ''; - bool _readingReceived = false; - int? _systolic, _diastolic, _pulse; - StreamSubscription? _readingSub; - StreamSubscription? _bleConnSub; - - late AnimationController _pulseCtrl; - late Animation _pulseAnim; + late final AnimationController _pulseCtrl; + late final Animation _pulseAnim; @override void initState() { @@ -40,40 +44,32 @@ class _DeviceScanPageState extends ConsumerState _pulseCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 1200), - ); + )..repeat(reverse: true); _pulseAnim = Tween( - begin: 0.8, - end: 1.4, + begin: 0.82, + end: 1.36, ).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut)); - _pulseCtrl.repeat(reverse: true); - _scanSub = FlutterBluePlus.scanResults.listen((list) { - if (!mounted) return; - final bpList = list.where((r) => OmronBleService.isBpDevice(r)).toList(); - setState(() { - _results.clear(); - _results.addAll(bpList); - }); - }); - _start(); + unawaited(_startScan()); } @override void dispose() { _pulseCtrl.dispose(); + _scanStopTimer?.cancel(); + _resultPruneTimer?.cancel(); _scanSub?.cancel(); - _readingSub?.cancel(); - _bleConnSub?.cancel(); - FlutterBluePlus.stopScan(); + unawaited(FlutterBluePlus.stopScan()); + unawaited(ref.read(healthBleServiceProvider).disconnect()); super.dispose(); } - Future _start() async { + Future _startScan() async { setState(() { _scanning = true; - _connected = false; + _connectingId = null; + _results.clear(); }); - _results.clear(); await [Permission.bluetoothScan, Permission.bluetoothConnect].request(); @@ -82,7 +78,7 @@ class _DeviceScanPageState extends ConsumerState if (locStatus != ServiceStatus.enabled && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('请先打开GPS定位,否则无法扫描蓝牙'), + content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'), backgroundColor: AppColors.warning, ), ); @@ -99,398 +95,331 @@ class _DeviceScanPageState extends ConsumerState try { await FlutterBluePlus.stopScan(); } catch (_) {} + _scanStartedAt = DateTime.now(); + await _scanSub?.cancel(); + _scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults); await FlutterBluePlus.startScan( timeout: const Duration(seconds: 30), androidScanMode: AndroidScanMode.lowLatency, + oneByOne: true, ); - Future.delayed(const Duration(seconds: 30), () { - FlutterBluePlus.stopScan(); + _scanStopTimer?.cancel(); + _scanStopTimer = Timer(const Duration(seconds: 30), () { if (mounted) setState(() => _scanning = false); }); + _resultPruneTimer?.cancel(); + _resultPruneTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted || _connectingId != null) return; + final pruned = _visibleResults(_results); + if (_sameResults(_results, pruned)) return; + setState(() { + _results + ..clear() + ..addAll(pruned); + }); + }); } - Future _connect(ScanResult r) async { - setState(() => _connectingId = r.device.remoteId.toString()); - FlutterBluePlus.stopScan(); - _scanSub?.cancel(); + void _onScanResults(List list) { + if (!mounted || _connectingId != null) return; + final scanStartedAt = _scanStartedAt; + if (scanStartedAt == null) return; + final boundDevices = ref.read(omronDeviceProvider).devices; + final supported = list.where((result) { + if (result.timeStamp.isBefore(scanStartedAt)) return false; + if (!result.advertisementData.connectable) return false; + if (DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) { + return false; + } + if (!HealthBleService.isSupportedHealthDevice(result)) return false; + return boundDevices.every( + (device) => device.id != result.device.remoteId.toString(), + ); + }); + final next = [..._results]; + for (final result in supported) { + final index = next.indexWhere( + (item) => item.device.remoteId == result.device.remoteId, + ); + if (index >= 0) { + next[index] = result; + } else { + next.add(result); + } + } + final visible = _visibleResults(next); + if (_sameResults(_results, visible)) return; + setState(() { + _results + ..clear() + ..addAll(visible); + }); + } + + List _visibleResults(List results) { + final visible = + results + .where( + (result) => + DateTime.now().difference(result.timeStamp) <= + _visibleDeviceWindow, + ) + .toList() + ..sort( + (a, b) => a.device.remoteId.toString().compareTo( + b.device.remoteId.toString(), + ), + ); + return visible; + } + + Future _connectBindAndSync(ScanResult result) async { + final remoteId = result.device.remoteId.toString(); + if (_connectingId != null) return; + final scanStartedAt = _scanStartedAt; + if (scanStartedAt == null || + result.timeStamp.isBefore(scanStartedAt) || + !result.advertisementData.connectable || + DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('设备已离线,请重新进入通信状态'))); + return; + } + if (ref.read(omronDeviceProvider).isBound && + ref.read(omronDeviceProvider).findById(remoteId) != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('该设备已绑定'))); + return; + } + setState(() => _connectingId = remoteId); + await FlutterBluePlus.stopScan(); + + final ble = ref.read(healthBleServiceProvider); + BoundBleDevice? boundDevice; try { - final ble = ref.read(omronBleServiceProvider); - await ble.connect(r.device); - final name = r.advertisementData.advName.isNotEmpty - ? r.advertisementData.advName - : (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计'); + boundDevice = await ble + .connectAndIdentify( + device: result.device, + name: _deviceNameOf(result), + ) + .timeout(const Duration(seconds: 15)); + await ref.read(omronDeviceProvider.notifier).bind(boundDevice); - await ref - .read(omronDeviceProvider.notifier) - .bind(r.device.remoteId.toString(), name); - - _bleConnSub = r.device.connectionState.listen((s) { - if (s == BluetoothConnectionState.disconnected && mounted) { - _readingSub?.cancel(); - ref.read(omronBleServiceProvider).disconnect(); - if (_readingReceived) { - popRoute(ref); - } else { - setState(() { - _connected = false; - }); - _start(); + if (boundDevice.type == BleDeviceType.bloodPressure) { + final syncResult = await ble.syncBoundDevice( + result.device, + boundDevice, + timeout: const Duration(seconds: 30), + ); + if (syncResult is BloodPressureSyncResult) { + final saved = await _persistBloodPressure( + syncResult.device, + syncResult.reading, + ); + if (mounted && saved) { + await _showBloodPressureDialog(syncResult.reading); } } - }); - - _readingSub = ble.readings.listen((reading) async { - debugPrint('[PAGE] 收到: ${reading.systolic}/${reading.diastolic}'); - if (mounted) { - setState(() { - _readingReceived = true; - _systolic = reading.systolic; - _diastolic = reading.diastolic; - _pulse = reading.pulse; - }); - try { - final api = ref.read(apiClientProvider); - await api.post('/api/health-records', data: reading.toHealthRecord()); - final heartRateRecord = reading.toHeartRateRecord(); - if (heartRateRecord != null) { - await api.post('/api/health-records', data: heartRateRecord); - } - await ref.read(omronDeviceProvider.notifier).onReading(reading); - } catch (e) { - debugPrint('[PAGE] 上报失败: $e'); - } - Future.delayed(const Duration(milliseconds: 1500), () { - if (mounted) popRoute(ref); - }); - } - }); + } else if (mounted) { + popRoute(ref); + } + if (mounted) popRoute(ref); + } on TimeoutException { + if (mounted && boundDevice != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${boundDevice.type.label}已绑定,但本次未收到数据')), + ); + popRoute(ref); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('连接超时,请确认设备仍处于通信状态'), + backgroundColor: AppColors.error, + ), + ); + unawaited(_startScan()); + } + } on UnsupportedBleDeviceException catch (e) { if (mounted) { - setState(() { - _connected = true; - _deviceName = name; - _connectingId = null; - _readingReceived = false; - }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message), backgroundColor: AppColors.error), + ); + unawaited(_startScan()); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('连接失败: $e'), backgroundColor: AppColors.error), + SnackBar(content: Text('连接失败:$e'), backgroundColor: AppColors.error), ); - _start(); + unawaited(_startScan()); } + } finally { + await ble.disconnect(); + if (mounted) setState(() => _connectingId = null); } } + Future _persistBloodPressure( + BoundBleDevice device, + BpReading reading, + ) async { + final notifier = ref.read(omronDeviceProvider.notifier); + if (await notifier.isDuplicateReading(device, reading)) return false; + + final api = ref.read(apiClientProvider); + await api.post('/api/health-records', data: reading.toHealthRecord()); + final heartRateRecord = reading.toHeartRateRecord(); + if (heartRateRecord != null) { + await api.post('/api/health-records', data: heartRateRecord); + } + await notifier.recordSuccessfulSync(device: device, reading: reading); + return true; + } + + Future _showBloodPressureDialog(BpReading reading) { + return showBleSyncDialog(context, reading: reading); + } + @override Widget build(BuildContext context) { return GradientScaffold( appBar: AppBar( - backgroundColor: Colors.white.withValues(alpha: 0.9), + backgroundColor: Colors.white.withValues(alpha: 0.92), elevation: 0, - title: Text( - _connected ? _deviceName : '添加设备', - style: const TextStyle(color: AppColors.textPrimary), + title: const Text( + '新增设备', + style: TextStyle(color: AppColors.textPrimary), ), leading: IconButton( icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), - onPressed: () { - if (_connected) ref.read(omronBleServiceProvider).disconnect(); - popRoute(ref); - }, + onPressed: () => popRoute(ref), ), ), - body: _connected ? _buildConnected() : _buildScan(), + body: _results.isEmpty + ? _ScanningEmpty(animation: _pulseAnim, scanning: _scanning) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), + itemCount: _results.length, + separatorBuilder: (context, index) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final result = _results[index]; + return _DeviceResultTile( + result: result, + connecting: + _connectingId == result.device.remoteId.toString(), + onConnect: () => _connectBindAndSync(result), + ); + }, + ), ); } - // ── 扫描视图 ── - Widget _buildScan() => Column( - children: [ - if (_scanning && _results.isEmpty) - Expanded(child: _buildScanningCenter()), - if (!_scanning && _results.isEmpty) - Expanded(child: _buildScanningCenter()), - if (_results.isNotEmpty) - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _results.length, - itemBuilder: (_, i) => _buildTile(_results[i]), - ), - ), - if (!_scanning) - Padding( - padding: const EdgeInsets.all(16), - child: SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _start, - icon: const Icon(Icons.refresh), - label: const Text('重新扫描'), - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.primary, - side: const BorderSide(color: AppColors.border), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - padding: const EdgeInsets.symmetric(vertical: 14), - ), - ), - ), - ), - ], - ); + String _deviceNameOf(ScanResult result) { + final advName = result.advertisementData.advName.trim(); + if (advName.isNotEmpty) return advName; + final platformName = result.device.platformName.trim(); + if (platformName.isNotEmpty) return platformName; + return HealthBleService.deviceTypeFromScan(result)?.label ?? '健康设备'; + } - Widget _buildScanningCenter() => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Stack( - alignment: Alignment.center, + bool _sameResults(List current, List next) { + if (current.length != next.length) return false; + for (var i = 0; i < current.length; i++) { + if (current[i].device.remoteId != next[i].device.remoteId) return false; + } + return true; + } +} + +class _ScanningEmpty extends StatelessWidget { + final Animation animation; + final bool scanning; + + const _ScanningEmpty({required this.animation, required this.scanning}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(30), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - AnimatedBuilder( - animation: _pulseAnim, - builder: (_, child) => Container( - width: 80 * _pulseAnim.value, - height: 80 * _pulseAnim.value, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColors.auraIndigo.withValues(alpha: 0.10), - ), + _ScanPulse(animation: animation), + const SizedBox(height: 22), + Text( + scanning ? '正在搜索设备' : '暂未发现设备', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, ), ), - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - gradient: AppColors.calmHealthGradient, - borderRadius: BorderRadius.circular(20), - boxShadow: AppColors.buttonShadow, - ), - child: const Icon( - Icons.bluetooth_searching, - size: 32, - color: Colors.white, - ), - ), - ], - ), - const SizedBox(height: 20), - Text( - _scanning ? '正在搜索蓝牙设备...' : '未发现设备', - style: const TextStyle(fontSize: 16, color: AppColors.textHint), - ), - const SizedBox(height: 8), - Text( - _scanning ? '请确保血压计处于通信模式' : '点击下方按钮重新搜索', - style: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC)), - ), - ], - ), - ); - - // ── 已连接视图 ── - Widget _buildConnected() => Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 500), - width: 100, - height: 100, - decoration: BoxDecoration( - gradient: _readingReceived - ? AppColors.calmHealthGradient - : AppColors.primaryGradient, - borderRadius: BorderRadius.circular(32), - boxShadow: AppColors.buttonShadow, - ), - child: Icon( - _readingReceived - ? Icons.check_rounded - : Icons.bluetooth_connected, - size: 48, - color: _readingReceived ? Colors.white : Colors.white, - ), - ), - const SizedBox(height: 24), - Text( - _readingReceived ? '测量完成' : '设备已连接', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 8), - - if (_readingReceived) ...[ - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '$_systolic', - style: const TextStyle( - fontSize: 56, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - height: 1, - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Text( - '/', - style: TextStyle( - fontSize: 24, - color: AppColors.textHint.withValues(alpha: 0.5), - ), - ), - ), - Text( - '$_diastolic', - style: const TextStyle( - fontSize: 56, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - height: 1, - ), - ), - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text( - 'mmHg', - style: TextStyle(fontSize: 15, color: AppColors.textHint), - ), - ), - ], - ), - if (_pulse != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 6, - ), - decoration: BoxDecoration( - color: AppColors.infoLight, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - '脉搏 $_pulse bpm', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - color: AppColors.primary, - ), - ), - ), - ), - const SizedBox(height: 16), - const Text( - '数据已自动同步', - style: TextStyle(fontSize: 14, color: AppColors.textHint), - ), const SizedBox(height: 8), - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.primary, - ), - ), - ] else ...[ const Text( - '请按下血压计的开始键进行测量', - style: TextStyle(fontSize: 15, color: AppColors.textHint), - ), - const SizedBox(height: 32), - AnimatedBuilder( - animation: _pulseAnim, - builder: (_, _) => Container( - width: 48, - height: 48, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: AppColors.primary.withValues(alpha: 0.3), - width: 2, - ), - ), - child: Center( - child: Transform.scale( - scale: _pulseAnim.value, - child: Container( - width: 12, - height: 12, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: AppColors.primary, - ), - ), - ), - ), + '请让设备进入通信状态并靠近手机。', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + height: 1.45, + color: AppColors.textSecondary, ), ), ], - ], + ), ), - ), - ); + ); + } +} - // ── 设备列表项 ── - Widget _buildTile(ScanResult r) { - final isConnecting = _connectingId == r.device.remoteId.toString(); - final name = r.advertisementData.advName.isNotEmpty - ? r.advertisementData.advName - : (r.device.platformName.isNotEmpty ? r.device.platformName : '未知设备'); +class _DeviceResultTile extends StatelessWidget { + final ScanResult result; + final bool connecting; + final VoidCallback onConnect; + const _DeviceResultTile({ + required this.result, + required this.connecting, + required this.onConnect, + }); + + @override + Widget build(BuildContext context) { + final type = HealthBleService.deviceTypeFromScan(result); + final name = _deviceNameOf(result, type); return Container( - margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(14), decoration: BoxDecoration( - gradient: AppColors.surfaceGradient, - borderRadius: BorderRadius.circular(16), + color: Colors.white, + borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, ), child: Row( children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: AppColors.calmHealthGradient, - borderRadius: BorderRadius.circular(12), - ), - child: const Icon(Icons.bluetooth, size: 22, color: Colors.white), - ), + Icon(type?.icon ?? Icons.bluetooth_rounded, color: AppColors.primary), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - name, + type?.label ?? '健康设备', style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, + fontSize: 15, + fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), - const SizedBox(height: 2), + const SizedBox(height: 3), Text( - '${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}', + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 12, color: AppColors.textHint, @@ -499,43 +428,60 @@ class _DeviceScanPageState extends ConsumerState ], ), ), - if (isConnecting) - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else - GestureDetector( - onTap: () => _connect(r), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 10, - ), - decoration: BoxDecoration( - gradient: AppColors.primaryGradient, - borderRadius: BorderRadius.circular(12), - boxShadow: AppColors.buttonShadow, - ), - child: const Text( - '连接', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ), - ), + FilledButton( + onPressed: connecting ? null : onConnect, + child: Text(connecting ? '连接中' : '连接'), + ), ], ), ); } - String _rssiLabel(int r) { - if (r > -55) return '强'; - if (r > -70) return '中'; - return '弱'; + String _deviceNameOf(ScanResult result, BleDeviceType? type) { + final advName = result.advertisementData.advName.trim(); + if (advName.isNotEmpty) return advName; + final platformName = result.device.platformName.trim(); + if (platformName.isNotEmpty) return platformName; + return type?.label ?? '健康设备'; + } +} + +class _ScanPulse extends StatelessWidget { + final Animation animation; + + const _ScanPulse({required this.animation}); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + AnimatedBuilder( + animation: animation, + builder: (context, child) => Container( + width: 78 * animation.value, + height: 78 * animation.value, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.primary.withValues(alpha: 0.08), + ), + ), + ), + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.borderLight), + ), + child: const Icon( + Icons.bluetooth_searching_rounded, + size: 32, + color: AppColors.primary, + ), + ), + ], + ); } } diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index d0b6014..5e12485 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -100,6 +100,7 @@ class _HomePageState extends ConsumerState { _lastMsgCount = currentCount; return Scaffold( + backgroundColor: const Color(0xFFFFFCFF), drawer: const HealthDrawer(), drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.35, body: AppBackground( diff --git a/health_app/lib/providers/omron_device_provider.dart b/health_app/lib/providers/omron_device_provider.dart index bec47ca..7e222b2 100644 --- a/health_app/lib/providers/omron_device_provider.dart +++ b/health_app/lib/providers/omron_device_provider.dart @@ -1,55 +1,63 @@ import 'dart:async'; +import 'dart:convert'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/ble_device.dart'; +import '../models/bp_reading.dart'; +import '../services/health_ble_service.dart'; import 'auth_provider.dart'; import 'data_providers.dart'; -import '../models/bp_reading.dart'; -import '../services/omron_ble_service.dart'; -/// BLE 服务单例 -final omronBleServiceProvider = Provider((ref) { - return OmronBleService(); +const _boundDevicesKey = 'bound_ble_devices'; +const _readingFingerprintsKey = 'ble_reading_fingerprints'; +const _legacyBpMacKey = 'bp_device_mac'; +const _legacyBpNameKey = 'bp_device_name'; +const _legacyBpLastSyncKey = 'bp_last_sync'; + +final healthBleServiceProvider = Provider((ref) { + final service = HealthBleService(); + ref.onDispose(service.dispose); + return service; }); -/// 设备绑定状态 +// Keep the old provider name as a compatibility alias while the feature is +// being migrated away from the Omron-specific naming. +final omronBleServiceProvider = healthBleServiceProvider; + class DeviceBindState { - final bool isBound; - final String? mac; - final String? name; - final String? lastSync; + final List devices; final bool isConnected; - final BpReading? lastReading; - const DeviceBindState({ - this.isBound = false, - this.mac, - this.name, - this.lastSync, - this.isConnected = false, - this.lastReading, - }); + const DeviceBindState({this.devices = const [], this.isConnected = false}); - DeviceBindState copyWith({ - bool? isBound, - String? mac, - String? name, - String? lastSync, - bool? isConnected, - BpReading? lastReading, - }) => DeviceBindState( - isBound: isBound ?? this.isBound, - mac: mac ?? this.mac, - name: name ?? this.name, - lastSync: lastSync ?? this.lastSync, - isConnected: isConnected ?? this.isConnected, - lastReading: lastReading ?? this.lastReading, - ); + bool get isBound => devices.isNotEmpty; + + DeviceBindState copyWith({List? devices, bool? isConnected}) { + return DeviceBindState( + devices: devices ?? this.devices, + isConnected: isConnected ?? this.isConnected, + ); + } + + List devicesOfType(BleDeviceType type) { + return devices.where((device) => device.type == type).toList(); + } + + BoundBleDevice? findById(String id) { + for (final device in devices) { + if (device.id == id) return device; + } + return null; + } } class DeviceBindNotifier extends Notifier { - StreamSubscription? _connSub; + StreamSubscription? _connSub; @override DeviceBindState build() { + ref.onDispose(() => _connSub?.cancel()); _loadBinding(); _listenConnection(); return const DeviceBindState(); @@ -57,52 +65,182 @@ class DeviceBindNotifier extends Notifier { void _listenConnection() { _connSub?.cancel(); - _connSub = ref.read(omronBleServiceProvider).connectionState.listen((connected) { + _connSub = ref.read(healthBleServiceProvider).connectionState.listen(( + connected, + ) { if (state.isConnected != connected) { state = state.copyWith(isConnected: connected); } - if (!connected && state.isBound) { - // 设备断开,但保持绑定信息(下次可以重连) - } }); } Future _loadBinding() async { final db = ref.read(localDbProvider); - final mac = await db.read('bp_device_mac'); - final name = await db.read('bp_device_name'); - final lastSync = await db.read('bp_last_sync'); - if (mac != null && mac.isNotEmpty) { - state = state.copyWith(isBound: true, mac: mac, name: name, lastSync: lastSync); + await _migrateLegacyBloodPressureDevice(); + final raw = await db.read(_boundDevicesKey); + final devices = _decodeDevices(raw); + state = state.copyWith(devices: devices); + } + + Future _migrateLegacyBloodPressureDevice() async { + final db = ref.read(localDbProvider); + final existing = await db.read(_boundDevicesKey); + final legacyMac = await db.read(_legacyBpMacKey); + if (existing != null || legacyMac == null || legacyMac.isEmpty) return; + + final legacyName = await db.read(_legacyBpNameKey); + final migrated = BoundBleDevice( + id: legacyMac, + name: legacyName?.isNotEmpty == true ? legacyName! : '血压计', + type: BleDeviceType.bloodPressure, + serviceUuid: BleDeviceType.bloodPressure.serviceUuid, + ); + await db.write(_boundDevicesKey, jsonEncode([migrated.toJson()])); + await db.delete(_legacyBpMacKey); + await db.delete(_legacyBpNameKey); + await db.delete(_legacyBpLastSyncKey); + } + + List _decodeDevices(String? raw) { + if (raw == null || raw.isEmpty) return []; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return []; + return decoded + .whereType() + .map( + (item) => BoundBleDevice.fromJson(Map.from(item)), + ) + .whereType() + .toList(); + } catch (_) { + return []; } } - Future bind(String mac, String name) async { + Future _saveDevices(List devices) async { final db = ref.read(localDbProvider); - await db.write('bp_device_mac', mac); - await db.write('bp_device_name', name); - state = state.copyWith(isBound: true, mac: mac, name: name); + await db.write( + _boundDevicesKey, + jsonEncode(devices.map((device) => device.toJson()).toList()), + ); + state = state.copyWith(devices: devices); } - Future unbind() async { - final db = ref.read(localDbProvider); - await db.delete('bp_device_mac'); - await db.delete('bp_device_name'); - await db.delete('bp_last_sync'); - state = const DeviceBindState(); + Future bind(BoundBleDevice device) async { + final devices = [...state.devices]; + final index = devices.indexWhere((item) => item.id == device.id); + if (index >= 0) { + devices[index] = device.copyWith(lastSyncAt: devices[index].lastSyncAt); + } else { + devices.add(device); + } + await _saveDevices(devices); } - void setConnected(bool connected) { - state = state.copyWith(isConnected: connected); + Future unbind(String id) async { + await _saveDevices( + state.devices.where((device) => device.id != id).toList(), + ); } - Future onReading(BpReading reading) async { - final db = ref.read(localDbProvider); - final lastSync = '${reading.timestamp.month}/${reading.timestamp.day} ${reading.timestamp.hour}:${reading.timestamp.minute.toString().padLeft(2, '0')}'; - await db.write('bp_last_sync', lastSync); - state = state.copyWith(lastSync: lastSync, lastReading: reading); + Future recordSuccessfulSync({ + required BoundBleDevice device, + required BpReading reading, + }) async { + await _rememberReadingFingerprint(device, reading); + final syncedAt = DateTime.now(); + final devices = state.devices.map((item) { + if (item.id != device.id) return item; + return item.copyWith(lastSyncAt: syncedAt); + }).toList(); + await _saveDevices(devices); ref.invalidate(latestHealthProvider); } + + Future isDuplicateReading( + BoundBleDevice device, + BpReading reading, + ) async { + final fingerprints = await _loadFingerprints(); + final now = DateTime.now(); + final pruned = _pruneFingerprints(fingerprints, now); + if (pruned.length != fingerprints.length) { + await _saveFingerprints(pruned); + } + + final key = _readingFingerprint(device, reading); + final lastSeenRaw = pruned[key]; + if (lastSeenRaw == null) return false; + final lastSeen = DateTime.tryParse(lastSeenRaw)?.toLocal(); + if (lastSeen == null) return false; + if (reading.hasDeviceTimestamp) return true; + return now.difference(lastSeen).inSeconds < 10; + } + + Future _rememberReadingFingerprint( + BoundBleDevice device, + BpReading reading, + ) async { + final now = DateTime.now(); + final fingerprints = _pruneFingerprints(await _loadFingerprints(), now); + fingerprints[_readingFingerprint(device, reading)] = now + .toUtc() + .toIso8601String(); + await _saveFingerprints(fingerprints); + } + + Future> _loadFingerprints() async { + final db = ref.read(localDbProvider); + final raw = await db.read(_readingFingerprintsKey); + if (raw == null || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return {}; + return decoded.map( + (key, value) => MapEntry(key.toString(), value.toString()), + ); + } catch (_) { + return {}; + } + } + + Future _saveFingerprints(Map fingerprints) async { + final db = ref.read(localDbProvider); + await db.write(_readingFingerprintsKey, jsonEncode(fingerprints)); + } + + Map _pruneFingerprints( + Map fingerprints, + DateTime now, + ) { + final pruned = {}; + for (final entry in fingerprints.entries) { + final seenAt = DateTime.tryParse(entry.value)?.toLocal(); + if (seenAt == null) continue; + if (now.difference(seenAt).inHours <= 24) { + pruned[entry.key] = entry.value; + } + } + return pruned; + } + + String _readingFingerprint(BoundBleDevice device, BpReading reading) { + final timePart = reading.hasDeviceTimestamp + ? reading.timestamp.toUtc().toIso8601String() + : 'no-device-time'; + return [ + device.id, + device.type.code, + timePart, + reading.systolic, + reading.diastolic, + reading.pulse ?? 0, + ].join('|'); + } } -final omronDeviceProvider = NotifierProvider(DeviceBindNotifier.new); +final omronDeviceProvider = + NotifierProvider( + DeviceBindNotifier.new, + ); diff --git a/health_app/lib/services/health_ble_service.dart b/health_app/lib/services/health_ble_service.dart new file mode 100644 index 0000000..3956eab --- /dev/null +++ b/health_app/lib/services/health_ble_service.dart @@ -0,0 +1,316 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/foundation.dart' show VoidCallback; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; + +import '../models/ble_device.dart'; +import '../models/bp_reading.dart'; + +class UnsupportedBleDeviceException implements Exception { + final String message; + + const UnsupportedBleDeviceException(this.message); + + @override + String toString() => message; +} + +class HealthBleService { + static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB'; + static const racpUuid = '00002A52-0000-1000-8000-00805F9B34FB'; + + BluetoothDevice? _device; + StreamSubscription? _connStateSub; + StreamSubscription>? _measurementSub; + final _connStateController = StreamController.broadcast(); + + Stream get connectionState => _connStateController.stream; + + static bool isSupportedHealthDevice(ScanResult result) { + return BleDeviceIdentifier.isSupportedScanResult(result); + } + + static BleDeviceType? deviceTypeFromScan(ScanResult result) { + return BleDeviceIdentifier.typeFromScan(result); + } + + Future connectAndIdentify({ + required BluetoothDevice device, + required String name, + }) async { + final services = await _connectAndDiscover(device); + final type = BleDeviceIdentifier.typeFromServices(services); + if (type == null) { + throw const UnsupportedBleDeviceException('暂不支持该设备'); + } + return BoundBleDevice( + id: device.remoteId.toString(), + name: name, + type: type, + serviceUuid: type.serviceUuid, + ); + } + + Future syncBoundDevice( + BluetoothDevice device, + BoundBleDevice bound, { + Duration timeout = const Duration(seconds: 45), + VoidCallback? onConnected, + VoidCallback? onMeasurementReceived, + }) async { + final services = await _connectAndDiscover(device); + onConnected?.call(); + final connectedType = BleDeviceIdentifier.typeFromServices(services); + if (connectedType != bound.type) { + throw const UnsupportedBleDeviceException('设备类型和绑定信息不一致'); + } + + return switch (bound.type) { + BleDeviceType.bloodPressure => BloodPressureSyncResult( + device: bound, + reading: await _syncBloodPressure( + services, + onMeasurementReceived: onMeasurementReceived, + ).timeout(timeout), + ), + BleDeviceType.glucose => throw const UnsupportedBleDeviceException( + '暂未支持血糖仪数据解析', + ), + BleDeviceType.weightScale => throw const UnsupportedBleDeviceException( + '暂未支持体重秤数据解析', + ), + BleDeviceType.pulseOximeter => throw const UnsupportedBleDeviceException( + '暂未支持血氧仪数据解析', + ), + }; + } + + Future> _connectAndDiscover( + BluetoothDevice device, + ) async { + await _measurementSub?.cancel(); + _measurementSub = null; + await _connStateSub?.cancel(); + _device = device; + + _connStateSub = device.connectionState.listen((state) { + _connStateController.add(state == BluetoothConnectionState.connected); + }); + + if (device.isConnected) { + try { + await device.disconnect(); + } catch (_) {} + } + await device.connect( + autoConnect: false, + timeout: const Duration(seconds: 10), + ); + try { + await device.requestMtu(512); + } catch (_) { + // Some devices or platforms do not allow MTU negotiation. + } + final services = await device.discoverServices(); + return services; + } + + Future _syncBloodPressure( + List services, { + VoidCallback? onMeasurementReceived, + }) async { + BluetoothService? bpService; + for (final service in services) { + if (service.uuid.str128.toUpperCase() == + BleDeviceIdentifier.bloodPressureServiceUuid) { + bpService = service; + break; + } + } + if (bpService == null) { + throw const UnsupportedBleDeviceException('未找到血压服务'); + } + + BluetoothCharacteristic? measurementChar; + BluetoothCharacteristic? racpChar; + for (final characteristic in bpService.characteristics) { + final uuid = characteristic.uuid.str128.toUpperCase(); + if (uuid == bpMeasurementUuid) measurementChar = characteristic; + if (uuid == racpUuid) racpChar = characteristic; + } + if (measurementChar == null) { + throw const UnsupportedBleDeviceException('未找到血压测量特征'); + } + + final completer = Completer(); + final readings = []; + Timer? settleTimer; + + void acceptReading(List raw) { + if (raw.isEmpty || completer.isCompleted) return; + try { + readings.add(_parseBloodPressureReading(raw)); + onMeasurementReceived?.call(); + settleTimer?.cancel(); + settleTimer = Timer(const Duration(milliseconds: 900), () { + final reading = _selectCurrentBloodPressureReading(readings); + if (!completer.isCompleted) { + completer.complete(reading); + } + }); + } catch (e, stack) { + if (!completer.isCompleted) completer.completeError(e, stack); + } + } + + _measurementSub = measurementChar.onValueReceived.listen( + (raw) { + acceptReading(raw); + }, + onError: (Object e, StackTrace stack) { + if (!completer.isCompleted) completer.completeError(e, stack); + }, + ); + + await _enableMeasurementUpdates(measurementChar); + + if (racpChar != null) { + try { + await racpChar.write([0x01, 0x01]); + } catch (_) {} + } + + try { + return await completer.future; + } finally { + settleTimer?.cancel(); + } + } + + BpReading _selectCurrentBloodPressureReading(List readings) { + if (readings.isEmpty) { + throw const FormatException('未收到血压数据'); + } + + final now = DateTime.now(); + final currentWindowStart = now.subtract(const Duration(minutes: 15)); + final currentWindowEnd = now.add(const Duration(minutes: 2)); + final timestampedCurrent = readings.where((reading) { + if (!reading.hasDeviceTimestamp) return false; + return !reading.timestamp.isBefore(currentWindowStart) && + !reading.timestamp.isAfter(currentWindowEnd); + }).toList(); + + if (timestampedCurrent.isNotEmpty) { + timestampedCurrent.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + return timestampedCurrent.first; + } + + final timestamped = readings + .where((reading) => reading.hasDeviceTimestamp) + .toList(); + if (timestamped.isNotEmpty) { + timestamped.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + return timestamped.first; + } + + return readings.last; + } + + Future _enableMeasurementUpdates( + BluetoothCharacteristic characteristic, + ) async { + final useIndication = characteristic.properties.indicate; + await characteristic.setNotifyValue(true, forceIndications: useIndication); + } + + Future disconnect() async { + await _measurementSub?.cancel(); + _measurementSub = null; + await _connStateSub?.cancel(); + _connStateSub = null; + await _device?.disconnect(); + _device = null; + _connStateController.add(false); + } + + void dispose() { + unawaited(disconnect()); + _connStateController.close(); + } + + BpReading _parseBloodPressureReading(List raw) { + if (raw.length < 7) { + throw const FormatException('血压数据长度不足'); + } + + final flags = raw[0]; + final isKpa = (flags & 0x01) != 0; + final hasTimestamp = (flags & 0x02) != 0; + final hasPulse = (flags & 0x04) != 0; + final hasUserId = (flags & 0x08) != 0; + final hasStatus = (flags & 0x10) != 0; + + int systolic = _decodeSFloat(raw[1] | (raw[2] << 8)).round(); + int diastolic = _decodeSFloat(raw[3] | (raw[4] << 8)).round(); + if (isKpa) { + systolic = (systolic * 7.50062).round(); + diastolic = (diastolic * 7.50062).round(); + } + + var offset = 7; + var timestamp = DateTime.now(); + if (hasTimestamp && raw.length >= offset + 7) { + timestamp = DateTime( + raw[offset] | (raw[offset + 1] << 8), + raw[offset + 2], + raw[offset + 3], + raw[offset + 4], + raw[offset + 5], + raw[offset + 6], + ); + offset += 7; + } + + int? pulse; + if (hasPulse && raw.length >= offset + 2) { + pulse = _decodeSFloat(raw[offset] | (raw[offset + 1] << 8)).round(); + offset += 2; + } + + String? userId; + if (hasUserId && raw.length > offset) { + userId = raw[offset].toString(); + offset++; + } + + var hasBodyMovement = false; + var hasIrregularPulse = false; + if (hasStatus && raw.length >= offset + 2) { + final status = raw[offset] | (raw[offset + 1] << 8); + hasBodyMovement = (status & 0x0001) != 0; + hasIrregularPulse = (status & 0x0800) != 0; + } + + return BpReading( + systolic: systolic, + diastolic: diastolic, + pulse: pulse, + timestamp: timestamp, + userId: userId, + isKpa: isKpa, + hasBodyMovement: hasBodyMovement, + hasIrregularPulse: hasIrregularPulse, + hasDeviceTimestamp: hasTimestamp, + ); + } + + static double _decodeSFloat(int raw) { + var mantissa = raw & 0x0FFF; + if (mantissa & 0x0800 != 0) mantissa |= 0xFFFFF000; + var exponent = (raw >> 12) & 0x0F; + if (exponent & 0x08 != 0) exponent |= 0xFFFFFFF0; + return (mantissa * pow(10, exponent)).toDouble(); + } +} diff --git a/health_app/lib/services/omron_ble_service.dart b/health_app/lib/services/omron_ble_service.dart deleted file mode 100644 index 96f55cf..0000000 --- a/health_app/lib/services/omron_ble_service.dart +++ /dev/null @@ -1,244 +0,0 @@ -import 'dart:async'; -import 'dart:math'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import '../models/bp_reading.dart'; - -/// 欧姆龙蓝牙血压计 BLE 服务 -class OmronBleService { - static const bpServiceUuid = '00001810-0000-1000-8000-00805F9B34FB'; - static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB'; - static const bpFeatureUuid = '00002A49-0000-1000-8000-00805F9B34FB'; - static const dateTimeUuid = '00002A08-0000-1000-8000-00805F9B34FB'; - static const intermediateCuffUuid = '00002A36-0000-1000-8000-00805F9B34FB'; - static const racpUuid = '00002A52-0000-1000-8000-00805F9B34FB'; - - BluetoothDevice? _device; - StreamSubscription>? _measurementSub; - StreamSubscription? _connStateSub; - final _readingsController = StreamController.broadcast(); - final _connStateController = StreamController.broadcast(); - Stream get readings => _readingsController.stream; - Stream get connectionState => _connStateController.stream; - - bool get isConnected => _device?.isConnected ?? false; - - /// 直接重连已知设备(通过MAC地址) - Future reconnectByMac(String mac) async { - debugPrint('[BLE] 直连设备: $mac'); - final bonded = await FlutterBluePlus.bondedDevices; - BluetoothDevice? target; - for (final d in bonded) { - if (d.remoteId.toString() == mac) { - target = d; - break; - } - } - if (target == null) { - debugPrint('[BLE] 未在已配对设备中找到 $mac'); - return false; - } - try { - await connect(target); - return true; - } catch (e) { - debugPrint('[BLE] 直连失败: $e'); - return false; - } - } - - static bool isBpDevice(ScanResult r) { - final name = [ - r.advertisementData.advName, - r.device.platformName, - ].where((n) => n.isNotEmpty).join(' ').toUpperCase(); - return name.contains('OMRON') || - name.contains('HEM') || - name.contains('BLESMART') || - name.contains('J735') || - name.contains('BPM') || - name.contains('BP'); - } - - /// 连接设备并订阅测量数据 - /// 设备连接后会自动发送存储的最后一次测量数据 - Future connect(BluetoothDevice device) async { - _device = device; - debugPrint('[BLE] ═══ 连接: "${device.platformName}" ═══'); - - _connStateSub = device.connectionState.listen((s) { - debugPrint('[BLE] 连接状态: $s'); - _connStateController.add(s == BluetoothConnectionState.connected); - }); - - // 1. GATT连接 - await device.connect(autoConnect: false); - debugPrint('[BLE] ① 已连接, MTU=${device.mtuNow}'); - try { await device.requestMtu(512); } catch (_) {} - - // 2. 发现服务 - final services = await device.discoverServices(); - debugPrint('[BLE] ② 发现${services.length}个服务'); - - // 遍历所有服务/特征 - BluetoothService? bpSvc; - BluetoothCharacteristic? measChar, racpChar; - for (final s in services) { - debugPrint('[BLE] 服务: ${s.uuid.str128}'); - for (final c in s.characteristics) { - final p = []; - if (c.properties.read) p.add('R'); - if (c.properties.write) p.add('W'); - if (c.properties.notify) p.add('N'); - if (c.properties.indicate) p.add('I'); - debugPrint('[BLE] ${c.uuid.str128} [$p]'); - for (final d in c.descriptors) { - debugPrint('[BLE] desc: ${d.uuid.str128}'); - } - } - if (s.uuid.str128.toUpperCase() == bpServiceUuid.toUpperCase()) bpSvc = s; - } - - if (bpSvc == null) throw Exception('未找到血压服务0x1810'); - for (final c in bpSvc.characteristics) { - final u = c.uuid.str128.toUpperCase(); - if (u == bpMeasurementUuid.toUpperCase()) measChar = c; - if (u == racpUuid.toUpperCase()) racpChar = c; - } - if (measChar == null) throw Exception('未找到0x2A35'); - debugPrint('[BLE] ③ 0x2A35: indicate=${measChar.properties.indicate} read=${measChar.properties.read}'); - debugPrint('[BLE] RACP: ${racpChar != null ? "有" : "无"}'); - - // ★ 3. 先订阅数据流(在配置CCCD之前!) - debugPrint('[BLE] ④ 订阅lastValueStream...'); - _measurementSub = measChar.lastValueStream.listen( - (raw) { - debugPrint('[BLE] ★★★ 收到数据! ${raw.length}字节 ★★★'); - debugPrint('[BLE] HEX: ${raw.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - _parseReading(raw); - }, - onError: (e) => debugPrint('[BLE] 流错误: $e'), - cancelOnError: false, - ); - - // 4. 配置CCCD - final isIndicate = measChar.properties.indicate; - final cccdVal = isIndicate ? [0x02, 0x00] : [0x01, 0x00]; - debugPrint('[BLE] ⑤ 配置CCCD → ${isIndicate ? "Indicate" : "Notify"}...'); - var ok = false; - for (final d in measChar.descriptors) { - if (d.uuid.str128 == '00002902-0000-1000-8000-00805F9B34FB') { - final before = await d.read(); - await d.write(cccdVal); - final after = await d.read(); - ok = after.length >= 2 && after[0] == cccdVal[0] && after[1] == cccdVal[1]; - debugPrint('[BLE] ⑤ CCCD: $before → $after ${ok ? "OK" : "FAIL"}'); - break; - } - } - if (!ok) { - await measChar.setNotifyValue(true, forceIndications: isIndicate); - debugPrint('[BLE] ⑤ 降级setNotifyValue, isNotifying=${measChar.isNotifying}'); - } - - // 5. 主动读取缓存数据(设备存储的最后一次测量) - debugPrint('[BLE] ⑥ 主动读取0x2A35...'); - if (measChar.properties.read) { - try { - final data = await measChar.read(); - if (data.isNotEmpty) { - debugPrint('[BLE] ⑥ 读到${data.length}字节 HEX=${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - _parseReading(data); - } else { - debugPrint('[BLE] ⑥ 空数据'); - } - } catch (e) { - debugPrint('[BLE] ⑥ 读取失败: $e'); - } - } - - // 6. RACP请求存储记录 - if (racpChar != null) { - debugPrint('[BLE] ⑦ RACP请求存储记录...'); - try { - await racpChar.write([0x01, 0x01]); // ReportStoredRecords, All - debugPrint('[BLE] ⑦ RACP已发送'); - } catch (e) { - debugPrint('[BLE] ⑦ RACP失败: $e'); - } - } - - debugPrint('[BLE] ═══ 连接完成, 等待数据 ═══'); - } - - Future disconnect() async { - _connStateSub?.cancel(); - _connStateSub = null; - _measurementSub?.cancel(); - _measurementSub = null; - await _device?.disconnect(); - _device = null; - debugPrint('[BLE] 已断开'); - } - - void dispose() { - disconnect(); - _readingsController.close(); - _connStateController.close(); - } - - // ── 解析 BLE 测量数据 ── - - void _parseReading(List raw) { - debugPrint('[BLE] 解析: flags=0x${raw[0].toRadixString(16)}, len=${raw.length}'); - if (raw.length < 7) { debugPrint('[BLE] 太短,跳过'); return; } - - final flags = raw[0]; - final isKpa = (flags & 0x01) != 0; - final hasTs = (flags & 0x02) != 0; - final hasPulse = (flags & 0x04) != 0; - final hasUid = (flags & 0x08) != 0; - final hasStatus = (flags & 0x10) != 0; - - int sys = _decodeSFloat(raw[1] | (raw[2] << 8)).round(); - int dia = _decodeSFloat(raw[3] | (raw[4] << 8)).round(); - if (isKpa) { sys = (sys * 7.50062).round(); dia = (dia * 7.50062).round(); } - - int off = 7; - DateTime ts = DateTime.now(); - if (hasTs && raw.length >= off + 7) { - ts = DateTime(raw[off] | (raw[off + 1] << 8), raw[off + 2], raw[off + 3], - raw[off + 4], raw[off + 5], raw[off + 6]); - off += 7; - } - - int? pulse; - if (hasPulse && raw.length >= off + 2) { - pulse = _decodeSFloat(raw[off] | (raw[off + 1] << 8)).round(); - off += 2; - } - - String? uid; - if (hasUid && raw.length > off) { uid = raw[off].toString(); off++; } - - bool bm = false, ir = false; - if (hasStatus && raw.length >= off + 2) { - int s = raw[off] | (raw[off + 1] << 8); - bm = (s & 0x0001) != 0; - ir = (s & 0x0800) != 0; - } - - final r = BpReading(systolic: sys, diastolic: dia, pulse: pulse, timestamp: ts, - userId: uid, isKpa: isKpa, hasBodyMovement: bm, hasIrregularPulse: ir); - debugPrint('[BLE] ★ $sys/$dia mmHg, pulse=$pulse ★'); - _readingsController.add(r); - } - - static double _decodeSFloat(int raw) { - int m = raw & 0x0FFF; - if (m & 0x0800 != 0) m |= 0xFFFFF000; - int e = (raw >> 12) & 0x0F; - if (e & 0x08 != 0) e |= 0xFFFFFFF0; - return (m * pow(10, e)).toDouble(); - } -} diff --git a/health_app/lib/widgets/ble_sync_dialog.dart b/health_app/lib/widgets/ble_sync_dialog.dart new file mode 100644 index 0000000..7f79035 --- /dev/null +++ b/health_app/lib/widgets/ble_sync_dialog.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; + +import '../core/app_colors.dart'; +import '../models/bp_reading.dart'; + +Future showBleSyncDialog( + BuildContext context, { + required BpReading reading, +}) { + return showDialog( + context: context, + barrierDismissible: false, + barrierColor: Colors.black.withValues(alpha: 0.54), + builder: (ctx) => Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 28), + backgroundColor: Colors.transparent, + child: Container( + width: double.infinity, + constraints: const BoxConstraints(maxWidth: 380), + padding: const EdgeInsets.fromLTRB(22, 28, 22, 22), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.14), + blurRadius: 34, + offset: const Offset(0, 18), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '数据已录入', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w900, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: _MetricCard( + label: '收缩压', + value: '${reading.systolic}', + unit: 'mmHg', + ), + ), + const SizedBox(width: 16), + Expanded( + child: _MetricCard( + label: '舒张压', + value: '${reading.diastolic}', + unit: 'mmHg', + ), + ), + ], + ), + if (reading.pulse != null) ...[ + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _MetricCard( + label: '脉搏', + value: '${reading.pulse}', + unit: 'bpm', + ), + ), + const Expanded(child: SizedBox.shrink()), + ], + ), + ], + const SizedBox(height: 28), + GestureDetector( + onTap: () => Navigator.pop(ctx), + child: Container( + height: 50, + width: double.infinity, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: AppColors.doctorGradient, + borderRadius: BorderRadius.circular(18), + boxShadow: AppColors.buttonShadow, + ), + child: const Text( + '知道了', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + ), + ), + ], + ), + ), + ), + ); +} + +class _MetricCard extends StatelessWidget { + final String label; + final String value; + final String unit; + + const _MetricCard({ + required this.label, + required this.value, + required this.unit, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + Text( + value, + style: const TextStyle( + fontSize: 36, + fontWeight: FontWeight.w900, + color: AppColors.textPrimary, + height: 1, + ), + ), + const SizedBox(height: 6), + Text( + unit, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textHint, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + } +} diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index d6592a0..fb603b4 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -342,6 +342,12 @@ class _NavigationSection extends StatelessWidget { route: 'exercisePlan', colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)], ), + _NavItem( + icon: Icons.bluetooth_connected_rounded, + title: '蓝牙设备', + route: 'devices', + colors: const [Color(0xFF60A5FA), Color(0xFFC4B5FD)], + ), ]; return _Panel( @@ -383,6 +389,7 @@ class _NavTile extends StatelessWidget { 'calendar' => '健康日历', 'followups' => '复查随访', 'exercisePlan' => '运动计划', + 'devices' => '蓝牙设备', _ => item.title, }; @@ -394,6 +401,7 @@ class _NavTile extends StatelessWidget { 'calendar' => const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)], 'followups' => const [Color(0xFFF472B6), Color(0xFFDB2777)], 'exercisePlan' => const [Color(0xFF7DD3FC), Color(0xFF60A5FA)], + 'devices' => const [Color(0xFF60A5FA), Color(0xFFC4B5FD)], _ => item.colors, };