Compare commits
5 Commits
sccsbc
...
6e5d3e64cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e5d3e64cd | ||
|
|
528859686f | ||
|
|
fade61ac21 | ||
|
|
e654c1e0cc | ||
|
|
335a3e6440 |
1
audit/2026-07-13/window.xml
Normal file
1
audit/2026-07-13/window.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/01-current.xml
Normal file
1
audit/2026-07-14/01-current.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/02-drawer.xml
Normal file
1
audit/2026-07-14/02-drawer.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/03-health-archive.xml
Normal file
1
audit/2026-07-14/03-health-archive.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/04-reports.xml
Normal file
1
audit/2026-07-14/04-reports.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/05-medications.xml
Normal file
1
audit/2026-07-14/05-medications.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/06-diet-records.xml
Normal file
1
audit/2026-07-14/06-diet-records.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/07-calendar.xml
Normal file
1
audit/2026-07-14/07-calendar.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/08-followups.xml
Normal file
1
audit/2026-07-14/08-followups.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,302]"><node NAF="true" index="0" text="" resource-id="" class="android.widget.Button" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[14,127][182,295]" /><node index="1" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="复查随访" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[600,173][840,250]" /></node><node index="1" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="暂无随访安排" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[600,1816][840,1872]" /><node index="2" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="医生创建随访后将显示在这里" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[401,1886][1039,1952]" /></node></node></node></node></node></node></node></node></hierarchy>
|
||||||
1
audit/2026-07-14/09-exercise.xml
Normal file
1
audit/2026-07-14/09-exercise.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/10-devices.xml
Normal file
1
audit/2026-07-14/10-devices.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/11-notifications.xml
Normal file
1
audit/2026-07-14/11-notifications.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/12-settings.xml
Normal file
1
audit/2026-07-14/12-settings.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/13-profile.xml
Normal file
1
audit/2026-07-14/13-profile.xml
Normal file
File diff suppressed because one or more lines are too long
261
audit/2026-07-14/project-audit-report.md
Normal file
261
audit/2026-07-14/project-audit-report.md
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
# 小脉健康项目全面检查报告
|
||||||
|
|
||||||
|
检查日期:2026-07-14
|
||||||
|
检查范围:Flutter Android 真机主要用户路径、Flutter 静态检查与测试、.NET 后端测试、前后端接口对应关系、用户/医生/管理员业务逻辑、权限安全、合规页面与 Android/iOS 发布准备。
|
||||||
|
本轮只检查,没有修改业务代码,也没有使用 Docker。
|
||||||
|
|
||||||
|
## 一、总体结论
|
||||||
|
|
||||||
|
项目主体功能已经比较完整,普通用户主路径能进入,当前 Flutter 静态检查通过,Flutter 47 项测试和后端 44 项测试全部通过。但测试通过不等于已经稳定:本次仍确认了多项自动测试没有覆盖的真实断点。
|
||||||
|
|
||||||
|
当前不适合进入正式环境或商店审核。最优先的不是继续统一小间距,而是先处理问诊鉴权、管理员验证码、上传文件访问、蓝牙上传、账号注销清理和正式包权限。这些问题会影响真实数据、安全或用户是否能完成任务。
|
||||||
|
|
||||||
|
## 二、必须先修的问题
|
||||||
|
|
||||||
|
### 1. AI 流式聊天的 token 只被“读出来”,没有验证真伪
|
||||||
|
|
||||||
|
- 用户表现:正常用户不一定马上发现,但攻击者可以自己伪造一个 token,冒充任意用户进入 AI 对话、读取该用户上下文,并可能触发健康数据写入。
|
||||||
|
- 原因:SSE 接口没有强制登录;备用 token 处理只解析 JWT 内容,没有校验签名、过期时间、签发方和接收方。
|
||||||
|
- 位置:`backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` 的 `GetUserIdFromToken`。
|
||||||
|
- 优先级:最高。
|
||||||
|
|
||||||
|
### 2. 医生问诊实时通道没有登录和问诊归属校验
|
||||||
|
|
||||||
|
- 用户表现:知道问诊编号的人可以加入房间,甚至把自己声明成“用户”或“医生”发送消息;消息还会真实写入数据库。
|
||||||
|
- 原因:SignalR Hub 没有鉴权,加入房间和发送消息时也没有核对当前账号是否属于该患者或医生。
|
||||||
|
- 位置:`backend/src/Health.WebApi/Hubs/ConsultationHub.cs`。
|
||||||
|
- 优先级:最高。
|
||||||
|
|
||||||
|
### 3. 管理员存在固定手机号和固定验证码
|
||||||
|
|
||||||
|
- 用户表现:正式环境若沿用当前代码,知道固定号码的人可用 `000000` 登录管理员端。
|
||||||
|
- 原因:管理员手机号和验证码直接写在认证服务中,而且不只在开发环境生效。
|
||||||
|
- 位置:`backend/src/Health.Infrastructure/Auth/AuthService.cs`。
|
||||||
|
- 优先级:最高。
|
||||||
|
|
||||||
|
### 4. 上传的报告、聊天图片和 PDF 可被未登录访问
|
||||||
|
|
||||||
|
- 用户表现:文件链接一旦泄露,不登录也能打开健康报告或聊天附件。
|
||||||
|
- 原因:整个 `/uploads` 目录作为公开静态目录提供,没有按用户、医生关系或临时授权校验。
|
||||||
|
- 位置:`backend/src/Health.WebApi/Program.cs` 的上传目录静态映射。
|
||||||
|
- 优先级:最高。
|
||||||
|
|
||||||
|
### 5. 蓝牙血压计能读到数据,但自动上传会失败
|
||||||
|
|
||||||
|
- 用户表现:设备连接成功、血压已经读取,最后却提示上传失败;血压和心率不会真正保存到后端。
|
||||||
|
- 原因:前端提交到 `/api/health-records/batch`,当前后端没有这个接口。
|
||||||
|
- 位置:`health_app/lib/pages/device/device_management_page.dart` 与 `backend/src/Health.WebApi/Endpoints/health_endpoints.cs`。
|
||||||
|
- 优先级:高。
|
||||||
|
|
||||||
|
### 6. 每次打开医生问诊页都会创建一条新问诊并消耗次数
|
||||||
|
|
||||||
|
- 用户表现:只是进入页面看一下、没有发送任何内容,也会新增问诊;反复进出会快速消耗每月次数。显示剩余 0 次时仍可能继续创建,甚至出现负数。
|
||||||
|
- 原因:前端初始化先查次数,然后无条件创建;后端创建接口没有真正执行三次上限,也没有验证医生仍然有效。
|
||||||
|
- 位置:`health_app/lib/providers/consultation_provider.dart`、`backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs`。
|
||||||
|
- 优先级:高。
|
||||||
|
|
||||||
|
### 7. 问诊消息发送失败时仍显示为已发送
|
||||||
|
|
||||||
|
- 用户表现:网络断开或服务器失败后,消息仍留在聊天里,没有失败标记和重试入口,用户会误以为医生已经收到。
|
||||||
|
- 原因:发送异常被静默忽略,本地消息没有回滚或转成失败状态。
|
||||||
|
- 位置:`health_app/lib/providers/consultation_provider.dart` 的 `sendMessage`。
|
||||||
|
- 优先级:高,且不符合“不能假成功”的项目要求。
|
||||||
|
|
||||||
|
### 8. Android 正式包没有网络权限
|
||||||
|
|
||||||
|
- 用户表现:Debug 真机可联网,但以后构建 release 包后,所有登录、数据、AI 和报告请求都无法联网。
|
||||||
|
- 原因:`INTERNET` 只在 debug/profile Manifest 中,main Manifest 没有;release 不会合并 debug 配置。
|
||||||
|
- 位置:`health_app/android/app/src/main/AndroidManifest.xml`。
|
||||||
|
- 优先级:正式包前必须修。
|
||||||
|
|
||||||
|
### 9. 账号注销只删数据库记录,不会清理服务器上传文件
|
||||||
|
|
||||||
|
- 用户表现:App 显示账号已删除,但报告原图、聊天图片、PDF 等仍可能留在服务器目录;公开链接仍可能继续访问。
|
||||||
|
- 原因:账号注销批量删除数据库数据时没有收集并删除物理文件。单独删除报告有删文件,注销账号没有走同一流程。
|
||||||
|
- 位置:`backend/src/Health.Infrastructure/Users/EfUserRepository.cs`。
|
||||||
|
- 优先级:高,涉及合规承诺和真实删除。
|
||||||
|
|
||||||
|
### 10. iOS 相机和相册权限说明缺失
|
||||||
|
|
||||||
|
- 用户表现:以后在 iPhone 上拍饮食、拍报告或选相册时,系统可能直接终止 App,而不是正常弹出权限框。
|
||||||
|
- 原因:Info.plist 有蓝牙说明,但没有 `NSCameraUsageDescription` 和 `NSPhotoLibraryUsageDescription`。
|
||||||
|
- 位置:`health_app/ios/Runner/Info.plist`。
|
||||||
|
- 优先级:iOS 调试前必须修。
|
||||||
|
|
||||||
|
## 三、重要功能和稳定性问题
|
||||||
|
|
||||||
|
### 11. 多个请求同时遇到登录过期时,可能刚刷新成功又被强制退出
|
||||||
|
|
||||||
|
多个 401 会同时拿同一个刷新 token。第一个请求刷新并保存新 token 后,第二个请求可能因旧 token 已失效而失败,随后把刚保存的新 token 一并清掉。用户会偶发跳回登录页。
|
||||||
|
|
||||||
|
位置:`health_app/lib/core/api_client.dart` 的 401 刷新逻辑。
|
||||||
|
|
||||||
|
### 12. 启动时只要服务器暂时连不上,就把用户当成未登录
|
||||||
|
|
||||||
|
刷新 token 的请求遇到断网或本地后端未启动时,认证状态直接变为未登录,但本地 token 又没有清除。用户会看到登录页,网络恢复后也不会自动重试,只能重启或重新操作。
|
||||||
|
|
||||||
|
位置:`health_app/lib/providers/auth_provider.dart` 的 `_checkAuth`。
|
||||||
|
|
||||||
|
### 13. 问诊轮询用随机 GUID 判断“之后的消息”,可能漏消息
|
||||||
|
|
||||||
|
SignalR 失败后会回退轮询。后端把随机消息 ID 与上一条随机 ID 做大小比较,这不等于消息时间顺序,可能漏掉医生新回复。正确游标应基于服务器时间或稳定递增字段。
|
||||||
|
|
||||||
|
位置:`backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs`。
|
||||||
|
|
||||||
|
### 14. 问诊 AI 开场白只存在前端,没有保存
|
||||||
|
|
||||||
|
用户看到的“AI 分身问候”不是数据库消息,医生端看不到;重新进入又会新建问诊并生成另一条本地问候,历史不连续。
|
||||||
|
|
||||||
|
位置:`health_app/lib/providers/consultation_provider.dart`。
|
||||||
|
|
||||||
|
### 15. 短信验证码没有频率、次数和防刷限制
|
||||||
|
|
||||||
|
发送接口可反复调用,验证码校验也没有失败次数限制。正式短信接入后会产生短信轰炸、费用和验证码暴力尝试风险。
|
||||||
|
|
||||||
|
位置:认证端点和 `AuthService`。
|
||||||
|
|
||||||
|
### 16. 管理员新增、停用、修改、删除医生时,几套医生数据可能不同步
|
||||||
|
|
||||||
|
- 手机号已属于普通用户时,仍可创建医生实体,但不会把已有用户升级并绑定医生档案。
|
||||||
|
- 修改医生手机号/姓名时,没有同步医生登录账号与医生档案。
|
||||||
|
- 停用医生只改一处状态,医生档案的状态可能仍是启用。
|
||||||
|
- 删除医生后,医生登录用户仍可能保留 Doctor 角色,但档案已失去关联,登录后只能看到“档案未关联”。
|
||||||
|
- 医生手机号没有唯一约束,可能出现重复医生。
|
||||||
|
|
||||||
|
位置:`backend/src/Health.Infrastructure/Admin/AdminService.cs`。
|
||||||
|
|
||||||
|
### 17. 附件本地路径没有限制在 uploads 目录内
|
||||||
|
|
||||||
|
附件 URL 中的相对路径直接拼到服务器路径,没有做规范化后再确认最终路径仍位于 uploads 内,也没有核对文件是否属于当前用户。这会带来跨用户读取或路径越界风险。
|
||||||
|
|
||||||
|
位置:`backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs`。
|
||||||
|
|
||||||
|
### 18. 通知点击先联网标记已读,失败时目的页面也打不开
|
||||||
|
|
||||||
|
用户点一条未读通知时,先请求后端标记已读;如果这一步失败,后续报告/用药/健康页面不会打开,也没有明确提示。
|
||||||
|
|
||||||
|
位置:`health_app/lib/pages/notifications/notification_center_page.dart`。
|
||||||
|
|
||||||
|
### 19. 用户端与医生端部分“今天/本月”按 UTC 计算
|
||||||
|
|
||||||
|
中国时区的凌晨时段可能被算到上一天或上个月,影响问诊次数、今日随访和提醒归类。正式环境应统一明确业务时区。
|
||||||
|
|
||||||
|
位置:问诊和医生工作台中使用 `DateTime.UtcNow.Date` 或 UTC 月份的逻辑。
|
||||||
|
|
||||||
|
## 四、UI 与交互问题
|
||||||
|
|
||||||
|
### 20. 用药和运动列表默认露出整片红色删除背景
|
||||||
|
|
||||||
|
真机截图中,用药行和运动行在没有滑动时已经整行变红;运动行还出现“打卡”和“删除”重叠。用户会误以为数据异常或正处于危险删除状态。
|
||||||
|
|
||||||
|
原因是通用滑动删除组件始终铺红色底层,而这些列表行本身透明。测试只验证滑动动作,没有验证未滑动时的最终视觉。
|
||||||
|
|
||||||
|
证据:`05-medications.png`、`09-exercise.png`。
|
||||||
|
位置:`health_app/lib/widgets/common_widgets.dart`。
|
||||||
|
|
||||||
|
### 21. 首页多个纯图标按钮缺少无障碍名称
|
||||||
|
|
||||||
|
真机可访问性结构中,菜单、附件、输入框和发送按钮等多个可点击区域没有名称。普通用户能凭图标猜,读屏用户无法知道用途。
|
||||||
|
|
||||||
|
证据:`01-current.xml`。
|
||||||
|
|
||||||
|
### 22. 删除动作风格不统一
|
||||||
|
|
||||||
|
报告、用药、运动使用滑动删除,通知使用整行侧滑,蓝牙设备直接显示红色垃圾桶,设置页则用大按钮。交互成本和误触风险不同。建议统一为“列表行滑动后显示删除 + 二次确认用于高价值数据”。
|
||||||
|
|
||||||
|
### 23. 设置页的红色占比过重
|
||||||
|
|
||||||
|
“删除账号”和“退出登录”两个大面积危险操作占据页面最强视觉层级,反而压过消息、隐私和服务协议。删除账号保持红色是合理的,但退出登录不必使用整块高饱和红色。
|
||||||
|
|
||||||
|
证据:`12-settings.png`。
|
||||||
|
|
||||||
|
### 24. 侧边栏健康仪表盘过于抢眼
|
||||||
|
|
||||||
|
大面积蓝紫粉渐变和四个小框成为最强区域,与项目“克制、专业、模块色只做识别”的偏好有偏差;指标小字和透明边框的对比度也偏弱。
|
||||||
|
|
||||||
|
证据:`02-drawer.png`。
|
||||||
|
|
||||||
|
### 25. 空状态表达不统一
|
||||||
|
|
||||||
|
复查随访使用居中图标和说明;饮食使用一条白色容器;其他页面可能只留空白。建议统一空状态的标题、原因和下一步操作,但不需要每页再套一张大卡片。
|
||||||
|
|
||||||
|
证据:`06-diet-records.png`、`08-followups.png`。
|
||||||
|
|
||||||
|
### 26. 部分图标按钮只有约 40dp
|
||||||
|
|
||||||
|
例如蓝牙页顶部新增按钮使用固定 40×40,低于常见的 48dp 触控建议;对手部不便或视力较弱的健康类用户不够友好。
|
||||||
|
|
||||||
|
### 27. 大字体、横屏、小屏和读屏尚无验证证据
|
||||||
|
|
||||||
|
许多页面使用固定高度、固定行布局和多个并排信息块。当前只在一台 1440×3200 Android 真机、默认字体比例下检查,不能据此确认大字体和 iOS 横屏不会溢出。
|
||||||
|
|
||||||
|
## 五、合规与发布准备问题
|
||||||
|
|
||||||
|
### 28. 合规文档仍有主体和联系方式占位符
|
||||||
|
|
||||||
|
隐私政策、服务协议、个人信息清单和 SDK 清单里仍有 `xxx`、待定短信服务商等内容,不能用于正式审核。
|
||||||
|
|
||||||
|
### 29. App 内文档与 H5 文档存在多套日期和内容
|
||||||
|
|
||||||
|
App 内隐私/协议日期为 6 月 29 日,个人信息与 SDK 清单为 7 月 9 日;H5 清单日期为 7 月 7 日。以后修改一处很容易漏另一处。建议确定单一内容源或至少增加一致性测试。
|
||||||
|
|
||||||
|
### 30. “关于我们”写了并不存在的意见反馈入口
|
||||||
|
|
||||||
|
文档提示“设置 → 意见反馈”,实际设置页没有该入口;版本还写成 `Build 20260101`,与当前 `1.0.0+1` 不一致。
|
||||||
|
|
||||||
|
### 31. 用户同意协议没有服务端记录
|
||||||
|
|
||||||
|
当前“同意”只是登录页内存中的一个布尔值,未保存同意时间、协议版本或政策更新后的重新同意记录。服务端接口也不要求同意状态,无法证明用户同意了哪一版条款。
|
||||||
|
|
||||||
|
### 32. Android 权限声明偏多且缺少版本限制
|
||||||
|
|
||||||
|
当前声明了蓝牙广播权限,但 App 主要是扫描/连接;旧版存储和蓝牙权限也没有按 Android 版本加 `maxSdkVersion`。正式个人信息清单应与实际最小权限保持一致。
|
||||||
|
|
||||||
|
### 33. 本地 token 使用普通 SQLite 明文保存
|
||||||
|
|
||||||
|
符合项目“不用 shared_preferences”的约定,但 access token 和 refresh token 没有系统级加密保护;同时 Android 也未显式关闭备份。正式版本需要决定是否改用系统安全存储或加密数据库,并限制备份。
|
||||||
|
|
||||||
|
### 34. iOS 名称和方向仍是模板状态
|
||||||
|
|
||||||
|
Info.plist 的显示名称还是 `Health App`,并允许 iPhone 横屏;当前页面主要按竖屏设计,尚未验证横屏。Bundle ID、签名和证书则仍按项目计划待配置。
|
||||||
|
|
||||||
|
### 35. CORS 当前允许任意来源并携带凭据
|
||||||
|
|
||||||
|
本地调试方便,但正式环境应只允许确定的 H5/管理端域名,不能继续接受任意 Origin。
|
||||||
|
|
||||||
|
## 六、已确认正常或已有保护的部分
|
||||||
|
|
||||||
|
- 当前 Flutter `analyze` 无错误。
|
||||||
|
- Flutter 47 项测试全部通过。
|
||||||
|
- 后端 44 项测试全部通过。
|
||||||
|
- 报告、用药、饮食、运动、健康数据等主要删除接口都按当前用户筛选,不是只删前端显示。
|
||||||
|
- 单独删除报告时会同时删除报告原文件。
|
||||||
|
- AI 历史对话恢复后仍保留原 conversationId,可以继续发送新消息;历史确认卡只读是合理保护。
|
||||||
|
- 管理员和医生普通 HTTP 端点已有角色过滤,问题主要集中在固定管理员验证码、医生数据同步和未鉴权 SignalR Hub。
|
||||||
|
- 设置、通知、报告列表已经较接近“一行一行、文字区分隔线”的目标风格。
|
||||||
|
- 隐私政策、服务协议、个人信息清单、SDK 清单和关于我们均已有 App 内入口与 H5 文件。
|
||||||
|
|
||||||
|
## 七、真机流程健康度
|
||||||
|
|
||||||
|
1. 首页与 AI 对话:可进入;视觉基本完整,但纯图标无障碍名称不足,AI SSE 鉴权有严重后端风险。
|
||||||
|
2. 侧边栏:可打开;功能入口齐全,仪表盘色彩和层级偏重。
|
||||||
|
3. 健康档案:可加载和编辑;固定保存区需要继续验证大字体遮挡。
|
||||||
|
4. 报告管理:列表正常;文件公开访问是严重隐私风险。
|
||||||
|
5. 用药管理:数据能加载;列表红底显示错误明显。
|
||||||
|
6. 饮食记录:日期、空状态和趋势能显示;空状态样式与其他页不统一。
|
||||||
|
7. 健康日历:日期和计划能显示;业务时区需要后端统一。
|
||||||
|
8. 复查随访:空状态清楚;本次账号没有数据,未实测详情。
|
||||||
|
9. 运动计划:数据能加载;红底和操作文字重叠明显。
|
||||||
|
10. 蓝牙设备:绑定设备能显示;自动读取后的批量上传链路断开。
|
||||||
|
11. 通知中心:历史和未读数能显示;未读通知在网络失败时可能打不开目的页。
|
||||||
|
12. 设置与合规入口:入口齐全;危险操作权重过高,文档仍有占位和不一致。
|
||||||
|
13. 个人信息:真机运行版本可进入;检查期间源代码刚加入新的基本资料编辑入口,当前运行包尚未热更新到这一版。
|
||||||
|
14. 医生端:已完成代码与接口核对;没有医生测试账号,本次未做完整真机流程。
|
||||||
|
15. 管理员端:已完成代码与接口核对;没有实际执行新增/停用/删除,避免改变真实数据。
|
||||||
|
16. iOS:仅做配置与代码检查;没有证书、Mac 构建和 iPhone 实测,不能确认界面与权限流程。
|
||||||
|
|
||||||
|
## 八、证据限制
|
||||||
|
|
||||||
|
- 真机截图来自本次检查时设备上正在运行的调试包。检查过程中项目源代码仍在被更新,后加入的个人资料编辑和设备错误文案没有重新构建到该运行包,因此相关结论以当前代码为准、旧界面截图只用于对应未变化页面。
|
||||||
|
- 没有执行真实删除、账号注销、医生管理、短信发送或蓝牙测量,避免改变现有数据;这些部分通过前后端代码和接口对应关系确认。
|
||||||
|
- 没有正式域名、HTTPS、短信服务商、对象存储、iOS 签名和商店账号,因此只检查了当前准备缺口。
|
||||||
1
audit/ui-pages/window.xml
Normal file
1
audit/ui-pages/window.xml
Normal file
File diff suppressed because one or more lines are too long
@@ -35,7 +35,7 @@ public interface IAiConversationService
|
|||||||
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
|
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||||
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||||
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
|
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
|
||||||
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
Task<bool> DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||||
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
|
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ public interface IAiConversationRepository
|
|||||||
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
||||||
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
|
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
|
||||||
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
|
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
|
||||||
|
Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct);
|
||||||
void Delete(Conversation conversation);
|
void Delete(Conversation conversation);
|
||||||
Task SaveChangesAsync(CancellationToken ct);
|
Task SaveChangesAsync(CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace Health.Application.AI;
|
|||||||
|
|
||||||
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
||||||
{
|
{
|
||||||
private const int HistoryConversationLimit = 7;
|
private const int HistoryConversationLimit = 30;
|
||||||
private readonly IAiConversationRepository _conversations = conversations;
|
private readonly IAiConversationRepository _conversations = conversations;
|
||||||
|
|
||||||
public async Task<OpenConversationResult> OpenAsync(
|
public async Task<OpenConversationResult> OpenAsync(
|
||||||
@@ -55,6 +55,10 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
await _conversations.DeleteLegacyDietCommentaryArtifactsAsync(
|
||||||
|
userId,
|
||||||
|
DietCommentaryPolicy.LegacyPromptPrefix,
|
||||||
|
ct);
|
||||||
var conversations = await _conversations.ListAsync(userId, ct);
|
var conversations = await _conversations.ListAsync(userId, ct);
|
||||||
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
|
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
|
||||||
}
|
}
|
||||||
@@ -79,13 +83,14 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
await _conversations.SaveChangesAsync(ct);
|
await _conversations.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
public async Task<bool> DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
||||||
if (conversation == null) return;
|
if (conversation == null) return false;
|
||||||
|
|
||||||
_conversations.Delete(conversation);
|
_conversations.Delete(conversation);
|
||||||
await _conversations.SaveChangesAsync(ct);
|
await _conversations.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)
|
public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)
|
||||||
|
|||||||
35
backend/src/Health.Application/AI/DietCommentaryPolicy.cs
Normal file
35
backend/src/Health.Application/AI/DietCommentaryPolicy.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Application.AI;
|
||||||
|
|
||||||
|
public sealed record DietCommentaryFood(string Name, string Portion, int Calories);
|
||||||
|
|
||||||
|
public static class DietCommentaryPolicy
|
||||||
|
{
|
||||||
|
public const string LegacyPromptPrefix = "饮食记录页需要展示本餐建议。";
|
||||||
|
|
||||||
|
public const string SystemPrompt = """
|
||||||
|
你是健康管理 App 中的饮食建议助手。根据用户本餐食物和健康档案,直接输出2到3条简短建议。
|
||||||
|
每条建议12到22个汉字,不要问候,不要说“好的”“当然”“建议如下”,不要编号,不要使用 Markdown。
|
||||||
|
优先围绕控盐控油、蔬菜和蛋白质搭配、血糖血脂风险及份量控制。
|
||||||
|
不作诊断,不提供治疗、处方、停药或调药结论。语言简洁、专业、面向普通用户。
|
||||||
|
""";
|
||||||
|
|
||||||
|
public static string BuildFoodDescription(IEnumerable<DietCommentaryFood> foods) =>
|
||||||
|
string.Join("、", foods.Select(food =>
|
||||||
|
$"{Normalize(food.Name, 40)}({Normalize(food.Portion, 30)},{Math.Clamp(food.Calories, 0, 10000)}千卡)"));
|
||||||
|
|
||||||
|
public static bool IsLegacyArtifact(IEnumerable<ConversationMessage> messages)
|
||||||
|
{
|
||||||
|
var userMessages = messages.Where(message => message.Role == MessageRole.User).ToList();
|
||||||
|
return userMessages.Count > 0 &&
|
||||||
|
userMessages.All(message => message.Content.TrimStart().StartsWith(LegacyPromptPrefix, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string value, int maxLength)
|
||||||
|
{
|
||||||
|
var normalized = string.Join(' ', value.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
return normalized.Length > maxLength ? normalized[..maxLength] : normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,9 @@ public sealed class PatientContextService(
|
|||||||
if (archive != null)
|
if (archive != null)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
|
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
|
||||||
if (archive.Surgeries.Count > 0)
|
if (archive.SurgeryHistoryStatus == "无")
|
||||||
|
sb.AppendLine("手术史: 无");
|
||||||
|
else if (archive.Surgeries.Count > 0)
|
||||||
sb.AppendLine($"手术: {string.Join(";", archive.Surgeries.Select(s => $"{s.Type} ({s.Date})"))}");
|
sb.AppendLine($"手术: {string.Join(";", archive.Surgeries.Select(s => $"{s.Type} ({s.Date})"))}");
|
||||||
else if (!string.IsNullOrEmpty(archive.SurgeryType))
|
else if (!string.IsNullOrEmpty(archive.SurgeryType))
|
||||||
sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
|
sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public sealed record HealthArchiveDto(
|
|||||||
IReadOnlyList<string> DietRestrictions,
|
IReadOnlyList<string> DietRestrictions,
|
||||||
IReadOnlyList<string> ChronicDiseases,
|
IReadOnlyList<string> ChronicDiseases,
|
||||||
string? FamilyHistory,
|
string? FamilyHistory,
|
||||||
|
string? SurgeryHistoryStatus,
|
||||||
DateTime UpdatedAt);
|
DateTime UpdatedAt);
|
||||||
|
|
||||||
public sealed record HealthArchiveUpdateRequest(
|
public sealed record HealthArchiveUpdateRequest(
|
||||||
@@ -32,7 +33,8 @@ public sealed record HealthArchiveUpdateRequest(
|
|||||||
IReadOnlyList<string>? DietRestrictions,
|
IReadOnlyList<string>? DietRestrictions,
|
||||||
IReadOnlyList<string>? ChronicDiseases,
|
IReadOnlyList<string>? ChronicDiseases,
|
||||||
string? FamilyHistory,
|
string? FamilyHistory,
|
||||||
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null);
|
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null,
|
||||||
|
string? SurgeryHistoryStatus = null);
|
||||||
|
|
||||||
public interface IHealthArchiveService
|
public interface IHealthArchiveService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Health.Domain.Entities;
|
using Health.Domain.Entities;
|
||||||
|
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Health.Application.HealthArchives;
|
namespace Health.Application.HealthArchives;
|
||||||
|
|
||||||
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
|
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
|
||||||
@@ -14,6 +16,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
|||||||
|
|
||||||
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
|
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
Validate(request);
|
||||||
var archive = await GetOrCreateEntityAsync(userId, ct);
|
var archive = await GetOrCreateEntityAsync(userId, ct);
|
||||||
Apply(archive, request);
|
Apply(archive, request);
|
||||||
archive.UpdatedAt = DateTime.UtcNow;
|
archive.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -110,6 +113,14 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
|||||||
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
|
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
|
||||||
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
|
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
|
||||||
if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory;
|
if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory;
|
||||||
|
if (request.SurgeryHistoryStatus != null)
|
||||||
|
archive.SurgeryHistoryStatus = request.SurgeryHistoryStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Validate(HealthArchiveUpdateRequest request)
|
||||||
|
{
|
||||||
|
if (request.SurgeryHistoryStatus is not null and not ("无" or "有"))
|
||||||
|
throw new ValidationException("手术史状态格式不正确");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object ToAiResult(HealthArchiveDto archive) => new
|
private static object ToAiResult(HealthArchiveDto archive) => new
|
||||||
@@ -123,6 +134,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
|||||||
archive.DietRestrictions,
|
archive.DietRestrictions,
|
||||||
archive.ChronicDiseases,
|
archive.ChronicDiseases,
|
||||||
archive.FamilyHistory,
|
archive.FamilyHistory,
|
||||||
|
archive.SurgeryHistoryStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static HealthArchiveDto ToDto(HealthArchive archive) => new(
|
private static HealthArchiveDto ToDto(HealthArchive archive) => new(
|
||||||
@@ -136,6 +148,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
|||||||
archive.DietRestrictions,
|
archive.DietRestrictions,
|
||||||
archive.ChronicDiseases,
|
archive.ChronicDiseases,
|
||||||
archive.FamilyHistory,
|
archive.FamilyHistory,
|
||||||
|
archive.SurgeryHistoryStatus,
|
||||||
archive.UpdatedAt);
|
archive.UpdatedAt);
|
||||||
|
|
||||||
private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)
|
private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public interface IHealthRecordService
|
|||||||
{
|
{
|
||||||
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
|
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
|
||||||
Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct);
|
Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||||
|
Task<IReadOnlyList<Guid>> CreateManyAsync(Guid userId, IReadOnlyList<HealthRecordUpsertRequest> requests, CancellationToken ct);
|
||||||
Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
|
Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||||
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
|
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
|
||||||
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);
|
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public static class HealthRecordRules
|
|||||||
{
|
{
|
||||||
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
|
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
|
||||||
HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60,
|
HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60,
|
||||||
HealthMetricType.Glucose => request.Value >= 7.0m || request.Value <= 3.8m,
|
HealthMetricType.Glucose => request.Value > 6.1m || request.Value < 3.9m,
|
||||||
HealthMetricType.SpO2 => request.Value <= 94,
|
HealthMetricType.SpO2 => request.Value <= 94,
|
||||||
HealthMetricType.Weight => false,
|
HealthMetricType.Weight => false,
|
||||||
_ => false
|
_ => false
|
||||||
@@ -58,4 +58,3 @@ public static class HealthRecordRules
|
|||||||
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
|
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,44 @@ public sealed class HealthRecordService(
|
|||||||
return record.Id;
|
return record.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Guid>> CreateManyAsync(
|
||||||
|
Guid userId,
|
||||||
|
IReadOnlyList<HealthRecordUpsertRequest> requests,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (requests.Count == 0)
|
||||||
|
throw new ArgumentException("至少需要一条健康记录", nameof(requests));
|
||||||
|
|
||||||
|
foreach (var request in requests)
|
||||||
|
HealthRecordRules.Validate(request);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var records = requests.Select(request => new HealthRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
MetricType = request.Type,
|
||||||
|
Systolic = request.Systolic,
|
||||||
|
Diastolic = request.Diastolic,
|
||||||
|
Value = request.Value,
|
||||||
|
Unit = request.Unit,
|
||||||
|
Source = request.Source,
|
||||||
|
RecordedAt = request.RecordedAt ?? now,
|
||||||
|
CreatedAt = now,
|
||||||
|
IsAbnormal = HealthRecordRules.CheckAbnormal(request),
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
foreach (var record in records)
|
||||||
|
await _records.AddAsync(record, ct);
|
||||||
|
|
||||||
|
await _records.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
foreach (var record in records)
|
||||||
|
await EnqueueAbnormalNotificationAsync(record, ct);
|
||||||
|
|
||||||
|
return records.Select(record => record.Id).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
|
public async Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var record = await _records.GetOwnedAsync(userId, id, ct);
|
var record = await _records.GetOwnedAsync(userId, id, ct);
|
||||||
@@ -101,6 +139,7 @@ public sealed class HealthRecordService(
|
|||||||
latest.Diastolic,
|
latest.Diastolic,
|
||||||
latest.Value,
|
latest.Value,
|
||||||
latest.Unit,
|
latest.Unit,
|
||||||
|
latest.IsAbnormal,
|
||||||
latest.RecordedAt
|
latest.RecordedAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -140,7 +179,7 @@ public sealed class HealthRecordService(
|
|||||||
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
|
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
|
||||||
HealthMetricType.HeartRate =>
|
HealthMetricType.HeartRate =>
|
||||||
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
|
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
|
||||||
HealthMetricType.Glucose when record.Value >= 7.0m =>
|
HealthMetricType.Glucose when record.Value > 6.1m =>
|
||||||
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L,高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
|
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L,高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
|
||||||
HealthMetricType.Glucose =>
|
HealthMetricType.Glucose =>
|
||||||
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L,低于参考范围。请及时关注身体状况。", "critical", "glucose"),
|
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L,低于参考范围。请及时关注身体状况。", "critical", "glucose"),
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ public sealed record MedicationPatchRequest(
|
|||||||
IReadOnlyList<TimeOnly>? TimeOfDay,
|
IReadOnlyList<TimeOnly>? TimeOfDay,
|
||||||
DateOnly? StartDate,
|
DateOnly? StartDate,
|
||||||
DateOnly? EndDate,
|
DateOnly? EndDate,
|
||||||
string? Notes);
|
string? Notes,
|
||||||
|
bool ClearEndDate = false);
|
||||||
|
|
||||||
public sealed record MedicationDto(
|
public sealed record MedicationDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Application.Medications;
|
||||||
|
|
||||||
|
public static class MedicationScheduleStatus
|
||||||
|
{
|
||||||
|
public static string Resolve(
|
||||||
|
TimeOnly scheduledTime,
|
||||||
|
TimeOnly currentTime,
|
||||||
|
MedicationLogStatus? logStatus)
|
||||||
|
{
|
||||||
|
if (logStatus.HasValue)
|
||||||
|
return logStatus == MedicationLogStatus.Taken ? "taken" : "skipped";
|
||||||
|
if (scheduledTime <= currentTime.AddMinutes(-30)) return "overdue";
|
||||||
|
if (scheduledTime <= currentTime.AddHours(3)) return "upcoming";
|
||||||
|
return "scheduled";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,8 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
|||||||
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
|
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
|
||||||
if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
|
if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
|
||||||
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
|
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
|
||||||
if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
|
if (request.ClearEndDate) med.EndDate = null;
|
||||||
|
else if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
|
||||||
ValidateDateRange(med.StartDate, med.EndDate);
|
ValidateDateRange(med.StartDate, med.EndDate);
|
||||||
if (request.Notes != null) med.Notes = request.Notes;
|
if (request.Notes != null) med.Notes = request.Notes;
|
||||||
med.UpdatedAt = DateTime.UtcNow;
|
med.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -130,15 +131,10 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
|||||||
foreach (var scheduledTime in med.TimeOfDay)
|
foreach (var scheduledTime in med.TimeOfDay)
|
||||||
{
|
{
|
||||||
var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime);
|
var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime);
|
||||||
string status;
|
var status = MedicationScheduleStatus.Resolve(
|
||||||
if (log != null)
|
scheduledTime,
|
||||||
status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped";
|
now,
|
||||||
else if (scheduledTime <= now.AddMinutes(-30))
|
log?.Status);
|
||||||
status = "overdue";
|
|
||||||
else if (scheduledTime <= now.AddHours(3))
|
|
||||||
status = "upcoming";
|
|
||||||
else
|
|
||||||
continue;
|
|
||||||
|
|
||||||
reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status));
|
reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,3 +58,10 @@ public interface INotificationOutboxProcessor
|
|||||||
Task RecoverStaleAsync(CancellationToken ct);
|
Task RecoverStaleAsync(CancellationToken ct);
|
||||||
Task<bool> ProcessNextAsync(CancellationToken ct);
|
Task<bool> ProcessNextAsync(CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record ReminderCatchUpResult(int CreatedCount);
|
||||||
|
|
||||||
|
public interface IReminderCatchUpService
|
||||||
|
{
|
||||||
|
Task<ReminderCatchUpResult> CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ public sealed record UserProfileUpdateRequest(
|
|||||||
string? Gender,
|
string? Gender,
|
||||||
DateOnly? BirthDate);
|
DateOnly? BirthDate);
|
||||||
|
|
||||||
|
public sealed record AccountFileReferences(
|
||||||
|
IReadOnlyList<string> FileUrls,
|
||||||
|
IReadOnlyList<string> ConversationMetadataJson);
|
||||||
|
|
||||||
public interface IUserService
|
public interface IUserService
|
||||||
{
|
{
|
||||||
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
|
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
|
||||||
@@ -26,6 +30,12 @@ public interface IUserService
|
|||||||
public interface IUserRepository
|
public interface IUserRepository
|
||||||
{
|
{
|
||||||
Task<User?> GetAsync(Guid userId, CancellationToken ct);
|
Task<User?> GetAsync(Guid userId, CancellationToken ct);
|
||||||
|
Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct);
|
||||||
Task SaveChangesAsync(CancellationToken ct);
|
Task SaveChangesAsync(CancellationToken ct);
|
||||||
Task DeleteAccountDataAsync(Guid userId, CancellationToken ct);
|
Task DeleteAccountDataAsync(Guid userId, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface IAccountFileCleanup
|
||||||
|
{
|
||||||
|
Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
namespace Health.Application.Users;
|
namespace Health.Application.Users;
|
||||||
|
|
||||||
public sealed class UserService(IUserRepository users) : IUserService
|
public sealed class UserService(
|
||||||
|
IUserRepository users,
|
||||||
|
IAccountFileCleanup fileCleanup) : IUserService
|
||||||
{
|
{
|
||||||
private readonly IUserRepository _users = users;
|
private readonly IUserRepository _users = users;
|
||||||
|
private readonly IAccountFileCleanup _fileCleanup = fileCleanup;
|
||||||
|
|
||||||
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
|
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -30,6 +33,10 @@ public sealed class UserService(IUserRepository users) : IUserService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task DeleteAccountAsync(Guid userId, CancellationToken ct) =>
|
public async Task DeleteAccountAsync(Guid userId, CancellationToken ct)
|
||||||
_users.DeleteAccountDataAsync(userId, ct);
|
{
|
||||||
|
var references = await _users.GetAccountFileReferencesAsync(userId, ct);
|
||||||
|
await _fileCleanup.DeleteAsync(userId, references, ct);
|
||||||
|
await _users.DeleteAccountDataAsync(userId, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public sealed class HealthArchive
|
|||||||
public List<string> DietRestrictions { get; set; } = []; // 饮食限制
|
public List<string> DietRestrictions { get; set; } = []; // 饮食限制
|
||||||
public List<string> ChronicDiseases { get; set; } = []; // 慢病史
|
public List<string> ChronicDiseases { get; set; } = []; // 慢病史
|
||||||
public string? FamilyHistory { get; set; } // 家族病史
|
public string? FamilyHistory { get; set; } // 家族病史
|
||||||
|
public string? SurgeryHistoryStatus { get; set; } // 无/有,区分未填写与无手术史
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
public User User { get; set; } = null!;
|
public User User { get; set; } = null!;
|
||||||
@@ -89,6 +90,10 @@ public sealed class NotificationPreference
|
|||||||
public bool FollowUpReminder { get; set; } = true;
|
public bool FollowUpReminder { get; set; } = true;
|
||||||
public bool DoctorReply { get; set; } = true;
|
public bool DoctorReply { get; set; } = true;
|
||||||
public bool AbnormalAlert { get; set; } = true;
|
public bool AbnormalAlert { get; set; } = true;
|
||||||
|
public bool PushEnabled { get; set; } = true;
|
||||||
|
public bool DndEnabled { get; set; }
|
||||||
|
public int DndStartMinutes { get; set; } = 22 * 60;
|
||||||
|
public int DndEndMinutes { get; set; } = 8 * 60;
|
||||||
// 健康录入提醒——总开关 + 5 子项
|
// 健康录入提醒——总开关 + 5 子项
|
||||||
public bool HealthRecordReminder { get; set; } = true;
|
public bool HealthRecordReminder { get; set; } = true;
|
||||||
public bool HealthRecordReminderBloodPressure { get; set; } = true;
|
public bool HealthRecordReminderBloodPressure { get; set; } = true;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public static class MedicationAgentHandler
|
|||||||
return toolName switch
|
return toolName switch
|
||||||
{
|
{
|
||||||
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
|
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
|
||||||
_ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" }
|
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,29 @@ public sealed class EfAiConversationRepository(AppDbContext db) : IAiConversatio
|
|||||||
public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) =>
|
public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) =>
|
||||||
await _db.ConversationMessages.AddAsync(message, ct);
|
await _db.ConversationMessages.AddAsync(message, ct);
|
||||||
|
|
||||||
|
public async Task<int> DeleteLegacyDietCommentaryArtifactsAsync(
|
||||||
|
Guid userId,
|
||||||
|
string promptPrefix,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var candidates = await _db.Conversations
|
||||||
|
.Include(conversation => conversation.Messages)
|
||||||
|
.Where(conversation =>
|
||||||
|
conversation.UserId == userId &&
|
||||||
|
conversation.Messages.Any(message =>
|
||||||
|
message.Role == MessageRole.User &&
|
||||||
|
message.Content.StartsWith(promptPrefix)))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var artifacts = candidates
|
||||||
|
.Where(conversation => DietCommentaryPolicy.IsLegacyArtifact(conversation.Messages))
|
||||||
|
.ToList();
|
||||||
|
if (artifacts.Count == 0) return 0;
|
||||||
|
|
||||||
|
_db.Conversations.RemoveRange(artifacts);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return artifacts.Count;
|
||||||
|
}
|
||||||
|
|
||||||
public void Delete(Conversation conversation) =>
|
public void Delete(Conversation conversation) =>
|
||||||
_db.Conversations.Remove(conversation);
|
_db.Conversations.Remove(conversation);
|
||||||
|
|
||||||
|
|||||||
@@ -157,9 +157,9 @@ public sealed class PromptManager
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
private const string UnifiedPrompt = """
|
private const string UnifiedPrompt = """
|
||||||
你是一个患者端 AI 健康管家,为心脏术后康复患者提供健康解释、记录和预问诊辅助服务。
|
你是患者端统一 AI 健康助手“小脉”。无论用户是否点击顶部功能入口,你都要理解其真实意图,并完成当前可用的健康解释、数据记录、用药管理、运动管理和预问诊辅助。
|
||||||
|
|
||||||
核心规则:首先理解用户意图属于哪个领域,然后调用对应的工具。
|
核心原则:先识别用户想做什么,再决定调用工具或继续对话。不能只在文字中声称“已录入”“已保存”或“请点击确认”,实际却没有调用写入工具并生成确认卡片。
|
||||||
|
|
||||||
领域判断与可用工具:
|
领域判断与可用工具:
|
||||||
1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data
|
1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data
|
||||||
@@ -182,10 +182,18 @@ public sealed class PromptManager
|
|||||||
|
|
||||||
规则:
|
规则:
|
||||||
- 用户可能一句话涉及多个领域,全部处理
|
- 用户可能一句话涉及多个领域,全部处理
|
||||||
- 数值明确+指标明确→直接生成待确认命令,不要追问
|
- 先判断用户是在咨询数值,还是希望记录数据,不能仅凭消息中出现指标和数值就自动录入
|
||||||
|
- 明确提问时先回答问题,不调用 record_health_data。例如“血氧98%是不是太高了”是在咨询,应解释正常范围,不生成录入确认卡片;除非用户同时明确要求记录
|
||||||
|
- 明确要求“记录、录入、保存、记一下”时调用 record_health_data。例如“帮我记录血压116/89”必须生成待确认命令
|
||||||
|
- 只有指标和数值、没有明显疑问或记录用语时,结合当前对话上下文判断;仍无法确定意图时,先简短询问用户是想了解数值还是保存记录
|
||||||
|
- 指标或数值缺失时只追问缺失部分,不猜测数值
|
||||||
|
- 多个明确指标要逐项调用 record_health_data,不能遗漏
|
||||||
- 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库
|
- 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库
|
||||||
- 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
- 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
||||||
|
- 没有真实调用写入工具时,禁止告诉用户“点击确认按钮”或暗示已经出现确认卡片
|
||||||
|
- 用户描述症状或身体不适时进入预问诊:每轮只追问一个最关键问题,连续追问症状部位、开始时间、程度、诱因和伴随表现;信息足够后给出可能相关方向、严重程度以及观察/门诊/尽快就医/急诊建议,不作正式确诊
|
||||||
|
- 预问诊通常追问 2-5 轮;发现危险信号时立即停止普通追问并优先给出就医提醒
|
||||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||||
- 回复语气温暖、专业
|
- 回复语气温和、清楚、专业,避免假装医生或作确定诊断
|
||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
|||||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
var query = _db.Users.AsNoTracking().Where(x => x.Role == "User");
|
var query = _db.Users.AsNoTracking().Where(x => x.Role == "User");
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
query = query.Where(x => (x.Name != null && x.Name.Contains(search)) || x.Phone.Contains(search));
|
query = query.Where(x =>
|
||||||
|
(x.Name != null && x.Name.Contains(search)) ||
|
||||||
|
(x.Phone != null && x.Phone.Contains(search)));
|
||||||
var total = await query.CountAsync(ct);
|
var total = await query.CountAsync(ct);
|
||||||
var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
.Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null })
|
.Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null })
|
||||||
|
|||||||
@@ -113,9 +113,13 @@ public sealed class AuthService(
|
|||||||
{
|
{
|
||||||
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return Error(40005, "Apple 登录验证失败,请重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 查找已有用户
|
// 2. 查找已有用户
|
||||||
|
|||||||
1473
backend/src/Health.Infrastructure/Data/Migrations/20260713022401_AddPushAndDndPreferences.Designer.cs
generated
Normal file
1473
backend/src/Health.Infrastructure/Data/Migrations/20260713022401_AddPushAndDndPreferences.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPushAndDndPreferences : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "DndEnabled",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DndEndMinutes",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DndStartMinutes",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "PushEnabled",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DndEnabled",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DndEndMinutes",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DndStartMinutes",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PushEnabled",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1477
backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
generated
Normal file
1477
backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddHealthArchiveSurgeryStatus : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SurgeryHistoryStatus",
|
||||||
|
table: "HealthArchives",
|
||||||
|
type: "character varying(8)",
|
||||||
|
maxLength: 8,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SurgeryHistoryStatus",
|
||||||
|
table: "HealthArchives");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -486,6 +486,10 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
b.Property<DateOnly?>("SurgeryDate")
|
b.Property<DateOnly?>("SurgeryDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("SurgeryHistoryStatus")
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
b.Property<string>("SurgeryType")
|
b.Property<string>("SurgeryType")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -673,6 +677,15 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("AbnormalAlert")
|
b.Property<bool>("AbnormalAlert")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("DndEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("DndEndMinutes")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("DndStartMinutes")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<bool>("DoctorReply")
|
b.Property<bool>("DoctorReply")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -700,6 +713,9 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("MedicationReminder")
|
b.Property<bool>("MedicationReminder")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PushEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
b.Property<DateTime>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
|||||||
builder.Entity<HealthArchive>(e =>
|
builder.Entity<HealthArchive>(e =>
|
||||||
{
|
{
|
||||||
e.HasIndex(a => a.UserId).IsUnique();
|
e.HasIndex(a => a.UserId).IsUnique();
|
||||||
|
e.Property(a => a.SurgeryHistoryStatus).HasMaxLength(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Entity<HealthArchiveSurgery>(e =>
|
builder.Entity<HealthArchiveSurgery>(e =>
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
using Health.Application.Notifications;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Notifications;
|
||||||
|
|
||||||
|
public sealed class EfReminderCatchUpService(AppDbContext db) : IReminderCatchUpService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db = db;
|
||||||
|
|
||||||
|
public async Task<ReminderCatchUpResult> CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pref = await GetPreferenceAsync(userId, ct);
|
||||||
|
if (!pref.PushEnabled) return new ReminderCatchUpResult(0);
|
||||||
|
|
||||||
|
var beijingNow = utcNow.AddHours(8);
|
||||||
|
var today = DateOnly.FromDateTime(beijingNow);
|
||||||
|
var todayStartUtc = beijingNow.Date.AddHours(-8);
|
||||||
|
var todayEndUtc = todayStartUtc.AddDays(1);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
created += await CreateHealthRecordReminderAsync(userId, pref, beijingNow, todayStartUtc, todayEndUtc, ct);
|
||||||
|
created += await CreateMedicationRemindersAsync(userId, pref, beijingNow, today, todayStartUtc, todayEndUtc, ct);
|
||||||
|
created += await CreateExerciseRemindersAsync(userId, pref, beijingNow, today, ct);
|
||||||
|
created += await CreateFollowUpRemindersAsync(userId, pref, beijingNow, ct);
|
||||||
|
|
||||||
|
if (created > 0)
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new ReminderCatchUpResult(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<NotificationPreference> GetPreferenceAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pref = await _db.NotificationPreferences.FirstOrDefaultAsync(x => x.UserId == userId, ct);
|
||||||
|
if (pref != null) return pref;
|
||||||
|
|
||||||
|
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
|
||||||
|
await _db.NotificationPreferences.AddAsync(pref, ct);
|
||||||
|
return pref;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateHealthRecordReminderAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
DateTime todayStartUtc,
|
||||||
|
DateTime todayEndUtc,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!pref.HealthRecordReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var window = HealthRecordWindow(beijingNow);
|
||||||
|
if (window == null) return 0;
|
||||||
|
|
||||||
|
var recordedTypes = await _db.HealthRecords.AsNoTracking()
|
||||||
|
.Where(r => r.UserId == userId && r.RecordedAt >= todayStartUtc && r.RecordedAt < todayEndUtc)
|
||||||
|
.Select(r => r.MetricType)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var missing = EnabledHealthMetrics(pref)
|
||||||
|
.Where(x => !recordedTypes.Contains(x.Type))
|
||||||
|
.ToList();
|
||||||
|
if (missing.Count == 0) return 0;
|
||||||
|
|
||||||
|
var date = DateOnly.FromDateTime(beijingNow);
|
||||||
|
var sourceId = StableGuid($"health_record|{userId:N}|{date:yyyyMMdd}|{window}");
|
||||||
|
return await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"health_record_reminder",
|
||||||
|
window == "evening" ? "今日健康指标还未完成" : "记得录入今日健康指标",
|
||||||
|
$"建议录入:{string.Join("、", missing.Select(x => x.Label))}",
|
||||||
|
"info",
|
||||||
|
"health",
|
||||||
|
null,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateMedicationRemindersAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
DateOnly today,
|
||||||
|
DateTime todayStartUtc,
|
||||||
|
DateTime todayEndUtc,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!pref.MedicationReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var now = TimeOnly.FromDateTime(beijingNow);
|
||||||
|
var earliest = now.AddHours(-4);
|
||||||
|
var medications = await _db.Medications.AsNoTracking()
|
||||||
|
.Where(m => m.UserId == userId && m.IsActive)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
foreach (var medication in medications.Where(m => IsMedicationActiveOn(m, today) && ShouldDoseToday(m, today)))
|
||||||
|
{
|
||||||
|
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInTimeWindow(t, earliest, now)))
|
||||||
|
{
|
||||||
|
var logged = await _db.MedicationLogs.AsNoTracking().AnyAsync(l =>
|
||||||
|
l.UserId == userId &&
|
||||||
|
l.MedicationId == medication.Id &&
|
||||||
|
l.ScheduledTime == scheduledTime &&
|
||||||
|
l.CreatedAt >= todayStartUtc &&
|
||||||
|
l.CreatedAt < todayEndUtc, ct);
|
||||||
|
if (logged) continue;
|
||||||
|
|
||||||
|
var sourceId = StableGuid($"medication|{userId:N}|{medication.Id:N}|{today:yyyyMMdd}|{scheduledTime:HHmm}");
|
||||||
|
var details = string.Join(" ", new[] { medication.Dosage, scheduledTime.ToString("HH:mm") }
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
created += await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"MedicationReminder",
|
||||||
|
"用药时间到了",
|
||||||
|
$"请按计划服用{medication.Name}{(details.Length > 0 ? $"({details})" : "")},服用后记得打卡。",
|
||||||
|
"info",
|
||||||
|
"medication",
|
||||||
|
medication.Id.ToString(),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateExerciseRemindersAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var now = TimeOnly.FromDateTime(beijingNow);
|
||||||
|
var dueItems = await _db.ExercisePlanItems.AsNoTracking()
|
||||||
|
.Include(x => x.Plan)
|
||||||
|
.Where(x => x.Plan.UserId == userId &&
|
||||||
|
x.ScheduledDate == today &&
|
||||||
|
!x.IsCompleted &&
|
||||||
|
!x.IsRestDay &&
|
||||||
|
x.Plan.ReminderTime <= now)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
foreach (var item in dueItems)
|
||||||
|
{
|
||||||
|
var sourceId = StableGuid($"exercise|{userId:N}|{item.Id:N}|{today:yyyyMMdd}");
|
||||||
|
created += await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"ExerciseReminder",
|
||||||
|
"今日运动计划",
|
||||||
|
$"今天安排了{item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。",
|
||||||
|
"info",
|
||||||
|
"exercise",
|
||||||
|
item.PlanId.ToString(),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateFollowUpRemindersAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!pref.FollowUpReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var nowUtc = beijingNow.AddHours(-8);
|
||||||
|
var upcomingEndUtc = nowUtc.AddHours(24);
|
||||||
|
var followUps = await _db.FollowUps.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId && x.ScheduledAt >= nowUtc && x.ScheduledAt <= upcomingEndUtc)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
foreach (var followUp in followUps)
|
||||||
|
{
|
||||||
|
var sourceId = StableGuid($"followup|{userId:N}|{followUp.Id:N}|24h");
|
||||||
|
created += await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"follow_up_reminder",
|
||||||
|
"复查提醒",
|
||||||
|
$"{followUp.Title} 即将到期,请提前安排时间。",
|
||||||
|
"info",
|
||||||
|
"followup",
|
||||||
|
followUp.Id.ToString(),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> AddNotificationIfMissingAsync(
|
||||||
|
Guid userId,
|
||||||
|
Guid sourceId,
|
||||||
|
string type,
|
||||||
|
string title,
|
||||||
|
string message,
|
||||||
|
string severity,
|
||||||
|
string? actionType,
|
||||||
|
string? actionTargetId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await _db.UserNotifications.AsNoTracking().AnyAsync(x => x.SourceId == sourceId, ct))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
await _db.UserNotifications.AddAsync(new UserNotification
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
SourceId = sourceId,
|
||||||
|
Type = type,
|
||||||
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
Severity = severity,
|
||||||
|
ActionType = actionType,
|
||||||
|
ActionTargetId = actionTargetId,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
}, ct);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? HealthRecordWindow(DateTime beijingNow)
|
||||||
|
{
|
||||||
|
var minutes = beijingNow.Hour * 60 + beijingNow.Minute;
|
||||||
|
if (minutes is >= 7 * 60 and <= 10 * 60 + 30) return "morning";
|
||||||
|
if (minutes is >= 11 * 60 + 30 and <= 14 * 60) return "noon";
|
||||||
|
if (minutes is >= 18 * 60 and <= 21 * 60 + 30) return "evening";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<(HealthMetricType Type, string Label)> EnabledHealthMetrics(NotificationPreference pref)
|
||||||
|
{
|
||||||
|
var result = new List<(HealthMetricType, string)>();
|
||||||
|
if (pref.HealthRecordReminderBloodPressure) result.Add((HealthMetricType.BloodPressure, "血压"));
|
||||||
|
if (pref.HealthRecordReminderHeartRate) result.Add((HealthMetricType.HeartRate, "心率"));
|
||||||
|
if (pref.HealthRecordReminderGlucose) result.Add((HealthMetricType.Glucose, "血糖"));
|
||||||
|
if (pref.HealthRecordReminderSpO2) result.Add((HealthMetricType.SpO2, "血氧"));
|
||||||
|
if (pref.HealthRecordReminderWeight) result.Add((HealthMetricType.Weight, "体重"));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInDnd(NotificationPreference pref, DateTime beijingNow)
|
||||||
|
{
|
||||||
|
if (!pref.DndEnabled) return false;
|
||||||
|
var minuteOfDay = beijingNow.Hour * 60 + beijingNow.Minute;
|
||||||
|
return pref.DndStartMinutes <= pref.DndEndMinutes
|
||||||
|
? minuteOfDay >= pref.DndStartMinutes && minuteOfDay < pref.DndEndMinutes
|
||||||
|
: minuteOfDay >= pref.DndStartMinutes || minuteOfDay < pref.DndEndMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsMedicationActiveOn(Medication medication, DateOnly date) =>
|
||||||
|
(medication.StartDate == null || medication.StartDate <= date) &&
|
||||||
|
(medication.EndDate == null || medication.EndDate >= date);
|
||||||
|
|
||||||
|
private static bool ShouldDoseToday(Medication medication, DateOnly today)
|
||||||
|
{
|
||||||
|
if (medication.Frequency is MedicationFrequency.Daily
|
||||||
|
or MedicationFrequency.TwiceDaily
|
||||||
|
or MedicationFrequency.ThreeTimesDaily
|
||||||
|
or MedicationFrequency.AsNeeded)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var startDate = medication.StartDate ?? today;
|
||||||
|
if (medication.Frequency == MedicationFrequency.EveryOtherDay)
|
||||||
|
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
|
||||||
|
if (medication.Frequency == MedicationFrequency.Weekly)
|
||||||
|
return today.DayOfWeek == startDate.DayOfWeek;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInTimeWindow(TimeOnly value, TimeOnly start, TimeOnly end) =>
|
||||||
|
end >= start ? value >= start && value <= end : value >= start || value <= end;
|
||||||
|
|
||||||
|
private static Guid StableGuid(string key)
|
||||||
|
{
|
||||||
|
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
|
||||||
|
return new Guid(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ public sealed class EfUserNotificationProducer(AppDbContext db) : IUserNotificat
|
|||||||
NotificationMessage message,
|
NotificationMessage message,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var pushEnabled = await _db.NotificationPreferences.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId)
|
||||||
|
.Select(x => (bool?)x.PushEnabled)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (pushEnabled is false) return false;
|
||||||
|
|
||||||
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct))
|
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -67,7 +73,7 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
|
|||||||
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
|
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
|
||||||
.ExecuteUpdateAsync(setters => setters
|
.ExecuteUpdateAsync(setters => setters
|
||||||
.SetProperty(x => x.Status, "Failed")
|
.SetProperty(x => x.Status, "Failed")
|
||||||
.SetProperty(x => x.LastError, "通知任务执行超时且已达到最大重试次数")
|
.SetProperty(x => x.LastError, "Notification task timed out and reached max retry count")
|
||||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,59 +87,89 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
|
|||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
if (candidate == null) return false;
|
if (candidate == null) return false;
|
||||||
|
|
||||||
var claimed = await _db.NotificationOutbox
|
var task = await _db.NotificationOutbox
|
||||||
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
|
.FirstOrDefaultAsync(x => x.Id == candidate.Id && x.Status == "Pending", ct);
|
||||||
.ExecuteUpdateAsync(setters => setters
|
if (task == null) return true;
|
||||||
.SetProperty(x => x.Status, "Processing")
|
|
||||||
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
|
task.Status = "Processing";
|
||||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
task.Attempts += 1;
|
||||||
if (claimed == 0) return true;
|
task.UpdatedAt = now;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var message = JsonSerializer.Deserialize<NotificationMessage>(candidate.Payload)
|
var message = JsonSerializer.Deserialize<NotificationMessage>(task.Payload)
|
||||||
?? throw new InvalidOperationException("通知消息内容为空");
|
?? throw new InvalidOperationException("Notification payload is empty");
|
||||||
|
|
||||||
if (!await _db.UserNotifications.AsNoTracking()
|
var preference = await _db.NotificationPreferences.AsNoTracking()
|
||||||
.AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct))
|
.FirstOrDefaultAsync(x => x.UserId == task.UserId, ct);
|
||||||
|
var deliver = preference == null || ShouldDeliver(preference, message.Type, DateTime.UtcNow.AddHours(8));
|
||||||
|
|
||||||
|
if (deliver && !await _db.UserNotifications.AsNoTracking()
|
||||||
|
.AnyAsync(x => x.SourceId == task.SourceTaskId, ct))
|
||||||
{
|
{
|
||||||
await _db.UserNotifications.AddAsync(new UserNotification
|
await _db.UserNotifications.AddAsync(new UserNotification
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
UserId = candidate.UserId,
|
UserId = task.UserId,
|
||||||
SourceId = candidate.SourceTaskId,
|
SourceId = task.SourceTaskId,
|
||||||
Type = message.Type,
|
Type = message.Type,
|
||||||
Title = message.Title,
|
Title = message.Title,
|
||||||
Message = message.Message,
|
Message = message.Message,
|
||||||
Severity = message.Severity,
|
Severity = message.Severity,
|
||||||
ActionType = message.ActionType,
|
ActionType = message.ActionType,
|
||||||
ActionTargetId = message.ActionTargetId,
|
ActionTargetId = message.ActionTargetId,
|
||||||
CreatedAt = candidate.CreatedAt,
|
CreatedAt = task.CreatedAt,
|
||||||
}, ct);
|
}, ct);
|
||||||
// Persist the inbox item first. If completion fails, stale-task
|
|
||||||
// recovery can safely retry because SourceId is idempotent.
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
|
task.Status = "Completed";
|
||||||
.ExecuteUpdateAsync(setters => setters
|
task.LastError = null;
|
||||||
.SetProperty(x => x.Status, "Completed")
|
task.UpdatedAt = DateTime.UtcNow;
|
||||||
.SetProperty(x => x.LastError, (string?)null)
|
await _db.SaveChangesAsync(ct);
|
||||||
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var attempts = candidate.Attempts + 1;
|
task.Status = task.Attempts >= MaxAttempts ? "Failed" : "Pending";
|
||||||
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
|
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
|
||||||
.ExecuteUpdateAsync(setters => setters
|
task.LastError = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
|
||||||
.SetProperty(x => x.Status, attempts >= MaxAttempts ? "Failed" : "Pending")
|
task.UpdatedAt = DateTime.UtcNow;
|
||||||
.SetProperty(x => x.AvailableAt,
|
await _db.SaveChangesAsync(ct);
|
||||||
DateTime.UtcNow.AddSeconds(Math.Pow(2, attempts) * 5))
|
|
||||||
.SetProperty(x => x.LastError,
|
|
||||||
ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message)
|
|
||||||
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool ShouldDeliver(NotificationPreference preference, string type, DateTime nowCst)
|
||||||
|
{
|
||||||
|
if (!preference.PushEnabled) return false;
|
||||||
|
|
||||||
|
var minuteOfDay = nowCst.Hour * 60 + nowCst.Minute;
|
||||||
|
var inDnd = preference.DndEnabled && (preference.DndStartMinutes <= preference.DndEndMinutes
|
||||||
|
? minuteOfDay >= preference.DndStartMinutes && minuteOfDay < preference.DndEndMinutes
|
||||||
|
: minuteOfDay >= preference.DndStartMinutes || minuteOfDay < preference.DndEndMinutes);
|
||||||
|
if (inDnd) return false;
|
||||||
|
|
||||||
|
return NormalizeType(type) switch
|
||||||
|
{
|
||||||
|
"medication_reminder" => preference.MedicationReminder,
|
||||||
|
"exercise_reminder" => true,
|
||||||
|
"follow_up_reminder" => preference.FollowUpReminder,
|
||||||
|
"doctor_reply" => preference.DoctorReply,
|
||||||
|
"abnormal_alert" => preference.AbnormalAlert,
|
||||||
|
"health_record_reminder" => preference.HealthRecordReminder,
|
||||||
|
"health_metric_alert" => preference.AbnormalAlert,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeType(string type) => type switch
|
||||||
|
{
|
||||||
|
"MedicationReminder" => "medication_reminder",
|
||||||
|
"ExerciseReminder" => "exercise_reminder",
|
||||||
|
"HealthMetricAlert" => "health_metric_alert",
|
||||||
|
"FollowUpReminder" => "follow_up_reminder",
|
||||||
|
_ => type.ToLowerInvariant(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,44 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
|
|||||||
public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
|
public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
|
||||||
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
||||||
|
|
||||||
|
public async Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var fileUrls = new List<string>();
|
||||||
|
|
||||||
|
var userAvatar = await _db.Users
|
||||||
|
.Where(u => u.Id == userId)
|
||||||
|
.Select(u => u.AvatarUrl)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
AddIfPresent(fileUrls, userAvatar);
|
||||||
|
|
||||||
|
var doctorProfile = await _db.DoctorProfiles
|
||||||
|
.Where(p => p.UserId == userId)
|
||||||
|
.Select(p => new { p.DoctorId, p.AvatarUrl })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
AddIfPresent(fileUrls, doctorProfile?.AvatarUrl);
|
||||||
|
|
||||||
|
if (doctorProfile?.DoctorId != null)
|
||||||
|
{
|
||||||
|
var doctorAvatar = await _db.Doctors
|
||||||
|
.Where(d => d.Id == doctorProfile.DoctorId)
|
||||||
|
.Select(d => d.AvatarUrl)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
AddIfPresent(fileUrls, doctorAvatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileUrls.AddRange(await _db.Reports
|
||||||
|
.Where(r => r.UserId == userId && r.FileUrl != "")
|
||||||
|
.Select(r => r.FileUrl)
|
||||||
|
.ToListAsync(ct));
|
||||||
|
|
||||||
|
var metadata = await _db.ConversationMessages
|
||||||
|
.Where(m => m.Conversation!.UserId == userId && m.MetadataJson != null && m.MetadataJson != "")
|
||||||
|
.Select(m => m.MetadataJson!)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new AccountFileReferences(fileUrls.Distinct().ToList(), metadata);
|
||||||
|
}
|
||||||
|
|
||||||
public Task SaveChangesAsync(CancellationToken ct) =>
|
public Task SaveChangesAsync(CancellationToken ct) =>
|
||||||
_db.SaveChangesAsync(ct);
|
_db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
@@ -50,4 +88,9 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
|
|||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void AddIfPresent(List<string> values, string? value)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value)) values.Add(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Health.Application.Users;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Users;
|
||||||
|
|
||||||
|
public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCleanup
|
||||||
|
{
|
||||||
|
private readonly string _uploadsRoot = Path.GetFullPath(uploadsRoot);
|
||||||
|
|
||||||
|
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var fileUrls = new HashSet<string>(references.FileUrls, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var metadataJson in references.ConversationMetadataJson)
|
||||||
|
AddMetadataUrls(fileUrls, metadataJson);
|
||||||
|
|
||||||
|
foreach (var fileUrl in fileUrls)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var localPath = ResolveLocalPath(fileUrl);
|
||||||
|
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var userDirectory = Path.Combine(_uploadsRoot, "users", userId.ToString("N"));
|
||||||
|
if (Directory.Exists(userDirectory)) Directory.Delete(userDirectory, recursive: true);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddMetadataUrls(HashSet<string> fileUrls, string metadataJson)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(metadataJson);
|
||||||
|
AddStringProperty(document.RootElement, "imageUrl", fileUrls);
|
||||||
|
AddStringProperty(document.RootElement, "pdfUrl", fileUrls);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// 旧消息元数据可能不是合法 JSON;不影响其余账号数据注销。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddStringProperty(JsonElement root, string propertyName, HashSet<string> fileUrls)
|
||||||
|
{
|
||||||
|
if (root.ValueKind != JsonValueKind.Object ||
|
||||||
|
!root.TryGetProperty(propertyName, out var property) ||
|
||||||
|
property.ValueKind != JsonValueKind.String)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var value = property.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(value)) fileUrls.Add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ResolveLocalPath(string fileUrl)
|
||||||
|
{
|
||||||
|
var urlPath = fileUrl;
|
||||||
|
if (Uri.TryCreate(fileUrl, UriKind.Absolute, out var absoluteUri))
|
||||||
|
urlPath = absoluteUri.AbsolutePath;
|
||||||
|
|
||||||
|
var uploadsIndex = urlPath.IndexOf("/uploads/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (uploadsIndex < 0) return null;
|
||||||
|
|
||||||
|
string relativePath;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
relativePath = Uri.UnescapeDataString(urlPath[(uploadsIndex + "/uploads/".Length)..]);
|
||||||
|
}
|
||||||
|
catch (UriFormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryIndex = relativePath.IndexOfAny(['?', '#']);
|
||||||
|
if (queryIndex >= 0) relativePath = relativePath[..queryIndex];
|
||||||
|
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||||
|
|
||||||
|
var fullPath = Path.GetFullPath(Path.Combine(_uploadsRoot, relativePath));
|
||||||
|
var rootPrefix = _uploadsRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||||
|
+ Path.DirectorySeparatorChar;
|
||||||
|
return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,107 +1,66 @@
|
|||||||
using Health.Application.Notifications;
|
using Health.Application.Notifications;
|
||||||
using Health.Domain.Enums;
|
|
||||||
|
|
||||||
namespace Health.WebApi.BackgroundServices;
|
namespace Health.WebApi.BackgroundServices;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户,
|
/// Produces in-app catch-up reminders for health records.
|
||||||
/// 对未录入指标且开关开启的用户推送提醒。
|
/// The actual reminder rules live in IReminderCatchUpService so foreground
|
||||||
/// 中国时区 UTC+8。
|
/// app checks and background scans stay consistent.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HealthRecordReminderService(
|
public sealed class HealthRecordReminderService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
private static readonly int[] ReminderHoursCst = [9, 13, 19];
|
||||||
private readonly ILogger<HealthRecordReminderService> _logger = logger;
|
|
||||||
|
|
||||||
// 北京时间 9:00 和 12:00 触发
|
|
||||||
private static readonly int[] ReminderHoursCst = [9, 12];
|
|
||||||
|
|
||||||
private DateTime _lastFired = DateTime.MinValue;
|
private DateTime _lastFired = DateTime.MinValue;
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("健康录入提醒服务已启动");
|
logger.LogInformation("Health record in-app reminder service started");
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var nowCst = DateTime.UtcNow.AddHours(8);
|
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||||
// 当前小时是否在触发列表,且今天该小时还没触发过
|
if (ReminderHoursCst.Contains(nowCst.Hour) &&
|
||||||
if (ReminderHoursCst.Contains(nowCst.Hour)
|
(_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
||||||
&& (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
|
||||||
{
|
{
|
||||||
await ScanAndPushAsync(nowCst, stoppingToken);
|
await CheckUsersAsync(DateTime.UtcNow, stoppingToken);
|
||||||
_lastFired = nowCst;
|
_lastFired = nowCst;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
catch (Exception ex) { _logger.LogError(ex, "健康录入提醒扫描异常"); }
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Health record in-app reminder scan failed");
|
||||||
|
}
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ScanAndPushAsync(DateTime nowCst, CancellationToken ct)
|
private async Task CheckUsersAsync(DateTime utcNow, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
var producer = scope.ServiceProvider.GetRequiredService<IUserNotificationProducer>();
|
var catchUp = scope.ServiceProvider.GetRequiredService<IReminderCatchUpService>();
|
||||||
|
|
||||||
var todayStart = nowCst.Date.AddHours(-8); // 北京 0:00 转 UTC
|
var userIds = await db.NotificationPreferences.AsNoTracking()
|
||||||
var todayEnd = todayStart.AddDays(1);
|
.Where(x => x.PushEnabled && x.HealthRecordReminder)
|
||||||
|
.Select(x => x.UserId)
|
||||||
// 取所有启用了健康录入提醒的用户偏好
|
|
||||||
var prefs = await db.NotificationPreferences
|
|
||||||
.Where(p => p.HealthRecordReminder)
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
foreach (var pref in prefs)
|
var created = 0;
|
||||||
|
foreach (var userId in userIds)
|
||||||
{
|
{
|
||||||
// 该用户当天已录入的指标
|
var result = await catchUp.CheckDueAsync(userId, utcNow, ct);
|
||||||
var recordedTypes = await db.HealthRecords
|
created += result.CreatedCount;
|
||||||
.Where(r => r.UserId == pref.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) continue;
|
|
||||||
|
|
||||||
// 每天每个时段每个用户只推一次——SourceTaskId 用 userId + 日期 + 小时
|
|
||||||
var sourceId = MakeSourceId(pref.UserId, nowCst);
|
|
||||||
var summary = string.Join("、", missingLabels);
|
|
||||||
|
|
||||||
await producer.EnqueueAsync(pref.UserId, sourceId, new NotificationMessage(
|
|
||||||
Type: "health_record_reminder",
|
|
||||||
Title: nowCst.Hour < 12 ? "早安,记得录入今日健康数据" : "下午好,今日健康数据还未录入",
|
|
||||||
Message: $"建议录入:{summary}",
|
|
||||||
Severity: "info",
|
|
||||||
ActionType: "health",
|
|
||||||
ActionTargetId: null), ct);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Guid MakeSourceId(Guid userId, DateTime nowCst)
|
if (created > 0)
|
||||||
{
|
logger.LogInformation("Created {Count} in-app catch-up reminders", created);
|
||||||
// 通过哈希构造稳定的 Guid,保证同一用户同一时段不重复
|
|
||||||
var key = $"hrr|{userId:N}|{nowCst:yyyyMMddHH}";
|
|
||||||
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
|
|
||||||
return new Guid(bytes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,8 +321,12 @@ public static class AiChatEndpoints
|
|||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
||||||
|
|
||||||
await conversations.DeleteAsync(userId.Value, id, ct);
|
var deleted = await conversations.DeleteAsync(userId.Value, id, ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return deleted
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Json(
|
||||||
|
new { code = 40401, data = (object?)null, message = "对话不存在" },
|
||||||
|
statusCode: StatusCodes.Status404NotFound);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 一键清空当前用户的全部对话
|
// 一键清空当前用户的全部对话
|
||||||
@@ -335,6 +339,49 @@ public static class AiChatEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 饮食分析页专用建议:不创建会话,不保存提示词或回复到历史记录。
|
||||||
|
app.MapPost("/api/ai/diet-commentary", async (
|
||||||
|
DietCommentaryRequest request,
|
||||||
|
HttpContext http,
|
||||||
|
DeepSeekClient llmClient,
|
||||||
|
IPatientContextService patientContexts,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == null)
|
||||||
|
return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
|
||||||
|
|
||||||
|
var foods = request.Foods?
|
||||||
|
.Where(food => !string.IsNullOrWhiteSpace(food.Name) &&
|
||||||
|
!string.IsNullOrWhiteSpace(food.Portion) &&
|
||||||
|
food.Calories > 0)
|
||||||
|
.Take(20)
|
||||||
|
.Select(food => new DietCommentaryFood(food.Name, food.Portion, food.Calories))
|
||||||
|
.ToList() ?? [];
|
||||||
|
if (foods.Count == 0)
|
||||||
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "请先完善食物信息" });
|
||||||
|
|
||||||
|
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
|
||||||
|
var foodDescription = DietCommentaryPolicy.BuildFoodDescription(foods);
|
||||||
|
var response = await llmClient.ChatAsync(
|
||||||
|
[
|
||||||
|
new ChatMessage
|
||||||
|
{
|
||||||
|
Role = "system",
|
||||||
|
Content = DietCommentaryPolicy.SystemPrompt + "\n\n当前用户健康档案:\n" + patientContext,
|
||||||
|
},
|
||||||
|
new ChatMessage { Role = "user", Content = "本餐食物:" + foodDescription },
|
||||||
|
],
|
||||||
|
maxTokens: 300,
|
||||||
|
temperature: 0.3f,
|
||||||
|
ct: ct);
|
||||||
|
var commentary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(commentary))
|
||||||
|
return Results.Ok(new { code = 50001, data = (object?)null, message = "饮食建议生成失败" });
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = new { commentary }, message = (string?)null });
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapPost("/api/ai/analyze-food-image", async (
|
app.MapPost("/api/ai/analyze-food-image", async (
|
||||||
HttpRequest httpRequest,
|
HttpRequest httpRequest,
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
@@ -623,7 +670,7 @@ public static class AiChatEndpoints
|
|||||||
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
|
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
|
||||||
break;
|
break;
|
||||||
case "glucose":
|
case "glucose":
|
||||||
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v >= 7.0m || v <= 3.8m);
|
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v > 6.1m || v < 3.9m);
|
||||||
break;
|
break;
|
||||||
case "spo2":
|
case "spo2":
|
||||||
AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94);
|
AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94);
|
||||||
@@ -818,3 +865,6 @@ public static class AiChatEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record DietCommentaryFoodRequest(string Name, string Portion, int Calories);
|
||||||
|
public sealed record DietCommentaryRequest(IReadOnlyList<DietCommentaryFoodRequest>? Foods);
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ public static class DietEndpoints
|
|||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
await diets.DeleteAsync(userId, id, ct);
|
var deleted = await diets.DeleteAsync(userId, id, ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return deleted
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "饮食记录不存在" });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ public static class DoctorEndpoints
|
|||||||
return profile?.DoctorId;
|
return profile?.DoctorId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static (DateTime StartUtc, DateTime EndUtc) GetBeijingDayRange(DateTime utcNow)
|
||||||
|
{
|
||||||
|
var beijingNow = utcNow.ToUniversalTime().AddHours(8);
|
||||||
|
var startUtc = new DateTime(
|
||||||
|
beijingNow.Year,
|
||||||
|
beijingNow.Month,
|
||||||
|
beijingNow.Day,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
DateTimeKind.Utc).AddHours(-8);
|
||||||
|
return (startUtc, startUtc.AddDays(1));
|
||||||
|
}
|
||||||
|
|
||||||
public static void MapDoctorEndpoints(this WebApplication app)
|
public static void MapDoctorEndpoints(this WebApplication app)
|
||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/doctor").RequireAuthorization();
|
var group = app.MapGroup("/api/doctor").RequireAuthorization();
|
||||||
@@ -56,8 +70,7 @@ public static class DoctorEndpoints
|
|||||||
var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId);
|
var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId);
|
||||||
var activeConsultations = await db.Consultations.CountAsync(c => c.User.DoctorId == doctorId && c.Status != ConsultationStatus.Closed);
|
var activeConsultations = await db.Consultations.CountAsync(c => c.User.DoctorId == doctorId && c.Status != ConsultationStatus.Closed);
|
||||||
var pendingReports = await db.Reports.CountAsync(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor);
|
var pendingReports = await db.Reports.CountAsync(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor);
|
||||||
var todayStart = DateTime.UtcNow.Date;
|
var (todayStart, todayEnd) = GetBeijingDayRange(DateTime.UtcNow);
|
||||||
var todayEnd = todayStart.AddDays(1);
|
|
||||||
var todayFollowUps = await db.FollowUps.CountAsync(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming);
|
var todayFollowUps = await db.FollowUps.CountAsync(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming);
|
||||||
|
|
||||||
// 待办列表
|
// 待办列表
|
||||||
@@ -112,7 +125,7 @@ public static class DoctorEndpoints
|
|||||||
{
|
{
|
||||||
query = query.Where(u =>
|
query = query.Where(u =>
|
||||||
(u.Name != null && u.Name.Contains(search)) ||
|
(u.Name != null && u.Name.Contains(search)) ||
|
||||||
u.Phone.Contains(search));
|
(u.Phone != null && u.Phone.Contains(search)));
|
||||||
}
|
}
|
||||||
var total = await query.CountAsync();
|
var total = await query.CountAsync();
|
||||||
var patients = await query.OrderByDescending(u => u.CreatedAt)
|
var patients = await query.OrderByDescending(u => u.CreatedAt)
|
||||||
@@ -190,7 +203,8 @@ public static class DoctorEndpoints
|
|||||||
user.HealthArchive.Allergies,
|
user.HealthArchive.Allergies,
|
||||||
user.HealthArchive.DietRestrictions,
|
user.HealthArchive.DietRestrictions,
|
||||||
user.HealthArchive.ChronicDiseases,
|
user.HealthArchive.ChronicDiseases,
|
||||||
user.HealthArchive.FamilyHistory
|
user.HealthArchive.FamilyHistory,
|
||||||
|
user.HealthArchive.SurgeryHistoryStatus
|
||||||
},
|
},
|
||||||
latestRecords,
|
latestRecords,
|
||||||
trendRecords,
|
trendRecords,
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ public static class ExerciseEndpoints
|
|||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
await exercises.DeleteAsync(userId, id, ct);
|
var deleted = await exercises.DeleteAsync(userId, id, ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return deleted
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "运动计划不存在" });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ public static class FileEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/files").RequireAuthorization();
|
var group = app.MapGroup("/api/files").RequireAuthorization();
|
||||||
|
|
||||||
group.MapPost("/upload", async (HttpRequest request, CancellationToken ct) =>
|
group.MapPost("/upload", async (HttpContext http, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
|
var request = http.Request;
|
||||||
if (!request.HasFormContentType)
|
if (!request.HasFormContentType)
|
||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "需要 multipart/form-data" });
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "需要 multipart/form-data" });
|
||||||
|
|
||||||
@@ -23,7 +24,12 @@ public static class FileEndpoints
|
|||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" });
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" });
|
||||||
|
|
||||||
var results = new List<object>();
|
var results = new List<object>();
|
||||||
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
var userDirectoryName = GetUserId(http).ToString("N");
|
||||||
|
var uploadsDir = Path.Combine(
|
||||||
|
Directory.GetCurrentDirectory(),
|
||||||
|
"uploads",
|
||||||
|
"users",
|
||||||
|
userDirectoryName);
|
||||||
Directory.CreateDirectory(uploadsDir);
|
Directory.CreateDirectory(uploadsDir);
|
||||||
|
|
||||||
foreach (var file in files)
|
foreach (var file in files)
|
||||||
@@ -48,7 +54,7 @@ public static class FileEndpoints
|
|||||||
id = fileId,
|
id = fileId,
|
||||||
name = file.FileName,
|
name = file.FileName,
|
||||||
size = file.Length,
|
size = file.Length,
|
||||||
url = $"/uploads/{storedName}",
|
url = $"/uploads/users/{userDirectoryName}/{storedName}",
|
||||||
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
|
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -56,4 +62,9 @@ public static class FileEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id)
|
||||||
|
? id
|
||||||
|
: Guid.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ public static class HealthEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = new { id }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { id }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapPost("/batch", async (
|
||||||
|
IReadOnlyList<CreateHealthRecordRequest> requests,
|
||||||
|
HttpContext http,
|
||||||
|
IHealthRecordService healthRecords,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var ids = await healthRecords.CreateManyAsync(
|
||||||
|
userId,
|
||||||
|
requests.Select(request => request.ToServiceRequest()).ToList(),
|
||||||
|
ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { ids }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
group.MapPut("/{id:guid}", async (
|
group.MapPut("/{id:guid}", async (
|
||||||
Guid id,
|
Guid id,
|
||||||
CreateHealthRecordRequest req,
|
CreateHealthRecordRequest req,
|
||||||
|
|||||||
@@ -36,8 +36,10 @@ public static class MedicationEndpoints
|
|||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
await medications.DeleteAsync(userId, id, ct);
|
var deleted = await medications.DeleteAsync(userId, id, ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return deleted
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "用药记录不存在" });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
@@ -75,8 +77,10 @@ public static class MedicationEndpoints
|
|||||||
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
|
var cancelled = await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return cancelled
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "未找到对应的服药打卡记录" });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,14 +114,19 @@ public static class MedicationEndpoints
|
|||||||
if (root.TryGetProperty("frequency", out var f) && Enum.TryParse<MedicationFrequency>(f.GetString(), out var freq))
|
if (root.TryGetProperty("frequency", out var f) && Enum.TryParse<MedicationFrequency>(f.GetString(), out var freq))
|
||||||
frequency = freq;
|
frequency = freq;
|
||||||
|
|
||||||
|
var hasEndDate = root.TryGetProperty("endDate", out var ed);
|
||||||
|
var clearEndDate = hasEndDate &&
|
||||||
|
(ed.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(ed.GetString()));
|
||||||
|
|
||||||
return new MedicationPatchRequest(
|
return new MedicationPatchRequest(
|
||||||
root.TryGetProperty("name", out var n) ? n.GetString() : null,
|
root.TryGetProperty("name", out var n) ? n.GetString() : null,
|
||||||
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
||||||
frequency,
|
frequency,
|
||||||
root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null,
|
root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null,
|
||||||
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
|
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
|
||||||
root.TryGetProperty("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
hasEndDate && !clearEndDate && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
||||||
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null);
|
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null,
|
||||||
|
clearEndDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<TimeOnly> ReadTimes(JsonElement root, string propertyName)
|
private static List<TimeOnly> ReadTimes(JsonElement root, string propertyName)
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ public static class NotificationEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapPost("/check-due", async (HttpContext http, IReminderCatchUpService reminders, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||||
|
var result = await reminders.CheckDueAsync(userId, DateTime.UtcNow, ct);
|
||||||
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
@@ -90,6 +98,10 @@ public static class NotificationEndpoints
|
|||||||
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
|
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
|
||||||
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value;
|
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value;
|
||||||
if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value;
|
if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value;
|
||||||
|
if (body.PushEnabled.HasValue) pref.PushEnabled = body.PushEnabled.Value;
|
||||||
|
if (body.DndEnabled.HasValue) pref.DndEnabled = body.DndEnabled.Value;
|
||||||
|
if (body.DndStartMinutes is >= 0 and < 1440) pref.DndStartMinutes = body.DndStartMinutes.Value;
|
||||||
|
if (body.DndEndMinutes is >= 0 and < 1440) pref.DndEndMinutes = body.DndEndMinutes.Value;
|
||||||
if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value;
|
if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value;
|
||||||
if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
|
if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
|
||||||
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
|
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
|
||||||
@@ -100,7 +112,7 @@ public static class NotificationEndpoints
|
|||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
// 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00
|
// 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00
|
||||||
if (body.HealthRecordReminder is true && pref.HealthRecordReminder)
|
if (body.HealthRecordReminder is true && pref.PushEnabled && pref.HealthRecordReminder)
|
||||||
{
|
{
|
||||||
var nowCst = DateTime.UtcNow.AddHours(8);
|
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||||
var todayStart = nowCst.Date.AddHours(-8);
|
var todayStart = nowCst.Date.AddHours(-8);
|
||||||
@@ -153,6 +165,10 @@ public static class NotificationEndpoints
|
|||||||
followUpReminder = pref.FollowUpReminder,
|
followUpReminder = pref.FollowUpReminder,
|
||||||
doctorReply = pref.DoctorReply,
|
doctorReply = pref.DoctorReply,
|
||||||
abnormalAlert = pref.AbnormalAlert,
|
abnormalAlert = pref.AbnormalAlert,
|
||||||
|
pushEnabled = pref.PushEnabled,
|
||||||
|
dndEnabled = pref.DndEnabled,
|
||||||
|
dndStartMinutes = pref.DndStartMinutes,
|
||||||
|
dndEndMinutes = pref.DndEndMinutes,
|
||||||
healthRecordReminder = pref.HealthRecordReminder,
|
healthRecordReminder = pref.HealthRecordReminder,
|
||||||
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
|
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
|
||||||
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
|
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
|
||||||
@@ -167,6 +183,10 @@ public sealed record NotificationPrefsUpdateDto(
|
|||||||
bool? FollowUpReminder,
|
bool? FollowUpReminder,
|
||||||
bool? DoctorReply,
|
bool? DoctorReply,
|
||||||
bool? AbnormalAlert,
|
bool? AbnormalAlert,
|
||||||
|
bool? PushEnabled,
|
||||||
|
bool? DndEnabled,
|
||||||
|
int? DndStartMinutes,
|
||||||
|
int? DndEndMinutes,
|
||||||
bool? HealthRecordReminder,
|
bool? HealthRecordReminder,
|
||||||
bool? HealthRecordReminderBloodPressure,
|
bool? HealthRecordReminderBloodPressure,
|
||||||
bool? HealthRecordReminderHeartRate,
|
bool? HealthRecordReminderHeartRate,
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ public static class UserEndpoints
|
|||||||
req.DietRestrictions,
|
req.DietRestrictions,
|
||||||
req.ChronicDiseases,
|
req.ChronicDiseases,
|
||||||
req.FamilyHistory,
|
req.FamilyHistory,
|
||||||
surgeries),
|
surgeries,
|
||||||
|
req.SurgeryHistoryStatus),
|
||||||
ct);
|
ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
@@ -70,6 +71,7 @@ public sealed record UpdateArchiveRequest(
|
|||||||
string? Diagnosis, string? SurgeryType, string? SurgeryDate,
|
string? Diagnosis, string? SurgeryType, string? SurgeryDate,
|
||||||
List<string>? Allergies, List<string>? DietRestrictions,
|
List<string>? Allergies, List<string>? DietRestrictions,
|
||||||
List<string>? ChronicDiseases, string? FamilyHistory,
|
List<string>? ChronicDiseases, string? FamilyHistory,
|
||||||
List<UpdateArchiveSurgeryRequest>? Surgeries);
|
List<UpdateArchiveSurgeryRequest>? Surgeries,
|
||||||
|
string? SurgeryHistoryStatus);
|
||||||
|
|
||||||
public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date);
|
public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date);
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ builder.Services.AddScoped<IInAppNotificationService, InAppNotificationService>(
|
|||||||
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
|
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
|
||||||
builder.Services.AddScoped<IUserNotificationProducer, EfUserNotificationProducer>();
|
builder.Services.AddScoped<IUserNotificationProducer, EfUserNotificationProducer>();
|
||||||
builder.Services.AddScoped<INotificationOutboxProcessor, EfNotificationOutboxProcessor>();
|
builder.Services.AddScoped<INotificationOutboxProcessor, EfNotificationOutboxProcessor>();
|
||||||
|
builder.Services.AddScoped<IReminderCatchUpService, EfReminderCatchUpService>();
|
||||||
builder.Services.AddScoped<IReportService, ReportService>();
|
builder.Services.AddScoped<IReportService, ReportService>();
|
||||||
builder.Services.AddScoped<IReportRepository, EfReportRepository>();
|
builder.Services.AddScoped<IReportRepository, EfReportRepository>();
|
||||||
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
|
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
|
||||||
@@ -150,6 +151,8 @@ builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
|
|||||||
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
|
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
|
||||||
builder.Services.AddScoped<IUserService, UserService>();
|
builder.Services.AddScoped<IUserService, UserService>();
|
||||||
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
|
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
|
||||||
|
builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup(
|
||||||
|
Path.Combine(Directory.GetCurrentDirectory(), "uploads")));
|
||||||
|
|
||||||
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)----
|
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)----
|
||||||
builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<p>请您在注册、登录和使用本应用前仔细阅读本政策。若您不同意本政策内容,请停止注册或使用相关服务。</p>
|
<p>请您在注册、登录和使用本应用前仔细阅读本政策。若您不同意本政策内容,请停止注册或使用相关服务。</p>
|
||||||
|
|
||||||
<h2>一、我们收集的信息</h2>
|
<h2>一、我们收集的信息</h2>
|
||||||
<p>为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划、在线医生咨询和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:</p>
|
<p>为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:</p>
|
||||||
|
|
||||||
<h3>1. 账号与身份信息</h3>
|
<h3>1. 账号与身份信息</h3>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
<h3>4. 蓝牙设备信息</h3>
|
<h3>4. 蓝牙设备信息</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前代码中已实现血压计数据同步,设备模型中预留了血糖仪、体重秤、血氧仪类型,但当前实际支持的数据解析以血压计为主。</li>
|
<li>当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前实际支持以采用标准蓝牙血压服务的血压计为主;其他设备类型将在功能正式开放后另行说明。</li>
|
||||||
<li>我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间,以及用于避免重复同步的短期记录指纹。</li>
|
<li>我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间,以及用于避免重复同步的短期记录指纹。</li>
|
||||||
<li>通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。</li>
|
<li>通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<p>如您不同意本协议,请停止注册或使用本应用。</p>
|
<p>如您不同意本协议,请停止注册或使用本应用。</p>
|
||||||
|
|
||||||
<h2>一、服务内容</h2>
|
<h2>一、服务内容</h2>
|
||||||
<p>小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释、在线医生咨询、随访和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。</p>
|
<p>小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。</p>
|
||||||
<p>本应用当前可能包含以下服务:</p>
|
<p>本应用当前可能包含以下服务:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>健康数据管理:记录和展示血压、心率、血糖、血氧、体重等指标。</li>
|
<li>健康数据管理:记录和展示血压、心率、血糖、血氧、体重等指标。</li>
|
||||||
@@ -37,7 +37,6 @@
|
|||||||
<li>饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。</li>
|
<li>饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。</li>
|
||||||
<li>AI 健康咨询:根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。</li>
|
<li>AI 健康咨询:根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。</li>
|
||||||
<li>用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。</li>
|
<li>用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。</li>
|
||||||
<li>在线医生咨询:在开通范围内与医生或相关服务人员进行消息沟通。</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>二、账号注册与使用</h2>
|
<h2>二、账号注册与使用</h2>
|
||||||
@@ -56,7 +55,6 @@
|
|||||||
<li>检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。</li>
|
<li>检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。</li>
|
||||||
<li>饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。</li>
|
<li>饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。</li>
|
||||||
<li>用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。</li>
|
<li>用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。</li>
|
||||||
<li>医生咨询、随访和报告审核等服务,应结合线下诊疗意见综合判断。</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p>如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。</p>
|
<p>如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。</p>
|
||||||
|
|
||||||
|
|||||||
120
backend/tests/Health.Tests/account_deletion_tests.cs
Normal file
120
backend/tests/Health.Tests/account_deletion_tests.cs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
using Health.Application.Users;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Infrastructure.Users;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class AccountDeletionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task LocalCleanup_DeletesOwnedUploadsWithoutTouchingOtherFiles()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), $"health-account-delete-{Guid.NewGuid():N}");
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var userDirectory = Path.Combine(root, "users", userId.ToString("N"));
|
||||||
|
var reportPath = Path.Combine(root, "reports", "owned-report.pdf");
|
||||||
|
var chatImagePath = Path.Combine(root, "owned-chat.jpg");
|
||||||
|
var otherUserPath = Path.Combine(root, "users", Guid.NewGuid().ToString("N"), "keep.jpg");
|
||||||
|
var outsidePath = Path.Combine(Path.GetDirectoryName(root)!, "outside-account-file.txt");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(userDirectory);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(otherUserPath)!);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(userDirectory, "unlinked-upload.jpg"), "owned");
|
||||||
|
await File.WriteAllTextAsync(reportPath, "owned");
|
||||||
|
await File.WriteAllTextAsync(chatImagePath, "owned");
|
||||||
|
await File.WriteAllTextAsync(otherUserPath, "other");
|
||||||
|
await File.WriteAllTextAsync(outsidePath, "outside");
|
||||||
|
|
||||||
|
var cleanup = new LocalAccountFileCleanup(root);
|
||||||
|
var references = new AccountFileReferences(
|
||||||
|
["/uploads/reports/owned-report.pdf", "/uploads/../outside-account-file.txt"],
|
||||||
|
["{\"imageUrl\":\"/uploads/owned-chat.jpg\"}"]);
|
||||||
|
|
||||||
|
await cleanup.DeleteAsync(userId, references, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(Directory.Exists(userDirectory));
|
||||||
|
Assert.False(File.Exists(reportPath));
|
||||||
|
Assert.False(File.Exists(chatImagePath));
|
||||||
|
Assert.True(File.Exists(otherUserPath));
|
||||||
|
Assert.True(File.Exists(outsidePath));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||||
|
if (File.Exists(outsidePath)) File.Delete(outsidePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UserService_CleansFilesBeforeDeletingDatabaseData()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var references = new AccountFileReferences(["/uploads/reports/report.pdf"], []);
|
||||||
|
var repository = new FakeUserRepository(references, calls);
|
||||||
|
var cleanup = new FakeAccountFileCleanup(calls);
|
||||||
|
var service = new UserService(repository, cleanup);
|
||||||
|
|
||||||
|
await service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(["references", "files", "database"], calls);
|
||||||
|
Assert.Same(references, cleanup.ReceivedReferences);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UserService_DoesNotDeleteDatabaseWhenFileCleanupFails()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var repository = new FakeUserRepository(new AccountFileReferences([], []), calls);
|
||||||
|
var service = new UserService(repository, new FailingAccountFileCleanup(calls));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<IOException>(() =>
|
||||||
|
service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Equal(["references", "files"], calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeUserRepository(
|
||||||
|
AccountFileReferences references,
|
||||||
|
List<string> calls) : IUserRepository
|
||||||
|
{
|
||||||
|
public Task<User?> GetAsync(Guid userId, CancellationToken ct) => Task.FromResult<User?>(null);
|
||||||
|
|
||||||
|
public Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
calls.Add("references");
|
||||||
|
return Task.FromResult(references);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task DeleteAccountDataAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
calls.Add("database");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeAccountFileCleanup(List<string> calls) : IAccountFileCleanup
|
||||||
|
{
|
||||||
|
public AccountFileReferences? ReceivedReferences { get; private set; }
|
||||||
|
|
||||||
|
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||||
|
{
|
||||||
|
calls.Add("files");
|
||||||
|
ReceivedReferences = references;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FailingAccountFileCleanup(List<string> calls) : IAccountFileCleanup
|
||||||
|
{
|
||||||
|
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||||
|
{
|
||||||
|
calls.Add("files");
|
||||||
|
throw new IOException("file is locked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,8 @@ public sealed class ApplicationServiceTests
|
|||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var repository = new FakeHealthArchiveRepository(new HealthArchive
|
var repository = new FakeHealthArchiveRepository(new HealthArchive
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), UserId = userId,
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
});
|
});
|
||||||
var service = new HealthArchiveService(repository);
|
var service = new HealthArchiveService(repository);
|
||||||
|
|
||||||
@@ -75,14 +76,35 @@ public sealed class ApplicationServiceTests
|
|||||||
Assert.InRange((before - repository.RecordedAfter!.Value).TotalDays, 364.99, 365.01);
|
Assert.InRange((before - repository.RecordedAfter!.Value).TotalDays, 364.99, 365.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HealthRecords_BatchPersistsAllReadingsWithOneSave()
|
||||||
|
{
|
||||||
|
var repository = new CapturingHealthRecordRepository();
|
||||||
|
var service = new HealthRecordService(repository);
|
||||||
|
|
||||||
|
var ids = await service.CreateManyAsync(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
[
|
||||||
|
new HealthRecordUpsertRequest(HealthMetricType.BloodPressure, 116, 89, null, "mmHg", HealthRecordSource.DeviceSync, DateTime.UtcNow),
|
||||||
|
new HealthRecordUpsertRequest(HealthMetricType.HeartRate, null, null, 72, "bpm", HealthRecordSource.DeviceSync, DateTime.UtcNow),
|
||||||
|
],
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, ids.Count);
|
||||||
|
Assert.Equal(2, repository.Added.Count);
|
||||||
|
Assert.Equal(1, repository.SaveCount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
|
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var repository = new FakeHealthArchiveRepository(new HealthArchive
|
var repository = new FakeHealthArchiveRepository(new HealthArchive
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), UserId = userId,
|
Id = Guid.NewGuid(),
|
||||||
SurgeryType = "Legacy surgery", SurgeryDate = new DateOnly(2010, 1, 2),
|
UserId = userId,
|
||||||
|
SurgeryType = "Legacy surgery",
|
||||||
|
SurgeryDate = new DateOnly(2010, 1, 2),
|
||||||
});
|
});
|
||||||
var service = new HealthArchiveService(repository);
|
var service = new HealthArchiveService(repository);
|
||||||
|
|
||||||
@@ -110,13 +132,71 @@ public sealed class ApplicationServiceTests
|
|||||||
Assert.False(result.Created);
|
Assert.False(result.Created);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Conversation_List_ReturnsAtMostThirtyRecentItems()
|
||||||
|
{
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var conversations = Enumerable.Range(0, 35)
|
||||||
|
.Select(index => new Conversation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
AgentType = AgentType.Unified,
|
||||||
|
UpdatedAt = DateTime.UtcNow.AddMinutes(-index),
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var service = new AiConversationService(new ListConversationRepository(conversations));
|
||||||
|
|
||||||
|
var result = await service.ListAsync(userId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(30, result.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Conversation_Delete_ReportsWhetherARecordWasActuallyDeleted()
|
||||||
|
{
|
||||||
|
var method = typeof(IAiConversationService).GetMethod(nameof(IAiConversationService.DeleteAsync));
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
Assert.Equal(typeof(Task<bool>), method!.ReturnType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Conversation_Open_ReactivatesTheSameOwnedConversation()
|
||||||
|
{
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var conversation = new Conversation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
AgentType = AgentType.Unified,
|
||||||
|
UpdatedAt = DateTime.UtcNow.AddDays(-1),
|
||||||
|
};
|
||||||
|
var service = new AiConversationService(new FakeConversationRepository(conversation));
|
||||||
|
|
||||||
|
var result = await service.OpenAsync(
|
||||||
|
userId,
|
||||||
|
conversation.Id,
|
||||||
|
AgentType.Unified,
|
||||||
|
"新的追问",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Found);
|
||||||
|
Assert.False(result.Created);
|
||||||
|
Assert.Equal(conversation.Id, result.ConversationId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Calendar_Month_AggregatesMedicationExerciseAndFollowUp()
|
public async Task Calendar_Month_AggregatesMedicationExerciseAndFollowUp()
|
||||||
{
|
{
|
||||||
var date = new DateOnly(2026, 6, 19);
|
var date = new DateOnly(2026, 6, 19);
|
||||||
var medication = new Medication
|
var medication = new Medication
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), IsActive = true, Name = "阿司匹林", StartDate = date.AddDays(-1), TimeOfDay = [new TimeOnly(8, 0)]
|
Id = Guid.NewGuid(),
|
||||||
|
IsActive = true,
|
||||||
|
Name = "阿司匹林",
|
||||||
|
StartDate = date.AddDays(-1),
|
||||||
|
TimeOfDay = [new TimeOnly(8, 0)]
|
||||||
};
|
};
|
||||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), StartDate = date, EndDate = date, ReminderTime = new TimeOnly(19, 0) };
|
var plan = new ExercisePlan { Id = Guid.NewGuid(), StartDate = date, EndDate = date, ReminderTime = new TimeOnly(19, 0) };
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = "散步", DurationMinutes = 30 });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = "散步", DurationMinutes = 30 });
|
||||||
@@ -154,7 +234,11 @@ public sealed class ApplicationServiceTests
|
|||||||
var repository = new FakeExerciseRepository();
|
var repository = new FakeExerciseRepository();
|
||||||
var plan = new ExercisePlan
|
var plan = new ExercisePlan
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today, EndDate = today.AddDays(9), ReminderTime = new TimeOnly(19, 0),
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
StartDate = today,
|
||||||
|
EndDate = today.AddDays(9),
|
||||||
|
ReminderTime = new TimeOnly(19, 0),
|
||||||
};
|
};
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today, ExerciseType = "跑步", DurationMinutes = 30 });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today, ExerciseType = "跑步", DurationMinutes = 30 });
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today.AddDays(7), ExerciseType = "跑步", DurationMinutes = 30 });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today.AddDays(7), ExerciseType = "跑步", DurationMinutes = 30 });
|
||||||
@@ -175,11 +259,19 @@ public sealed class ApplicationServiceTests
|
|||||||
var repository = new FakeExerciseRepository();
|
var repository = new FakeExerciseRepository();
|
||||||
var plan = new ExercisePlan
|
var plan = new ExercisePlan
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today.AddDays(-1), EndDate = today, ReminderTime = new TimeOnly(19, 0),
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
StartDate = today.AddDays(-1),
|
||||||
|
EndDate = today,
|
||||||
|
ReminderTime = new TimeOnly(19, 0),
|
||||||
};
|
};
|
||||||
var item = new ExercisePlanItem
|
var item = new ExercisePlanItem
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), Plan = plan, ScheduledDate = today.AddDays(-1), ExerciseType = "散步", DurationMinutes = 30,
|
Id = Guid.NewGuid(),
|
||||||
|
Plan = plan,
|
||||||
|
ScheduledDate = today.AddDays(-1),
|
||||||
|
ExerciseType = "散步",
|
||||||
|
DurationMinutes = 30,
|
||||||
};
|
};
|
||||||
plan.Items.Add(item);
|
plan.Items.Add(item);
|
||||||
repository.SetPlan(plan);
|
repository.SetPlan(plan);
|
||||||
@@ -199,6 +291,8 @@ public sealed class ApplicationServiceTests
|
|||||||
private sealed class CapturingHealthRecordRepository : IHealthRecordRepository
|
private sealed class CapturingHealthRecordRepository : IHealthRecordRepository
|
||||||
{
|
{
|
||||||
public DateTime? RecordedAfter { get; private set; }
|
public DateTime? RecordedAfter { get; private set; }
|
||||||
|
public List<HealthRecord> Added { get; } = [];
|
||||||
|
public int SaveCount { get; private set; }
|
||||||
public Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct) =>
|
public Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct) =>
|
||||||
Task.FromResult<IReadOnlyList<HealthRecord>>([]);
|
Task.FromResult<IReadOnlyList<HealthRecord>>([]);
|
||||||
public Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => Task.FromResult<HealthRecord?>(null);
|
public Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => Task.FromResult<HealthRecord?>(null);
|
||||||
@@ -208,9 +302,9 @@ public sealed class ApplicationServiceTests
|
|||||||
RecordedAfter = recordedAfter;
|
RecordedAfter = recordedAfter;
|
||||||
return Task.FromResult<IReadOnlyList<HealthRecord>>([]);
|
return Task.FromResult<IReadOnlyList<HealthRecord>>([]);
|
||||||
}
|
}
|
||||||
public Task AddAsync(HealthRecord record, CancellationToken ct) => Task.CompletedTask;
|
public Task AddAsync(HealthRecord record, CancellationToken ct) { Added.Add(record); return Task.CompletedTask; }
|
||||||
public void Delete(HealthRecord record) { }
|
public void Delete(HealthRecord record) { }
|
||||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
public Task SaveChangesAsync(CancellationToken ct) { SaveCount++; return Task.CompletedTask; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository
|
private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository
|
||||||
@@ -223,10 +317,38 @@ public sealed class ApplicationServiceTests
|
|||||||
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
||||||
public Task AddConversationAsync(Conversation value, CancellationToken ct) => Task.CompletedTask;
|
public Task AddConversationAsync(Conversation value, CancellationToken ct) => Task.CompletedTask;
|
||||||
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
|
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct) => Task.FromResult(0);
|
||||||
public void Delete(Conversation value) { }
|
public void Delete(Conversation value) { }
|
||||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class ListConversationRepository(List<Conversation> conversations) : IAiConversationRepository
|
||||||
|
{
|
||||||
|
private readonly List<Conversation> _conversations = conversations;
|
||||||
|
public Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) =>
|
||||||
|
Task.FromResult(_conversations.FirstOrDefault(item => item.Id == conversationId && item.UserId == userId));
|
||||||
|
public Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct) =>
|
||||||
|
Task.FromResult(_conversations.FirstOrDefault(item => item.Id == conversationId));
|
||||||
|
public Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct) =>
|
||||||
|
Task.FromResult<IReadOnlyList<Conversation>>(_conversations
|
||||||
|
.Where(item => item.UserId == userId)
|
||||||
|
.OrderByDescending(item => item.UpdatedAt)
|
||||||
|
.ToList());
|
||||||
|
public Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct) =>
|
||||||
|
Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
||||||
|
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) =>
|
||||||
|
Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
||||||
|
public Task AddConversationAsync(Conversation value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_conversations.Add(value);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct) => Task.FromResult(0);
|
||||||
|
public void Delete(Conversation value) => _conversations.Remove(value);
|
||||||
|
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeCalendarRepository(CalendarDataSnapshot snapshot) : ICalendarRepository
|
private sealed class FakeCalendarRepository(CalendarDataSnapshot snapshot) : ICalendarRepository
|
||||||
{
|
{
|
||||||
public Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) => Task.FromResult(snapshot);
|
public Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) => Task.FromResult(snapshot);
|
||||||
|
|||||||
56
backend/tests/Health.Tests/diet_commentary_tests.cs
Normal file
56
backend/tests/Health.Tests/diet_commentary_tests.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using Health.Application.AI;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class DietCommentaryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FoodDescription_ContainsOnlyStructuredFoodData()
|
||||||
|
{
|
||||||
|
var result = DietCommentaryPolicy.BuildFoodDescription(
|
||||||
|
[
|
||||||
|
new DietCommentaryFood("番茄炒蛋", "半盘", 220),
|
||||||
|
new DietCommentaryFood("米饭", "一碗", 260),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Equal("番茄炒蛋(半盘,220千卡)、米饭(一碗,260千卡)", result);
|
||||||
|
Assert.DoesNotContain("请结合", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyArtifact_RequiresEveryUserMessageToUseInternalPrefix()
|
||||||
|
{
|
||||||
|
var artifact = new Conversation
|
||||||
|
{
|
||||||
|
Messages =
|
||||||
|
[
|
||||||
|
new ConversationMessage
|
||||||
|
{
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = DietCommentaryPolicy.LegacyPromptPrefix + "食物为:米饭",
|
||||||
|
},
|
||||||
|
new ConversationMessage
|
||||||
|
{
|
||||||
|
Role = MessageRole.Assistant,
|
||||||
|
Content = "注意搭配蔬菜",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var normal = new Conversation
|
||||||
|
{
|
||||||
|
Messages =
|
||||||
|
[
|
||||||
|
new ConversationMessage
|
||||||
|
{
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = "我想了解今天的饮食记录",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(DietCommentaryPolicy.IsLegacyArtifact(artifact.Messages));
|
||||||
|
Assert.False(DietCommentaryPolicy.IsLegacyArtifact(normal.Messages));
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/tests/Health.Tests/doctor_endpoint_tests.cs
Normal file
17
backend/tests/Health.Tests/doctor_endpoint_tests.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class DoctorEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BeijingDayRange_MapsEarlyUtcTimeToTheCorrectLocalDate()
|
||||||
|
{
|
||||||
|
var utcNow = new DateTime(2026, 7, 13, 17, 30, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var (startUtc, endUtc) = DoctorEndpoints.GetBeijingDayRange(utcNow);
|
||||||
|
|
||||||
|
Assert.Equal(new DateTime(2026, 7, 13, 16, 0, 0, DateTimeKind.Utc), startUtc);
|
||||||
|
Assert.Equal(new DateTime(2026, 7, 14, 16, 0, 0, DateTimeKind.Utc), endUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,6 +184,7 @@ public class EntityTests
|
|||||||
DietRestrictions = ["低盐", "低脂"],
|
DietRestrictions = ["低盐", "低脂"],
|
||||||
ChronicDiseases = ["高血压", "高血脂"],
|
ChronicDiseases = ["高血压", "高血脂"],
|
||||||
FamilyHistory = "父亲冠心病",
|
FamilyHistory = "父亲冠心病",
|
||||||
|
SurgeryHistoryStatus = "有",
|
||||||
};
|
};
|
||||||
db.HealthArchives.Add(archive);
|
db.HealthArchives.Add(archive);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -193,6 +194,7 @@ public class EntityTests
|
|||||||
Assert.Equal("冠心病", saved!.Diagnosis);
|
Assert.Equal("冠心病", saved!.Diagnosis);
|
||||||
Assert.Equal(2, saved.DietRestrictions.Count);
|
Assert.Equal(2, saved.DietRestrictions.Count);
|
||||||
Assert.Contains("低盐", saved.DietRestrictions);
|
Assert.Contains("低盐", saved.DietRestrictions);
|
||||||
|
Assert.Equal("有", saved.SurgeryHistoryStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
29
backend/tests/Health.Tests/medication_schedule_tests.cs
Normal file
29
backend/tests/Health.Tests/medication_schedule_tests.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using Health.Application.Medications;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class MedicationScheduleTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FutureDoseOutsideReminderWindow_RemainsVisibleAsScheduled()
|
||||||
|
{
|
||||||
|
var status = MedicationScheduleStatus.Resolve(
|
||||||
|
new TimeOnly(18, 0),
|
||||||
|
new TimeOnly(8, 0),
|
||||||
|
logStatus: null);
|
||||||
|
|
||||||
|
Assert.Equal("scheduled", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoggedDose_UsesItsActualStatus()
|
||||||
|
{
|
||||||
|
var status = MedicationScheduleStatus.Resolve(
|
||||||
|
new TimeOnly(8, 0),
|
||||||
|
new TimeOnly(12, 0),
|
||||||
|
MedicationLogStatus.Taken);
|
||||||
|
|
||||||
|
Assert.Equal("taken", status);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
backend/tests/Health.Tests/medication_update_tests.cs
Normal file
54
backend/tests/Health.Tests/medication_update_tests.cs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
using Health.Application.Medications;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class MedicationUpdateTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_CanClearAnExistingEndDate()
|
||||||
|
{
|
||||||
|
var medication = new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
Name = "阿司匹林",
|
||||||
|
StartDate = new DateOnly(2026, 7, 1),
|
||||||
|
EndDate = new DateOnly(2026, 7, 31),
|
||||||
|
};
|
||||||
|
var repository = new FakeMedicationRepository(medication);
|
||||||
|
var service = new MedicationService(repository);
|
||||||
|
|
||||||
|
var updated = await service.UpdateAsync(
|
||||||
|
medication.UserId,
|
||||||
|
medication.Id,
|
||||||
|
new MedicationPatchRequest(
|
||||||
|
null, null, null, null, null, null, null,
|
||||||
|
ClearEndDate: true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(updated);
|
||||||
|
Assert.Null(medication.EndDate);
|
||||||
|
Assert.True(repository.Saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeMedicationRepository(Medication medication) : IMedicationRepository
|
||||||
|
{
|
||||||
|
public bool Saved { get; private set; }
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||||
|
public Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||||
|
public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<IReadOnlyList<MedicationLog>>([]);
|
||||||
|
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult<Medication?>(medication.UserId == userId && medication.Id == medicationId ? medication : null);
|
||||||
|
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId);
|
||||||
|
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
|
||||||
|
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(false);
|
||||||
|
public Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
|
||||||
|
public Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||||
|
public Task AddMedicationAsync(Medication value, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task AddLogAsync(MedicationLog log, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public void DeleteMedication(Medication value) { }
|
||||||
|
public void DeleteLog(MedicationLog log) { }
|
||||||
|
public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Health.Application.Medications;
|
using Health.Application.Medications;
|
||||||
|
using Health.Application.AI;
|
||||||
using Health.Domain.Entities;
|
using Health.Domain.Entities;
|
||||||
using Health.Domain.Enums;
|
using Health.Domain.Enums;
|
||||||
using Health.Infrastructure.AI;
|
using Health.Infrastructure.AI;
|
||||||
@@ -13,6 +14,50 @@ namespace Health.Tests;
|
|||||||
|
|
||||||
public sealed class PersistencePipelineTests
|
public sealed class PersistencePipelineTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ConversationCleanup_DeletesOnlyLegacyDietCommentaryArtifacts()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var artifact = new Conversation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
AgentType = AgentType.Default,
|
||||||
|
};
|
||||||
|
artifact.Messages.Add(new ConversationMessage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ConversationId = artifact.Id,
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = DietCommentaryPolicy.LegacyPromptPrefix + "食物为:米饭",
|
||||||
|
});
|
||||||
|
var normal = new Conversation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
AgentType = AgentType.Default,
|
||||||
|
};
|
||||||
|
normal.Messages.Add(new ConversationMessage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ConversationId = normal.Id,
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = "请分析我今天吃的米饭",
|
||||||
|
});
|
||||||
|
db.Conversations.AddRange(artifact, normal);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var repository = new EfAiConversationRepository(db);
|
||||||
|
|
||||||
|
var deleted = await repository.DeleteLegacyDietCommentaryArtifactsAsync(
|
||||||
|
userId,
|
||||||
|
DietCommentaryPolicy.LegacyPromptPrefix,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, deleted);
|
||||||
|
Assert.Equal(normal.Id, Assert.Single(db.Conversations).Id);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner()
|
public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner()
|
||||||
{
|
{
|
||||||
@@ -168,6 +213,178 @@ public sealed class PersistencePipelineTests
|
|||||||
Assert.Empty(tasks);
|
Assert.Empty(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotificationOutbox_PushDisabledDoesNotCreateInAppNotification()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var sourceId = Guid.NewGuid();
|
||||||
|
db.NotificationPreferences.Add(new NotificationPreference
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
PushEnabled = false,
|
||||||
|
MedicationReminder = true,
|
||||||
|
HealthRecordReminder = false,
|
||||||
|
});
|
||||||
|
db.NotificationOutbox.Add(new NotificationOutboxRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
SourceTaskId = sourceId,
|
||||||
|
Type = "MedicationReminder",
|
||||||
|
Payload = """
|
||||||
|
{"Type":"MedicationReminder","Title":"用药时间到了","Message":"请按计划服药","Severity":"info","ActionType":"medication","ActionTargetId":null}
|
||||||
|
""",
|
||||||
|
Status = "Pending",
|
||||||
|
AvailableAt = DateTime.UtcNow,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var processor = new EfNotificationOutboxProcessor(db);
|
||||||
|
|
||||||
|
await processor.ProcessNextAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Empty(db.UserNotifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReminderCatchUp_PushDisabledDoesNotCreateHealthRecordReminder()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
db.NotificationPreferences.Add(new NotificationPreference
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
PushEnabled = false,
|
||||||
|
HealthRecordReminder = true,
|
||||||
|
HealthRecordReminderBloodPressure = true,
|
||||||
|
HealthRecordReminderHeartRate = false,
|
||||||
|
HealthRecordReminderGlucose = false,
|
||||||
|
HealthRecordReminderSpO2 = false,
|
||||||
|
HealthRecordReminderWeight = false,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new EfReminderCatchUpService(db);
|
||||||
|
var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc); // 13:00 Beijing
|
||||||
|
|
||||||
|
var created = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||||
|
var secondPass = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, created.CreatedCount);
|
||||||
|
Assert.Equal(0, secondPass.CreatedCount);
|
||||||
|
Assert.Empty(db.UserNotifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReminderCatchUp_PushEnabledCreatesOnlySelectedMissingMetric()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
db.NotificationPreferences.Add(new NotificationPreference
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
PushEnabled = true,
|
||||||
|
HealthRecordReminder = true,
|
||||||
|
HealthRecordReminderBloodPressure = true,
|
||||||
|
HealthRecordReminderHeartRate = false,
|
||||||
|
HealthRecordReminderGlucose = false,
|
||||||
|
HealthRecordReminderSpO2 = false,
|
||||||
|
HealthRecordReminderWeight = false,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new EfReminderCatchUpService(db);
|
||||||
|
var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var result = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.CreatedCount);
|
||||||
|
var notification = Assert.Single(db.UserNotifications);
|
||||||
|
Assert.Contains("血压", notification.Message);
|
||||||
|
Assert.DoesNotContain("心率", notification.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReminderCatchUp_DndStillPreventsReminder()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
db.NotificationPreferences.Add(new NotificationPreference
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
PushEnabled = true,
|
||||||
|
HealthRecordReminder = true,
|
||||||
|
HealthRecordReminderBloodPressure = true,
|
||||||
|
DndEnabled = true,
|
||||||
|
DndStartMinutes = 12 * 60,
|
||||||
|
DndEndMinutes = 14 * 60,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new EfReminderCatchUpService(db);
|
||||||
|
var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var result = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, result.CreatedCount);
|
||||||
|
Assert.Empty(db.UserNotifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReminderCatchUp_CreatesMedicationAndExerciseRemindersForDueItems()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var date = new DateOnly(2026, 7, 13);
|
||||||
|
db.NotificationPreferences.Add(new NotificationPreference
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
PushEnabled = true,
|
||||||
|
MedicationReminder = true,
|
||||||
|
HealthRecordReminder = false,
|
||||||
|
});
|
||||||
|
db.Medications.Add(new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
Name = "测试药物",
|
||||||
|
Dosage = "1片",
|
||||||
|
Frequency = MedicationFrequency.Daily,
|
||||||
|
TimeOfDay = [new TimeOnly(8, 0)],
|
||||||
|
StartDate = date,
|
||||||
|
IsActive = true,
|
||||||
|
});
|
||||||
|
var plan = new ExercisePlan
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
StartDate = date,
|
||||||
|
EndDate = date,
|
||||||
|
ReminderTime = new TimeOnly(9, 0),
|
||||||
|
};
|
||||||
|
plan.Items.Add(new ExercisePlanItem
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ScheduledDate = date,
|
||||||
|
ExerciseType = "散步",
|
||||||
|
DurationMinutes = 30,
|
||||||
|
});
|
||||||
|
db.ExercisePlans.Add(plan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new EfReminderCatchUpService(db);
|
||||||
|
var nowUtc = new DateTime(2026, 7, 13, 2, 0, 0, DateTimeKind.Utc); // 10:00 Beijing
|
||||||
|
|
||||||
|
var created = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, created.CreatedCount);
|
||||||
|
Assert.Contains(db.UserNotifications, x => x.Type == "MedicationReminder");
|
||||||
|
Assert.Contains(db.UserNotifications, x => x.Type == "ExerciseReminder");
|
||||||
|
}
|
||||||
|
|
||||||
private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new()
|
private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
|||||||
18
backend/tests/Health.Tests/prompt_manager_tests.cs
Normal file
18
backend/tests/Health.Tests/prompt_manager_tests.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using Health.Infrastructure.AI;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class PromptManagerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void UnifiedPrompt_DistinguishesQuestionsFromEntryRequests()
|
||||||
|
{
|
||||||
|
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
|
||||||
|
|
||||||
|
Assert.Contains("先判断用户是在咨询数值,还是希望记录数据", prompt);
|
||||||
|
Assert.Contains("血氧98%是不是太高了", prompt);
|
||||||
|
Assert.Contains("先回答问题,不调用 record_health_data", prompt);
|
||||||
|
Assert.Contains("帮我记录血压116/89", prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
233
docs/HANDOFF-2026-07-16.md
Normal file
233
docs/HANDOFF-2026-07-16.md
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
# 小脉健康项目交接文档
|
||||||
|
|
||||||
|
更新时间:2026-07-16
|
||||||
|
工作目录:`D:\health_project`
|
||||||
|
当前分支:`main`
|
||||||
|
|
||||||
|
## 1. 项目当前阶段
|
||||||
|
|
||||||
|
项目主体已经完成,包括 Flutter App 和 .NET 后端。目前处于:
|
||||||
|
|
||||||
|
> Android 本地真机测试、功能稳定优化、前端 UI 统一、合规准备,以及等待正式环境和资质流程。
|
||||||
|
|
||||||
|
当前不是“马上提交应用商店审核”的状态。
|
||||||
|
|
||||||
|
- Android 一直以本地真机为主要测试平台。
|
||||||
|
- iOS 代码已经合入并做过基础适配,但尚未完成本地真实 iPhone 的完整回归。
|
||||||
|
- 后端及 H5 合规页面仍以本地环境为主,正式服务器、域名和 HTTPS 尚未最终切换。
|
||||||
|
- 正式 API、数据库、JWT、短信、文件存储和 CORS 要在服务器及域名确定后统一配置。
|
||||||
|
- 正式 Android keystore、发布签名、APK/AAB 仍取决于外部资质和正式配置交付。
|
||||||
|
- 苹果开发者账号、证书、Bundle ID、签名和 TestFlight 权限仍取决于外部流程。
|
||||||
|
- ICP、公安备案、软著、安全评估和应用市场账号等由外部继续推进。
|
||||||
|
- 项目明确不使用 Docker,不要围绕 Docker 增加工作。
|
||||||
|
|
||||||
|
## 2. Git 与代码状态
|
||||||
|
|
||||||
|
- 当前分支:`main`,不要创建额外分支。
|
||||||
|
- 当前提交:`5288596 merge: integrate sccsbc release preparation safely`。
|
||||||
|
- `origin/main` 当前为 `fade61a`,本地 `main` 比远端多 6 个提交。
|
||||||
|
- `sccsbc` 的 Apple 登录、上线准备和 Android 签名相关提交已经通过合并提交进入本地 `main`。
|
||||||
|
- 合并采用“保留当前新版业务和 UI、吸收对方上线准备”的方式,没有用对方旧快照覆盖现有代码。
|
||||||
|
- 当前工作区有大量未提交修改。这些包含用户现阶段 UI、iOS 适配和交互调整,不能执行 `git reset --hard`、`git checkout --` 或用旧分支覆盖。
|
||||||
|
- 开始新修改前必须先查看 `git status` 和相关文件差异,只改当前任务涉及的代码。
|
||||||
|
|
||||||
|
当前主要未跟踪正式资源:
|
||||||
|
|
||||||
|
- `health_app/assets/branding/login_background_v2.png`
|
||||||
|
- `health_app/assets/branding/drawer_background_v2.png`
|
||||||
|
- `health_app/assets/branding/health_login_character_transparent.png`
|
||||||
|
- `health_app/test/native_navigation_test.dart`
|
||||||
|
|
||||||
|
## 3. 用户的协作习惯
|
||||||
|
|
||||||
|
- 先用用户视角解释,不要堆技术术语。
|
||||||
|
- 一次讨论一个明确问题,说明表现、原因和修改结果。
|
||||||
|
- 用户说“先讨论”时不能直接修改。
|
||||||
|
- 用户说“开始改”“动手”“去做”后直接实现,不要反复确认。
|
||||||
|
- 不要擅自扩大范围,不要增加与当前目标无关的工作量。
|
||||||
|
- 用户可能同时修改其他文件,必须保留并兼容用户改动。
|
||||||
|
- 用户通常自己构建和安装;除非明确要求,不要运行耗时构建或安装。
|
||||||
|
- 简单改动只做快速格式或静态检查,不要反复跑长测试。
|
||||||
|
- 数据删除、注销、历史对话等操作必须对应真实后端结果,不能只改前端显示。
|
||||||
|
- 修改聊天相关代码时,必须保证恢复历史对话后仍可继续聊天。
|
||||||
|
|
||||||
|
## 4. 已确认的 UI 方向
|
||||||
|
|
||||||
|
### 整体基调
|
||||||
|
|
||||||
|
- 首页现有品牌化紫色背景和整体结构基本保留。
|
||||||
|
- 普通二级页面以白色内容区为主,使用极浅中性灰建立页面与内容组边界。
|
||||||
|
- 不允许把模块颜色铺满整个页面、标题栏或大卡片。
|
||||||
|
- 风格要求干净、克制、专业、柔和,避免大面积紫色、重橙色和过亮颜色。
|
||||||
|
|
||||||
|
### 列表
|
||||||
|
|
||||||
|
- 同一内容组内连续排列,不要每行单独套白卡片。
|
||||||
|
- 分隔线只从文字区域开始,不贯穿左侧图标区域。
|
||||||
|
- 删除背景默认完全隐藏,左滑后才显示。
|
||||||
|
- 删除失败时不能提前从前端移除真实数据。
|
||||||
|
|
||||||
|
### 图标
|
||||||
|
|
||||||
|
- 全 App 图标样式必须统一,颜色可以按模块变化。
|
||||||
|
- 优先使用同一套线性、圆润图标。
|
||||||
|
- 同类列表的图标底座尺寸、圆角、图标尺寸和对齐保持一致。
|
||||||
|
- 运动模块已统一使用更合适的跑步图标,修改时要同步首页今日健康、胶囊和运动页面等所有入口。
|
||||||
|
|
||||||
|
### 色彩
|
||||||
|
|
||||||
|
- 通用输入焦点、选中和品牌操作继续使用 App 紫色体系。
|
||||||
|
- 模块色只用于模块识别、图标、状态和少量关键数字。
|
||||||
|
- 成功、警告、错误和信息颜色必须按语义使用,不能与模块色混用。
|
||||||
|
- 当前页面背景为 `#F3F5F8`,白色内容区用于建立层次。
|
||||||
|
- 当前健康模块为克制青绿色体系;运动模块为蓝靛体系;用药模块保持原有蓝青色体系。
|
||||||
|
- 今日健康异常状态已从偏黑的棕红色改为鲜明红橙色 `#E8562A`。
|
||||||
|
|
||||||
|
### 圆角、阴影和字体
|
||||||
|
|
||||||
|
- 所有圆角优先使用项目已有的 `AppRadius`,不要随手写不同圆角。
|
||||||
|
- 卡片不能层层嵌套,也不能同时堆重描边和重阴影。
|
||||||
|
- 不要随意缩小字体;列表紧凑但必须清楚可读。
|
||||||
|
- 选中状态不能改变文字大小、字重或位置。
|
||||||
|
- 为避免 Android 跟随系统楷书时粗体失控,项目已减少 `w800/w900` 的使用,主要层级使用 `w600/w700`。
|
||||||
|
|
||||||
|
完整基础规范见:`docs/ui-design-system.md`。实际逐页精修仍以真机截图和用户确认优先。
|
||||||
|
|
||||||
|
## 5. 最近完成的界面与交互修改
|
||||||
|
|
||||||
|
### 登录页
|
||||||
|
|
||||||
|
- 登录页使用新背景:`login_background_v2.png`。
|
||||||
|
- 新背景去掉旧版气泡、玻璃方块和心电线,改为低饱和蓝紫柔光,中央保持安静。
|
||||||
|
- App 图标继续使用原来的白底人物版本,没有继续使用错误生成的紫色底图标。
|
||||||
|
- 登录页单独使用透明人物资源:`health_login_character_transparent.png`。
|
||||||
|
- 登录键盘掉帧已针对性优化:
|
||||||
|
- 背景使用独立重绘边界,键盘弹出时背景不重复重绘。
|
||||||
|
- 删除原来每次键盘高度变化都会重启的 `AnimatedPadding`。
|
||||||
|
- 表单改为轻量 `Transform.translate` 位移,最大抬升 150 px。
|
||||||
|
- 下滑表单可以关闭键盘。
|
||||||
|
- 这项键盘优化只做了格式和差异检查,尚未经过用户重新安装后的真机确认。
|
||||||
|
- 透明人物由原人物参考生成并去除背景。用户非常重视人物不变形;需要真机确认其造型是否与原人物完全一致。如果仍有偏差,应使用确定性的抠图方式处理原图,不能再次重新设计人物。
|
||||||
|
|
||||||
|
### App 图标与启动页
|
||||||
|
|
||||||
|
- Android 和 iOS 已恢复原来的白底人物图标及周围小元素。
|
||||||
|
- 已撤销错误的紫色底图标和重新生成的人物衍生图。
|
||||||
|
- Flutter 启动页恢复使用 `health_splash_master.png`。
|
||||||
|
- Android 原生启动图恢复使用原 `launch_brand.png`。
|
||||||
|
- iOS AppIcon 和 LaunchImage 配置恢复引用原文件。
|
||||||
|
|
||||||
|
### 侧边栏
|
||||||
|
|
||||||
|
- 使用新背景:`drawer_background_v2.png`。
|
||||||
|
- 背景改为顶部淡蓝紫、向下过渡白色,遮罩同步调轻。
|
||||||
|
- 对话记录区域继续保留当前结构,不要因为“区域转换突然”擅自重排;用户认为类似蚂蚁阿福的结构可以接受。
|
||||||
|
- 当前侧边栏仍是:系统整页拖拽关闭,只在中间对话流区域使用自定义右滑触发;智能体胶囊和输入区不应触发侧边栏。
|
||||||
|
- 顶部菜单按钮仍可打开侧边栏。
|
||||||
|
- 不要再次擅自改成推页式侧栏或另一套 Drawer 手势,除非用户重新明确要求。
|
||||||
|
|
||||||
|
### 首页和今日健康
|
||||||
|
|
||||||
|
- 首页整体结构用户基本满意,不要进行大面积重构。
|
||||||
|
- 今日健康加载策略已调整,避免返回首页时先显示两行再闪成三行。
|
||||||
|
- 健康概览图标已统一为“记数据”中使用的图标风格。
|
||||||
|
- 跑步、用药等入口图标需与智能体胶囊保持同样样式。
|
||||||
|
- 今日健康异常文字和右侧警告图标当前统一为 `#E8562A`。
|
||||||
|
- 首页键盘布局保持原来的结构;上一轮误加的首页键盘悬浮布局已经撤回。
|
||||||
|
|
||||||
|
### 通知中心
|
||||||
|
|
||||||
|
- 通知列表重新压缩并调整信息布局。
|
||||||
|
- 用户指定单行最低高度约 82 px,不要再次压到 68 px。
|
||||||
|
- 标签和时间已重新安排,时间位于右侧区域,减少不必要的独立行。
|
||||||
|
- 列表内容要保持视觉垂直居中。
|
||||||
|
- 未读提醒区域不能使用大面积强紫色。
|
||||||
|
|
||||||
|
### 用药、运动、报告和设备
|
||||||
|
|
||||||
|
- 用药与运动列表的红色删除背景“提前露出”问题已处理过;后续修改需继续检查右侧红底不能浮出。
|
||||||
|
- 运动计划列表已向用药、报告的连续列表形式统一,并补充前置运动图标。
|
||||||
|
- 报告页面以靛紫色为模块识别,但“查看原始报告”不能使用突兀的大紫色实心按钮。
|
||||||
|
- 蓝牙设备管理仅允许修改 UI,不得改变扫描、连接、自动读取、上传和解绑逻辑。
|
||||||
|
- 蓝牙页右下角自动扫描动画必须保留,用于表达页面正在持续检测设备。
|
||||||
|
|
||||||
|
## 6. 功能与平台适配状态
|
||||||
|
|
||||||
|
### 已合并/已处理
|
||||||
|
|
||||||
|
- `sccsbc` 分支的 Apple 登录和部分上线准备代码已经合并。
|
||||||
|
- Apple 登录前端按钮、服务端身份映射和相关迁移已进入当前代码历史。
|
||||||
|
- Android 签名相关配置结构已合入,但真实 keystore、密码和正式证书不应写入 Git。
|
||||||
|
- iOS 页面导航已加入原生风格返回适配相关测试文件,但尚未在真实 iPhone 全量验证。
|
||||||
|
- iOS 与 Android 共用同一套 Flutter 业务页面;平台差异主要在权限、签名、Apple 登录和系统交互。
|
||||||
|
- Android 本地电脑与手机同 Wi-Fi 的开发 API 地址方式继续保留;iPhone 真机调试时需要配置为电脑局域网 IP,而不是 `localhost`。
|
||||||
|
|
||||||
|
### 暂不投入的功能
|
||||||
|
|
||||||
|
- 医生端尚未正式完成。
|
||||||
|
- 医患实时交流、问诊和随访目前不是近期上线重点。
|
||||||
|
- 未启用的医生端、问诊和随访页面不需要继续做 UI 精修,也不要为它们扩大工作量。
|
||||||
|
- 但已有页面和入口不能伪装成已经可用的正式医疗服务。
|
||||||
|
|
||||||
|
## 7. Apple 审核与医疗合规风险
|
||||||
|
|
||||||
|
苹果反馈涉及 Guideline 1.4.1 和 2.1,要求:
|
||||||
|
|
||||||
|
- 外接医疗硬件的监管批准材料。
|
||||||
|
- 能证明 App 与硬件按描述工作的硬件测试报告或同行评议研究。
|
||||||
|
- 在真实 Apple 设备上完成硬件首次配对和完整流程的演示视频。
|
||||||
|
- 涉及医疗数据、诊断或治疗建议时提供相应监管批准文件。
|
||||||
|
|
||||||
|
当前本地测试主要使用欧姆龙 J735 血压计,但代码定位为通用 BLE 设备。后续必须在产品表述和审核材料之间作出一致选择:
|
||||||
|
|
||||||
|
- 如果继续声明支持医疗硬件,需要设备授权、监管文件、测试材料和真实 iPhone 演示。
|
||||||
|
- 如果定位为个人健康记录工具,需要削弱“医疗服务、诊断、治疗建议、恢复健康”等可能触发医疗器械审查的表述,并明确 AI 仅提供一般健康信息,不替代医生。
|
||||||
|
- 不要在没有材料时直接回复苹果称已经满足要求。
|
||||||
|
|
||||||
|
## 8. 正式环境前仍需完成
|
||||||
|
|
||||||
|
- 正式服务器、域名、HTTPS。
|
||||||
|
- 正式数据库与备份方案。
|
||||||
|
- 正式 JWT 密钥和密钥轮换。
|
||||||
|
- 正式短信服务。
|
||||||
|
- 正式文件/对象存储及删除策略。
|
||||||
|
- 正式 CORS 白名单。
|
||||||
|
- Android 正式 keystore、release 签名、APK/AAB。
|
||||||
|
- Apple 开发者证书、Bundle ID、签名、TestFlight 真机流程。
|
||||||
|
- 隐私政策、服务协议、个人信息清单、第三方 SDK 清单和关于我们的最终定稿。
|
||||||
|
- ICP、公安备案、软著、安全评估及各应用市场账号。
|
||||||
|
- Apple 医疗硬件与医疗建议相关审核材料或产品定位调整。
|
||||||
|
|
||||||
|
## 9. 当前验证情况
|
||||||
|
|
||||||
|
- 最近图片和 UI 修改只做了 `dart format`、`git diff --check`、资源引用和配置文件检查。
|
||||||
|
- 用户明确要求不要反复运行耗时测试,因此最近没有执行完整 Flutter build、安装或全量回归。
|
||||||
|
- Android 和 iOS 图标恢复后尚需用户重新构建查看系统桌面实际裁切。
|
||||||
|
- 登录页透明人物、新背景和键盘流畅度尚需 Android 真机确认。
|
||||||
|
- iOS 尚需真实 iPhone 验证 Apple 登录、蓝牙权限、页面侧滑返回、键盘和 TestFlight 包。
|
||||||
|
|
||||||
|
## 10. 下一窗口建议的第一步
|
||||||
|
|
||||||
|
1. 先运行 `git status --short`,确认用户是否又修改了文件。
|
||||||
|
2. 不要回退当前工作区,不要重新合并 `sccsbc`。
|
||||||
|
3. 让用户先安装当前 Android 包,优先确认:
|
||||||
|
- 登录页透明人物是否仍保持原人物造型。
|
||||||
|
- 登录键盘弹出是否明显更流畅,输入框是否始终可见。
|
||||||
|
- App 图标是否恢复白底且人物显示完整。
|
||||||
|
- 新登录背景和侧边栏背景在真机上的亮度与边界。
|
||||||
|
- 今日健康异常红橙色是否清楚但不过重。
|
||||||
|
4. 根据用户真机截图逐项微调,每次只改明确页面。
|
||||||
|
5. 用户确认当前视觉效果后,再整理未使用的项目图片并提交代码;删除前必须确认没有 Android、iOS 或 Flutter 配置引用。
|
||||||
|
|
||||||
|
## 11. 禁止事项
|
||||||
|
|
||||||
|
- 不使用 Docker。
|
||||||
|
- 不创建其他分支。
|
||||||
|
- 不覆盖或回退用户正在修改的文件。
|
||||||
|
- 不用旧快照替换当前 main。
|
||||||
|
- 不在用户说“先讨论”时直接修改。
|
||||||
|
- 不随意扩大到未启用的医生端、问诊和随访页面。
|
||||||
|
- 不重新设计“小脉健康”人物形象,只允许适配背景、留白、尺寸和系统裁切。
|
||||||
|
- 不为了 UI 修改蓝牙、聊天、删除、注销和历史恢复等业务逻辑。
|
||||||
|
- 不声称构建、安装或真机验证成功,除非实际执行并看到结果。
|
||||||
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# UI System First Pass Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Build the first pass of the app-wide UI system and migrate the highest-impact frontend surfaces to the agreed typography, color, icon, layout, button, feedback, and AI content rules.
|
|
||||||
|
|
||||||
**Architecture:** Centralize design decisions in token and common widget files, then migrate pages by replacing local hardcoded visuals with shared module visuals and components. Keep this pass focused on visible consistency and maintainability without adding the full accessibility-mode feature yet.
|
|
||||||
|
|
||||||
**Tech Stack:** Flutter, Riverpod, shadcn_ui Lucide icons, flutter_markdown.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Design Tokens And Common Widgets
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/core/app_colors.dart`
|
|
||||||
- Modify: `health_app/lib/core/app_theme.dart`
|
|
||||||
- Create: `health_app/lib/core/app_design_tokens.dart`
|
|
||||||
- Create: `health_app/lib/core/app_module_visuals.dart`
|
|
||||||
- Create: `health_app/lib/widgets/app_buttons.dart`
|
|
||||||
- Create: `health_app/lib/widgets/app_status_badge.dart`
|
|
||||||
- Create: `health_app/lib/widgets/app_toast.dart`
|
|
||||||
- Create: `health_app/lib/widgets/ai_content.dart`
|
|
||||||
|
|
||||||
- [ ] Add typography, spacing, radius, shadow, module color, and module icon tokens.
|
|
||||||
- [ ] Add reusable gradient-outline button and module-aware buttons.
|
|
||||||
- [ ] Add top-center toast helper for light feedback.
|
|
||||||
- [ ] Add shared AI Markdown renderer and plain text cleaner.
|
|
||||||
- [ ] Run `flutter analyze`.
|
|
||||||
|
|
||||||
### Task 2: AI Conversation And Cards
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/pages/home/widgets/chat_messages_view.dart`
|
|
||||||
- Modify: `health_app/lib/pages/home/home_page.dart`
|
|
||||||
|
|
||||||
- [ ] Apply shared AI content renderer to AI bubbles.
|
|
||||||
- [ ] Normalize user bubble, AI bubble, attachments, AI generated note, and Markdown typography.
|
|
||||||
- [ ] Update intelligent agent visuals to use shared module icons and colors, including `dumbbell` for exercise and `pill` for medication.
|
|
||||||
- [ ] Preserve gradient-outline white-background buttons in agent welcome cards.
|
|
||||||
- [ ] Keep today health card gradient outline and ensure empty medication windows still show a light status.
|
|
||||||
- [ ] Run `flutter analyze lib/pages/home/widgets/chat_messages_view.dart lib/pages/home/home_page.dart`.
|
|
||||||
|
|
||||||
### Task 3: Sidebar
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/widgets/health_drawer.dart`
|
|
||||||
|
|
||||||
- [ ] Keep current structure: user header, health dashboard, common functions, conversation records.
|
|
||||||
- [ ] Convert user header to no-frame layout with settings on the right.
|
|
||||||
- [ ] Keep health dashboard as a light panel.
|
|
||||||
- [ ] Convert common functions to a two-column light matrix without heavy per-item cards.
|
|
||||||
- [ ] Keep conversation records as lightweight rows with summary left and date right.
|
|
||||||
- [ ] Run `flutter analyze lib/widgets/health_drawer.dart`.
|
|
||||||
|
|
||||||
### Task 4: Core Management Pages
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/widgets/enterprise_widgets.dart`
|
|
||||||
- Modify: `health_app/lib/pages/medication/medication_list_page.dart`
|
|
||||||
- Modify: `health_app/lib/pages/medication/medication_checkin_page.dart`
|
|
||||||
- Modify: `health_app/lib/pages/remaining_pages.dart`
|
|
||||||
- Modify: `health_app/lib/pages/report/report_pages.dart`
|
|
||||||
- Modify: `health_app/lib/pages/report/ai_analysis_page.dart`
|
|
||||||
- Modify: `health_app/lib/pages/notifications/notification_center_page.dart`
|
|
||||||
|
|
||||||
- [ ] Update summary panels to avoid repeating page titles and reduce icon emphasis.
|
|
||||||
- [ ] Apply module colors and Lucide icons to medication, exercise, reports, and notifications.
|
|
||||||
- [ ] Apply shared AI content renderer to report AI text to prevent visible raw Markdown markers such as `**`.
|
|
||||||
- [ ] Normalize list title/subtitle/tag sizes and row heights.
|
|
||||||
- [ ] Run targeted `flutter analyze` on modified files.
|
|
||||||
|
|
||||||
### Task 5: Profile, Health Archive, Diet, Device
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/pages/profile/profile_page.dart`
|
|
||||||
- Modify: `health_app/lib/pages/remaining_pages.dart`
|
|
||||||
- Modify: `health_app/lib/pages/diet/diet_capture_page.dart`
|
|
||||||
- Modify: `health_app/lib/pages/device/device_management_page.dart`
|
|
||||||
- Modify: `health_app/lib/widgets/ble_sync_dialog.dart`
|
|
||||||
|
|
||||||
- [ ] Use two-column short-field layout where appropriate in profile/health archive surfaces already in scope.
|
|
||||||
- [ ] Apply module tokens to diet and device surfaces.
|
|
||||||
- [ ] Improve Bluetooth/device panels with shared radius, typography, and module colors.
|
|
||||||
- [ ] Replace obvious bottom SnackBar style feedback in touched flows with top-center toast where low-risk.
|
|
||||||
- [ ] Run targeted `flutter analyze` on modified files.
|
|
||||||
|
|
||||||
### Task 6: Full Verification
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Test: `health_app`
|
|
||||||
|
|
||||||
- [ ] Run `flutter analyze`.
|
|
||||||
- [ ] If available and practical, run a smoke test or build command for the Flutter app.
|
|
||||||
- [ ] Summarize changed files and any deferred surfaces.
|
|
||||||
|
|
||||||
@@ -1,688 +0,0 @@
|
|||||||
# Apple Sign-In 集成实施方案
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** 为健康管家 App 集成 Sign in with Apple,让 iOS 用户可以通过 Apple ID 一键登录/注册。
|
|
||||||
|
|
||||||
**Architecture:** 后端新增 `POST /api/auth/apple-login` 端点,验证 Apple 返回的 identityToken(JWT),查找或创建 User 记录(无需手机号),返回 JWT token。前端添加 `sign_in_with_apple` Flutter 包,在登录页放置 Apple 登录按钮。
|
|
||||||
|
|
||||||
**Tech Stack:** Flutter 3.44 + Dart 3.12 | C# .NET 10 + EF Core + PostgreSQL | sign_in_with_apple 包 | Apple RSA 公钥验证
|
|
||||||
|
|
||||||
**设计决策:**
|
|
||||||
- User.Phone 改为可空(Apple 用户无手机号),PostgreSQL 对多个 NULL 值不违反唯一索引
|
|
||||||
- 新增 `AppleUserId` 字段(可空字符串 + 唯一索引)标识 Apple 用户
|
|
||||||
- Apple 登录首次无需绑定医生(doctorId 可为空),用户可在个人中心后续设置
|
|
||||||
- Apple identityToken 验证:缓存 Apple 公钥,本地验签,验证 audience/issuer/有效期
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: 后端 — User 实体 + DB 迁移
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `backend/src/Health.Domain/Entities/user.cs`
|
|
||||||
- Modify: `backend/src/Health.Infrastructure/Data/app_db_context.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: User 实体添加 AppleUserId 字段,Phone 改为可空**
|
|
||||||
|
|
||||||
`backend/src/Health.Domain/Entities/user.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace Health.Domain.Entities;
|
|
||||||
|
|
||||||
public sealed class User
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public string? Phone { get; set; } // 改为可空,Apple 用户无手机号
|
|
||||||
public string? AppleUserId { get; set; } // Apple 唯一标识
|
|
||||||
public string Role { get; set; } = "User";
|
|
||||||
public Guid? DoctorId { get; set; }
|
|
||||||
public string? Name { get; set; }
|
|
||||||
// ... 其余字段不变
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: AppDbContext 添加 AppleUserId 配置**
|
|
||||||
|
|
||||||
`backend/src/Health.Infrastructure/Data/app_db_context.cs` 的 User 配置段:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
builder.Entity<User>(e =>
|
|
||||||
{
|
|
||||||
e.HasIndex(u => u.Phone).IsUnique();
|
|
||||||
e.HasIndex(u => u.AppleUserId).IsUnique(); // 新增
|
|
||||||
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: 验证编译**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: 后端 — Apple JWT 验证服务
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `backend/src/Health.Infrastructure/Services/apple_token_validator.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 创建 AppleTokenValidator**
|
|
||||||
|
|
||||||
`backend/src/Health.Infrastructure/Services/apple_token_validator.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
|
|
||||||
namespace Health.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Apple IdentityToken 验证:下载公钥、验签、检查声明
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AppleTokenValidator
|
|
||||||
{
|
|
||||||
private const string AppleKeysUrl = "https://appleid.apple.com/auth/keys";
|
|
||||||
private const string AppleIssuer = "https://appleid.apple.com";
|
|
||||||
private static readonly HttpClient _http = new();
|
|
||||||
private static List<JsonWebKey>? _cachedKeys;
|
|
||||||
private static DateTime _keysExpiry = DateTime.MinValue;
|
|
||||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
|
||||||
|
|
||||||
private readonly string _clientId;
|
|
||||||
|
|
||||||
public AppleTokenValidator(IConfiguration config)
|
|
||||||
{
|
|
||||||
_clientId = config["APPLE_CLIENT_ID"] ?? "com.datalumina.YYA";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 验证 Apple identityToken,返回 sub(用户唯一标识)
|
|
||||||
/// </summary>
|
|
||||||
public async Task<string> ValidateAsync(string identityToken, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var handler = new JwtSecurityTokenHandler();
|
|
||||||
// 先解码 header 获取 kid
|
|
||||||
var rawToken = handler.ReadJwtToken(identityToken);
|
|
||||||
var kid = rawToken.Header.Kid;
|
|
||||||
|
|
||||||
var keys = await GetKeysAsync(ct);
|
|
||||||
var key = keys.FirstOrDefault(k => k.KeyId == kid)
|
|
||||||
?? throw new SecurityTokenException($"No matching key for kid: {kid}");
|
|
||||||
|
|
||||||
var validationParams = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidIssuer = AppleIssuer,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidAudiences = [_clientId, "com.datalumina.YYA.signin"],
|
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
IssuerSigningKey = key,
|
|
||||||
ClockSkew = TimeSpan.FromMinutes(1),
|
|
||||||
};
|
|
||||||
|
|
||||||
var principal = handler.ValidateToken(identityToken, validationParams, out _);
|
|
||||||
var sub = principal.FindFirst("sub")?.Value;
|
|
||||||
if (string.IsNullOrWhiteSpace(sub))
|
|
||||||
throw new SecurityTokenException("Missing 'sub' claim in Apple identityToken");
|
|
||||||
|
|
||||||
return sub;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<List<JsonWebKey>> GetKeysAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
|
||||||
|
|
||||||
await _lock.WaitAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
|
||||||
|
|
||||||
var response = await _http.GetStringAsync(AppleKeysUrl, ct);
|
|
||||||
var jwks = new JsonWebKeySet(response);
|
|
||||||
_cachedKeys = jwks.Keys.ToList();
|
|
||||||
_keysExpiry = DateTime.UtcNow.AddHours(6); // 缓存 6 小时
|
|
||||||
return _cachedKeys;
|
|
||||||
}
|
|
||||||
finally { _lock.Release(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: 注册服务到 DI**
|
|
||||||
|
|
||||||
在 `backend/src/Health.WebApi/Program.cs` 的 services 注册区添加:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
builder.Services.AddSingleton<AppleTokenValidator>();
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: 验证编译**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: 后端 — AuthService 添加 Apple 登录
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `backend/src/Health.Application/Auth/AuthContracts.cs`
|
|
||||||
- Modify: `backend/src/Health.Infrastructure/Auth/AuthService.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加 AppleLoginCommand DTO 和接口方法**
|
|
||||||
|
|
||||||
`backend/src/Health.Application/Auth/AuthContracts.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace Health.Application.Auth;
|
|
||||||
|
|
||||||
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
|
||||||
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
|
||||||
public sealed record AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name); // 新增
|
|
||||||
|
|
||||||
public interface IAuthService
|
|
||||||
{
|
|
||||||
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
|
||||||
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
|
||||||
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
|
||||||
Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct); // 新增
|
|
||||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
|
||||||
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: AuthService 实现 AppleLoginAsync**
|
|
||||||
|
|
||||||
`backend/src/Health.Infrastructure/Auth/AuthService.cs`:
|
|
||||||
|
|
||||||
在类的顶部依赖注入区添加 `AppleTokenValidator`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed class AuthService(
|
|
||||||
AppDbContext db,
|
|
||||||
JwtProvider jwt,
|
|
||||||
SmsService sms,
|
|
||||||
AppleTokenValidator appleValidator // 新增
|
|
||||||
) : IAuthService
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _db = db;
|
|
||||||
private readonly JwtProvider _jwt = jwt;
|
|
||||||
private readonly SmsService _sms = sms;
|
|
||||||
private readonly AppleTokenValidator _appleValidator = appleValidator;
|
|
||||||
// ... 现有代码不变
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `LogoutAsync` 方法之后添加新方法,并修改 `AddTokens` 的 phone 参数类型为 `string?`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
|
|
||||||
{
|
|
||||||
// 1. 验证 Apple identityToken
|
|
||||||
string appleUserId;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 查找已有用户
|
|
||||||
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
|
|
||||||
if (existingUser != null)
|
|
||||||
{
|
|
||||||
// 已有用户 → 直接登录
|
|
||||||
existingUser.UpdatedAt = DateTime.UtcNow;
|
|
||||||
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
|
|
||||||
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return new AuthResult(0, new
|
|
||||||
{
|
|
||||||
tokens.accessToken, tokens.refreshToken,
|
|
||||||
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
|
|
||||||
var newUser = new User
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Phone = null, // Apple 用户未绑定手机
|
|
||||||
AppleUserId = appleUserId,
|
|
||||||
Role = "User",
|
|
||||||
Name = command.Name,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
UpdatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
_db.Users.Add(newUser);
|
|
||||||
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
|
|
||||||
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
|
|
||||||
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return new AuthResult(0, new
|
|
||||||
{
|
|
||||||
newTokens.accessToken, newTokens.refreshToken,
|
|
||||||
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
同时修改 `AddTokens` 方法签名,phone 改为可空:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
|
|
||||||
{
|
|
||||||
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
|
|
||||||
var refresh = _jwt.GenerateRefreshToken();
|
|
||||||
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
|
||||||
return (access, refresh);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**说明**: `AddTokens` 的 phone 参数改为 `string?`,`GenerateAccessToken` 接收到空字符串(Apple 用户无需手机号)。`RefreshAsync` 和 `RegisterAsync` 中现有的 `AddTokens` 调用无需修改(传入 `string?` 的 `user.Phone` 自动匹配)。Admin 的 `AddTokens(AdminId, AdminPhone, "Admin")`(`AdminPhone` 是 `string` 常量)也无需修改。
|
|
||||||
|
|
||||||
- [ ] **Step 3: 验证编译**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: 后端 — 添加 Apple 登录端点
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加 AppleLoginRequest 和端点**
|
|
||||||
|
|
||||||
`backend/src/Health.WebApi/Endpoints/auth_endpoints.cs`:
|
|
||||||
|
|
||||||
在现有端点中添加:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 在 MapAuthEndpoints 方法内、logout 端点之后添加:
|
|
||||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
|
||||||
```
|
|
||||||
|
|
||||||
在文件末尾添加 DTO:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
|
||||||
```
|
|
||||||
|
|
||||||
文件最终结果为:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using Health.Application.Auth;
|
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
|
||||||
|
|
||||||
public static class AuthEndpoints
|
|
||||||
{
|
|
||||||
public static void MapAuthEndpoints(this WebApplication app)
|
|
||||||
{
|
|
||||||
app.MapPost("/api/auth/send-sms", async (SendSmsRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.SendCodeAsync(request.Phone, app.Environment.IsDevelopment(), ct)));
|
|
||||||
app.MapPost("/api/auth/register", async (RegisterRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
|
||||||
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
|
||||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
|
||||||
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
|
||||||
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
await auth.LogoutAsync(request.RefreshToken, ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IResult ToResult(AuthResult result) =>
|
|
||||||
Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record SendSmsRequest(string Phone);
|
|
||||||
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
|
||||||
public sealed record LoginRequest(string Phone, string SmsCode);
|
|
||||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
|
||||||
public sealed record RefreshRequest(string RefreshToken);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: 验证编译**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: 后端 — 重启并测试端点连通性
|
|
||||||
|
|
||||||
- [ ] **Step 1: 重启后端服务**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet run &
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: 测试无 token 调用(预期返回 40005,token 无效)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -w "\nHTTP %{http_code}" -X POST "http://localhost:5000/api/auth/apple-login" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"identityToken":"invalid","name":"test"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: HTTP 200, code=40005, message 包含"验证失败"
|
|
||||||
|
|
||||||
- [ ] **Step 3: 测试真实场景**
|
|
||||||
|
|
||||||
此时无法用 curl 直接测(需要真实的 Apple identityToken)。稍后在 Task 8 中从 App 端联合验证。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: 前端 — 添加 sign_in_with_apple 依赖
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/pubspec.yaml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加依赖**
|
|
||||||
|
|
||||||
`health_app/pubspec.yaml`,在 dependencies 区添加:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Apple 登录
|
|
||||||
sign_in_with_apple: ^6.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: 安装依赖**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd health_app && flutter pub get
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: 前端 — AuthProvider 添加 Apple 登录方法
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/providers/auth_provider.dart`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加 appleLogin 方法**
|
|
||||||
|
|
||||||
`health_app/lib/providers/auth_provider.dart`,在 `login()` 方法之后、`logout()` 方法之前添加:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
/// Apple 登录
|
|
||||||
Future<String?> appleLogin(String identityToken, String? authorizationCode, String? name) async {
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiClientProvider);
|
|
||||||
final response = await api.post(
|
|
||||||
'/api/auth/apple-login',
|
|
||||||
data: {
|
|
||||||
'identityToken': identityToken,
|
|
||||||
if (authorizationCode != null) 'authorizationCode': authorizationCode,
|
|
||||||
if (name != null && name.isNotEmpty) 'name': name,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
final data = response.data['data'];
|
|
||||||
if (data == null) return response.data['message'] ?? 'Apple 登录失败';
|
|
||||||
|
|
||||||
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
|
||||||
final user = data['user'];
|
|
||||||
state = AuthState(
|
|
||||||
isLoggedIn: true,
|
|
||||||
isLoading: false,
|
|
||||||
user: UserInfo(
|
|
||||||
id: user['id'] ?? '',
|
|
||||||
phone: user['phone'] ?? '',
|
|
||||||
role: user['role'] ?? 'User',
|
|
||||||
name: user['name'],
|
|
||||||
avatarUrl: user['avatarUrl'],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
return 'Apple 登录失败: $e';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意**: `UserInfo` 的 `phone` 字段当前是 `required String`。Apple 用户可能无手机号。`user['phone']` 可能是 null。在 login() 方法中 Phone 断言 `phone: user['phone'] ?? ''` 已有兜底,appleLogin 保持一致。
|
|
||||||
|
|
||||||
- [ ] **Step 2: 编译检查**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd health_app && flutter analyze lib/providers/auth_provider.dart
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 8: 前端 — 登录页添加 Apple 登录按钮
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/pages/auth/login_page.dart`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加 Apple 登录按钮组件和点击逻辑**
|
|
||||||
|
|
||||||
`health_app/lib/pages/auth/login_page.dart`:
|
|
||||||
|
|
||||||
顶部添加 import:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `_LoginPageState` 类中添加 Apple 登录方法(在 `_startCountdown` 方法之后):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
Future<void> _appleSignIn() async {
|
|
||||||
if (_loading) return;
|
|
||||||
setState(() {
|
|
||||||
_loading = true;
|
|
||||||
_error = null;
|
|
||||||
_successMsg = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final credential = await SignInWithApple.getAppleIDCredential(
|
|
||||||
scopes: [
|
|
||||||
AppleIDAuthorizationScopes.fullName,
|
|
||||||
],
|
|
||||||
webAuthenticationOptions: WebAuthenticationOptions(
|
|
||||||
clientId: 'com.datalumina.YYA.signin',
|
|
||||||
redirectUri: Uri.parse('https://erpapi.datalumina.cn/xiaomai/api/auth/apple-login'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final identityToken = credential.identityToken;
|
|
||||||
if (identityToken == null) {
|
|
||||||
setState(() {
|
|
||||||
_loading = false;
|
|
||||||
_error = 'Apple 登录未返回身份信息,请重试';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final name = credential.givenName != null && credential.familyName != null
|
|
||||||
? '${credential.familyName}${credential.givenName}'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
final err = await ref.read(authProvider.notifier).appleLogin(
|
|
||||||
identityToken,
|
|
||||||
credential.authorizationCode,
|
|
||||||
name,
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _loading = false);
|
|
||||||
if (err != null) {
|
|
||||||
setState(() => _error = err);
|
|
||||||
} else {
|
|
||||||
_goHome();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_loading = false;
|
|
||||||
if (e is SignInWithAppleAuthorizationException) {
|
|
||||||
_error = 'Apple 登录已取消';
|
|
||||||
} else {
|
|
||||||
_error = 'Apple 登录失败: $e';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `_LoginFormCard` 的 children 中,在 `_PrimaryButton` 和"去注册"链接之间(`_PrimaryButton` 之后,`const SizedBox(height: 16)` 之前)插入 Apple 按钮:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
// --- Apple 登录按钮 ---
|
|
||||||
SignInWithAppleButton(
|
|
||||||
onPressed: _appleSignIn,
|
|
||||||
style: SignInWithAppleButtonStyle.whiteOutline,
|
|
||||||
height: 48,
|
|
||||||
cornerRadius: 14,
|
|
||||||
),
|
|
||||||
// --- 结束 ---
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
```
|
|
||||||
|
|
||||||
即在 build 方法中 `_LoginFormCard` child 的末尾(`_PrimaryButton` 之后、`GestureDetector` "去注册" 之前):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// ... 上面是 _PrimaryButton 的 const SizedBox(height: 22) 和 _PrimaryButton 本身
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
SignInWithAppleButton(
|
|
||||||
onPressed: _appleSignIn,
|
|
||||||
style: SignInWithAppleButtonStyle.whiteOutline,
|
|
||||||
height: 48,
|
|
||||||
cornerRadius: 14,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => setState(() {
|
|
||||||
_isLogin = !_isLogin;
|
|
||||||
_error = null;
|
|
||||||
_successMsg = null;
|
|
||||||
}),
|
|
||||||
// ... 去注册/去登录 链接
|
|
||||||
),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 1 补充**: `_submit()` 方法中也需要加 `_loading` 检查,确保 Apple 按钮点击期间短信登录不响应。现有 `_submit` 已有 `setState(() => _loading = true)`,问题不大。
|
|
||||||
|
|
||||||
- [ ] **Step 2: 编译检查**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd health_app && flutter analyze lib/pages/auth/login_page.dart
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: 编译 iOS 验证无语法错误**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd health_app && flutter build ios --debug --no-codesign
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 9: iOS — Xcode 配置 Sign in with Apple
|
|
||||||
|
|
||||||
- [ ] **Step 1: 在 Xcode 中启用 Sign in with Apple Capability**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
open health_app/ios/Runner.xcworkspace
|
|
||||||
```
|
|
||||||
|
|
||||||
在 Xcode 中操作:
|
|
||||||
1. 选择 Runner target → Signing & Capabilities
|
|
||||||
2. 点击 "+ Capability" → 选择 "Sign in with Apple"
|
|
||||||
3. 确认 .entitlements 文件自动生成了 `com.apple.developer.applesignin` entitlement
|
|
||||||
|
|
||||||
这也会自动在 `project.pbxproj` 中写入相关配置,后续 git diff 可以看到变化。
|
|
||||||
|
|
||||||
- [ ] **Step 2: 验证 .entitlements 文件**
|
|
||||||
|
|
||||||
确认 `health_app/ios/Runner/Runner.entitlements` 包含:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<key>com.apple.developer.applesignin</key>
|
|
||||||
<array>
|
|
||||||
<string>Default</string>
|
|
||||||
</array>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 10: App Store Connect 配置
|
|
||||||
|
|
||||||
- [ ] **Step 1: 在 App Store Connect 中启用 Sign in with Apple**
|
|
||||||
|
|
||||||
1. 打开 [App Store Connect](https://appstoreconnect.apple.com)
|
|
||||||
2. 进入 App 记录 → App 隐私 → Sign in with Apple
|
|
||||||
3. 确认已为 `com.datalumina.YYA` 这个 App ID 启用了 Sign in with Apple capability
|
|
||||||
|
|
||||||
如果还未创建 App Store Connect 上的 App 记录,需要在 Certificates, Identifiers & Profiles 中为 App ID 手动勾选 "Sign in with Apple" capability。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 验证计划
|
|
||||||
|
|
||||||
**后端单元验证:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 后端编译通过
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
|
|
||||||
# 2. 启动后端
|
|
||||||
cd backend/src/Health.WebApi && dotnet run
|
|
||||||
|
|
||||||
# 3. 测试 Apple 登录端点(无 token 场景)
|
|
||||||
curl -s -X POST "http://localhost:5000/api/auth/apple-login" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"identityToken":"test","name":"测试"}'
|
|
||||||
# 预期: HTTP 200, body 包含 code=40005
|
|
||||||
|
|
||||||
# 4. 测试现有发送短信端点未被影响
|
|
||||||
curl -s -X POST "http://localhost:5000/api/auth/send-sms" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"phone":"13800138000"}'
|
|
||||||
# 预期: HTTP 200, code=0
|
|
||||||
```
|
|
||||||
|
|
||||||
**前端验证:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 编译检查
|
|
||||||
cd health_app && flutter analyze
|
|
||||||
|
|
||||||
# 2. iOS 构建通过
|
|
||||||
flutter build ios --debug --no-codesign
|
|
||||||
|
|
||||||
# 3. 在模拟器上运行
|
|
||||||
flutter run -d "iPhone 17 Pro"
|
|
||||||
# 验证:登录页显示 Apple 登录按钮;点击后弹出 Apple ID 授权界面
|
|
||||||
```
|
|
||||||
|
|
||||||
**端到端验证(模拟器):**
|
|
||||||
1. 模拟器上启动 App → 进入登录页
|
|
||||||
2. 点击 Apple 登录按钮 → Apple 授权弹窗出现 → 选择"继续"
|
|
||||||
3. 后端验证 identityToken → 创建新用户或登录已有 → 返回 JWT → App 进入首页
|
|
||||||
4. 二次打开 App → 如果之前 Apple 登录成功 + refresh_token 有效 → 自动恢复登录态
|
|
||||||
|
|
||||||
**DB 验证:**
|
|
||||||
```sql
|
|
||||||
-- 确认 Apple 用户创建成功
|
|
||||||
SELECT id, phone, apple_user_id, name, role FROM "Users" WHERE apple_user_id IS NOT NULL;
|
|
||||||
```
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -7,7 +9,7 @@ plugins {
|
|||||||
|
|
||||||
// 加载正式签名配置
|
// 加载正式签名配置
|
||||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
val keystoreProperties = java.util.Properties()
|
val keystoreProperties = Properties()
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||||
}
|
}
|
||||||
@@ -38,10 +40,12 @@ android {
|
|||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
keyAlias = keystoreProperties["keyAlias"] as String?
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
keyPassword = keystoreProperties["keyPassword"] as String?
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
storeFile = keystoreProperties.getProperty("storeFile")?.let { path ->
|
||||||
storePassword = keystoreProperties["storePassword"] as String?
|
file(path)
|
||||||
|
}
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||||
|
|
||||||
<!-- BLE 蓝牙血压计 -->
|
<!-- BLE 蓝牙健康设备(当前实现血压计同步) -->
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||||
<application
|
<application
|
||||||
|
|||||||
BIN
health_app/assets/branding/drawer_background_v2.png
Normal file
BIN
health_app/assets/branding/drawer_background_v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 834 KiB |
BIN
health_app/assets/branding/login_background_v2.png
Normal file
BIN
health_app/assets/branding/login_background_v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -70,9 +70,9 @@
|
|||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = ""
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
|
|||||||
@@ -27,15 +27,22 @@
|
|||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
|
<string>需要使用蓝牙连接支持的血压计等健康设备,以同步用户主动测量的健康数据</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
<string>需要使用蓝牙连接血压计</string>
|
<string>需要使用蓝牙连接支持的血压计等健康设备</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
|
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
<string>需要保存图片到相册</string>
|
<string>需要保存图片到相册</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>需要访问相册以选取饮食照片、体检报告或个人头像</string>
|
<string>需要访问相册以选取饮食照片、体检报告或个人头像</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>用于在同一局域网内连接小脉健康服务,以完成登录、健康数据同步和文件上传</string>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ final appReadyProvider = Provider<bool>((ref) {
|
|||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
if (auth.isLoading) return false; // 还在判登录态 → 继续盖 Splash
|
if (auth.isLoading) return false; // 还在判登录态 → 继续盖 Splash
|
||||||
if (!auth.isLoggedIn) return true; // 未登录 → 就绪,露出登录页
|
if (!auth.isLoggedIn) return true; // 未登录 → 就绪,露出登录页
|
||||||
|
final currentRoute = ref.watch(routeStackProvider).last.name;
|
||||||
|
if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash
|
||||||
final role = auth.user?.role ?? 'User';
|
final role = auth.user?.role ?? 'User';
|
||||||
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
|
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
|
||||||
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
|
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
|
||||||
@@ -104,10 +106,15 @@ class _RootNavigator extends ConsumerWidget {
|
|||||||
final stack = ref.watch(routeStackProvider);
|
final stack = ref.watch(routeStackProvider);
|
||||||
final current = stack.last;
|
final current = stack.last;
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
|
final isPublicStaticText = current.name == 'staticText';
|
||||||
|
|
||||||
// 登录后自动跳转(在下一帧完成,无闪烁)
|
// 登录后自动跳转(在下一帧完成,无闪烁)
|
||||||
if (authState.isLoggedIn && current.name == 'login') {
|
if (authState.isLoggedIn && current.name == 'login') {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
ref.invalidate(latestHealthProvider);
|
||||||
|
ref.invalidate(medicationListProvider);
|
||||||
|
ref.invalidate(medicationReminderProvider);
|
||||||
|
ref.invalidate(currentExercisePlanProvider);
|
||||||
final role = authState.user?.role ?? 'User';
|
final role = authState.user?.role ?? 'User';
|
||||||
if (role == 'Admin') {
|
if (role == 'Admin') {
|
||||||
goRoute(ref, 'adminHome');
|
goRoute(ref, 'adminHome');
|
||||||
@@ -120,7 +127,8 @@ class _RootNavigator extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
if (!authState.isLoading &&
|
if (!authState.isLoading &&
|
||||||
!authState.isLoggedIn &&
|
!authState.isLoggedIn &&
|
||||||
current.name != 'login') {
|
current.name != 'login' &&
|
||||||
|
!isPublicStaticText) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
goRoute(ref, 'login');
|
goRoute(ref, 'login');
|
||||||
});
|
});
|
||||||
@@ -131,7 +139,26 @@ class _RootNavigator extends ConsumerWidget {
|
|||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop) popRoute(ref);
|
if (!didPop) popRoute(ref);
|
||||||
},
|
},
|
||||||
child: buildPage(current, ref),
|
child: Navigator(
|
||||||
|
pages: [
|
||||||
|
for (var index = 0; index < stack.length; index++)
|
||||||
|
MaterialPage<void>(
|
||||||
|
key: ValueKey(_pageKey(stack[index], index)),
|
||||||
|
name: stack[index].name,
|
||||||
|
child: buildPage(stack[index], ref),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onDidRemovePage: (_) {
|
||||||
|
if (ref.read(routeStackProvider).length > 1) popRoute(ref);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _pageKey(RouteInfo route, int index) {
|
||||||
|
final params = route.params.entries
|
||||||
|
.map((entry) => '${entry.key}=${entry.value}')
|
||||||
|
.join('&');
|
||||||
|
return '$index:${route.name}:$params';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kReleaseMode;
|
||||||
import 'local_database.dart';
|
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: 'https://erpapi.datalumina.cn/xiaomai',
|
defaultValue: kReleaseMode
|
||||||
|
? 'https://erpapi.datalumina.cn/xiaomai'
|
||||||
|
: 'http://10.4.237.12:5000',
|
||||||
);
|
);
|
||||||
|
|
||||||
class ApiException implements Exception {
|
class ApiException implements Exception {
|
||||||
|
|||||||
@@ -20,30 +20,30 @@ class AppColors {
|
|||||||
static const Color meadowAccent = Color(0xFFBAE6FD);
|
static const Color meadowAccent = Color(0xFFBAE6FD);
|
||||||
static const Color sageAccent = Color(0xFFE9D5FF);
|
static const Color sageAccent = Color(0xFFE9D5FF);
|
||||||
|
|
||||||
static const Color health = Color(0xFF3B82F6);
|
static const Color health = Color(0xFF2D9D8F);
|
||||||
static const Color healthLight = Color(0xFFEFF6FF);
|
static const Color healthLight = Color(0xFFECF8F5);
|
||||||
static const Color healthBorder = Color(0xFFBFDBFE);
|
static const Color healthBorder = Color(0xFFCBE9E2);
|
||||||
static const Color medication = Color(0xFF8B5CF6);
|
static const Color medication = Color(0xFF00B8D9);
|
||||||
static const Color medicationLight = Color(0xFFF5F3FF);
|
static const Color medicationLight = Color(0xFFEAFBFF);
|
||||||
static const Color medicationBorder = Color(0xFFDDD6FE);
|
static const Color medicationBorder = Color(0xFFB8F0FA);
|
||||||
static const Color exercise = Color(0xFF10B981);
|
static const Color exercise = Color(0xFF566FD4);
|
||||||
static const Color exerciseLight = Color(0xFFECFDF5);
|
static const Color exerciseLight = Color(0xFFF1F3FD);
|
||||||
static const Color exerciseBorder = Color(0xFFA7F3D0);
|
static const Color exerciseBorder = Color(0xFFD7DCF6);
|
||||||
static const Color report = Color(0xFF2563EB);
|
static const Color report = Color(0xFF6366F1);
|
||||||
static const Color reportLight = Color(0xFFEEF2FF);
|
static const Color reportLight = Color(0xFFEFFBFF);
|
||||||
static const Color reportBorder = Color(0xFFC7D2FE);
|
static const Color reportBorder = Color(0xFFBAE6FD);
|
||||||
static const Color diet = Color(0xFFF97316);
|
static const Color diet = Color(0xFFF7971E);
|
||||||
static const Color dietLight = Color(0xFFFFF7ED);
|
static const Color dietLight = Color(0xFFFFF8DB);
|
||||||
static const Color dietBorder = Color(0xFFFED7AA);
|
static const Color dietBorder = Color(0xFFFFE58A);
|
||||||
static const Color device = Color(0xFF06B6D4);
|
static const Color device = Color(0xFF2563EB);
|
||||||
static const Color deviceLight = Color(0xFFECFEFF);
|
static const Color deviceLight = Color(0xFFEFF6FF);
|
||||||
static const Color deviceBorder = Color(0xFFA5F3FC);
|
static const Color deviceBorder = Color(0xFFBFDBFE);
|
||||||
static const Color notification = Color(0xFF7C5CFF);
|
static const Color notification = Color(0xFF7C5CFF);
|
||||||
static const Color notificationLight = Color(0xFFF5F3FF);
|
static const Color notificationLight = Color(0xFFF5F3FF);
|
||||||
static const Color notificationBorder = Color(0xFFDDD6FE);
|
static const Color notificationBorder = Color(0xFFDDD6FE);
|
||||||
static const Color doctor = Color(0xFF0F766E);
|
static const Color doctor = Color(0xFFFFAFBD);
|
||||||
static const Color doctorLight = Color(0xFFF0FDFA);
|
static const Color doctorLight = Color(0xFFFFF3EF);
|
||||||
static const Color doctorBorder = Color(0xFF99F6E4);
|
static const Color doctorBorder = Color(0xFFFFD7CB);
|
||||||
static const Color calendar = Color(0xFF6366F1);
|
static const Color calendar = Color(0xFF6366F1);
|
||||||
static const Color calendarLight = Color(0xFFEEF2FF);
|
static const Color calendarLight = Color(0xFFEEF2FF);
|
||||||
static const Color calendarBorder = Color(0xFFC7D2FE);
|
static const Color calendarBorder = Color(0xFFC7D2FE);
|
||||||
@@ -51,7 +51,7 @@ class AppColors {
|
|||||||
static const Color followupLight = Color(0xFFFDF2F8);
|
static const Color followupLight = Color(0xFFFDF2F8);
|
||||||
static const Color followupBorder = Color(0xFFFBCFE8);
|
static const Color followupBorder = Color(0xFFFBCFE8);
|
||||||
|
|
||||||
static const Color background = Color(0xFFF8FAFF);
|
static const Color background = Color(0xFFF3F5F8);
|
||||||
static const Color backgroundSoft = Color(0xFFFFFFFF);
|
static const Color backgroundSoft = Color(0xFFFFFFFF);
|
||||||
static const Color cardBackground = Color(0xFFFFFFFF);
|
static const Color cardBackground = Color(0xFFFFFFFF);
|
||||||
static const Color cardInner = Color(0xFFF1F5F9);
|
static const Color cardInner = Color(0xFFF1F5F9);
|
||||||
@@ -70,10 +70,13 @@ class AppColors {
|
|||||||
|
|
||||||
static const Color success = Color(0xFF16A34A);
|
static const Color success = Color(0xFF16A34A);
|
||||||
static const Color successLight = Color(0xFFEAF8EF);
|
static const Color successLight = Color(0xFFEAF8EF);
|
||||||
|
static const Color successText = Color(0xFF15803D);
|
||||||
static const Color error = Color(0xFFEF4444);
|
static const Color error = Color(0xFFEF4444);
|
||||||
static const Color errorLight = Color(0xFFFEECEF);
|
static const Color errorLight = Color(0xFFFEECEF);
|
||||||
|
static const Color errorText = Color(0xFFDC2626);
|
||||||
static const Color warning = Color(0xFFF59E0B);
|
static const Color warning = Color(0xFFF59E0B);
|
||||||
static const Color warningLight = Color(0xFFFFF7E6);
|
static const Color warningLight = Color(0xFFFFF7E6);
|
||||||
|
static const Color warningText = Color(0xFFB45309);
|
||||||
static const Color infoLight = Color(0xFFEFF5FF);
|
static const Color infoLight = Color(0xFFEFF5FF);
|
||||||
|
|
||||||
static const LinearGradient primaryGradient = LinearGradient(
|
static const LinearGradient primaryGradient = LinearGradient(
|
||||||
@@ -157,45 +160,45 @@ class AppColors {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient doctorGradient = LinearGradient(
|
static const LinearGradient doctorGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.centerLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.centerRight,
|
||||||
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
|
colors: [Color(0xFFFFC3A0), Color(0xFFFFAFBD)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient healthGradient = LinearGradient(
|
static const LinearGradient healthGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.centerLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.centerRight,
|
||||||
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
colors: [Color(0xFF2FAE9B), Color(0xFF63BFAE)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient medicationGradient = LinearGradient(
|
static const LinearGradient medicationGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFA78BFA), Color(0xFF7C3AED)],
|
colors: [Color(0xFF4FACFE), Color(0xFF00F2FE)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient exerciseGradient = LinearGradient(
|
static const LinearGradient exerciseGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF34D399), Color(0xFF059669)],
|
colors: [Color(0xFF5B7CDE), Color(0xFF7569D9)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient reportGradient = LinearGradient(
|
static const LinearGradient reportGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
colors: [Color(0xFF38BDF8), Color(0xFF818CF8)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient dietGradient = LinearGradient(
|
static const LinearGradient dietGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFFB923C), Color(0xFFF97316)],
|
colors: [Color(0xFFF7971E), Color(0xFFFFD200)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient deviceGradient = LinearGradient(
|
static const LinearGradient deviceGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF22D3EE), Color(0xFF0891B2)],
|
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient notificationGradient = LinearGradient(
|
static const LinearGradient notificationGradient = LinearGradient(
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ class AppTextStyles {
|
|||||||
|
|
||||||
static const TextStyle appBarTitle = TextStyle(
|
static const TextStyle appBarTitle = TextStyle(
|
||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
static const TextStyle summaryTitle = TextStyle(
|
static const TextStyle summaryTitle = TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
);
|
);
|
||||||
@@ -95,13 +95,13 @@ class AppTextStyles {
|
|||||||
|
|
||||||
static const TextStyle sectionTitle = TextStyle(
|
static const TextStyle sectionTitle = TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
static const TextStyle listTitle = TextStyle(
|
static const TextStyle listTitle = TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.22,
|
height: 1.22,
|
||||||
);
|
);
|
||||||
@@ -122,7 +122,7 @@ class AppTextStyles {
|
|||||||
|
|
||||||
static const TextStyle formLabel = TextStyle(
|
static const TextStyle formLabel = TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -134,19 +134,19 @@ class AppTextStyles {
|
|||||||
|
|
||||||
static const TextStyle tag = TextStyle(
|
static const TextStyle tag = TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
height: 1.15,
|
height: 1.15,
|
||||||
);
|
);
|
||||||
|
|
||||||
static const TextStyle button = TextStyle(
|
static const TextStyle button = TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
);
|
);
|
||||||
|
|
||||||
static const TextStyle miniButton = TextStyle(
|
static const TextStyle miniButton = TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
);
|
);
|
||||||
|
|
||||||
static const TextStyle chatBody = TextStyle(
|
static const TextStyle chatBody = TextStyle(
|
||||||
@@ -158,7 +158,7 @@ class AppTextStyles {
|
|||||||
static const TextStyle chatTitle = TextStyle(
|
static const TextStyle chatTitle = TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
height: 1.35,
|
height: 1.35,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -168,4 +168,3 @@ class AppTextStyles {
|
|||||||
height: 1.25,
|
height: 1.25,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class AppModuleVisual {
|
|||||||
class AppModuleVisuals {
|
class AppModuleVisuals {
|
||||||
AppModuleVisuals._();
|
AppModuleVisuals._();
|
||||||
|
|
||||||
|
static const healthOverviewIcon = Icons.trending_up;
|
||||||
|
|
||||||
static const ai = AppModuleVisual(
|
static const ai = AppModuleVisual(
|
||||||
module: AppModule.ai,
|
module: AppModule.ai,
|
||||||
label: 'AI',
|
label: 'AI',
|
||||||
@@ -73,7 +75,7 @@ class AppModuleVisuals {
|
|||||||
static const exercise = AppModuleVisual(
|
static const exercise = AppModuleVisual(
|
||||||
module: AppModule.exercise,
|
module: AppModule.exercise,
|
||||||
label: '运动',
|
label: '运动',
|
||||||
icon: LucideIcons.footprints,
|
icon: Icons.directions_run_outlined,
|
||||||
color: AppColors.exercise,
|
color: AppColors.exercise,
|
||||||
lightColor: AppColors.exerciseLight,
|
lightColor: AppColors.exerciseLight,
|
||||||
borderColor: AppColors.exerciseBorder,
|
borderColor: AppColors.exerciseBorder,
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import '../pages/settings/notification_prefs_page.dart';
|
|||||||
import '../pages/notifications/notification_center_page.dart';
|
import '../pages/notifications/notification_center_page.dart';
|
||||||
import '../pages/history/conversation_history_page.dart';
|
import '../pages/history/conversation_history_page.dart';
|
||||||
import '../pages/profile/profile_page.dart';
|
import '../pages/profile/profile_page.dart';
|
||||||
|
import '../pages/profile/profile_edit_page.dart';
|
||||||
import '../pages/diet/diet_capture_page.dart';
|
import '../pages/diet/diet_capture_page.dart';
|
||||||
|
import '../pages/exercise/exercise_plan_page.dart';
|
||||||
import '../pages/device/device_scan_page.dart';
|
import '../pages/device/device_scan_page.dart';
|
||||||
import '../pages/device/device_management_page.dart';
|
import '../pages/device/device_management_page.dart';
|
||||||
import '../pages/remaining_pages.dart';
|
import '../pages/remaining_pages.dart';
|
||||||
@@ -67,9 +69,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
case 'medCheckIn':
|
case 'medCheckIn':
|
||||||
return MedicationCheckInPage(medId: params['id']);
|
return MedicationCheckInPage(medId: params['id']);
|
||||||
case 'medicationEdit':
|
case 'medicationEdit':
|
||||||
return const MedicationEditPage();
|
return MedicationEditPage(id: params['id']);
|
||||||
case 'reports':
|
case 'reports':
|
||||||
return const ReportListPage();
|
return ReportListPage(openUploadOnEnter: params['openUpload'] == 'true');
|
||||||
case 'aiAnalysis':
|
case 'aiAnalysis':
|
||||||
final id = _requiredParam(params, 'id');
|
final id = _requiredParam(params, 'id');
|
||||||
return id == null ? _missingParamPage() : AiAnalysisPage(id: id);
|
return id == null ? _missingParamPage() : AiAnalysisPage(id: id);
|
||||||
@@ -77,11 +79,12 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
return ReportOriginalPage(
|
return ReportOriginalPage(
|
||||||
url: params['url'] ?? '',
|
url: params['url'] ?? '',
|
||||||
title: params['title'] ?? '原始报告',
|
title: params['title'] ?? '原始报告',
|
||||||
|
fileType: params['fileType'] ?? 'Image',
|
||||||
);
|
);
|
||||||
case 'exercisePlan':
|
case 'exercisePlan':
|
||||||
return const ExercisePlanPage();
|
return const EnterpriseExercisePlanPage();
|
||||||
case 'exercisePlanDetail':
|
case 'exercisePlanDetail':
|
||||||
return const ExercisePlanPage();
|
return const EnterpriseExercisePlanPage();
|
||||||
case 'exerciseCreate':
|
case 'exerciseCreate':
|
||||||
return const ExercisePlanCreatePage();
|
return const ExercisePlanCreatePage();
|
||||||
case 'dietRecords':
|
case 'dietRecords':
|
||||||
@@ -90,6 +93,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
return const DietCapturePage();
|
return const DietCapturePage();
|
||||||
case 'profile':
|
case 'profile':
|
||||||
return const ProfilePage();
|
return const ProfilePage();
|
||||||
|
case 'profileEdit':
|
||||||
|
return const ProfileEditPage();
|
||||||
case 'doctorProfile':
|
case 'doctorProfile':
|
||||||
return const DoctorProfileEditPage();
|
return const DoctorProfileEditPage();
|
||||||
case 'doctorSettings':
|
case 'doctorSettings':
|
||||||
|
|||||||
@@ -45,8 +45,7 @@ Future<DateTime?> showAppDatePicker(
|
|||||||
final monthCtrl = FixedExtentScrollController(initialItem: m - 1);
|
final monthCtrl = FixedExtentScrollController(initialItem: m - 1);
|
||||||
final dayCtrl = FixedExtentScrollController(initialItem: d - 1);
|
final dayCtrl = FixedExtentScrollController(initialItem: d - 1);
|
||||||
|
|
||||||
int daysInMonth(int year, int month) =>
|
int daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
|
||||||
DateTime(year, month + 1, 0).day;
|
|
||||||
|
|
||||||
void clampDay() {
|
void clampDay() {
|
||||||
final maxDay = daysInMonth(y, m);
|
final maxDay = daysInMonth(y, m);
|
||||||
@@ -70,12 +69,22 @@ Future<DateTime?> showAppDatePicker(
|
|||||||
children: [
|
children: [
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
|
child: const Text(
|
||||||
|
'取消',
|
||||||
|
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
),
|
),
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
|
child: const Text(
|
||||||
|
'确定',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
clampDay();
|
clampDay();
|
||||||
Navigator.pop(ctx, DateTime(y, m, d));
|
Navigator.pop(ctx, DateTime(y, m, d));
|
||||||
@@ -87,7 +96,9 @@ Future<DateTime?> showAppDatePicker(
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: yearCtrl,
|
scrollController: yearCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
@@ -97,9 +108,16 @@ Future<DateTime?> showAppDatePicker(
|
|||||||
final maxDay = daysInMonth(y, m);
|
final maxDay = daysInMonth(y, m);
|
||||||
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
|
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
|
||||||
},
|
},
|
||||||
children: [for (var i = minDate.year; i <= maxDate.year; i++) Center(child: Text('$i', style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = minDate.year; i <= maxDate.year; i++)
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
Center(child: Text('$i', style: _pickerItemStyle)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: monthCtrl,
|
scrollController: monthCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
@@ -109,15 +127,37 @@ Future<DateTime?> showAppDatePicker(
|
|||||||
final maxDay = daysInMonth(y, m);
|
final maxDay = daysInMonth(y, m);
|
||||||
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
|
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
|
||||||
},
|
},
|
||||||
children: [for (var i = 1; i <= 12; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = 1; i <= 12; i++)
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: dayCtrl,
|
scrollController: dayCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) => d = i + 1,
|
onSelectedItemChanged: (i) => d = i + 1,
|
||||||
children: [for (var i = 1; i <= daysInMonth(y, m); i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = 1; i <= daysInMonth(y, m); i++)
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -153,13 +193,24 @@ Future<TimeOfDay?> showAppTimePicker(
|
|||||||
children: [
|
children: [
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
|
child: const Text(
|
||||||
|
'取消',
|
||||||
|
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
),
|
),
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
|
child: const Text(
|
||||||
onPressed: () => Navigator.pop(ctx, TimeOfDay(hour: hour, minute: minute)),
|
'确定',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(ctx, TimeOfDay(hour: hour, minute: minute)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -168,20 +219,46 @@ Future<TimeOfDay?> showAppTimePicker(
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Spacer(flex: 1),
|
const Spacer(flex: 1),
|
||||||
Expanded(flex: 2, child: _wrapPicker(CupertinoPicker(
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: hourCtrl,
|
scrollController: hourCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) => hour = i,
|
onSelectedItemChanged: (i) => hour = i,
|
||||||
children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = 0; i <= 23; i++)
|
||||||
Expanded(flex: 2, child: _wrapPicker(CupertinoPicker(
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: minuteCtrl,
|
scrollController: minuteCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) => minute = i,
|
onSelectedItemChanged: (i) => minute = i,
|
||||||
children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = 0; i <= 59; i++)
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const Spacer(flex: 1),
|
const Spacer(flex: 1),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -211,8 +288,7 @@ Future<DateTime?> showAppDateTimePicker(
|
|||||||
final hourCtrl = FixedExtentScrollController(initialItem: h);
|
final hourCtrl = FixedExtentScrollController(initialItem: h);
|
||||||
final minuteCtrl = FixedExtentScrollController(initialItem: min);
|
final minuteCtrl = FixedExtentScrollController(initialItem: min);
|
||||||
|
|
||||||
int daysInMonth(int year, int month) =>
|
int daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
|
||||||
DateTime(year, month + 1, 0).day;
|
|
||||||
|
|
||||||
void clampDay() {
|
void clampDay() {
|
||||||
final maxDay = daysInMonth(y, m);
|
final maxDay = daysInMonth(y, m);
|
||||||
@@ -236,12 +312,22 @@ Future<DateTime?> showAppDateTimePicker(
|
|||||||
children: [
|
children: [
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
|
child: const Text(
|
||||||
|
'取消',
|
||||||
|
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
),
|
),
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
|
child: const Text(
|
||||||
|
'确定',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
clampDay();
|
clampDay();
|
||||||
Navigator.pop(ctx, DateTime(y, m, d, h, min));
|
Navigator.pop(ctx, DateTime(y, m, d, h, min));
|
||||||
@@ -253,41 +339,102 @@ Future<DateTime?> showAppDateTimePicker(
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: yearCtrl,
|
scrollController: yearCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) { y = minDate.year + i; clampDay(); },
|
onSelectedItemChanged: (i) {
|
||||||
children: [for (var i = minDate.year; i <= maxDate.year; i++) Center(child: Text('$i', style: _pickerItemStyle))],
|
y = minDate.year + i;
|
||||||
))),
|
clampDay();
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
},
|
||||||
|
children: [
|
||||||
|
for (var i = minDate.year; i <= maxDate.year; i++)
|
||||||
|
Center(child: Text('$i', style: _pickerItemStyle)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: monthCtrl,
|
scrollController: monthCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) { m = i + 1; clampDay(); },
|
onSelectedItemChanged: (i) {
|
||||||
children: [for (var i = 1; i <= 12; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
m = i + 1;
|
||||||
))),
|
clampDay();
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
},
|
||||||
|
children: [
|
||||||
|
for (var i = 1; i <= 12; i++)
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: dayCtrl,
|
scrollController: dayCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) => d = i + 1,
|
onSelectedItemChanged: (i) => d = i + 1,
|
||||||
children: [for (var i = 1; i <= daysInMonth(y, m); i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = 1; i <= daysInMonth(y, m); i++)
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: hourCtrl,
|
scrollController: hourCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) => h = i,
|
onSelectedItemChanged: (i) => h = i,
|
||||||
children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = 0; i <= 23; i++)
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: minuteCtrl,
|
scrollController: minuteCtrl,
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) => min = i,
|
onSelectedItemChanged: (i) => min = i,
|
||||||
children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
children: [
|
||||||
))),
|
for (var i = 0; i <= 59; i++)
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -322,28 +469,47 @@ Future<int?> showAppCountPicker(
|
|||||||
children: [
|
children: [
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
|
child: const Text(
|
||||||
|
'取消',
|
||||||
|
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
),
|
),
|
||||||
CupertinoButton(
|
CupertinoButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
|
child: const Text(
|
||||||
|
'确定',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
onPressed: () => Navigator.pop(ctx, selected),
|
onPressed: () => Navigator.pop(ctx, selected),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _wrapPicker(CupertinoPicker(
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
selectionOverlay: null,
|
selectionOverlay: null,
|
||||||
scrollController: FixedExtentScrollController(initialItem: selected - min),
|
scrollController: FixedExtentScrollController(
|
||||||
|
initialItem: selected - min,
|
||||||
|
),
|
||||||
itemExtent: 40,
|
itemExtent: 40,
|
||||||
onSelectedItemChanged: (i) => selected = i + min,
|
onSelectedItemChanged: (i) => selected = i + min,
|
||||||
children: [
|
children: [
|
||||||
for (var i = min; i <= max; i++)
|
for (var i = min; i <= max; i++)
|
||||||
Center(child: Text(label.isEmpty ? '$i' : '$i$label', style: _pickerItemStyle)),
|
Center(
|
||||||
|
child: Text(
|
||||||
|
label.isEmpty ? '$i' : '$i$label',
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -362,7 +528,16 @@ class AppBackground extends StatelessWidget {
|
|||||||
final content = safeArea ? SafeArea(child: child) : child;
|
final content = safeArea ? SafeArea(child: child) : child;
|
||||||
|
|
||||||
// 全局浅灰白背景,已弃用紫色渐变。
|
// 全局浅灰白背景,已弃用紫色渐变。
|
||||||
return ColoredBox(color: const Color(0xFFF6F8FC), child: content);
|
return DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFF0EDFF), Color(0xFFE6ECFF)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: content,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,10 +561,13 @@ class GradientScaffold extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Scaffold(
|
Widget build(BuildContext context) => Scaffold(
|
||||||
// 内部页面统一使用浅灰白底,对话流首页因直接使用 AppBackground 仍保留紫色渐变。
|
// 二级页面用浅中性画布承托白色内容组;首页拥有自己的独立背景实现。
|
||||||
backgroundColor: const Color(0xFFF6F8FC),
|
backgroundColor: AppColors.background,
|
||||||
appBar: appBar,
|
appBar: appBar,
|
||||||
body: body,
|
body: ColoredBox(
|
||||||
|
color: AppColors.background,
|
||||||
|
child: SizedBox.expand(child: body ?? const SizedBox.shrink()),
|
||||||
|
),
|
||||||
floatingActionButton: floatingActionButton,
|
floatingActionButton: floatingActionButton,
|
||||||
drawer: drawer,
|
drawer: drawer,
|
||||||
bottomNavigationBar: bottomNavigationBar,
|
bottomNavigationBar: bottomNavigationBar,
|
||||||
@@ -485,21 +663,21 @@ class AppTheme {
|
|||||||
surface: surface,
|
surface: surface,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
),
|
),
|
||||||
scaffoldBackgroundColor: Colors.transparent,
|
scaffoldBackgroundColor: AppColors.background,
|
||||||
splashColor: primary.withValues(alpha: 0.06),
|
splashColor: primary.withValues(alpha: 0.06),
|
||||||
highlightColor: Colors.transparent,
|
highlightColor: Colors.transparent,
|
||||||
hoverColor: primaryLight.withValues(alpha: 0.55),
|
hoverColor: primaryLight.withValues(alpha: 0.55),
|
||||||
fontFamily: null,
|
fontFamily: null,
|
||||||
|
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
backgroundColor: Colors.white.withValues(alpha: 0.9),
|
backgroundColor: Colors.white,
|
||||||
foregroundColor: text,
|
foregroundColor: text,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
scrolledUnderElevation: 0,
|
scrolledUnderElevation: 0,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
titleTextStyle: const TextStyle(
|
titleTextStyle: TextStyle(
|
||||||
fontSize: 17,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: text,
|
color: text,
|
||||||
),
|
),
|
||||||
@@ -546,7 +724,7 @@ class AppTheme {
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
minimumSize: const Size(double.infinity, 50),
|
minimumSize: const Size(double.infinity, 50),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg)),
|
||||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -575,12 +753,12 @@ class AppTheme {
|
|||||||
textTheme: const TextTheme(
|
textTheme: const TextTheme(
|
||||||
headlineLarge: TextStyle(
|
headlineLarge: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
color: text,
|
color: text,
|
||||||
),
|
),
|
||||||
titleLarge: TextStyle(
|
titleLarge: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
color: text,
|
color: text,
|
||||||
),
|
),
|
||||||
titleMedium: TextStyle(
|
titleMedium: TextStyle(
|
||||||
|
|||||||
@@ -34,17 +34,19 @@ class LocalDatabase {
|
|||||||
|
|
||||||
Future<void> write(String key, String value) async {
|
Future<void> write(String key, String value) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.insert(
|
await db.insert('kv_store', {
|
||||||
'kv_store',
|
'key': key,
|
||||||
{'key': key, 'value': value},
|
'value': value,
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> read(String key) async {
|
Future<String?> read(String key) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final result =
|
final result = await db.query(
|
||||||
await db.query('kv_store', where: 'key = ?', whereArgs: [key]);
|
'kv_store',
|
||||||
|
where: 'key = ?',
|
||||||
|
whereArgs: [key],
|
||||||
|
);
|
||||||
if (result.isEmpty) return null;
|
if (result.isEmpty) return null;
|
||||||
return result.first['value'] as String?;
|
return result.first['value'] as String?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ class RouteStackNotifier extends Notifier<List<RouteInfo>> {
|
|||||||
|
|
||||||
/// 路由栈 Provider
|
/// 路由栈 Provider
|
||||||
final routeStackProvider =
|
final routeStackProvider =
|
||||||
NotifierProvider<RouteStackNotifier, List<RouteInfo>>(RouteStackNotifier.new);
|
NotifierProvider<RouteStackNotifier, List<RouteInfo>>(
|
||||||
|
RouteStackNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
/// 当前路由
|
/// 当前路由
|
||||||
final currentRouteProvider = Provider<RouteInfo>((ref) {
|
final currentRouteProvider = Provider<RouteInfo>((ref) {
|
||||||
@@ -46,13 +48,21 @@ void _dismissKeyboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 跳转(替换整个栈)
|
/// 跳转(替换整个栈)
|
||||||
void goRoute(WidgetRef ref, String name, {Map<String, String> params = const {}}) {
|
void goRoute(
|
||||||
|
WidgetRef ref,
|
||||||
|
String name, {
|
||||||
|
Map<String, String> params = const {},
|
||||||
|
}) {
|
||||||
_dismissKeyboard();
|
_dismissKeyboard();
|
||||||
ref.read(routeStackProvider.notifier).replace(name, params: params);
|
ref.read(routeStackProvider.notifier).replace(name, params: params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 推入新页面
|
/// 推入新页面
|
||||||
void pushRoute(WidgetRef ref, String name, {Map<String, String> params = const {}}) {
|
void pushRoute(
|
||||||
|
WidgetRef ref,
|
||||||
|
String name, {
|
||||||
|
Map<String, String> params = const {},
|
||||||
|
}) {
|
||||||
_dismissKeyboard();
|
_dismissKeyboard();
|
||||||
ref.read(routeStackProvider.notifier).push(name, params: params);
|
ref.read(routeStackProvider.notifier).push(name, params: params);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/data_providers.dart' show adminServiceProvider;
|
import '../../providers/data_providers.dart'
|
||||||
|
show adminServiceProvider, doctorListProvider;
|
||||||
import '../../widgets/app_toast.dart';
|
import '../../widgets/app_toast.dart';
|
||||||
|
|
||||||
class AdminAddDoctorPage extends ConsumerStatefulWidget {
|
class AdminAddDoctorPage extends ConsumerStatefulWidget {
|
||||||
@@ -48,6 +49,7 @@ class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
|
|||||||
'professionalDirection': _directionCtrl.text.trim(),
|
'professionalDirection': _directionCtrl.text.trim(),
|
||||||
});
|
});
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
ref.invalidate(doctorListProvider);
|
||||||
AppToast.show(context, '添加成功', type: AppToastType.success);
|
AppToast.show(context, '添加成功', type: AppToastType.success);
|
||||||
popRoute(ref);
|
popRoute(ref);
|
||||||
}
|
}
|
||||||
@@ -65,7 +67,7 @@ class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: AppColors.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
title: const Text(
|
title: const Text(
|
||||||
|
|||||||
@@ -5,25 +5,32 @@ import '../../widgets/admin_drawer.dart';
|
|||||||
import 'admin_doctors_page.dart';
|
import 'admin_doctors_page.dart';
|
||||||
import 'admin_patients_page.dart';
|
import 'admin_patients_page.dart';
|
||||||
|
|
||||||
final adminPageProvider = NotifierProvider<AdminPageNotifier, String>(AdminPageNotifier.new);
|
final adminPageProvider = NotifierProvider<AdminPageNotifier, String>(
|
||||||
|
AdminPageNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
class AdminPageNotifier extends Notifier<String> {
|
class AdminPageNotifier extends Notifier<String> {
|
||||||
@override String build() => 'doctors';
|
@override
|
||||||
|
String build() => 'doctors';
|
||||||
void set(String page) => state = page;
|
void set(String page) => state = page;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AdminHomePage extends ConsumerWidget {
|
class AdminHomePage extends ConsumerWidget {
|
||||||
const AdminHomePage({super.key});
|
const AdminHomePage({super.key});
|
||||||
|
|
||||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final page = ref.watch(adminPageProvider);
|
final page = ref.watch(adminPageProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.pageGrey,
|
backgroundColor: AppColors.pageGrey,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: const Text('系统管理', style: TextStyle(color: AppColors.textPrimary)),
|
title: const Text(
|
||||||
|
'系统管理',
|
||||||
|
style: TextStyle(color: AppColors.textPrimary),
|
||||||
|
),
|
||||||
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||||
),
|
),
|
||||||
drawer: const AdminDrawer(),
|
drawer: const AdminDrawer(),
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_design_tokens.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
import '../../widgets/app_gradient_widgets.dart';
|
||||||
|
|
||||||
class LoginPage extends ConsumerStatefulWidget {
|
class LoginPage extends ConsumerStatefulWidget {
|
||||||
const LoginPage({super.key});
|
const LoginPage({super.key});
|
||||||
@@ -15,7 +19,12 @@ class LoginPage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||||
static const _loginBg = 'assets/branding/login_background_v1.png';
|
static const _loginBg = 'assets/branding/login_background_v2.png';
|
||||||
|
static const _loginActionGradient = AppColors.primaryGradient;
|
||||||
|
|
||||||
|
void _openStaticText(String type) {
|
||||||
|
pushRoute(ref, 'staticText', params: {'type': type});
|
||||||
|
}
|
||||||
|
|
||||||
final _phoneCtrl = TextEditingController();
|
final _phoneCtrl = TextEditingController();
|
||||||
final _codeCtrl = TextEditingController();
|
final _codeCtrl = TextEditingController();
|
||||||
@@ -54,7 +63,8 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
setState(() => _error = result.error);
|
setState(() => _error = result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.devCode != null) _codeCtrl.text = result.devCode!;
|
if (!mounted) return;
|
||||||
|
if (kDebugMode && result.devCode != null) _codeCtrl.text = result.devCode!;
|
||||||
setState(() => _countdown = 60);
|
setState(() => _countdown = 60);
|
||||||
_startCountdown();
|
_startCountdown();
|
||||||
}
|
}
|
||||||
@@ -69,6 +79,11 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
// Apple 登录
|
// Apple 登录
|
||||||
Future<void> _appleSignIn() async {
|
Future<void> _appleSignIn() async {
|
||||||
if (_loading) return;
|
if (_loading) return;
|
||||||
|
if (!_agreed) {
|
||||||
|
final accepted = await _showAgreementDialog();
|
||||||
|
if (!mounted || accepted != true) return;
|
||||||
|
setState(() => _agreed = true);
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
@@ -77,15 +92,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final credential = await SignInWithApple.getAppleIDCredential(
|
final credential = await SignInWithApple.getAppleIDCredential(
|
||||||
scopes: [
|
scopes: [AppleIDAuthorizationScopes.fullName],
|
||||||
AppleIDAuthorizationScopes.fullName,
|
|
||||||
],
|
|
||||||
webAuthenticationOptions: WebAuthenticationOptions(
|
|
||||||
clientId: 'com.datalumina.YYA.signin',
|
|
||||||
redirectUri: Uri.parse(
|
|
||||||
'https://erpapi.datalumina.cn/xiaomai/api/auth/apple-login',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final identityToken = credential.identityToken;
|
final identityToken = credential.identityToken;
|
||||||
@@ -99,21 +106,20 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final name = credential.givenName != null && credential.familyName != null
|
final name = '${credential.familyName ?? ''}${credential.givenName ?? ''}'
|
||||||
? '${credential.familyName}${credential.givenName}'
|
.trim();
|
||||||
: null;
|
|
||||||
|
|
||||||
final err = await ref.read(authProvider.notifier).appleLogin(
|
final err = await ref
|
||||||
|
.read(authProvider.notifier)
|
||||||
|
.appleLogin(
|
||||||
identityToken,
|
identityToken,
|
||||||
credential.authorizationCode,
|
credential.authorizationCode,
|
||||||
name,
|
name.isEmpty ? null : name,
|
||||||
);
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
if (err != null) {
|
if (err != null) {
|
||||||
setState(() => _error = err);
|
setState(() => _error = err);
|
||||||
} else {
|
|
||||||
_goHome();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -123,7 +129,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
if (e is SignInWithAppleAuthorizationException) {
|
if (e is SignInWithAppleAuthorizationException) {
|
||||||
_error = 'Apple 登录已取消';
|
_error = 'Apple 登录已取消';
|
||||||
} else {
|
} else {
|
||||||
_error = 'Apple 登录失败: $e';
|
_error = 'Apple 登录失败,请稍后重试';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -131,6 +137,18 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submit() async {
|
Future<void> _submit() async {
|
||||||
|
if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) {
|
||||||
|
setState(() => _error = '请输入手机号和验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_isLogin && _nameCtrl.text.trim().isEmpty) {
|
||||||
|
setState(() => _error = '请输入姓名');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_isLogin && _selectedDoctorId == null) {
|
||||||
|
setState(() => _error = '请选择健康服务医生');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!_agreed) {
|
if (!_agreed) {
|
||||||
final accepted = await _showAgreementDialog();
|
final accepted = await _showAgreementDialog();
|
||||||
if (!mounted || accepted != true) return;
|
if (!mounted || accepted != true) return;
|
||||||
@@ -139,10 +157,6 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) {
|
|
||||||
setState(() => _error = '请输入手机号和验证码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
@@ -155,20 +169,6 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
.read(authProvider.notifier)
|
.read(authProvider.notifier)
|
||||||
.login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
|
.login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
|
||||||
} else {
|
} else {
|
||||||
if (_selectedDoctorId == null) {
|
|
||||||
setState(() {
|
|
||||||
_loading = false;
|
|
||||||
_error = '请选择医生';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_nameCtrl.text.trim().isEmpty) {
|
|
||||||
setState(() {
|
|
||||||
_loading = false;
|
|
||||||
_error = '请输入姓名';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
err = await ref
|
err = await ref
|
||||||
.read(authProvider.notifier)
|
.read(authProvider.notifier)
|
||||||
.register(
|
.register(
|
||||||
@@ -179,27 +179,14 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
if (err != null) {
|
if (err != null) {
|
||||||
if (err.contains('未注册')) setState(() => _isLogin = false);
|
if (err.contains('未注册')) setState(() => _isLogin = false);
|
||||||
setState(() => _error = err);
|
setState(() => _error = err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_isLogin) {
|
// 登录和注册成功后的页面切换统一由根导航监听认证状态处理。
|
||||||
setState(() {
|
|
||||||
_isLogin = true;
|
|
||||||
_successMsg = '注册成功,请登录';
|
|
||||||
_error = null;
|
|
||||||
_phoneCtrl.clear();
|
|
||||||
_codeCtrl.clear();
|
|
||||||
_nameCtrl.clear();
|
|
||||||
_selectedDoctorId = null;
|
|
||||||
_selectedDoctorName = null;
|
|
||||||
});
|
|
||||||
ref.read(authProvider.notifier).logout();
|
|
||||||
} else {
|
|
||||||
_goHome();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool?> _showAgreementDialog() {
|
Future<bool?> _showAgreementDialog() {
|
||||||
@@ -213,10 +200,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
constraints: const BoxConstraints(maxWidth: 380),
|
constraints: const BoxConstraints(maxWidth: 380),
|
||||||
padding: const EdgeInsets.fromLTRB(22, 28, 22, 22),
|
padding: const EdgeInsets.fromLTRB(22, 26, 22, 22),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: AppRadius.xlBorder,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.14),
|
color: Colors.black.withValues(alpha: 0.14),
|
||||||
@@ -233,7 +220,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 21,
|
fontSize: 21,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
),
|
),
|
||||||
@@ -241,23 +228,51 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
RichText(
|
RichText(
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
text: const TextSpan(
|
text: TextSpan(
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
height: 1.55,
|
height: 1.55,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
TextSpan(text: '请阅读并同意'),
|
const TextSpan(text: '请阅读并同意'),
|
||||||
TextSpan(
|
WidgetSpan(
|
||||||
text: '《服务协议》',
|
alignment: PlaceholderAlignment.baseline,
|
||||||
style: TextStyle(color: AppColors.primary),
|
baseline: TextBaseline.alphabetic,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx, false);
|
||||||
|
_openStaticText('terms');
|
||||||
|
},
|
||||||
|
child: AppGradientText(
|
||||||
|
'《服务协议》',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.55,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const TextSpan(text: '和'),
|
||||||
|
WidgetSpan(
|
||||||
|
alignment: PlaceholderAlignment.baseline,
|
||||||
|
baseline: TextBaseline.alphabetic,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx, false);
|
||||||
|
_openStaticText('privacy');
|
||||||
|
},
|
||||||
|
child: AppGradientText(
|
||||||
|
'《隐私政策》',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.55,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextSpan(text: '和'),
|
|
||||||
TextSpan(
|
|
||||||
text: '《隐私政策》',
|
|
||||||
style: TextStyle(color: AppColors.primary),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -266,26 +281,9 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SizedBox(
|
child: AppGradientOutlineButton(
|
||||||
height: 50,
|
label: '不同意',
|
||||||
child: OutlinedButton(
|
onTap: () => Navigator.pop(ctx, false),
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.primary,
|
|
||||||
side: BorderSide(
|
|
||||||
color: AppColors.primary.withValues(alpha: 0.62),
|
|
||||||
width: 1.2,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
),
|
|
||||||
textStyle: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Text('不同意'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -296,8 +294,8 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
height: 50,
|
height: 50,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.doctorGradient,
|
gradient: _loginActionGradient,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: AppRadius.mdBorder,
|
||||||
boxShadow: AppColors.buttonShadow,
|
boxShadow: AppColors.buttonShadow,
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
@@ -305,7 +303,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -320,27 +318,13 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _goHome() {
|
|
||||||
ref.invalidate(latestHealthProvider);
|
|
||||||
ref.invalidate(medicationListProvider);
|
|
||||||
ref.invalidate(medicationReminderProvider);
|
|
||||||
ref.invalidate(currentExercisePlanProvider);
|
|
||||||
final role = ref.read(authProvider).user?.role ?? 'User';
|
|
||||||
if (role == 'Admin') {
|
|
||||||
goRoute(ref, 'adminHome');
|
|
||||||
} else if (role == 'Doctor') {
|
|
||||||
goRoute(ref, 'doctorHome');
|
|
||||||
} else {
|
|
||||||
goRoute(ref, 'home');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showDoctorPicker() {
|
void _showDoctorPicker() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
|
showDragHandle: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
|
||||||
),
|
),
|
||||||
builder: (ctx) => Consumer(
|
builder: (ctx) => Consumer(
|
||||||
builder: (ctx, ref, _) {
|
builder: (ctx, ref, _) {
|
||||||
@@ -361,26 +345,44 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.all(18),
|
padding: EdgeInsets.fromLTRB(20, 2, 20, 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'请选择医生',
|
'选择健康服务医生',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: AppColors.divider),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
itemCount: active.length,
|
itemCount: active.length,
|
||||||
|
separatorBuilder: (_, _) => const Padding(
|
||||||
|
padding: EdgeInsets.only(left: 50),
|
||||||
|
child: Divider(height: 1, color: AppColors.divider),
|
||||||
|
),
|
||||||
itemBuilder: (_, i) {
|
itemBuilder: (_, i) {
|
||||||
final d = active[i];
|
final d = active[i];
|
||||||
final name = d['name'] ?? '';
|
final name = d['name'] ?? '';
|
||||||
final title = d['title'] ?? '';
|
final title = d['title'] ?? '';
|
||||||
final dept = d['department'] ?? '';
|
final dept = d['department'] ?? '';
|
||||||
return ListTile(
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primarySoft,
|
||||||
|
borderRadius: AppRadius.smBorder,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
LucideIcons.stethoscope,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
name,
|
name,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -396,8 +398,9 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
),
|
),
|
||||||
trailing: _selectedDoctorId == d['id']?.toString()
|
trailing: _selectedDoctorId == d['id']?.toString()
|
||||||
? const Icon(
|
? const Icon(
|
||||||
Icons.check_circle,
|
LucideIcons.circleCheck,
|
||||||
color: AppColors.primary,
|
size: 22,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -429,22 +432,20 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final authState = ref.watch(authProvider);
|
|
||||||
if (authState.isLoggedIn && !authState.isLoading) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _goHome());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
resizeToAvoidBottomInset: false,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
|
child: RepaintBoundary(
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
_loginBg,
|
_loginBg,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -452,20 +453,23 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [
|
||||||
Colors.white.withValues(alpha: 0.10),
|
Colors.white.withValues(alpha: 0.02),
|
||||||
Colors.white.withValues(alpha: 0.28),
|
Colors.white.withValues(alpha: 0.12),
|
||||||
Colors.white.withValues(alpha: 0.54),
|
Colors.white.withValues(alpha: 0.40),
|
||||||
],
|
],
|
||||||
stops: const [0.0, 0.52, 1.0],
|
stops: const [0.0, 0.52, 1.0],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SafeArea(
|
_LoginKeyboardLift(
|
||||||
|
child: SafeArea(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 24),
|
keyboardDismissBehavior:
|
||||||
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 22, 20, 24),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
minHeight: (constraints.maxHeight - 52).clamp(
|
minHeight: (constraints.maxHeight - 52).clamp(
|
||||||
@@ -481,7 +485,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const _LoginHero(),
|
const _LoginHero(),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 22),
|
||||||
_LoginFormCard(
|
_LoginFormCard(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -502,7 +506,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
'姓名',
|
'姓名',
|
||||||
TextInputType.name,
|
TextInputType.name,
|
||||||
20,
|
20,
|
||||||
icon: Icons.person_outline_rounded,
|
icon: LucideIcons.userRound,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
@@ -511,8 +515,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
'手机号',
|
'手机号',
|
||||||
TextInputType.phone,
|
TextInputType.phone,
|
||||||
11,
|
11,
|
||||||
icon: Icons.local_phone_rounded,
|
icon: LucideIcons.phone,
|
||||||
gradientIcon: true,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
@@ -523,8 +526,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
'验证码',
|
'验证码',
|
||||||
TextInputType.number,
|
TextInputType.number,
|
||||||
6,
|
6,
|
||||||
icon: Icons.sms_rounded,
|
icon: LucideIcons.messageSquareText,
|
||||||
gradientIcon: true,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
@@ -542,6 +544,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
agreed: _agreed,
|
agreed: _agreed,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
setState(() => _agreed = !_agreed),
|
setState(() => _agreed = !_agreed),
|
||||||
|
onTermsTap: () =>
|
||||||
|
_openStaticText('terms'),
|
||||||
|
onPrivacyTap: () =>
|
||||||
|
_openStaticText('privacy'),
|
||||||
),
|
),
|
||||||
if (_error != null) ...[
|
if (_error != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -556,12 +562,16 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
label: _isLogin ? '登录' : '注册',
|
label: _isLogin ? '登录' : '注册',
|
||||||
onTap: _loading ? null : _submit,
|
onTap: _loading ? null : _submit,
|
||||||
),
|
),
|
||||||
// Apple 登录按钮(仅 iOS/macOS)
|
if (_isLogin &&
|
||||||
if (_isLogin && (Platform.isIOS || Platform.isMacOS)) ...[
|
(Platform.isIOS ||
|
||||||
|
Platform.isMacOS)) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
SignInWithAppleButton(
|
SignInWithAppleButton(
|
||||||
onPressed: _loading ? () {} : () { _appleSignIn(); },
|
onPressed: _loading
|
||||||
style: SignInWithAppleButtonStyle.whiteOutlined,
|
? () {}
|
||||||
|
: _appleSignIn,
|
||||||
|
style: SignInWithAppleButtonStyle
|
||||||
|
.whiteOutlined,
|
||||||
height: 48,
|
height: 48,
|
||||||
text: '通过 Apple 登录',
|
text: '通过 Apple 登录',
|
||||||
),
|
),
|
||||||
@@ -573,13 +583,13 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
_successMsg = null;
|
_successMsg = null;
|
||||||
}),
|
}),
|
||||||
child: Text(
|
child: Center(
|
||||||
|
child: AppGradientText(
|
||||||
_isLogin ? '没有账号?去注册' : '已有账号?去登录',
|
_isLogin ? '没有账号?去注册' : '已有账号?去登录',
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.primary,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -595,12 +605,30 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _LoginKeyboardLift extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const _LoginKeyboardLift({required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final keyboardHeight = MediaQuery.viewInsetsOf(context).bottom;
|
||||||
|
final lift = (keyboardHeight * 0.42).clamp(0.0, 150.0);
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(0, -lift),
|
||||||
|
transformHitTests: true,
|
||||||
|
child: RepaintBoundary(child: child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _LoginHero extends StatelessWidget {
|
class _LoginHero extends StatelessWidget {
|
||||||
const _LoginHero();
|
const _LoginHero();
|
||||||
|
|
||||||
@@ -609,12 +637,12 @@ class _LoginHero extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_BrandMark(),
|
_BrandMark(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
const Text(
|
const Text(
|
||||||
'小脉健康',
|
'小脉健康',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 30,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
),
|
),
|
||||||
@@ -645,14 +673,13 @@ class _LoginFormCard extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 20),
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.94),
|
color: Colors.white.withValues(alpha: 0.88),
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: AppRadius.xlBorder,
|
||||||
border: Border.all(color: AppColors.border, width: 1.1),
|
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF475467).withValues(alpha: 0.10),
|
color: const Color(0xFF475467).withValues(alpha: 0.07),
|
||||||
blurRadius: 30,
|
blurRadius: 24,
|
||||||
offset: const Offset(0, 16),
|
offset: const Offset(0, 12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -665,14 +692,11 @@ class _BrandMark extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: 132,
|
width: 116,
|
||||||
height: 132,
|
height: 116,
|
||||||
child: Transform.scale(
|
|
||||||
scale: 1.08,
|
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/branding/health_icon_master.png',
|
'assets/branding/health_login_character_transparent.png',
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.contain,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -684,16 +708,8 @@ class _TextField extends StatelessWidget {
|
|||||||
final TextInputType type;
|
final TextInputType type;
|
||||||
final int maxLen;
|
final int maxLen;
|
||||||
final IconData? icon;
|
final IconData? icon;
|
||||||
final bool gradientIcon;
|
|
||||||
|
|
||||||
const _TextField(
|
const _TextField(this.ctrl, this.hint, this.type, this.maxLen, {this.icon});
|
||||||
this.ctrl,
|
|
||||||
this.hint,
|
|
||||||
this.type,
|
|
||||||
this.maxLen, {
|
|
||||||
this.icon,
|
|
||||||
this.gradientIcon = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => TextField(
|
Widget build(BuildContext context) => TextField(
|
||||||
@@ -710,38 +726,22 @@ class _TextField extends StatelessWidget {
|
|||||||
counterText: '',
|
counterText: '',
|
||||||
prefixIcon: icon == null
|
prefixIcon: icon == null
|
||||||
? null
|
? null
|
||||||
: gradientIcon
|
: Icon(icon, size: 20, color: AppColors.primaryDark),
|
||||||
? _GradientPrefixIcon(icon: icon!)
|
|
||||||
: Icon(icon, size: 20, color: AppColors.textSecondary),
|
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFFF8FAFC),
|
fillColor: AppColors.cardInner,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 15),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 15),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: AppRadius.mdBorder,
|
||||||
borderSide: const BorderSide(color: AppColors.border, width: 1.1),
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: AppRadius.mdBorder,
|
||||||
borderSide: const BorderSide(color: AppColors.primary, width: 1.2),
|
borderSide: const BorderSide(color: AppColors.primary, width: 1.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GradientPrefixIcon extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
const _GradientPrefixIcon({required this.icon});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ShaderMask(
|
|
||||||
shaderCallback: AppColors.doctorGradient.createShader,
|
|
||||||
blendMode: BlendMode.srcIn,
|
|
||||||
child: Icon(icon, size: 23, color: Colors.white),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DoctorSelector extends StatelessWidget {
|
class _DoctorSelector extends StatelessWidget {
|
||||||
final String? name;
|
final String? name;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
@@ -755,21 +755,20 @@ class _DoctorSelector extends StatelessWidget {
|
|||||||
height: 50,
|
height: 50,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: AppColors.cardInner,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: AppRadius.mdBorder,
|
||||||
border: Border.all(color: AppColors.border, width: 1.1),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
const Icon(
|
||||||
Icons.medical_services_outlined,
|
LucideIcons.stethoscope,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
name ?? '请选择医生',
|
name ?? '请选择健康服务医生(必选)',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -779,7 +778,11 @@ class _DoctorSelector extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(Icons.keyboard_arrow_down, color: AppColors.textHint),
|
const Icon(
|
||||||
|
LucideIcons.chevronDown,
|
||||||
|
size: 19,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -807,11 +810,10 @@ class _SmsButton extends StatelessWidget {
|
|||||||
height: 50,
|
height: 50,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: disabled ? null : AppColors.doctorGradient,
|
color: disabled ? AppColors.cardInner : Colors.white,
|
||||||
color: disabled ? AppColors.cardInner : null,
|
borderRadius: AppRadius.mdBorder,
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: disabled ? AppColors.border : Colors.transparent,
|
color: disabled ? AppColors.borderLight : AppColors.primaryLight,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -823,7 +825,7 @@ class _SmsButton extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: disabled ? AppColors.textSecondary : Colors.white,
|
color: disabled ? AppColors.textSecondary : AppColors.primaryDark,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -834,64 +836,67 @@ class _SmsButton extends StatelessWidget {
|
|||||||
class _Agreement extends StatelessWidget {
|
class _Agreement extends StatelessWidget {
|
||||||
final bool agreed;
|
final bool agreed;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _Agreement({required this.agreed, required this.onTap});
|
final VoidCallback onTermsTap;
|
||||||
|
final VoidCallback onPrivacyTap;
|
||||||
|
const _Agreement({
|
||||||
|
required this.agreed,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onTermsTap,
|
||||||
|
required this.onPrivacyTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
const textStyle = TextStyle(fontSize: 13, color: AppColors.textHint);
|
||||||
onTap: onTap,
|
const linkStyle = TextStyle(fontSize: 13, fontWeight: FontWeight.w700);
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Container(
|
||||||
width: 18,
|
width: 18,
|
||||||
height: 18,
|
height: 18,
|
||||||
margin: const EdgeInsets.only(right: 7, top: 1),
|
margin: const EdgeInsets.only(right: 7),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: agreed ? AppColors.primary : Colors.white,
|
gradient: agreed ? _LoginPageState._loginActionGradient : null,
|
||||||
borderRadius: BorderRadius.circular(5),
|
color: agreed ? null : Colors.white,
|
||||||
|
borderRadius: AppRadius.xsBorder,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: agreed ? AppColors.primary : AppColors.border,
|
color: agreed ? Colors.transparent : AppColors.border,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: agreed
|
child: agreed
|
||||||
? const Icon(Icons.check, size: 13, color: Colors.white)
|
? const Icon(Icons.check, size: 13, color: Colors.white)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
Flexible(
|
),
|
||||||
child: RichText(
|
Expanded(
|
||||||
text: const TextSpan(
|
child: Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
TextSpan(
|
GestureDetector(
|
||||||
text: '已阅读并同意',
|
onTap: onTap,
|
||||||
style: TextStyle(fontSize: 12, color: AppColors.textHint),
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: const Text('已阅读并同意', style: textStyle),
|
||||||
),
|
),
|
||||||
TextSpan(
|
GestureDetector(
|
||||||
text: '《服务协议》',
|
onTap: onTermsTap,
|
||||||
style: TextStyle(
|
behavior: HitTestBehavior.opaque,
|
||||||
fontSize: 12,
|
child: const AppGradientText('《服务协议》', style: linkStyle),
|
||||||
color: AppColors.primary,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextSpan(
|
|
||||||
text: '和',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppColors.textHint),
|
|
||||||
),
|
|
||||||
TextSpan(
|
|
||||||
text: '《隐私政策》',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppColors.primary,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
),
|
||||||
|
const Text('和', style: textStyle),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onPrivacyTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: const AppGradientText('《隐私政策》', style: linkStyle),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -909,7 +914,7 @@ class _NoticeBanner extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: success ? AppColors.successLight : AppColors.errorLight,
|
color: success ? AppColors.successLight : AppColors.errorLight,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: AppRadius.mdBorder,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: success
|
color: success
|
||||||
? AppColors.success.withValues(alpha: 0.16)
|
? AppColors.success.withValues(alpha: 0.16)
|
||||||
@@ -959,8 +964,8 @@ class _PrimaryButton extends StatelessWidget {
|
|||||||
height: 52,
|
height: 52,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.doctorGradient,
|
gradient: _LoginPageState._loginActionGradient,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: AppRadius.mdBorder,
|
||||||
boxShadow: AppColors.buttonShadow,
|
boxShadow: AppColors.buttonShadow,
|
||||||
),
|
),
|
||||||
child: loading
|
child: loading
|
||||||
@@ -977,7 +982,7 @@ class _PrimaryButton extends StatelessWidget {
|
|||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
41
health_app/lib/pages/care_plan_ui_logic.dart
Normal file
41
health_app/lib/pages/care_plan_ui_logic.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
enum CarePlanPhase { active, upcoming, ended }
|
||||||
|
|
||||||
|
CarePlanPhase resolveCarePlanPhase({
|
||||||
|
bool enabled = true,
|
||||||
|
String? startDate,
|
||||||
|
String? endDate,
|
||||||
|
DateTime? today,
|
||||||
|
}) {
|
||||||
|
if (!enabled) return CarePlanPhase.ended;
|
||||||
|
|
||||||
|
final current = _dateOnly(today ?? DateTime.now());
|
||||||
|
final start = DateTime.tryParse(startDate ?? '');
|
||||||
|
final end = DateTime.tryParse(endDate ?? '');
|
||||||
|
if (start != null && _dateOnly(start).isAfter(current)) {
|
||||||
|
return CarePlanPhase.upcoming;
|
||||||
|
}
|
||||||
|
if (end != null && _dateOnly(end).isBefore(current)) {
|
||||||
|
return CarePlanPhase.ended;
|
||||||
|
}
|
||||||
|
return CarePlanPhase.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? validateMedicationForm({
|
||||||
|
required String name,
|
||||||
|
required String dosage,
|
||||||
|
required List<String> times,
|
||||||
|
required DateTime startDate,
|
||||||
|
DateTime? endDate,
|
||||||
|
}) {
|
||||||
|
if (name.trim().isEmpty) return '请输入药品名称';
|
||||||
|
if (dosage.trim().isEmpty) return '请输入服药剂量';
|
||||||
|
if (times.isEmpty) return '请至少设置一个服药时间';
|
||||||
|
if (times.toSet().length != times.length) return '服药时间不能重复';
|
||||||
|
if (endDate != null && _dateOnly(endDate).isBefore(_dateOnly(startDate))) {
|
||||||
|
return '结束日期不能早于开始日期';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _dateOnly(DateTime value) =>
|
||||||
|
DateTime(value.year, value.month, value.day);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -63,7 +63,7 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
|||||||
|
|
||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.transparent,
|
||||||
title: Column(
|
title: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ 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/api_client.dart';
|
||||||
import '../../core/app_module_visuals.dart';
|
import '../../core/app_module_visuals.dart';
|
||||||
|
import '../../core/app_design_tokens.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/ble_device.dart';
|
||||||
@@ -17,6 +19,8 @@ import '../../providers/omron_device_provider.dart';
|
|||||||
import '../../services/health_ble_service.dart';
|
import '../../services/health_ble_service.dart';
|
||||||
import '../../widgets/ble_sync_dialog.dart';
|
import '../../widgets/ble_sync_dialog.dart';
|
||||||
import '../../widgets/app_toast.dart';
|
import '../../widgets/app_toast.dart';
|
||||||
|
import '../../widgets/app_empty_state.dart';
|
||||||
|
import 'device_sync_ui_logic.dart';
|
||||||
|
|
||||||
const _deviceVisual = AppModuleVisuals.device;
|
const _deviceVisual = AppModuleVisuals.device;
|
||||||
|
|
||||||
@@ -45,6 +49,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
bool _scanning = false;
|
bool _scanning = false;
|
||||||
bool _permissionBlocked = false;
|
bool _permissionBlocked = false;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
String? _pageMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -58,15 +63,18 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
begin: 0.85,
|
begin: 0.85,
|
||||||
end: 1.35,
|
end: 1.35,
|
||||||
).animate(CurvedAnimation(parent: _scanCtrl, curve: Curves.easeInOut));
|
).animate(CurvedAnimation(parent: _scanCtrl, curve: Curves.easeInOut));
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) unawaited(_startForegroundScan());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_scanSub?.cancel();
|
_scanSub?.cancel();
|
||||||
_scanCtrl.dispose();
|
|
||||||
unawaited(FlutterBluePlus.stopScan());
|
unawaited(FlutterBluePlus.stopScan());
|
||||||
unawaited(_bleService.disconnect());
|
unawaited(_bleService.disconnect());
|
||||||
|
_scanCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +102,19 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
if (adapterState != BluetoothAdapterState.on) {
|
if (adapterState != BluetoothAdapterState.on) {
|
||||||
try {
|
try {
|
||||||
await FlutterBluePlus.turnOn();
|
await FlutterBluePlus.turnOn();
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _pageMessage = '蓝牙未开启,无法查找已绑定设备');
|
||||||
|
AppToast.show(
|
||||||
|
context,
|
||||||
|
deviceSyncErrorMessage(
|
||||||
|
const DeviceSyncFailure(DeviceSyncFailureStage.bluetooth),
|
||||||
|
),
|
||||||
|
type: AppToastType.warning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -104,6 +124,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
final boundIds = ref
|
final boundIds = ref
|
||||||
.read(omronDeviceProvider)
|
.read(omronDeviceProvider)
|
||||||
.devices
|
.devices
|
||||||
|
.where((device) => HealthBleService.isSyncImplemented(device.type))
|
||||||
.map((device) => device.id)
|
.map((device) => device.id)
|
||||||
.toList();
|
.toList();
|
||||||
if (boundIds.isEmpty) {
|
if (boundIds.isEmpty) {
|
||||||
@@ -121,23 +142,44 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
androidScanMode: AndroidScanMode.lowLatency,
|
androidScanMode: AndroidScanMode.lowLatency,
|
||||||
oneByOne: true,
|
oneByOne: true,
|
||||||
);
|
);
|
||||||
if (mounted) setState(() => _scanning = true);
|
if (mounted) {
|
||||||
} catch (e) {
|
setState(() {
|
||||||
if (mounted) setState(() => _scanning = false);
|
_scanning = true;
|
||||||
|
_pageMessage = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_scanning = false;
|
||||||
|
_pageMessage = '设备查找失败,请检查蓝牙状态后重试';
|
||||||
|
});
|
||||||
|
AppToast.show(context, _pageMessage!, type: AppToastType.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _ensureBlePermissions() async {
|
Future<bool> _ensureBlePermissions() async {
|
||||||
final statuses = await [
|
Map<Permission, PermissionStatus> statuses;
|
||||||
|
try {
|
||||||
|
statuses = await [
|
||||||
Permission.bluetoothScan,
|
Permission.bluetoothScan,
|
||||||
Permission.bluetoothConnect,
|
Permission.bluetoothConnect,
|
||||||
].request();
|
].request();
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _pageMessage = '无法读取蓝牙权限状态');
|
||||||
|
AppToast.show(context, _pageMessage!, type: AppToastType.error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
final granted = statuses.values.every((status) => status.isGranted);
|
final granted = statuses.values.every((status) => status.isGranted);
|
||||||
if (granted) {
|
if (granted) {
|
||||||
_permissionBlocked = false;
|
_permissionBlocked = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
_permissionBlocked = true;
|
_permissionBlocked = true;
|
||||||
|
if (mounted) setState(() => _pageMessage = '需要蓝牙权限才能同步设备');
|
||||||
if (_disposed || !mounted) return false;
|
if (_disposed || !mounted) return false;
|
||||||
AppToast.show(context, '请开启蓝牙权限后再同步设备', type: AppToastType.warning);
|
AppToast.show(context, '请开启蓝牙权限后再同步设备', type: AppToastType.warning);
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
@@ -224,6 +266,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_busyDeviceId = bound.id;
|
_busyDeviceId = bound.id;
|
||||||
_connectedDeviceId = null;
|
_connectedDeviceId = null;
|
||||||
|
_pageMessage = '正在连接 ${bound.name}';
|
||||||
});
|
});
|
||||||
|
|
||||||
await FlutterBluePlus.stopScan();
|
await FlutterBluePlus.stopScan();
|
||||||
@@ -237,7 +280,10 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
timeout: const Duration(seconds: 35),
|
timeout: const Duration(seconds: 35),
|
||||||
onConnected: () {
|
onConnected: () {
|
||||||
if (!_disposed && mounted) {
|
if (!_disposed && mounted) {
|
||||||
setState(() => _connectedDeviceId = bound.id);
|
setState(() {
|
||||||
|
_connectedDeviceId = bound.id;
|
||||||
|
_pageMessage = '已连接,等待设备测量数据';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMeasurementReceived: () {
|
onMeasurementReceived: () {
|
||||||
@@ -246,21 +292,48 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
);
|
);
|
||||||
if (syncResult is BloodPressureSyncResult) {
|
if (syncResult is BloodPressureSyncResult) {
|
||||||
if (_disposed || !mounted) return;
|
if (_disposed || !mounted) return;
|
||||||
|
setState(() => _pageMessage = '已读取数据,正在安全上传');
|
||||||
final saved = await _persistBloodPressure(
|
final saved = await _persistBloodPressure(
|
||||||
syncResult.device,
|
syncResult.device,
|
||||||
syncResult.reading,
|
syncResult.reading,
|
||||||
);
|
);
|
||||||
if (!_disposed && mounted && saved) {
|
if (!_disposed && mounted && saved) {
|
||||||
|
setState(() => _pageMessage = '同步完成');
|
||||||
await _showBloodPressureDialog(syncResult.reading);
|
await _showBloodPressureDialog(syncResult.reading);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
if (!_disposed && mounted && _connectedDeviceId == bound.id) {
|
|
||||||
AppToast.show(context, '已连接设备,但本次未收到测量数据', type: AppToastType.warning);
|
|
||||||
}
|
|
||||||
} on Exception {
|
|
||||||
if (!_disposed && mounted) {
|
if (!_disposed && mounted) {
|
||||||
AppToast.show(context, '数据录入失败,请稍后重试', type: AppToastType.error);
|
final message = _connectedDeviceId == bound.id
|
||||||
|
? deviceSyncErrorMessage(
|
||||||
|
const DeviceSyncFailure(DeviceSyncFailureStage.measurement),
|
||||||
|
)
|
||||||
|
: deviceSyncErrorMessage(
|
||||||
|
const DeviceSyncFailure(DeviceSyncFailureStage.connection),
|
||||||
|
);
|
||||||
|
setState(() => _pageMessage = message);
|
||||||
|
AppToast.show(context, message, type: AppToastType.warning);
|
||||||
|
}
|
||||||
|
} on UnsupportedBleDeviceException catch (error) {
|
||||||
|
if (!_disposed && mounted) {
|
||||||
|
setState(() => _pageMessage = error.message);
|
||||||
|
AppToast.show(context, error.message, type: AppToastType.warning);
|
||||||
|
}
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (!_disposed && mounted) {
|
||||||
|
final message = deviceSyncErrorMessage(
|
||||||
|
DeviceSyncFailure(DeviceSyncFailureStage.upload, error.message),
|
||||||
|
);
|
||||||
|
setState(() => _pageMessage = message);
|
||||||
|
AppToast.show(context, message, type: AppToastType.error);
|
||||||
|
}
|
||||||
|
} on Exception catch (error) {
|
||||||
|
if (!_disposed && mounted) {
|
||||||
|
final message = deviceSyncErrorMessage(
|
||||||
|
DeviceSyncFailure(DeviceSyncFailureStage.connection, '$error'),
|
||||||
|
);
|
||||||
|
setState(() => _pageMessage = message);
|
||||||
|
AppToast.show(context, message, type: AppToastType.error);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await _bleService.disconnect();
|
await _bleService.disconnect();
|
||||||
@@ -282,11 +355,12 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
if (await notifier.isDuplicateReading(device, reading)) return false;
|
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());
|
final records = <Map<String, dynamic>>[reading.toHealthRecord()];
|
||||||
final heartRateRecord = reading.toHeartRateRecord();
|
final heartRateRecord = reading.toHeartRateRecord();
|
||||||
if (heartRateRecord != null) {
|
if (heartRateRecord != null) {
|
||||||
await api.post('/api/health-records', data: heartRateRecord);
|
records.add(heartRateRecord);
|
||||||
}
|
}
|
||||||
|
await api.post('/api/health-records/batch', data: records);
|
||||||
await notifier.recordSuccessfulSync(device: device, reading: reading);
|
await notifier.recordSuccessfulSync(device: device, reading: reading);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -299,10 +373,14 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(omronDeviceProvider);
|
final state = ref.watch(omronDeviceProvider);
|
||||||
final devices = state.devices;
|
final devices = state.devices;
|
||||||
final connected = _connectedDeviceId != null;
|
final syncableDevices = devices
|
||||||
final boundKey = _boundIdsKey(devices.map((device) => device.id).toList());
|
.where((device) => HealthBleService.isSyncImplemented(device.type))
|
||||||
|
.toList();
|
||||||
|
final boundKey = _boundIdsKey(
|
||||||
|
syncableDevices.map((device) => device.id).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
if (devices.isNotEmpty &&
|
if (syncableDevices.isNotEmpty &&
|
||||||
!_scanning &&
|
!_scanning &&
|
||||||
_busyDeviceId == null &&
|
_busyDeviceId == null &&
|
||||||
!_permissionBlocked &&
|
!_permissionBlocked &&
|
||||||
@@ -313,7 +391,10 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
ref.listen<DeviceBindState>(omronDeviceProvider, (prev, next) {
|
ref.listen<DeviceBindState>(omronDeviceProvider, (prev, next) {
|
||||||
final nextIds = next.devices.map((device) => device.id).toList();
|
final nextIds = next.devices
|
||||||
|
.where((device) => HealthBleService.isSyncImplemented(device.type))
|
||||||
|
.map((device) => device.id)
|
||||||
|
.toList();
|
||||||
final nextKey = _boundIdsKey(nextIds);
|
final nextKey = _boundIdsKey(nextIds);
|
||||||
if (nextIds.isEmpty) {
|
if (nextIds.isEmpty) {
|
||||||
_activeScanBoundKey = '';
|
_activeScanBoundKey = '';
|
||||||
@@ -343,9 +424,11 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
tooltip: '新增设备',
|
tooltip: '新增设备',
|
||||||
onPressed: () => pushRoute(ref, 'deviceScan'),
|
onPressed: () => pushRoute(ref, 'deviceScan'),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: _deviceVisual.lightColor,
|
backgroundColor: Colors.white,
|
||||||
foregroundColor: _deviceVisual.color,
|
foregroundColor: AppColors.primaryDark,
|
||||||
fixedSize: const Size(40, 40),
|
fixedSize: const Size(40, 40),
|
||||||
|
side: const BorderSide(color: AppColors.primaryLight),
|
||||||
|
shape: const CircleBorder(),
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.add_rounded),
|
icon: const Icon(Icons.add_rounded),
|
||||||
),
|
),
|
||||||
@@ -358,6 +441,20 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
padding: const EdgeInsets.fromLTRB(18, 10, 18, 120),
|
padding: const EdgeInsets.fromLTRB(18, 10, 18, 120),
|
||||||
children: [
|
children: [
|
||||||
_DevicesHeader(deviceCount: devices.length),
|
_DevicesHeader(deviceCount: devices.length),
|
||||||
|
if (devices.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_SyncStatusBar(
|
||||||
|
message:
|
||||||
|
_pageMessage ??
|
||||||
|
(syncableDevices.isEmpty
|
||||||
|
? '当前设备已绑定,暂不支持自动同步'
|
||||||
|
: _scanning
|
||||||
|
? '正在等待已绑定设备的测量数据'
|
||||||
|
: '设备同步已暂停'),
|
||||||
|
active: _scanning || _busyDeviceId != null,
|
||||||
|
onRetry: _busyDeviceId == null ? _startForegroundScan : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
if (devices.isEmpty)
|
if (devices.isEmpty)
|
||||||
const _EmptyDevices()
|
const _EmptyDevices()
|
||||||
@@ -382,7 +479,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
child: _ScanIndicator(
|
child: _ScanIndicator(
|
||||||
animation: _scanAnim,
|
animation: _scanAnim,
|
||||||
scanning: _scanning,
|
scanning: _scanning,
|
||||||
syncing: connected,
|
syncing: _connectedDeviceId != null || _busyDeviceId != null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -394,7 +491,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
final ok = await showDialog<bool>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(borderRadius: AppRadius.xlBorder),
|
||||||
title: const Text('解绑设备'),
|
title: const Text('解绑设备'),
|
||||||
content: Text('确定解绑这台${device.type.label}吗?'),
|
content: Text('确定解绑这台${device.type.label}吗?'),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -404,7 +501,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
style: TextButton.styleFrom(foregroundColor: AppColors.error),
|
style: TextButton.styleFrom(foregroundColor: AppColors.errorText),
|
||||||
child: const Text('解绑'),
|
child: const Text('解绑'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -416,309 +513,6 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DevicesHeader extends StatelessWidget {
|
|
||||||
final int deviceCount;
|
|
||||||
|
|
||||||
const _DevicesHeader({required this.deviceCount});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(color: AppColors.borderLight, width: 1.1),
|
|
||||||
boxShadow: [AppTheme.shadowLight],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: _deviceVisual.gradient,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.bluetooth_audio_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 27,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'已绑定设备',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 19,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'$deviceCount 台设备',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DeviceSection extends StatelessWidget {
|
|
||||||
final BleDeviceType type;
|
|
||||||
final List<BoundBleDevice> devices;
|
|
||||||
final String? connectedDeviceId;
|
|
||||||
final String? busyDeviceId;
|
|
||||||
final ValueChanged<BoundBleDevice> onUnbind;
|
|
||||||
|
|
||||||
const _DeviceSection({
|
|
||||||
required this.type,
|
|
||||||
required this.devices,
|
|
||||||
required this.connectedDeviceId,
|
|
||||||
required this.busyDeviceId,
|
|
||||||
required this.onUnbind,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 34,
|
|
||||||
height: 34,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _deviceVisual.lightColor,
|
|
||||||
borderRadius: BorderRadius.circular(11),
|
|
||||||
border: Border.all(color: AppColors.borderLight),
|
|
||||||
),
|
|
||||||
child: Icon(type.icon, size: 19, color: _deviceVisual.color),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
type.label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'${devices.length}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: AppColors.textHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (final device in devices)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
|
||||||
child: _BoundDeviceTile(
|
|
||||||
device: device,
|
|
||||||
connected: connectedDeviceId == device.id,
|
|
||||||
onUnbind: () => onUnbind(device),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BoundDeviceTile extends StatelessWidget {
|
|
||||||
final BoundBleDevice device;
|
|
||||||
final bool connected;
|
|
||||||
final VoidCallback onUnbind;
|
|
||||||
|
|
||||||
const _BoundDeviceTile({
|
|
||||||
required this.device,
|
|
||||||
required this.connected,
|
|
||||||
required this.onUnbind,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final accent = connected ? AppColors.success : AppColors.textHint;
|
|
||||||
return AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 180),
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 15, 10, 15),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
border: Border.all(
|
|
||||||
color: connected ? AppColors.success : AppColors.border,
|
|
||||||
width: connected ? 1.4 : 1.1,
|
|
||||||
),
|
|
||||||
boxShadow: connected
|
|
||||||
? [
|
|
||||||
BoxShadow(
|
|
||||||
color: AppColors.success.withValues(alpha: 0.14),
|
|
||||||
blurRadius: 18,
|
|
||||||
offset: const Offset(0, 8),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: AppColors.cardShadowLight,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 180),
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: connected
|
|
||||||
? AppColors.successLight
|
|
||||||
: const Color(0xFFF8FAFC),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: AppColors.borderLight),
|
|
||||||
),
|
|
||||||
child: Icon(device.type.icon, color: accent, size: 25),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
device.name,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'最近同步:${_formatLastSync(device.lastSyncAt)}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.textHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (connected)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.only(right: 4),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.successLight,
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'已连接',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
height: 1,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: AppColors.success,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: '解绑设备',
|
|
||||||
onPressed: connected ? null : onUnbind,
|
|
||||||
icon: const Icon(Icons.delete_outline_rounded),
|
|
||||||
color: connected ? AppColors.textHint : AppColors.error,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatLastSync(DateTime? value) {
|
|
||||||
if (value == null) return '暂无';
|
|
||||||
final now = DateTime.now();
|
|
||||||
final dayLabel =
|
|
||||||
value.year == now.year &&
|
|
||||||
value.month == now.month &&
|
|
||||||
value.day == now.day
|
|
||||||
? '今天'
|
|
||||||
: '${value.month}/${value.day}';
|
|
||||||
final minute = value.minute.toString().padLeft(2, '0');
|
|
||||||
return '$dayLabel ${value.hour}:$minute';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EmptyDevices extends StatelessWidget {
|
|
||||||
const _EmptyDevices();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.fromLTRB(24, 46, 24, 46),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(22),
|
|
||||||
border: Border.all(color: AppColors.borderLight, width: 1.1),
|
|
||||||
boxShadow: [AppTheme.shadowLight],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 72,
|
|
||||||
height: 72,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFEFF6FF),
|
|
||||||
borderRadius: BorderRadius.circular(22),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.bluetooth_disabled_rounded,
|
|
||||||
color: Color(0xFF2563EB),
|
|
||||||
size: 36,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
const Text(
|
|
||||||
'还没有绑定设备',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 19,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Text(
|
|
||||||
'右上角添加设备',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ScanIndicator extends StatelessWidget {
|
class _ScanIndicator extends StatelessWidget {
|
||||||
final Animation<double> animation;
|
final Animation<double> animation;
|
||||||
final bool scanning;
|
final bool scanning;
|
||||||
@@ -747,7 +541,7 @@ class _ScanIndicator extends StatelessWidget {
|
|||||||
height: 78 * animation.value,
|
height: 78 * animation.value,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: const Color(0xFF2563EB).withValues(alpha: 0.10),
|
color: AppColors.device.withValues(alpha: 0.10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -758,7 +552,7 @@ class _ScanIndicator extends StatelessWidget {
|
|||||||
height: 64 * animation.value,
|
height: 64 * animation.value,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: const Color(0xFF2563EB).withValues(alpha: 0.18),
|
color: AppColors.device.withValues(alpha: 0.18),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -767,13 +561,13 @@ class _ScanIndicator extends StatelessWidget {
|
|||||||
width: 58,
|
width: 58,
|
||||||
height: 58,
|
height: 58,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF2563EB),
|
color: AppColors.device,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF2563EB).withValues(alpha: 0.24),
|
color: AppColors.device.withValues(alpha: 0.22),
|
||||||
blurRadius: 18,
|
blurRadius: 16,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 7),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -790,3 +584,339 @@ class _ScanIndicator extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DevicesHeader extends StatelessWidget {
|
||||||
|
final int deviceCount;
|
||||||
|
|
||||||
|
const _DevicesHeader({required this.deviceCount});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _deviceVisual.lightColor,
|
||||||
|
borderRadius: AppRadius.smBorder,
|
||||||
|
),
|
||||||
|
child: Icon(_deviceVisual.icon, color: AppColors.device, size: 27),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'已绑定设备',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$deviceCount 台设备',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeviceSection extends StatelessWidget {
|
||||||
|
final BleDeviceType type;
|
||||||
|
final List<BoundBleDevice> devices;
|
||||||
|
final String? connectedDeviceId;
|
||||||
|
final String? busyDeviceId;
|
||||||
|
final ValueChanged<BoundBleDevice> onUnbind;
|
||||||
|
|
||||||
|
const _DeviceSection({
|
||||||
|
required this.type,
|
||||||
|
required this.devices,
|
||||||
|
required this.connectedDeviceId,
|
||||||
|
required this.busyDeviceId,
|
||||||
|
required this.onUnbind,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Colors.white,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 13, 14, 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _deviceVisual.lightColor,
|
||||||
|
borderRadius: AppRadius.smBorder,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
type.icon,
|
||||||
|
size: 19,
|
||||||
|
color: _deviceVisual.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
type.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${devices.length} 台',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (var index = 0; index < devices.length; index++)
|
||||||
|
_BoundDeviceTile(
|
||||||
|
device: devices[index],
|
||||||
|
connected: connectedDeviceId == devices[index].id,
|
||||||
|
busy: busyDeviceId == devices[index].id,
|
||||||
|
showDivider: index < devices.length - 1,
|
||||||
|
onUnbind: () => onUnbind(devices[index]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BoundDeviceTile extends StatelessWidget {
|
||||||
|
final BoundBleDevice device;
|
||||||
|
final bool connected;
|
||||||
|
final bool busy;
|
||||||
|
final bool showDivider;
|
||||||
|
final VoidCallback onUnbind;
|
||||||
|
|
||||||
|
const _BoundDeviceTile({
|
||||||
|
required this.device,
|
||||||
|
required this.connected,
|
||||||
|
required this.busy,
|
||||||
|
required this.showDivider,
|
||||||
|
required this.onUnbind,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final accent = connected ? AppColors.successText : AppColors.textHint;
|
||||||
|
final availability = deviceSyncAvailabilityLabel(device.type);
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
padding: const EdgeInsets.only(left: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: connected
|
||||||
|
? AppColors.successLight.withValues(alpha: 0.45)
|
||||||
|
: Colors.white,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: connected ? AppColors.successLight : AppColors.deviceLight,
|
||||||
|
borderRadius: AppRadius.smBorder,
|
||||||
|
),
|
||||||
|
child: Icon(device.type.icon, color: accent, size: 22),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(minHeight: 70),
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 12, 6, 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: showDivider
|
||||||
|
? const Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: AppColors.divider,
|
||||||
|
width: 0.7,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
device.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
availability ??
|
||||||
|
(busy
|
||||||
|
? connected
|
||||||
|
? '已连接,正在读取数据'
|
||||||
|
: '正在连接设备'
|
||||||
|
: '最近同步:${_formatLastSync(device.lastSyncAt)}'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: availability == null
|
||||||
|
? AppColors.textHint
|
||||||
|
: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (connected)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(right: 2),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 9,
|
||||||
|
vertical: 5,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.successLight,
|
||||||
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'已连接',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.successText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '解绑设备',
|
||||||
|
onPressed: busy ? null : onUnbind,
|
||||||
|
icon: const Icon(Icons.delete_outline_rounded, size: 21),
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatLastSync(DateTime? value) {
|
||||||
|
if (value == null) return '暂无';
|
||||||
|
final now = DateTime.now();
|
||||||
|
final dayLabel =
|
||||||
|
value.year == now.year &&
|
||||||
|
value.month == now.month &&
|
||||||
|
value.day == now.day
|
||||||
|
? '今天'
|
||||||
|
: '${value.month}-${value.day}';
|
||||||
|
final minute = value.minute.toString().padLeft(2, '0');
|
||||||
|
return '$dayLabel ${value.hour}:$minute';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyDevices extends StatelessWidget {
|
||||||
|
const _EmptyDevices();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
),
|
||||||
|
child: AppEmptyState(
|
||||||
|
icon: _deviceVisual.icon,
|
||||||
|
iconColor: _deviceVisual.color,
|
||||||
|
title: '还没有绑定设备',
|
||||||
|
subtitle: '点击右上角添加设备',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SyncStatusBar extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
final bool active;
|
||||||
|
final Future<void> Function()? onRetry;
|
||||||
|
|
||||||
|
const _SyncStatusBar({
|
||||||
|
required this.message,
|
||||||
|
required this.active,
|
||||||
|
required this.onRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 11, 8, 11),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? AppColors.deviceLight : Colors.white,
|
||||||
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
border: Border.all(color: AppColors.borderLight),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
active ? Icons.sync_rounded : Icons.info_outline_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: active ? AppColors.device : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!active && onRetry != null)
|
||||||
|
TextButton(onPressed: onRetry, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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_design_tokens.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/ble_device.dart';
|
||||||
@@ -290,8 +291,6 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white.withValues(alpha: 0.92),
|
|
||||||
elevation: 0,
|
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'新增设备',
|
'新增设备',
|
||||||
style: TextStyle(color: AppColors.textPrimary),
|
style: TextStyle(color: AppColors.textPrimary),
|
||||||
@@ -303,19 +302,30 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
),
|
),
|
||||||
body: _results.isEmpty
|
body: _results.isEmpty
|
||||||
? _ScanningEmpty(animation: _pulseAnim, scanning: _scanning)
|
? _ScanningEmpty(animation: _pulseAnim, scanning: _scanning)
|
||||||
: ListView.separated(
|
: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
|
||||||
itemCount: _results.length,
|
children: [
|
||||||
separatorBuilder: (context, index) => const SizedBox(height: 10),
|
ClipRRect(
|
||||||
itemBuilder: (context, index) {
|
borderRadius: AppRadius.lgBorder,
|
||||||
final result = _results[index];
|
child: ColoredBox(
|
||||||
return _DeviceResultTile(
|
color: Colors.white,
|
||||||
result: result,
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (var index = 0; index < _results.length; index++)
|
||||||
|
_DeviceResultTile(
|
||||||
|
result: _results[index],
|
||||||
connecting:
|
connecting:
|
||||||
_connectingId == result.device.remoteId.toString(),
|
_connectingId ==
|
||||||
onConnect: () => _connectBindAndSync(result),
|
_results[index].device.remoteId.toString(),
|
||||||
);
|
showDivider: index < _results.length - 1,
|
||||||
},
|
onConnect: () =>
|
||||||
|
_connectBindAndSync(_results[index]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -389,7 +399,7 @@ class _ScanningEmpty extends StatelessWidget {
|
|||||||
scanning ? '正在搜索设备' : '暂未发现设备',
|
scanning ? '正在搜索设备' : '暂未发现设备',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -413,11 +423,13 @@ class _ScanningEmpty extends StatelessWidget {
|
|||||||
class _DeviceResultTile extends StatelessWidget {
|
class _DeviceResultTile extends StatelessWidget {
|
||||||
final ScanResult result;
|
final ScanResult result;
|
||||||
final bool connecting;
|
final bool connecting;
|
||||||
|
final bool showDivider;
|
||||||
final VoidCallback onConnect;
|
final VoidCallback onConnect;
|
||||||
|
|
||||||
const _DeviceResultTile({
|
const _DeviceResultTile({
|
||||||
required this.result,
|
required this.result,
|
||||||
required this.connecting,
|
required this.connecting,
|
||||||
|
required this.showDivider,
|
||||||
required this.onConnect,
|
required this.onConnect,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -425,33 +437,40 @@ class _DeviceResultTile extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final type = HealthBleService.deviceTypeFromScan(result);
|
final type = HealthBleService.deviceTypeFromScan(result);
|
||||||
final name = _deviceNameOf(result, type);
|
final name = _deviceNameOf(result, type);
|
||||||
return Container(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(15),
|
padding: const EdgeInsets.only(left: 14),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: AppColors.border, width: 1.1),
|
|
||||||
boxShadow: [AppTheme.shadowLight],
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 48,
|
width: 42,
|
||||||
height: 48,
|
height: 42,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primary.withValues(alpha: 0.10),
|
color: AppColors.deviceLight,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: AppRadius.smBorder,
|
||||||
border: Border.all(
|
|
||||||
color: AppColors.primary.withValues(alpha: 0.10),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
type?.icon ?? Icons.bluetooth_rounded,
|
type?.icon ?? Icons.bluetooth_rounded,
|
||||||
color: AppColors.primary,
|
color: AppColors.device,
|
||||||
size: 25,
|
size: 25,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(minHeight: 70),
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 12, 12, 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: showDivider
|
||||||
|
? const Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: AppColors.divider,
|
||||||
|
width: 0.7,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -459,8 +478,8 @@ class _DeviceResultTile extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
type?.label ?? '健康设备',
|
type?.label ?? '健康设备',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -470,7 +489,7 @@ class _DeviceResultTile extends StatelessWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
color: AppColors.textHint,
|
color: AppColors.textHint,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -479,17 +498,25 @@ class _DeviceResultTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 38,
|
height: 36,
|
||||||
child: FilledButton(
|
child: OutlinedButton(
|
||||||
onPressed: connecting ? null : onConnect,
|
onPressed: connecting ? null : onConnect,
|
||||||
style: FilledButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: AppColors.device,
|
||||||
|
disabledForegroundColor: AppColors.textHint,
|
||||||
|
side: BorderSide(
|
||||||
|
color: connecting
|
||||||
|
? AppColors.borderLight
|
||||||
|
: AppColors.device,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 13),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: AppRadius.smBorder,
|
||||||
),
|
),
|
||||||
textStyle: const TextStyle(
|
textStyle: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(connecting ? '连接中' : '连接'),
|
child: Text(connecting ? '连接中' : '连接'),
|
||||||
@@ -497,6 +524,10 @@ class _DeviceResultTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +557,7 @@ class _ScanPulse extends StatelessWidget {
|
|||||||
height: 78 * animation.value,
|
height: 78 * animation.value,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: AppColors.primary.withValues(alpha: 0.08),
|
color: AppColors.device.withValues(alpha: 0.08),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -535,13 +566,13 @@ class _ScanPulse extends StatelessWidget {
|
|||||||
height: 64,
|
height: 64,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: AppRadius.xlBorder,
|
||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.bluetooth_searching_rounded,
|
Icons.bluetooth_searching_rounded,
|
||||||
size: 32,
|
size: 32,
|
||||||
color: AppColors.primary,
|
color: AppColors.device,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
33
health_app/lib/pages/device/device_sync_ui_logic.dart
Normal file
33
health_app/lib/pages/device/device_sync_ui_logic.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import '../../models/ble_device.dart';
|
||||||
|
|
||||||
|
enum DeviceSyncFailureStage {
|
||||||
|
permission,
|
||||||
|
bluetooth,
|
||||||
|
connection,
|
||||||
|
measurement,
|
||||||
|
upload,
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeviceSyncFailure implements Exception {
|
||||||
|
final DeviceSyncFailureStage stage;
|
||||||
|
final String? detail;
|
||||||
|
|
||||||
|
const DeviceSyncFailure(this.stage, [this.detail]);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? deviceSyncAvailabilityLabel(BleDeviceType type) {
|
||||||
|
return type == BleDeviceType.bloodPressure ? null : '暂不支持自动同步';
|
||||||
|
}
|
||||||
|
|
||||||
|
String deviceSyncErrorMessage(DeviceSyncFailure failure) {
|
||||||
|
final detail = failure.detail?.trim();
|
||||||
|
return switch (failure.stage) {
|
||||||
|
DeviceSyncFailureStage.permission => '请开启蓝牙权限后再同步设备',
|
||||||
|
DeviceSyncFailureStage.bluetooth => '蓝牙未开启,请开启后重试',
|
||||||
|
DeviceSyncFailureStage.connection =>
|
||||||
|
detail?.isNotEmpty == true ? '设备连接失败:$detail' : '设备连接失败,请靠近设备后重试',
|
||||||
|
DeviceSyncFailureStage.measurement => '已连接设备,但本次未收到测量数据',
|
||||||
|
DeviceSyncFailureStage.upload =>
|
||||||
|
detail?.isNotEmpty == true ? '数据上传失败:$detail' : '数据已读取,但上传失败,请检查网络后重试',
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
154
health_app/lib/pages/diet/diet_nutrition_widgets.dart
Normal file
154
health_app/lib/pages/diet/diet_nutrition_widgets.dart
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/app_colors.dart';
|
||||||
|
|
||||||
|
class DietPalette {
|
||||||
|
DietPalette._();
|
||||||
|
|
||||||
|
static const primary = Color(0xFF6476E8);
|
||||||
|
static const primarySoft = Color(0xFFEEF0FF);
|
||||||
|
static const protein = Color(0xFF38A77B);
|
||||||
|
static const carbs = Color(0xFF4E91D8);
|
||||||
|
static const fat = Color(0xFFE58A76);
|
||||||
|
static const calorie = Color(0xFF6D6FEA);
|
||||||
|
static const gradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF8FA7FF), primary],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DietCalorieRing extends StatelessWidget {
|
||||||
|
final int calories;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const DietCalorieRing({super.key, required this.calories, this.size = 116});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final progress = (calories / 800).clamp(0.04, 1.0);
|
||||||
|
return Semantics(
|
||||||
|
label: '总热量 $calories 千卡',
|
||||||
|
child: SizedBox.square(
|
||||||
|
dimension: size,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _CalorieRingPainter(progress),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$calories',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'千卡',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DietMacroBar extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final int value;
|
||||||
|
final Color color;
|
||||||
|
final double reference;
|
||||||
|
|
||||||
|
const DietMacroBar({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.color,
|
||||||
|
required this.reference,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final progress = (value / reference).clamp(0.0, 1.0);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'$value g',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
minHeight: 5,
|
||||||
|
value: progress,
|
||||||
|
backgroundColor: color.withValues(alpha: 0.12),
|
||||||
|
valueColor: AlwaysStoppedAnimation(color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CalorieRingPainter extends CustomPainter {
|
||||||
|
final double progress;
|
||||||
|
|
||||||
|
const _CalorieRingPainter(this.progress);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final center = size.center(Offset.zero);
|
||||||
|
final radius = size.shortestSide / 2 - 8;
|
||||||
|
final track = Paint()
|
||||||
|
..color = DietPalette.primarySoft
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 9
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
final progressPaint = Paint()
|
||||||
|
..shader = DietPalette.gradient.createShader(Offset.zero & size)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 9
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
|
||||||
|
canvas.drawCircle(center, radius, track);
|
||||||
|
canvas.drawArc(
|
||||||
|
Rect.fromCircle(center: center, radius: radius),
|
||||||
|
-math.pi / 2,
|
||||||
|
math.pi * 2 * progress,
|
||||||
|
false,
|
||||||
|
progressPaint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _CalorieRingPainter oldDelegate) =>
|
||||||
|
oldDelegate.progress != progress;
|
||||||
|
}
|
||||||
24
health_app/lib/pages/diet/diet_record_logic.dart
Normal file
24
health_app/lib/pages/diet/diet_record_logic.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
enum DietRecordSwipeAction { edit, delete }
|
||||||
|
|
||||||
|
DietRecordSwipeAction dietRecordSwipeAction(DismissDirection direction) =>
|
||||||
|
direction == DismissDirection.startToEnd
|
||||||
|
? DietRecordSwipeAction.edit
|
||||||
|
: DietRecordSwipeAction.delete;
|
||||||
|
|
||||||
|
List<DateTime> recentDietDates(DateTime now, {int count = 7}) {
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
return List.generate(
|
||||||
|
count,
|
||||||
|
(index) => today.subtract(Duration(days: count - 1 - index)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isSavableFood({
|
||||||
|
required String name,
|
||||||
|
required String portion,
|
||||||
|
required int calories,
|
||||||
|
}) {
|
||||||
|
return name.trim().isNotEmpty && portion.trim().isNotEmpty && calories > 0;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user