refactor: 重构蓝牙设备页与新增设备页的扫描-连接-录入流程
- 通用 BLE 健康设备识别(血压计/血糖仪/体重秤/血氧仪 标准 service UUID) - 蓝牙设备页静默自动同步:仅匹配已绑定的设备名+类型,收到数据后变绿 - 新增设备页:扫描所有支持类型设备 → 选连接 → 绑定 + 同步首次数据 - 修复 read 缓存导致的重复录入循环(删除主动 read,依赖 indicate 推送) - 修复 isConnected 缓存状态导致连接异常(强制先 disconnect 再 connect) - 录入弹窗:血压心率同等级展示、3 秒自动关闭/手动确认 - 顺手更新本机开发 IP 与清理过时设计文档
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
using Health.Application.Notifications;
|
using Health.Application.Notifications;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
@@ -96,6 +98,48 @@ public static class NotificationEndpoints
|
|||||||
if (body.HealthRecordReminderWeight.HasValue) pref.HealthRecordReminderWeight = body.HealthRecordReminderWeight.Value;
|
if (body.HealthRecordReminderWeight.HasValue) pref.HealthRecordReminderWeight = body.HealthRecordReminderWeight.Value;
|
||||||
pref.UpdatedAt = DateTime.UtcNow;
|
pref.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(ct);
|
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<IUserNotificationProducer>();
|
||||||
|
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 });
|
return Results.Ok(new { code = 0, data = ToDto(pref), message = (string?)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 个低。*
|
|
||||||
@@ -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<T>` + `BackgroundService`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
业务 Service = 生产者
|
|
||||||
Channel<TJob> = 内存任务队列
|
|
||||||
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。
|
|
||||||
- 上传、列表、详情、删除、查看原图、分析失败状态保持可用。
|
|
||||||
- 后端编译通过,前端报告页分析通过。
|
|
||||||
@@ -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色"即可。
|
|
||||||
@@ -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 天
|
|
||||||
@@ -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 后台断连 | 前台连接同步,完成后断开;不做后台持久连接 |
|
|
||||||
@@ -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<List<int>>? _subscription;
|
|
||||||
final _readingsController = StreamController<BpReading>.broadcast();
|
|
||||||
Stream<BpReading> get readings => _readingsController.stream;
|
|
||||||
|
|
||||||
// ── 扫描 ──
|
|
||||||
Stream<ScanResult> 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<void> 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<int> 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<void> _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<void> disconnect() async {
|
|
||||||
_subscription?.cancel();
|
|
||||||
await _device?.disconnect();
|
|
||||||
_device = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
disconnect();
|
|
||||||
_readingsController.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.3.3 数据同步到后端
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// 在 Provider 中监听
|
|
||||||
final omronReadingsProvider = StreamProvider<BpReading>((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
|
|
||||||
<!-- Android 12+ -->
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
|
||||||
<!-- Android 11 及以下需要定位权限来扫描蓝牙 -->
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
|
||||||
android:maxSdkVersion="30" />
|
|
||||||
```
|
|
||||||
|
|
||||||
#### iOS (`Info.plist`)
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
|
||||||
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
|
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
|
||||||
<string>需要使用蓝牙连接血压计</string>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、交互流程设计
|
|
||||||
|
|
||||||
### 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<DeviceScanPage> createState() => _DeviceScanPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
|
|
||||||
final _ble = OmronBleService();
|
|
||||||
final _results = <ScanResult>[];
|
|
||||||
bool _scanning = false;
|
|
||||||
String? _connectingId;
|
|
||||||
|
|
||||||
@override void initState() {
|
|
||||||
super.initState();
|
|
||||||
_startScan();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _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<void> _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<DeviceManagerPage> createState() => _DeviceManagerPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DeviceManagerPageState extends ConsumerState<DeviceManagerPage> {
|
|
||||||
String? _boundMac;
|
|
||||||
String? _boundName;
|
|
||||||
String? _lastSync;
|
|
||||||
bool _loading = true;
|
|
||||||
|
|
||||||
@override void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadBoundDevice();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _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<void> _unbind() async {
|
|
||||||
final ok = await showDialog<bool>(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<void> _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
|
|
||||||
@@ -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<User> 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 上不要继续单页单独调色,应该先统一设计体系,再逐步替换页面。工程上先修安全和接口契约,再做大面积美化。这样项目会从“原型功能很多”变成“真正像一个可信赖的健康产品”。
|
|
||||||
@@ -6,7 +6,7 @@ import 'local_database.dart';
|
|||||||
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
||||||
const String baseUrl = String.fromEnvironment(
|
const String baseUrl = String.fromEnvironment(
|
||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: 'http://10.4.202.36:5000',
|
defaultValue: 'http://10.4.232.251:5000',
|
||||||
);
|
);
|
||||||
|
|
||||||
class ApiException implements Exception {
|
class ApiException implements Exception {
|
||||||
|
|||||||
187
health_app/lib/models/ble_device.dart
Normal file
187
health_app/lib/models/ble_device.dart
Normal file
@@ -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<SupportedMetric> 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<SupportedMetric> 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<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'type': type.code,
|
||||||
|
'serviceUuid': serviceUuid,
|
||||||
|
'lastSyncAt': lastSyncAt?.toUtc().toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
static BoundBleDevice? fromJson(Map<String, dynamic> 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<BluetoothService> services) {
|
||||||
|
return typeFromUuidStrings(services.map((service) => service.uuid.str128));
|
||||||
|
}
|
||||||
|
|
||||||
|
static BleDeviceType? typeFromUuidStrings(Iterable<String> 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;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ class BpReading {
|
|||||||
final bool isKpa;
|
final bool isKpa;
|
||||||
final bool hasBodyMovement;
|
final bool hasBodyMovement;
|
||||||
final bool hasIrregularPulse;
|
final bool hasIrregularPulse;
|
||||||
|
final bool hasDeviceTimestamp;
|
||||||
|
|
||||||
const BpReading({
|
const BpReading({
|
||||||
required this.systolic,
|
required this.systolic,
|
||||||
@@ -18,6 +19,7 @@ class BpReading {
|
|||||||
this.isKpa = false,
|
this.isKpa = false,
|
||||||
this.hasBodyMovement = false,
|
this.hasBodyMovement = false,
|
||||||
this.hasIrregularPulse = false,
|
this.hasIrregularPulse = false,
|
||||||
|
this.hasDeviceTimestamp = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get display => '$systolic/$diastolic';
|
String get display => '$systolic/$diastolic';
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
'id': r['id'],
|
'id': r['id'],
|
||||||
'type': t,
|
'type': t,
|
||||||
'date':
|
'date':
|
||||||
DateTime.tryParse(r['recordedAt']?.toString() ?? '') ??
|
DateTime.tryParse(
|
||||||
|
r['recordedAt']?.toString() ?? '',
|
||||||
|
)?.toLocal() ??
|
||||||
DateTime.now(),
|
DateTime.now(),
|
||||||
'systolic': r['systolic'],
|
'systolic': r['systolic'],
|
||||||
'diastolic': r['diastolic'],
|
'diastolic': r['diastolic'],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,42 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
|
import '../../models/ble_device.dart';
|
||||||
|
import '../../models/bp_reading.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/omron_device_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 {
|
class DeviceScanPage extends ConsumerStatefulWidget {
|
||||||
const DeviceScanPage({super.key});
|
const DeviceScanPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
|
ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
|
static const _visibleDeviceWindow = Duration(seconds: 12);
|
||||||
|
|
||||||
final _results = <ScanResult>[];
|
final _results = <ScanResult>[];
|
||||||
bool _scanning = false;
|
StreamSubscription<List<ScanResult>>? _scanSub;
|
||||||
|
Timer? _scanStopTimer;
|
||||||
|
Timer? _resultPruneTimer;
|
||||||
String? _connectingId;
|
String? _connectingId;
|
||||||
StreamSubscription? _scanSub;
|
DateTime? _scanStartedAt;
|
||||||
|
bool _scanning = false;
|
||||||
|
|
||||||
bool _connected = false;
|
late final AnimationController _pulseCtrl;
|
||||||
String _deviceName = '';
|
late final Animation<double> _pulseAnim;
|
||||||
bool _readingReceived = false;
|
|
||||||
int? _systolic, _diastolic, _pulse;
|
|
||||||
StreamSubscription? _readingSub;
|
|
||||||
StreamSubscription? _bleConnSub;
|
|
||||||
|
|
||||||
late AnimationController _pulseCtrl;
|
|
||||||
late Animation<double> _pulseAnim;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -40,40 +44,32 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
_pulseCtrl = AnimationController(
|
_pulseCtrl = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 1200),
|
duration: const Duration(milliseconds: 1200),
|
||||||
);
|
)..repeat(reverse: true);
|
||||||
_pulseAnim = Tween(
|
_pulseAnim = Tween(
|
||||||
begin: 0.8,
|
begin: 0.82,
|
||||||
end: 1.4,
|
end: 1.36,
|
||||||
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
||||||
_pulseCtrl.repeat(reverse: true);
|
|
||||||
|
|
||||||
_scanSub = FlutterBluePlus.scanResults.listen((list) {
|
unawaited(_startScan());
|
||||||
if (!mounted) return;
|
|
||||||
final bpList = list.where((r) => OmronBleService.isBpDevice(r)).toList();
|
|
||||||
setState(() {
|
|
||||||
_results.clear();
|
|
||||||
_results.addAll(bpList);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
_start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_pulseCtrl.dispose();
|
_pulseCtrl.dispose();
|
||||||
|
_scanStopTimer?.cancel();
|
||||||
|
_resultPruneTimer?.cancel();
|
||||||
_scanSub?.cancel();
|
_scanSub?.cancel();
|
||||||
_readingSub?.cancel();
|
unawaited(FlutterBluePlus.stopScan());
|
||||||
_bleConnSub?.cancel();
|
unawaited(ref.read(healthBleServiceProvider).disconnect());
|
||||||
FlutterBluePlus.stopScan();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _start() async {
|
Future<void> _startScan() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_scanning = true;
|
_scanning = true;
|
||||||
_connected = false;
|
_connectingId = null;
|
||||||
});
|
|
||||||
_results.clear();
|
_results.clear();
|
||||||
|
});
|
||||||
|
|
||||||
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
|
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
|
||||||
|
|
||||||
@@ -82,7 +78,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
if (locStatus != ServiceStatus.enabled && mounted) {
|
if (locStatus != ServiceStatus.enabled && mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('请先打开GPS定位,否则无法扫描蓝牙'),
|
content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'),
|
||||||
backgroundColor: AppColors.warning,
|
backgroundColor: AppColors.warning,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -99,398 +95,331 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
try {
|
try {
|
||||||
await FlutterBluePlus.stopScan();
|
await FlutterBluePlus.stopScan();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
_scanStartedAt = DateTime.now();
|
||||||
|
await _scanSub?.cancel();
|
||||||
|
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
|
||||||
await FlutterBluePlus.startScan(
|
await FlutterBluePlus.startScan(
|
||||||
timeout: const Duration(seconds: 30),
|
timeout: const Duration(seconds: 30),
|
||||||
androidScanMode: AndroidScanMode.lowLatency,
|
androidScanMode: AndroidScanMode.lowLatency,
|
||||||
|
oneByOne: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
Future.delayed(const Duration(seconds: 30), () {
|
_scanStopTimer?.cancel();
|
||||||
FlutterBluePlus.stopScan();
|
_scanStopTimer = Timer(const Duration(seconds: 30), () {
|
||||||
if (mounted) setState(() => _scanning = false);
|
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<void> _connect(ScanResult r) async {
|
void _onScanResults(List<ScanResult> list) {
|
||||||
setState(() => _connectingId = r.device.remoteId.toString());
|
if (!mounted || _connectingId != null) return;
|
||||||
FlutterBluePlus.stopScan();
|
final scanStartedAt = _scanStartedAt;
|
||||||
_scanSub?.cancel();
|
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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
final next = [..._results];
|
||||||
final ble = ref.read(omronBleServiceProvider);
|
for (final result in supported) {
|
||||||
await ble.connect(r.device);
|
final index = next.indexWhere(
|
||||||
final name = r.advertisementData.advName.isNotEmpty
|
(item) => item.device.remoteId == result.device.remoteId,
|
||||||
? r.advertisementData.advName
|
);
|
||||||
: (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计');
|
if (index >= 0) {
|
||||||
|
next[index] = result;
|
||||||
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 {
|
} else {
|
||||||
|
next.add(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final visible = _visibleResults(next);
|
||||||
|
if (_sameResults(_results, visible)) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_connected = false;
|
_results
|
||||||
|
..clear()
|
||||||
|
..addAll(visible);
|
||||||
});
|
});
|
||||||
_start();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
_readingSub = ble.readings.listen((reading) async {
|
List<ScanResult> _visibleResults(List<ScanResult> results) {
|
||||||
debugPrint('[PAGE] 收到: ${reading.systolic}/${reading.diastolic}');
|
final visible =
|
||||||
if (mounted) {
|
results
|
||||||
setState(() {
|
.where(
|
||||||
_readingReceived = true;
|
(result) =>
|
||||||
_systolic = reading.systolic;
|
DateTime.now().difference(result.timeStamp) <=
|
||||||
_diastolic = reading.diastolic;
|
_visibleDeviceWindow,
|
||||||
_pulse = reading.pulse;
|
)
|
||||||
});
|
.toList()
|
||||||
|
..sort(
|
||||||
|
(a, b) => a.device.remoteId.toString().compareTo(
|
||||||
|
b.device.remoteId.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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 {
|
try {
|
||||||
|
boundDevice = await ble
|
||||||
|
.connectAndIdentify(
|
||||||
|
device: result.device,
|
||||||
|
name: _deviceNameOf(result),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 15));
|
||||||
|
await ref.read(omronDeviceProvider.notifier).bind(boundDevice);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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) {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
unawaited(_startScan());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await ble.disconnect();
|
||||||
|
if (mounted) setState(() => _connectingId = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _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);
|
final api = ref.read(apiClientProvider);
|
||||||
await api.post('/api/health-records', data: reading.toHealthRecord());
|
await api.post('/api/health-records', data: reading.toHealthRecord());
|
||||||
final heartRateRecord = reading.toHeartRateRecord();
|
final heartRateRecord = reading.toHeartRateRecord();
|
||||||
if (heartRateRecord != null) {
|
if (heartRateRecord != null) {
|
||||||
await api.post('/api/health-records', data: heartRateRecord);
|
await api.post('/api/health-records', data: heartRateRecord);
|
||||||
}
|
}
|
||||||
await ref.read(omronDeviceProvider.notifier).onReading(reading);
|
await notifier.recordSuccessfulSync(device: device, reading: reading);
|
||||||
} catch (e) {
|
return true;
|
||||||
debugPrint('[PAGE] 上报失败: $e');
|
|
||||||
}
|
}
|
||||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
|
||||||
if (mounted) popRoute(ref);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mounted) {
|
Future<void> _showBloodPressureDialog(BpReading reading) {
|
||||||
setState(() {
|
return showBleSyncDialog(context, reading: reading);
|
||||||
_connected = true;
|
|
||||||
_deviceName = name;
|
|
||||||
_connectingId = null;
|
|
||||||
_readingReceived = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('连接失败: $e'), backgroundColor: AppColors.error),
|
|
||||||
);
|
|
||||||
_start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white.withValues(alpha: 0.9),
|
backgroundColor: Colors.white.withValues(alpha: 0.92),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: Text(
|
title: const Text(
|
||||||
_connected ? _deviceName : '添加设备',
|
'新增设备',
|
||||||
style: const TextStyle(color: AppColors.textPrimary),
|
style: TextStyle(color: AppColors.textPrimary),
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||||
onPressed: () {
|
onPressed: () => popRoute(ref),
|
||||||
if (_connected) ref.read(omronBleServiceProvider).disconnect();
|
),
|
||||||
popRoute(ref);
|
),
|
||||||
|
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),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
body: _connected ? _buildConnected() : _buildScan(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 扫描视图 ──
|
String _deviceNameOf(ScanResult result) {
|
||||||
Widget _buildScan() => Column(
|
final advName = result.advertisementData.advName.trim();
|
||||||
children: [
|
if (advName.isNotEmpty) return advName;
|
||||||
if (_scanning && _results.isEmpty)
|
final platformName = result.device.platformName.trim();
|
||||||
Expanded(child: _buildScanningCenter()),
|
if (platformName.isNotEmpty) return platformName;
|
||||||
if (!_scanning && _results.isEmpty)
|
return HealthBleService.deviceTypeFromScan(result)?.label ?? '健康设备';
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _buildScanningCenter() => Center(
|
bool _sameResults(List<ScanResult> current, List<ScanResult> next) {
|
||||||
child: Column(
|
if (current.length != next.length) return false;
|
||||||
mainAxisSize: MainAxisSize.min,
|
for (var i = 0; i < current.length; i++) {
|
||||||
children: [
|
if (current[i].device.remoteId != next[i].device.remoteId) return false;
|
||||||
Stack(
|
}
|
||||||
alignment: Alignment.center,
|
return true;
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── 已连接视图 ──
|
class _ScanningEmpty extends StatelessWidget {
|
||||||
Widget _buildConnected() => Center(
|
final Animation<double> animation;
|
||||||
|
final bool scanning;
|
||||||
|
|
||||||
|
const _ScanningEmpty({required this.animation, required this.scanning});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(30),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
AnimatedContainer(
|
_ScanPulse(animation: animation),
|
||||||
duration: const Duration(milliseconds: 500),
|
const SizedBox(height: 22),
|
||||||
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(
|
Text(
|
||||||
_readingReceived ? '测量完成' : '设备已连接',
|
scanning ? '正在搜索设备' : '暂未发现设备',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 22,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
if (_readingReceived) ...[
|
'请让设备进入通信状态并靠近手机。',
|
||||||
Row(
|
textAlign: TextAlign.center,
|
||||||
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(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 14,
|
||||||
color: AppColors.textHint.withValues(alpha: 0.5),
|
height: 1.45,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 设备列表项 ──
|
class _DeviceResultTile extends StatelessWidget {
|
||||||
Widget _buildTile(ScanResult r) {
|
final ScanResult result;
|
||||||
final isConnecting = _connectingId == r.device.remoteId.toString();
|
final bool connecting;
|
||||||
final name = r.advertisementData.advName.isNotEmpty
|
final VoidCallback onConnect;
|
||||||
? r.advertisementData.advName
|
|
||||||
: (r.device.platformName.isNotEmpty ? r.device.platformName : '未知设备');
|
|
||||||
|
|
||||||
|
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(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.surfaceGradient,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
boxShadow: AppColors.cardShadowLight,
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Icon(type?.icon ?? Icons.bluetooth_rounded, color: AppColors.primary),
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.calmHealthGradient,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.bluetooth, size: 22, color: Colors.white),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
name,
|
type?.label ?? '健康设备',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
'${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}',
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.textHint,
|
color: AppColors.textHint,
|
||||||
@@ -499,43 +428,60 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isConnecting)
|
FilledButton(
|
||||||
const SizedBox(
|
onPressed: connecting ? null : onConnect,
|
||||||
width: 24,
|
child: Text(connecting ? '连接中' : '连接'),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _rssiLabel(int r) {
|
String _deviceNameOf(ScanResult result, BleDeviceType? type) {
|
||||||
if (r > -55) return '强';
|
final advName = result.advertisementData.advName.trim();
|
||||||
if (r > -70) return '中';
|
if (advName.isNotEmpty) return advName;
|
||||||
return '弱';
|
final platformName = result.device.platformName.trim();
|
||||||
|
if (platformName.isNotEmpty) return platformName;
|
||||||
|
return type?.label ?? '健康设备';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScanPulse extends StatelessWidget {
|
||||||
|
final Animation<double> 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
_lastMsgCount = currentCount;
|
_lastMsgCount = currentCount;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFFFFCFF),
|
||||||
drawer: const HealthDrawer(),
|
drawer: const HealthDrawer(),
|
||||||
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.35,
|
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.35,
|
||||||
body: AppBackground(
|
body: AppBackground(
|
||||||
|
|||||||
@@ -1,55 +1,63 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
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 'auth_provider.dart';
|
||||||
import 'data_providers.dart';
|
import 'data_providers.dart';
|
||||||
import '../models/bp_reading.dart';
|
|
||||||
import '../services/omron_ble_service.dart';
|
|
||||||
|
|
||||||
/// BLE 服务单例
|
const _boundDevicesKey = 'bound_ble_devices';
|
||||||
final omronBleServiceProvider = Provider<OmronBleService>((ref) {
|
const _readingFingerprintsKey = 'ble_reading_fingerprints';
|
||||||
return OmronBleService();
|
const _legacyBpMacKey = 'bp_device_mac';
|
||||||
|
const _legacyBpNameKey = 'bp_device_name';
|
||||||
|
const _legacyBpLastSyncKey = 'bp_last_sync';
|
||||||
|
|
||||||
|
final healthBleServiceProvider = Provider<HealthBleService>((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 {
|
class DeviceBindState {
|
||||||
final bool isBound;
|
final List<BoundBleDevice> devices;
|
||||||
final String? mac;
|
|
||||||
final String? name;
|
|
||||||
final String? lastSync;
|
|
||||||
final bool isConnected;
|
final bool isConnected;
|
||||||
final BpReading? lastReading;
|
|
||||||
|
|
||||||
const DeviceBindState({
|
const DeviceBindState({this.devices = const [], this.isConnected = false});
|
||||||
this.isBound = false,
|
|
||||||
this.mac,
|
|
||||||
this.name,
|
|
||||||
this.lastSync,
|
|
||||||
this.isConnected = false,
|
|
||||||
this.lastReading,
|
|
||||||
});
|
|
||||||
|
|
||||||
DeviceBindState copyWith({
|
bool get isBound => devices.isNotEmpty;
|
||||||
bool? isBound,
|
|
||||||
String? mac,
|
DeviceBindState copyWith({List<BoundBleDevice>? devices, bool? isConnected}) {
|
||||||
String? name,
|
return DeviceBindState(
|
||||||
String? lastSync,
|
devices: devices ?? this.devices,
|
||||||
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,
|
isConnected: isConnected ?? this.isConnected,
|
||||||
lastReading: lastReading ?? this.lastReading,
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BoundBleDevice> 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<DeviceBindState> {
|
class DeviceBindNotifier extends Notifier<DeviceBindState> {
|
||||||
StreamSubscription? _connSub;
|
StreamSubscription<bool>? _connSub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
DeviceBindState build() {
|
DeviceBindState build() {
|
||||||
|
ref.onDispose(() => _connSub?.cancel());
|
||||||
_loadBinding();
|
_loadBinding();
|
||||||
_listenConnection();
|
_listenConnection();
|
||||||
return const DeviceBindState();
|
return const DeviceBindState();
|
||||||
@@ -57,52 +65,182 @@ class DeviceBindNotifier extends Notifier<DeviceBindState> {
|
|||||||
|
|
||||||
void _listenConnection() {
|
void _listenConnection() {
|
||||||
_connSub?.cancel();
|
_connSub?.cancel();
|
||||||
_connSub = ref.read(omronBleServiceProvider).connectionState.listen((connected) {
|
_connSub = ref.read(healthBleServiceProvider).connectionState.listen((
|
||||||
|
connected,
|
||||||
|
) {
|
||||||
if (state.isConnected != connected) {
|
if (state.isConnected != connected) {
|
||||||
state = state.copyWith(isConnected: connected);
|
state = state.copyWith(isConnected: connected);
|
||||||
}
|
}
|
||||||
if (!connected && state.isBound) {
|
|
||||||
// 设备断开,但保持绑定信息(下次可以重连)
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadBinding() async {
|
Future<void> _loadBinding() async {
|
||||||
final db = ref.read(localDbProvider);
|
final db = ref.read(localDbProvider);
|
||||||
final mac = await db.read('bp_device_mac');
|
await _migrateLegacyBloodPressureDevice();
|
||||||
final name = await db.read('bp_device_name');
|
final raw = await db.read(_boundDevicesKey);
|
||||||
final lastSync = await db.read('bp_last_sync');
|
final devices = _decodeDevices(raw);
|
||||||
if (mac != null && mac.isNotEmpty) {
|
state = state.copyWith(devices: devices);
|
||||||
state = state.copyWith(isBound: true, mac: mac, name: name, lastSync: lastSync);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> bind(String mac, String name) async {
|
Future<void> _migrateLegacyBloodPressureDevice() async {
|
||||||
final db = ref.read(localDbProvider);
|
final db = ref.read(localDbProvider);
|
||||||
await db.write('bp_device_mac', mac);
|
final existing = await db.read(_boundDevicesKey);
|
||||||
await db.write('bp_device_name', name);
|
final legacyMac = await db.read(_legacyBpMacKey);
|
||||||
state = state.copyWith(isBound: true, mac: mac, name: name);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> unbind() async {
|
List<BoundBleDevice> _decodeDevices(String? raw) {
|
||||||
|
if (raw == null || raw.isEmpty) return [];
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! List) return [];
|
||||||
|
return decoded
|
||||||
|
.whereType<Map>()
|
||||||
|
.map(
|
||||||
|
(item) => BoundBleDevice.fromJson(Map<String, dynamic>.from(item)),
|
||||||
|
)
|
||||||
|
.whereType<BoundBleDevice>()
|
||||||
|
.toList();
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveDevices(List<BoundBleDevice> devices) async {
|
||||||
final db = ref.read(localDbProvider);
|
final db = ref.read(localDbProvider);
|
||||||
await db.delete('bp_device_mac');
|
await db.write(
|
||||||
await db.delete('bp_device_name');
|
_boundDevicesKey,
|
||||||
await db.delete('bp_last_sync');
|
jsonEncode(devices.map((device) => device.toJson()).toList()),
|
||||||
state = const DeviceBindState();
|
);
|
||||||
|
state = state.copyWith(devices: devices);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setConnected(bool connected) {
|
Future<void> bind(BoundBleDevice device) async {
|
||||||
state = state.copyWith(isConnected: connected);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> onReading(BpReading reading) async {
|
Future<void> unbind(String id) async {
|
||||||
final db = ref.read(localDbProvider);
|
await _saveDevices(
|
||||||
final lastSync = '${reading.timestamp.month}/${reading.timestamp.day} ${reading.timestamp.hour}:${reading.timestamp.minute.toString().padLeft(2, '0')}';
|
state.devices.where((device) => device.id != id).toList(),
|
||||||
await db.write('bp_last_sync', lastSync);
|
);
|
||||||
state = state.copyWith(lastSync: lastSync, lastReading: reading);
|
}
|
||||||
|
|
||||||
|
Future<void> 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);
|
ref.invalidate(latestHealthProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> 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<void> _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<Map<String, String>> _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<void> _saveFingerprints(Map<String, String> fingerprints) async {
|
||||||
|
final db = ref.read(localDbProvider);
|
||||||
|
await db.write(_readingFingerprintsKey, jsonEncode(fingerprints));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> _pruneFingerprints(
|
||||||
|
Map<String, String> fingerprints,
|
||||||
|
DateTime now,
|
||||||
|
) {
|
||||||
|
final pruned = <String, String>{};
|
||||||
|
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, DeviceBindState>(DeviceBindNotifier.new);
|
final omronDeviceProvider =
|
||||||
|
NotifierProvider<DeviceBindNotifier, DeviceBindState>(
|
||||||
|
DeviceBindNotifier.new,
|
||||||
|
);
|
||||||
|
|||||||
316
health_app/lib/services/health_ble_service.dart
Normal file
316
health_app/lib/services/health_ble_service.dart
Normal file
@@ -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<BluetoothConnectionState>? _connStateSub;
|
||||||
|
StreamSubscription<List<int>>? _measurementSub;
|
||||||
|
final _connStateController = StreamController<bool>.broadcast();
|
||||||
|
|
||||||
|
Stream<bool> get connectionState => _connStateController.stream;
|
||||||
|
|
||||||
|
static bool isSupportedHealthDevice(ScanResult result) {
|
||||||
|
return BleDeviceIdentifier.isSupportedScanResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
static BleDeviceType? deviceTypeFromScan(ScanResult result) {
|
||||||
|
return BleDeviceIdentifier.typeFromScan(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BoundBleDevice> 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<HealthBleSyncResult> 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<List<BluetoothService>> _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<BpReading> _syncBloodPressure(
|
||||||
|
List<BluetoothService> 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<BpReading>();
|
||||||
|
final readings = <BpReading>[];
|
||||||
|
Timer? settleTimer;
|
||||||
|
|
||||||
|
void acceptReading(List<int> 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<BpReading> 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<void> _enableMeasurementUpdates(
|
||||||
|
BluetoothCharacteristic characteristic,
|
||||||
|
) async {
|
||||||
|
final useIndication = characteristic.properties.indicate;
|
||||||
|
await characteristic.setNotifyValue(true, forceIndications: useIndication);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<int> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<int>>? _measurementSub;
|
|
||||||
StreamSubscription<BluetoothConnectionState>? _connStateSub;
|
|
||||||
final _readingsController = StreamController<BpReading>.broadcast();
|
|
||||||
final _connStateController = StreamController<bool>.broadcast();
|
|
||||||
Stream<BpReading> get readings => _readingsController.stream;
|
|
||||||
Stream<bool> get connectionState => _connStateController.stream;
|
|
||||||
|
|
||||||
bool get isConnected => _device?.isConnected ?? false;
|
|
||||||
|
|
||||||
/// 直接重连已知设备(通过MAC地址)
|
|
||||||
Future<bool> 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<void> 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 = <String>[];
|
|
||||||
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<void> 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<int> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
160
health_app/lib/widgets/ble_sync_dialog.dart
Normal file
160
health_app/lib/widgets/ble_sync_dialog.dart
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../core/app_colors.dart';
|
||||||
|
import '../models/bp_reading.dart';
|
||||||
|
|
||||||
|
Future<void> showBleSyncDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required BpReading reading,
|
||||||
|
}) {
|
||||||
|
return showDialog<void>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -342,6 +342,12 @@ class _NavigationSection extends StatelessWidget {
|
|||||||
route: 'exercisePlan',
|
route: 'exercisePlan',
|
||||||
colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)],
|
colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)],
|
||||||
),
|
),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.bluetooth_connected_rounded,
|
||||||
|
title: '蓝牙设备',
|
||||||
|
route: 'devices',
|
||||||
|
colors: const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
return _Panel(
|
return _Panel(
|
||||||
@@ -383,6 +389,7 @@ class _NavTile extends StatelessWidget {
|
|||||||
'calendar' => '健康日历',
|
'calendar' => '健康日历',
|
||||||
'followups' => '复查随访',
|
'followups' => '复查随访',
|
||||||
'exercisePlan' => '运动计划',
|
'exercisePlan' => '运动计划',
|
||||||
|
'devices' => '蓝牙设备',
|
||||||
_ => item.title,
|
_ => item.title,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -394,6 +401,7 @@ class _NavTile extends StatelessWidget {
|
|||||||
'calendar' => const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
|
'calendar' => const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
|
||||||
'followups' => const [Color(0xFFF472B6), Color(0xFFDB2777)],
|
'followups' => const [Color(0xFFF472B6), Color(0xFFDB2777)],
|
||||||
'exercisePlan' => const [Color(0xFF7DD3FC), Color(0xFF60A5FA)],
|
'exercisePlan' => const [Color(0xFF7DD3FC), Color(0xFF60A5FA)],
|
||||||
|
'devices' => const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
|
||||||
_ => item.colors,
|
_ => item.colors,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user