Compare commits
3 Commits
sccsbc
...
fade61ac21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 轮;发现危险信号时立即停止普通追问并优先给出就医提醒
|
||||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||||
- 回复语气温暖、专业
|
- 回复语气温和、清楚、专业,避免假装医生或作确定诊断
|
||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|||||||
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");
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,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);
|
||||||
|
|
||||||
// 待办列表
|
// 待办列表
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -142,6 +142,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>();
|
||||||
@@ -149,6 +150,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 =>
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Secondary Page Color Refresh Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Execute this plan inline in the current session. Do not create a branch or worktree.
|
||||||
|
|
||||||
|
**Goal:** Produce a restrained first-pass visual refresh for secondary pages while leaving the home page and all business behavior unchanged.
|
||||||
|
|
||||||
|
**Architecture:** Centralize a light neutral secondary-page canvas with a white AppBar in `GradientScaffold`, keep the existing home implementation untouched, and introduce one reusable outlined create FAB for the pages that currently expose add/upload actions. Fix swipe-delete rows at the shared widget layer so medication and exercise rows always paint an opaque white content surface over the red delete action.
|
||||||
|
|
||||||
|
**Tech Stack:** Flutter, Material 3, flutter_test.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Work on the existing `main` branch and preserve all unrelated dirty-worktree changes.
|
||||||
|
- Do not change backend APIs, providers, routing, or page business logic.
|
||||||
|
- Keep the current home page visual design unchanged.
|
||||||
|
- Secondary pages use a `#F7F8FA` canvas with white content surfaces; module colors remain content accents only.
|
||||||
|
- Reuse the existing `AppRadius` and `AppColors.actionOutlineGradient` tokens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Lock the shared secondary-page visual behavior
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `health_app/test/swipe_delete_tile_test.dart`
|
||||||
|
- Create: `health_app/test/secondary_page_visuals_test.dart`
|
||||||
|
- Modify: `health_app/lib/core/app_theme.dart`
|
||||||
|
- Modify: `health_app/lib/widgets/common_widgets.dart`
|
||||||
|
|
||||||
|
- [ ] Add failing widget tests for an opaque swipe-row surface, a white `GradientScaffold`, and the shared outlined create FAB.
|
||||||
|
- [ ] Run the targeted tests and confirm they fail for the missing visual behavior.
|
||||||
|
- [ ] Make `GradientScaffold` and the global AppBar surface white without changing the home page.
|
||||||
|
- [ ] Add the reusable white, purple-gradient-outline create FAB.
|
||||||
|
- [ ] Paint swipe-row content white so red appears only in the revealed action area.
|
||||||
|
- [ ] Run the targeted tests and confirm they pass.
|
||||||
|
|
||||||
|
### Task 2: Adopt the common create action on active user pages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `health_app/lib/pages/chart/trend_page.dart`
|
||||||
|
- Modify: `health_app/lib/pages/medication/medication_list_page.dart`
|
||||||
|
- Modify: `health_app/lib/pages/exercise/exercise_plan_page.dart`
|
||||||
|
- Modify: `health_app/lib/pages/report/report_pages.dart`
|
||||||
|
|
||||||
|
- [ ] Replace page-specific solid-color FAB styling with the shared create FAB.
|
||||||
|
- [ ] Preserve every existing callback, tooltip, route, and upload sheet.
|
||||||
|
- [ ] Format only the touched Dart files.
|
||||||
|
- [ ] Run the focused widget tests, the full Flutter test suite, and `flutter analyze`.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 通知设置改造设计
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让通知设置展示真实的服务端状态,保存失败时明确提示并恢复原值;将五个健康指标开关收纳到底部选择弹窗中,同时修复通知总开关未真正参与后端投递判断的问题。
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
主页面保持四个区域:通知总开关、通知类型、健康录入提醒、免打扰时段。
|
||||||
|
|
||||||
|
健康录入提醒区域只展示:
|
||||||
|
|
||||||
|
- “每日健康录入提醒”总开关。
|
||||||
|
- “提醒指标”入口,副标题展示当前已选指标摘要。
|
||||||
|
|
||||||
|
点击“提醒指标”后打开底部弹窗。弹窗使用复选框选择血压、心率、血糖、血氧和体重,至少保留一项,点击“保存”后一次性提交全部指标设置。
|
||||||
|
|
||||||
|
## 状态与保存
|
||||||
|
|
||||||
|
前端使用明确的通知偏好状态对象,不再直接使用无类型的 Map。状态区分首次加载、单项保存、指标批量保存和错误信息。
|
||||||
|
|
||||||
|
- 首次加载成功后才展示设置内容。
|
||||||
|
- 首次加载失败展示错误页和重试按钮,不使用默认值冒充服务端数据。
|
||||||
|
- 普通开关修改时只锁定当前操作,后端成功后采用响应中的完整偏好更新页面。
|
||||||
|
- 保存失败恢复原值并显示错误提示。
|
||||||
|
- 指标弹窗先维护本地草稿,点击保存时一次提交五个字段,避免连续请求和部分成功。
|
||||||
|
- 免打扰时间采用相同的真实保存与失败恢复规则。
|
||||||
|
|
||||||
|
## 后端行为
|
||||||
|
|
||||||
|
保留现有 `/api/notification-prefs` GET 和 PUT 接口。PUT 继续支持部分字段更新并返回保存后的完整偏好。
|
||||||
|
|
||||||
|
通知发送管线必须优先检查 `PushEnabled`。总开关关闭时,不生成或投递任何用户通知;健康录入、用药、随访、医生回复和异常提醒继续读取各自开关,免打扰规则继续生效。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
- 网络或服务错误显示后端可读信息,无法解析时使用统一提示。
|
||||||
|
- 保存过程中禁止同一设置重复提交。
|
||||||
|
- 指标未选择任何一项时,弹窗内提示至少选择一项,不发送请求。
|
||||||
|
- 后端返回失败时不显示成功状态。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- Flutter 单元测试覆盖偏好解析、指标摘要、至少选择一项和失败回滚。
|
||||||
|
- Flutter 组件测试覆盖加载失败、指标弹窗批量选择和保存中禁用。
|
||||||
|
- 后端测试覆盖 `PushEnabled=false` 时所有通知类型均不投递,以及各分类开关和免打扰继续生效。
|
||||||
|
- 运行 `flutter analyze`、Flutter 全量测试和相关 .NET 测试。
|
||||||
79
docs/ui-design-system.md
Normal file
79
docs/ui-design-system.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# 健康 App 前端 UI 设计系统
|
||||||
|
|
||||||
|
这份规范用于后续逐页精修。首页是品牌化例外,保持当前紫色背景和已有结构;以下规则主要约束普通二级页面。
|
||||||
|
|
||||||
|
## 1. 页面层级
|
||||||
|
|
||||||
|
- 标题栏:纯白背景,深色标题和返回图标。
|
||||||
|
- 页面画布:`#F7F8FA`,只负责建立内容边界,不作为卡片颜色。
|
||||||
|
- 主要内容组:纯白背景,区域间保留 12–16 px 的画布间距。
|
||||||
|
- 列表行:同一内容组内连续排列,用文字区域浅分隔线区分,不为每一行单独套卡片。
|
||||||
|
- 重点对象:报告、设备、AI 分析和关键概览可以使用独立白色卡片。
|
||||||
|
|
||||||
|
## 2. 颜色角色
|
||||||
|
|
||||||
|
- 品牌紫:通用选中、焦点、品牌按钮描边和少量重点操作。
|
||||||
|
- 模块色:只用于统一样式的图标、极浅图标底、图表曲线、小标签和状态识别。
|
||||||
|
- 中性色:页面画布、分隔线、正文层级和不可用状态。
|
||||||
|
- 语义色:绿色表示成功/正常,黄色表示提醒/等待,红色表示错误/危险,蓝色表示信息/进行中。
|
||||||
|
- 禁止模块色用于整页背景、大面积头部、大卡片底色和普通新增按钮。
|
||||||
|
|
||||||
|
## 3. 间距
|
||||||
|
|
||||||
|
- 页面左右边距:16 px。
|
||||||
|
- 页面顶部内容间距:12 px。
|
||||||
|
- 内容组间距:12–16 px。
|
||||||
|
- 列表图标与文字:12 px。
|
||||||
|
- 列表行垂直内边距:根据内容使用 12 或 16 px,不通过缩小字体制造紧凑感。
|
||||||
|
|
||||||
|
## 4. 圆角与阴影
|
||||||
|
|
||||||
|
- 所有圆角使用 `AppRadius`,不得在页面里随意新增数值。
|
||||||
|
- 连续内容组默认使用 16 px 圆角。
|
||||||
|
- 输入框和小型操作使用 10–14 px 圆角。
|
||||||
|
- 普通内容组默认无阴影、无描边。
|
||||||
|
- 浮动操作可以使用一层轻微中性阴影;同一组件不同时使用明显边框和重阴影。
|
||||||
|
|
||||||
|
## 5. 字体层级
|
||||||
|
|
||||||
|
- 页面标题:19–20 px,粗体,颜色 `textPrimary`。
|
||||||
|
- 区域标题:18 px,粗体。
|
||||||
|
- 列表主标题:16–17 px,中粗体。
|
||||||
|
- 正文和补充说明:13–15 px,保证对比度,不使用过浅小字。
|
||||||
|
- 数值、单位、份量和热量在同一行时保持基线、字号和字重协调。
|
||||||
|
- 选中状态不能改变文字大小、字重或位置。
|
||||||
|
|
||||||
|
## 6. 图标
|
||||||
|
|
||||||
|
- 全 App 优先使用同一套 Material 线性 `outlined/rounded` 图标,不混用大面积实心图标。
|
||||||
|
- 普通列表图标底座统一为 40×40 px、10 px 圆角,图标统一为 22 px。
|
||||||
|
- 不同模块可以改变图标和极浅底色,但尺寸、线条感觉、底座形状和对齐必须一致。
|
||||||
|
- 无底座的小型操作图标统一为 20–24 px,并保证至少 44 px 点击区域。
|
||||||
|
|
||||||
|
## 7. 按钮
|
||||||
|
|
||||||
|
- 通用新增:白底、紫色渐变细描边、深紫色线性加号;所有模块共用同一组件。
|
||||||
|
- 页面主要操作:优先使用白底紫色品牌描边;是否使用实心按钮由逐页任务重要性决定。
|
||||||
|
- 次要操作:白底或极浅灰底、深色文字。
|
||||||
|
- 危险操作:白底红字;只有确认删除阶段显示红色危险面。
|
||||||
|
- 新增、上传、绑定和记录属于通用操作,不跟随模块颜色。
|
||||||
|
|
||||||
|
## 8. 列表与滑动操作
|
||||||
|
|
||||||
|
- 列表行连续排列,分隔线从文字区域开始,不穿过左侧图标。
|
||||||
|
- 整行可点击,附属按钮不能缩小主点击区域。
|
||||||
|
- 删除背景默认完全隐藏,向左打开删除操作后才出现。
|
||||||
|
- 删除失败时保留真实列表数据,不提前移除前端项目。
|
||||||
|
|
||||||
|
## 9. 弹窗、表单和状态
|
||||||
|
|
||||||
|
- 弹窗、底部面板统一白色,不跟随模块色。
|
||||||
|
- 输入框焦点继续使用品牌紫,错误提示使用语义红。
|
||||||
|
- 空状态说明原因并提供一个明确下一步;加载、空、失败和未开放必须能被用户区分。
|
||||||
|
- 暂未启用的问诊和随访显示明确准备状态,不伪装成可用功能。
|
||||||
|
|
||||||
|
## 10. 逐页精修原则
|
||||||
|
|
||||||
|
- 每次只精修一个真实页面或一组完全同构页面。
|
||||||
|
- 先看真机截图,再确定该页的信息层级、内容组、操作和模块色用量。
|
||||||
|
- 不因建立设计系统而批量重排所有页面;公共组件只提供一致基础,页面布局仍按实际任务设计。
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
<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
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'local_database.dart';
|
|||||||
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
||||||
const String baseUrl = String.fromEnvironment(
|
const String baseUrl = String.fromEnvironment(
|
||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: 'http://10.4.221.78:5000',
|
defaultValue: '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(0xFF34D399);
|
||||||
static const Color healthLight = Color(0xFFEFF6FF);
|
static const Color healthLight = Color(0xFFF0FDF7);
|
||||||
static const Color healthBorder = Color(0xFFBFDBFE);
|
static const Color healthBorder = Color(0xFFC8F7E1);
|
||||||
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(0xFF4F7DD9);
|
||||||
static const Color exerciseLight = Color(0xFFECFDF5);
|
static const Color exerciseLight = Color(0xFFEFF4FF);
|
||||||
static const Color exerciseBorder = Color(0xFFA7F3D0);
|
static const Color exerciseBorder = Color(0xFFD5E2FF);
|
||||||
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(0xFFF7F8FA);
|
||||||
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(0xFF6EE7B7), Color(0xFF67E8F9)],
|
||||||
);
|
);
|
||||||
|
|
||||||
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(0xFFE0C3FC), Color(0xFF8EC5FC)],
|
||||||
);
|
);
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
@@ -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,22 +663,22 @@ 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.w800,
|
||||||
color: text,
|
color: text,
|
||||||
),
|
),
|
||||||
toolbarHeight: 52,
|
toolbarHeight: 52,
|
||||||
|
|||||||
@@ -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,9 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shadcn_ui/shadcn_ui.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});
|
||||||
@@ -13,6 +17,11 @@ 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_v1.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();
|
||||||
@@ -51,7 +60,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();
|
||||||
}
|
}
|
||||||
@@ -64,6 +74,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;
|
||||||
@@ -72,10 +94,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;
|
||||||
@@ -88,20 +106,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(
|
||||||
@@ -112,27 +116,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() {
|
||||||
@@ -146,10 +137,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),
|
||||||
@@ -174,23 +165,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),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -199,26 +218,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),
|
||||||
@@ -229,8 +231,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(
|
||||||
@@ -253,27 +255,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, _) {
|
||||||
@@ -294,9 +282,9 @@ 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.w800,
|
||||||
@@ -304,16 +292,34 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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(
|
||||||
@@ -329,8 +335,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: () {
|
||||||
@@ -362,12 +369,8 @@ 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: [
|
||||||
@@ -385,20 +388,26 @@ 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(
|
AnimatedPadding(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.viewInsetsOf(context).bottom,
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 24),
|
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(
|
||||||
@@ -414,7 +423,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,
|
||||||
@@ -435,7 +444,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),
|
||||||
],
|
],
|
||||||
@@ -444,8 +453,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(
|
||||||
@@ -456,8 +464,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),
|
||||||
@@ -475,6 +482,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),
|
||||||
@@ -496,13 +507,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.w800,
|
||||||
color: AppColors.primary,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -518,6 +529,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -532,11 +544,11 @@ 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.w900,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
@@ -568,14 +580,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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -588,10 +599,22 @@ class _BrandMark extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: 132,
|
width: 108,
|
||||||
height: 132,
|
height: 108,
|
||||||
child: Transform.scale(
|
child: Container(
|
||||||
scale: 1.08,
|
clipBehavior: Clip.antiAlias,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.72),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: 0.86)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.10),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/branding/health_icon_master.png',
|
'assets/branding/health_icon_master.png',
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
@@ -607,16 +630,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(
|
||||||
@@ -633,38 +648,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;
|
||||||
@@ -678,21 +677,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,
|
||||||
@@ -702,7 +700,11 @@ class _DoctorSelector extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(Icons.keyboard_arrow_down, color: AppColors.textHint),
|
const Icon(
|
||||||
|
LucideIcons.chevronDown,
|
||||||
|
size: 19,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -730,11 +732,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(
|
||||||
@@ -746,7 +747,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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -757,64 +758,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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -832,7 +836,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)
|
||||||
@@ -882,8 +886,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
|
||||||
|
|||||||
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,6 +513,78 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ScanIndicator extends StatelessWidget {
|
||||||
|
final Animation<double> animation;
|
||||||
|
final bool scanning;
|
||||||
|
final bool syncing;
|
||||||
|
|
||||||
|
const _ScanIndicator({
|
||||||
|
required this.animation,
|
||||||
|
required this.scanning,
|
||||||
|
required this.syncing,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final active = scanning || syncing;
|
||||||
|
return SizedBox(
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
if (active) ...[
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) => Container(
|
||||||
|
width: 78 * animation.value,
|
||||||
|
height: 78 * animation.value,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: AppColors.device.withValues(alpha: 0.10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) => Container(
|
||||||
|
width: 64 * animation.value,
|
||||||
|
height: 64 * animation.value,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: AppColors.device.withValues(alpha: 0.18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Container(
|
||||||
|
width: 58,
|
||||||
|
height: 58,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.device,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.device.withValues(alpha: 0.22),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 7),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
syncing
|
||||||
|
? Icons.bluetooth_connected_rounded
|
||||||
|
: Icons.bluetooth_searching_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _DevicesHeader extends StatelessWidget {
|
class _DevicesHeader extends StatelessWidget {
|
||||||
final int deviceCount;
|
final int deviceCount;
|
||||||
|
|
||||||
@@ -427,9 +596,7 @@ class _DevicesHeader extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: AppRadius.lgBorder,
|
||||||
border: Border.all(color: AppColors.borderLight, width: 1.1),
|
|
||||||
boxShadow: [AppTheme.shadowLight],
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -437,14 +604,10 @@ class _DevicesHeader extends StatelessWidget {
|
|||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: _deviceVisual.gradient,
|
color: _deviceVisual.lightColor,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: AppRadius.smBorder,
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.bluetooth_audio_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 27,
|
|
||||||
),
|
),
|
||||||
|
child: Icon(_deviceVisual.icon, color: AppColors.device, size: 27),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -494,11 +657,14 @@ class _DeviceSection extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return ClipRRect(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Colors.white,
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
padding: const EdgeInsets.fromLTRB(14, 13, 14, 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -506,10 +672,13 @@ class _DeviceSection extends StatelessWidget {
|
|||||||
height: 34,
|
height: 34,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _deviceVisual.lightColor,
|
color: _deviceVisual.lightColor,
|
||||||
borderRadius: BorderRadius.circular(11),
|
borderRadius: AppRadius.smBorder,
|
||||||
border: Border.all(color: AppColors.borderLight),
|
),
|
||||||
|
child: Icon(
|
||||||
|
type.icon,
|
||||||
|
size: 19,
|
||||||
|
color: _deviceVisual.color,
|
||||||
),
|
),
|
||||||
child: Icon(type.icon, size: 19, color: _deviceVisual.color),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Text(
|
Text(
|
||||||
@@ -520,99 +689,9 @@ class _DeviceSection extends StatelessWidget {
|
|||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
'${devices.length}',
|
'${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(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -622,29 +701,141 @@ class _BoundDeviceTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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.w800,
|
||||||
|
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)
|
if (connected)
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(right: 4),
|
margin: const EdgeInsets.only(right: 2),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 9,
|
||||||
|
vertical: 5,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.successLight,
|
color: AppColors.successLight,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: AppRadius.pillBorder,
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'已连接',
|
'已连接',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
height: 1,
|
height: 1,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.success,
|
color: AppColors.successText,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: '解绑设备',
|
tooltip: '解绑设备',
|
||||||
onPressed: connected ? null : onUnbind,
|
onPressed: busy ? null : onUnbind,
|
||||||
icon: const Icon(Icons.delete_outline_rounded),
|
icon: const Icon(Icons.delete_outline_rounded, size: 21),
|
||||||
color: connected ? AppColors.textHint : AppColors.error,
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -659,7 +850,7 @@ class _BoundDeviceTile extends StatelessWidget {
|
|||||||
value.month == now.month &&
|
value.month == now.month &&
|
||||||
value.day == now.day
|
value.day == now.day
|
||||||
? '今天'
|
? '今天'
|
||||||
: '${value.month}/${value.day}';
|
: '${value.month}-${value.day}';
|
||||||
final minute = value.minute.toString().padLeft(2, '0');
|
final minute = value.minute.toString().padLeft(2, '0');
|
||||||
return '$dayLabel ${value.hour}:$minute';
|
return '$dayLabel ${value.hour}:$minute';
|
||||||
}
|
}
|
||||||
@@ -672,121 +863,60 @@ class _EmptyDevices extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.fromLTRB(24, 46, 24, 46),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: AppRadius.lgBorder,
|
||||||
border: Border.all(color: AppColors.borderLight, width: 1.1),
|
|
||||||
boxShadow: [AppTheme.shadowLight],
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: AppEmptyState(
|
||||||
children: [
|
icon: _deviceVisual.icon,
|
||||||
Container(
|
iconColor: _deviceVisual.color,
|
||||||
width: 72,
|
title: '还没有绑定设备',
|
||||||
height: 72,
|
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(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEFF6FF),
|
color: active ? AppColors.deviceLight : Colors.white,
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: AppRadius.mdBorder,
|
||||||
|
border: Border.all(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Row(
|
||||||
Icons.bluetooth_disabled_rounded,
|
children: [
|
||||||
color: Color(0xFF2563EB),
|
Icon(
|
||||||
size: 36,
|
active ? Icons.sync_rounded : Icons.info_outline_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: active ? AppColors.device : AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 10),
|
||||||
const SizedBox(height: 18),
|
Expanded(
|
||||||
const Text(
|
child: Text(
|
||||||
'还没有绑定设备',
|
message,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 19,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Text(
|
|
||||||
'右上角添加设备',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (!active && onRetry != null)
|
||||||
|
TextButton(onPressed: onRetry, child: const Text('重试')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ScanIndicator extends StatelessWidget {
|
|
||||||
final Animation<double> animation;
|
|
||||||
final bool scanning;
|
|
||||||
final bool syncing;
|
|
||||||
|
|
||||||
const _ScanIndicator({
|
|
||||||
required this.animation,
|
|
||||||
required this.scanning,
|
|
||||||
required this.syncing,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final active = scanning || syncing;
|
|
||||||
return SizedBox(
|
|
||||||
width: 88,
|
|
||||||
height: 88,
|
|
||||||
child: Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
if (active) ...[
|
|
||||||
AnimatedBuilder(
|
|
||||||
animation: animation,
|
|
||||||
builder: (context, child) => Container(
|
|
||||||
width: 78 * animation.value,
|
|
||||||
height: 78 * animation.value,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: const Color(0xFF2563EB).withValues(alpha: 0.10),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
AnimatedBuilder(
|
|
||||||
animation: animation,
|
|
||||||
builder: (context, child) => Container(
|
|
||||||
width: 64 * animation.value,
|
|
||||||
height: 64 * animation.value,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: const Color(0xFF2563EB).withValues(alpha: 0.18),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
Container(
|
|
||||||
width: 58,
|
|
||||||
height: 58,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFF2563EB),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0xFF2563EB).withValues(alpha: 0.24),
|
|
||||||
blurRadius: 18,
|
|
||||||
offset: const Offset(0, 8),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
syncing
|
|
||||||
? Icons.bluetooth_connected_rounded
|
|
||||||
: Icons.bluetooth_searching_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 28,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,7 +478,7 @@ 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.w800,
|
||||||
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,13 +498,21 @@ 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,
|
||||||
@@ -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.w800,
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@ class _DoctorFollowUpEditPageState
|
|||||||
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: Text(
|
title: Text(
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class DoctorHomePage extends ConsumerWidget {
|
|||||||
child: Scaffold(
|
child: 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: Text(
|
title: Text(
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class DoctorPatientDetailPage extends ConsumerWidget {
|
|||||||
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(
|
||||||
@@ -125,11 +125,13 @@ class DoctorPatientDetailPage extends ConsumerWidget {
|
|||||||
(archive['surgeries'] as List).isNotEmpty)
|
(archive['surgeries'] as List).isNotEmpty)
|
||||||
_row(
|
_row(
|
||||||
'手术史',
|
'手术史',
|
||||||
(archive['surgeries'] as List).map((item) {
|
(archive['surgeries'] as List)
|
||||||
|
.map((item) {
|
||||||
final surgery = item as Map;
|
final surgery = item as Map;
|
||||||
final date = surgery['date']?.toString() ?? '';
|
final date = surgery['date']?.toString() ?? '';
|
||||||
return '${surgery['type'] ?? ''}${date.isEmpty ? '' : ' $date'}';
|
return '${surgery['type'] ?? ''}${date.isEmpty ? '' : ' $date'}';
|
||||||
}).join(';'),
|
})
|
||||||
|
.join(';'),
|
||||||
)
|
)
|
||||||
else if (archive['surgeryType'] != null)
|
else if (archive['surgeryType'] != null)
|
||||||
_row(
|
_row(
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
|||||||
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(
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class _DoctorReportDetailPageState
|
|||||||
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(
|
||||||
@@ -298,7 +298,11 @@ class _DoctorReportDetailPageState
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
pushRoute(ref, 'reportOriginal', params: {'url': fileUrl, 'title': '原始报告'});
|
pushRoute(
|
||||||
|
ref,
|
||||||
|
'reportOriginal',
|
||||||
|
params: {'url': fileUrl, 'title': '原始报告'},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.visibility, size: 18),
|
icon: const Icon(Icons.visibility, size: 18),
|
||||||
label: const Text('查看原始报告'),
|
label: const Text('查看原始报告'),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class DoctorSettingsPage extends ConsumerWidget {
|
|||||||
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('设置', style: TextStyle(color: AppColors.textPrimary)),
|
title: const Text('设置', style: TextStyle(color: AppColors.textPrimary)),
|
||||||
@@ -28,8 +28,20 @@ class DoctorSettingsPage extends ConsumerWidget {
|
|||||||
'推送通知',
|
'推送通知',
|
||||||
trailing: Switch(value: true, onChanged: (_) {}),
|
trailing: Switch(value: true, onChanged: (_) {}),
|
||||||
),
|
),
|
||||||
_SettingTile(Icons.description_outlined, '隐私政策', onTap: () { pushRoute(ref, 'staticText', params: {'type': 'privacy'}); }),
|
_SettingTile(
|
||||||
_SettingTile(Icons.article_outlined, '用户协议', onTap: () { pushRoute(ref, 'staticText', params: {'type': 'terms'}); }),
|
Icons.description_outlined,
|
||||||
|
'隐私政策',
|
||||||
|
onTap: () {
|
||||||
|
pushRoute(ref, 'staticText', params: {'type': 'privacy'});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_SettingTile(
|
||||||
|
Icons.article_outlined,
|
||||||
|
'用户协议',
|
||||||
|
onTap: () {
|
||||||
|
pushRoute(ref, 'staticText', params: {'type': 'terms'});
|
||||||
|
},
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_SettingTile(
|
_SettingTile(
|
||||||
Icons.delete_forever_outlined,
|
Icons.delete_forever_outlined,
|
||||||
|
|||||||
604
health_app/lib/pages/exercise/exercise_plan_page.dart
Normal file
604
health_app/lib/pages/exercise/exercise_plan_page.dart
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/api_client.dart';
|
||||||
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_design_tokens.dart';
|
||||||
|
import '../../core/app_module_visuals.dart';
|
||||||
|
import '../../core/app_theme.dart';
|
||||||
|
import '../../core/navigation_provider.dart';
|
||||||
|
import '../../providers/data_providers.dart';
|
||||||
|
import '../../widgets/app_empty_state.dart';
|
||||||
|
import '../../widgets/app_future_view.dart';
|
||||||
|
import '../../widgets/app_toast.dart';
|
||||||
|
import '../../widgets/common_widgets.dart';
|
||||||
|
import '../care_plan_ui_logic.dart';
|
||||||
|
|
||||||
|
class EnterpriseExercisePlanPage extends ConsumerStatefulWidget {
|
||||||
|
const EnterpriseExercisePlanPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<EnterpriseExercisePlanPage> createState() =>
|
||||||
|
_EnterpriseExercisePlanPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EnterpriseExercisePlanPageState
|
||||||
|
extends ConsumerState<EnterpriseExercisePlanPage> {
|
||||||
|
Future<List<Map<String, dynamic>>>? _future;
|
||||||
|
final Set<String> _busyItems = {};
|
||||||
|
final Set<String> _deletingPlans = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _load() => setState(() {
|
||||||
|
_future = ref.read(exerciseServiceProvider).getPlans();
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
_load();
|
||||||
|
await _future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleCheckIn(String itemId) async {
|
||||||
|
if (itemId.isEmpty || _busyItems.contains(itemId)) return;
|
||||||
|
setState(() => _busyItems.add(itemId));
|
||||||
|
try {
|
||||||
|
await ref.read(exerciseServiceProvider).checkIn(itemId);
|
||||||
|
ref.invalidate(currentExercisePlanProvider);
|
||||||
|
_load();
|
||||||
|
} catch (error) {
|
||||||
|
if (mounted) {
|
||||||
|
AppToast.show(
|
||||||
|
context,
|
||||||
|
error is ApiException ? error.message : '打卡失败,请稍后重试',
|
||||||
|
type: AppToastType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busyItems.remove(itemId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(String id, String title) async {
|
||||||
|
if (id.isEmpty || _deletingPlans.contains(id)) return;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('删除运动计划'),
|
||||||
|
content: Text('确定删除“$title”吗?删除后无法恢复。'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppColors.errorText),
|
||||||
|
child: const Text('删除'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
setState(() => _deletingPlans.add(id));
|
||||||
|
try {
|
||||||
|
await ref.read(exerciseServiceProvider).deletePlan(id);
|
||||||
|
ref.invalidate(currentExercisePlanProvider);
|
||||||
|
_load();
|
||||||
|
if (mounted) {
|
||||||
|
AppToast.show(context, '运动计划已删除', type: AppToastType.success);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (mounted) {
|
||||||
|
AppToast.show(
|
||||||
|
context,
|
||||||
|
error is ApiException ? error.message : '删除失败,请稍后重试',
|
||||||
|
type: AppToastType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _deletingPlans.remove(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GradientScaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => popRoute(ref),
|
||||||
|
),
|
||||||
|
title: const Text('运动计划'),
|
||||||
|
),
|
||||||
|
floatingActionButton: AppCreateFab(
|
||||||
|
tooltip: '新建运动计划',
|
||||||
|
onPressed: () => pushRoute(ref, 'exerciseCreate'),
|
||||||
|
),
|
||||||
|
body: AppFutureView<List<Map<String, dynamic>>>(
|
||||||
|
future: _future,
|
||||||
|
onRetry: _load,
|
||||||
|
errorTitle: '运动计划加载失败',
|
||||||
|
onData: (_, plans) {
|
||||||
|
if (plans.isEmpty) {
|
||||||
|
return AppEmptyState(
|
||||||
|
icon: AppModuleVisuals.exercise.icon,
|
||||||
|
title: '暂无运动计划',
|
||||||
|
subtitle: '点击右下角添加运动计划',
|
||||||
|
iconColor: AppColors.exercise,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final todayKey = _dateKey(DateTime.now());
|
||||||
|
final validPlans = plans.where((plan) {
|
||||||
|
return (plan['items'] as List?)?.isNotEmpty == true;
|
||||||
|
}).toList();
|
||||||
|
final todayItems = validPlans
|
||||||
|
.expand(_itemsOf)
|
||||||
|
.where((item) => item['scheduledDate']?.toString() == todayKey)
|
||||||
|
.where((item) => item['isRestDay'] != true)
|
||||||
|
.toList();
|
||||||
|
final completedToday = todayItems
|
||||||
|
.where((item) => item['isCompleted'] == true)
|
||||||
|
.length;
|
||||||
|
final groups = <CarePlanPhase, List<Map<String, dynamic>>>{
|
||||||
|
for (final phase in CarePlanPhase.values) phase: [],
|
||||||
|
};
|
||||||
|
for (final plan in validPlans) {
|
||||||
|
groups[resolveCarePlanPhase(
|
||||||
|
startDate: plan['startDate']?.toString(),
|
||||||
|
endDate: plan['endDate']?.toString(),
|
||||||
|
)]!
|
||||||
|
.add(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _refresh,
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: AppSpacing.pageWithFab,
|
||||||
|
children: [
|
||||||
|
_TodayExercisePanel(
|
||||||
|
items: todayItems,
|
||||||
|
completed: completedToday,
|
||||||
|
busyItems: _busyItems,
|
||||||
|
onCheckIn: _toggleCheckIn,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
for (final phase in CarePlanPhase.values)
|
||||||
|
if (groups[phase]!.isNotEmpty) ...[
|
||||||
|
_ExercisePlanGroup(
|
||||||
|
phase: phase,
|
||||||
|
plans: groups[phase]!,
|
||||||
|
todayKey: todayKey,
|
||||||
|
busyItems: _busyItems,
|
||||||
|
deletingPlans: _deletingPlans,
|
||||||
|
onCheckIn: _toggleCheckIn,
|
||||||
|
onDelete: _confirmDelete,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TodayExercisePanel extends StatelessWidget {
|
||||||
|
final List<Map<String, dynamic>> items;
|
||||||
|
final int completed;
|
||||||
|
final Set<String> busyItems;
|
||||||
|
final Future<void> Function(String) onCheckIn;
|
||||||
|
|
||||||
|
const _TodayExercisePanel({
|
||||||
|
required this.items,
|
||||||
|
required this.completed,
|
||||||
|
required this.busyItems,
|
||||||
|
required this.onCheckIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final progress = items.isEmpty ? 0.0 : completed / items.length;
|
||||||
|
final next = items.cast<Map<String, dynamic>?>().firstWhere(
|
||||||
|
(item) => item?['isCompleted'] != true,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
padding: AppSpacing.panel,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.exerciseLight,
|
||||||
|
borderRadius: AppRadius.smBorder,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
AppModuleVisuals.exercise.icon,
|
||||||
|
color: AppColors.exercise,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('今日休息', style: AppTextStyles.sectionTitle),
|
||||||
|
SizedBox(height: 3),
|
||||||
|
Text('今天没有安排运动,保持轻松活动即可', style: AppTextStyles.listSubtitle),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Container(
|
||||||
|
padding: AppSpacing.panel,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('今日任务', style: AppTextStyles.sectionTitle),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
'$completed / ${items.length} 已完成',
|
||||||
|
style: AppTextStyles.listSubtitle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (next != null)
|
||||||
|
_CompactActionButton(
|
||||||
|
busy: busyItems.contains(next['id']?.toString() ?? ''),
|
||||||
|
completed: false,
|
||||||
|
onPressed: () => onCheckIn(next['id']?.toString() ?? ''),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
minHeight: 5,
|
||||||
|
value: progress,
|
||||||
|
backgroundColor: AppColors.cardInner,
|
||||||
|
valueColor: const AlwaysStoppedAnimation(AppColors.exercise),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (next != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'${next['exerciseType'] ?? '运动'} · ${next['durationMinutes'] ?? 0} 分钟',
|
||||||
|
style: AppTextStyles.listTitle.copyWith(fontSize: 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExercisePlanGroup extends StatelessWidget {
|
||||||
|
final CarePlanPhase phase;
|
||||||
|
final List<Map<String, dynamic>> plans;
|
||||||
|
final String todayKey;
|
||||||
|
final Set<String> busyItems;
|
||||||
|
final Set<String> deletingPlans;
|
||||||
|
final Future<void> Function(String) onCheckIn;
|
||||||
|
final Future<void> Function(String, String) onDelete;
|
||||||
|
|
||||||
|
const _ExercisePlanGroup({
|
||||||
|
required this.phase,
|
||||||
|
required this.plans,
|
||||||
|
required this.todayKey,
|
||||||
|
required this.busyItems,
|
||||||
|
required this.deletingPlans,
|
||||||
|
required this.onCheckIn,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = _phaseColor(phase);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 2, bottom: 9),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${_phaseLabel(phase)}(${plans.length})',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: List.generate(plans.length, (index) {
|
||||||
|
final plan = plans[index];
|
||||||
|
final items = _itemsOf(plan);
|
||||||
|
final id = plan['id']?.toString() ?? '';
|
||||||
|
final title = _exerciseTitle(items);
|
||||||
|
final isDeleting = deletingPlans.contains(id);
|
||||||
|
return SwipeDeleteTile(
|
||||||
|
key: ValueKey(id),
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
enabled: !isDeleting,
|
||||||
|
onDelete: () => onDelete(id, title),
|
||||||
|
child: _ExercisePlanRow(
|
||||||
|
plan: plan,
|
||||||
|
title: title,
|
||||||
|
phase: phase,
|
||||||
|
todayKey: todayKey,
|
||||||
|
busyItems: busyItems,
|
||||||
|
deleting: isDeleting,
|
||||||
|
showDivider: index < plans.length - 1,
|
||||||
|
onCheckIn: onCheckIn,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExercisePlanRow extends StatelessWidget {
|
||||||
|
final Map<String, dynamic> plan;
|
||||||
|
final String title;
|
||||||
|
final CarePlanPhase phase;
|
||||||
|
final String todayKey;
|
||||||
|
final Set<String> busyItems;
|
||||||
|
final bool deleting;
|
||||||
|
final bool showDivider;
|
||||||
|
final Future<void> Function(String) onCheckIn;
|
||||||
|
|
||||||
|
const _ExercisePlanRow({
|
||||||
|
required this.plan,
|
||||||
|
required this.title,
|
||||||
|
required this.phase,
|
||||||
|
required this.todayKey,
|
||||||
|
required this.busyItems,
|
||||||
|
required this.deleting,
|
||||||
|
required this.showDivider,
|
||||||
|
required this.onCheckIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final items = _itemsOf(plan);
|
||||||
|
final done = items.where((item) => item['isCompleted'] == true).length;
|
||||||
|
final progress = items.isEmpty ? 0.0 : done / items.length;
|
||||||
|
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
|
||||||
|
(item) => item?['scheduledDate']?.toString() == todayKey,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
final canCheckIn =
|
||||||
|
phase == CarePlanPhase.active &&
|
||||||
|
todayItem != null &&
|
||||||
|
todayItem['isRestDay'] != true;
|
||||||
|
final itemId = todayItem?['id']?.toString() ?? '';
|
||||||
|
final completed = todayItem?['isCompleted'] == true;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.white,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.exerciseLight,
|
||||||
|
borderRadius: AppRadius.smBorder,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
AppModuleVisuals.exercise.icon,
|
||||||
|
color: AppColors.exercise,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: AppTextStyles.listTitle.copyWith(
|
||||||
|
fontSize: 17,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'$done/${items.length}天',
|
||||||
|
style: AppTextStyles.tag,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${_compactDate(plan['startDate'])} - ${_compactDate(plan['endDate'])}',
|
||||||
|
style: AppTextStyles.listSubtitle.copyWith(
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
minHeight: 4,
|
||||||
|
value: progress,
|
||||||
|
backgroundColor: AppColors.cardInner,
|
||||||
|
valueColor: AlwaysStoppedAnimation(
|
||||||
|
_phaseColor(phase),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (canCheckIn && !deleting) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_CompactActionButton(
|
||||||
|
busy: busyItems.contains(itemId) || deleting,
|
||||||
|
completed: completed,
|
||||||
|
onPressed: () => onCheckIn(itemId),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (deleting)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
const Positioned(
|
||||||
|
left: 68,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 0.7,
|
||||||
|
color: AppColors.divider,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompactActionButton extends StatelessWidget {
|
||||||
|
final bool busy;
|
||||||
|
final bool completed;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
const _CompactActionButton({
|
||||||
|
required this.busy,
|
||||||
|
required this.completed,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 38,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: busy ? null : onPressed,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
backgroundColor: completed ? AppColors.successLight : Colors.white,
|
||||||
|
foregroundColor: completed
|
||||||
|
? AppColors.successText
|
||||||
|
: AppColors.exercise,
|
||||||
|
side: BorderSide(
|
||||||
|
color: completed ? AppColors.successText : AppColors.exercise,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: AppRadius.smBorder),
|
||||||
|
),
|
||||||
|
child: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 15,
|
||||||
|
height: 15,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Text(completed ? '撤销' : '打卡', style: AppTextStyles.miniButton),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _itemsOf(Map<String, dynamic> plan) =>
|
||||||
|
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
|
|
||||||
|
String _exerciseTitle(List<Map<String, dynamic>> items) {
|
||||||
|
final names = items
|
||||||
|
.where((item) => item['isRestDay'] != true)
|
||||||
|
.map((item) => item['exerciseType']?.toString().trim() ?? '')
|
||||||
|
.where((name) => name.isNotEmpty)
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
if (names.isEmpty) return '运动计划';
|
||||||
|
return names.length == 1 ? names.first : '${names.first}等${names.length}项';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _dateKey(DateTime date) =>
|
||||||
|
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
String _compactDate(Object? value) {
|
||||||
|
final parsed = DateTime.tryParse(value?.toString() ?? '');
|
||||||
|
return parsed == null
|
||||||
|
? '--'
|
||||||
|
: '${parsed.year}.${parsed.month.toString().padLeft(2, '0')}.${parsed.day.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _phaseLabel(CarePlanPhase phase) => switch (phase) {
|
||||||
|
CarePlanPhase.active => '进行中',
|
||||||
|
CarePlanPhase.upcoming => '即将开始',
|
||||||
|
CarePlanPhase.ended => '已结束',
|
||||||
|
};
|
||||||
|
|
||||||
|
Color _phaseColor(CarePlanPhase phase) => switch (phase) {
|
||||||
|
CarePlanPhase.active => AppColors.successText,
|
||||||
|
CarePlanPhase.upcoming => AppColors.blueMeasure,
|
||||||
|
CarePlanPhase.ended => AppColors.textHint,
|
||||||
|
};
|
||||||
@@ -2,12 +2,24 @@ 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/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 '../../providers/chat_provider.dart';
|
import '../../providers/chat_provider.dart';
|
||||||
import '../../providers/conversation_history_provider.dart';
|
import '../../providers/conversation_history_provider.dart';
|
||||||
import '../../widgets/app_toast.dart';
|
import '../../widgets/app_toast.dart';
|
||||||
|
|
||||||
|
Future<bool> deleteConversationForDismiss(
|
||||||
|
Future<void> Function() delete,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
await delete();
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ConversationHistoryPage extends ConsumerWidget {
|
class ConversationHistoryPage extends ConsumerWidget {
|
||||||
const ConversationHistoryPage({super.key});
|
const ConversationHistoryPage({super.key});
|
||||||
|
|
||||||
@@ -17,7 +29,7 @@ class ConversationHistoryPage extends ConsumerWidget {
|
|||||||
|
|
||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white.withValues(alpha: 0.9),
|
backgroundColor: Colors.white,
|
||||||
title: const Text('对话记录'),
|
title: const Text('对话记录'),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
@@ -29,7 +41,7 @@ class ConversationHistoryPage extends ConsumerWidget {
|
|||||||
child: const Text(
|
child: const Text(
|
||||||
'清空',
|
'清空',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.error,
|
color: AppColors.errorText,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -51,13 +63,15 @@ class ConversationHistoryPage extends ConsumerWidget {
|
|||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
|
||||||
itemCount: list.length,
|
itemCount: list.length,
|
||||||
separatorBuilder: (_, index) => const SizedBox(height: 10),
|
separatorBuilder: (_, index) => const SizedBox.shrink(),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = list[index];
|
final item = list[index];
|
||||||
return _HistoryTile(
|
return _HistoryTile(
|
||||||
item: item,
|
item: item,
|
||||||
onTap: () => _openConversation(context, ref, item.id),
|
onTap: () => _openConversation(context, ref, item.id),
|
||||||
onDelete: () => _deleteOne(context, ref, item.id),
|
onDelete: () => _deleteOne(context, ref, item.id),
|
||||||
|
isFirst: index == 0,
|
||||||
|
isLast: index == list.length - 1,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -81,21 +95,22 @@ class ConversationHistoryPage extends ConsumerWidget {
|
|||||||
popRoute(ref);
|
popRoute(ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deleteOne(
|
Future<bool> _deleteOne(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
String id,
|
String id,
|
||||||
) async {
|
) async {
|
||||||
try {
|
final deleted = await deleteConversationForDismiss(
|
||||||
await ref.read(conversationHistoryProvider.notifier).deleteOne(id);
|
() => ref.read(conversationHistoryProvider.notifier).deleteOne(id),
|
||||||
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
AppToast.show(context, '已删除', type: AppToastType.success);
|
AppToast.show(
|
||||||
}
|
context,
|
||||||
} catch (_) {
|
deleted ? '已删除' : '删除失败,请稍后重试',
|
||||||
if (context.mounted) {
|
type: deleted ? AppToastType.success : AppToastType.error,
|
||||||
AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmClearAll(BuildContext context, WidgetRef ref) async {
|
Future<void> _confirmClearAll(BuildContext context, WidgetRef ref) async {
|
||||||
@@ -111,7 +126,7 @@ class ConversationHistoryPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
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('清空'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -136,12 +151,16 @@ class ConversationHistoryPage extends ConsumerWidget {
|
|||||||
class _HistoryTile extends StatelessWidget {
|
class _HistoryTile extends StatelessWidget {
|
||||||
final ConversationListItem item;
|
final ConversationListItem item;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final VoidCallback onDelete;
|
final Future<bool> Function() onDelete;
|
||||||
|
final bool isFirst;
|
||||||
|
final bool isLast;
|
||||||
|
|
||||||
const _HistoryTile({
|
const _HistoryTile({
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
|
required this.isFirst,
|
||||||
|
required this.isLast,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -153,25 +172,25 @@ class _HistoryTile extends StatelessWidget {
|
|||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
padding: const EdgeInsets.only(right: 18),
|
padding: const EdgeInsets.only(right: 18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.error,
|
color: AppColors.errorText,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: AppRadius.lgBorder,
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.delete_outline, color: Colors.white),
|
child: const Icon(Icons.delete_outline, color: Colors.white),
|
||||||
),
|
),
|
||||||
confirmDismiss: (_) async {
|
confirmDismiss: (_) => onDelete(),
|
||||||
onDelete();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: _rowRadius,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: _rowRadius,
|
||||||
border: Border.all(color: AppColors.borderLight),
|
border: isLast
|
||||||
boxShadow: AppColors.cardShadowLight,
|
? null
|
||||||
|
: const Border(
|
||||||
|
bottom: BorderSide(color: AppColors.divider, width: 0.7),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -225,8 +244,15 @@ class _HistoryTile extends StatelessWidget {
|
|||||||
local.day == yesterday.day) {
|
local.day == yesterday.day) {
|
||||||
return '昨天';
|
return '昨天';
|
||||||
}
|
}
|
||||||
return '${local.month}/${local.day}';
|
return '${local.month}-${local.day}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BorderRadius get _rowRadius => BorderRadius.only(
|
||||||
|
topLeft: isFirst ? const Radius.circular(AppRadius.lg) : Radius.zero,
|
||||||
|
topRight: isFirst ? const Radius.circular(AppRadius.lg) : Radius.zero,
|
||||||
|
bottomLeft: isLast ? const Radius.circular(AppRadius.lg) : Radius.zero,
|
||||||
|
bottomRight: isLast ? const Radius.circular(AppRadius.lg) : Radius.zero,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmptyHint extends StatelessWidget {
|
class _EmptyHint extends StatelessWidget {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:flutter/gestures.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:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_design_tokens.dart';
|
||||||
import '../../core/app_module_visuals.dart';
|
import '../../core/app_module_visuals.dart';
|
||||||
import '../../core/app_theme.dart';
|
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/chat_provider.dart';
|
import '../../providers/chat_provider.dart';
|
||||||
@@ -28,41 +29,31 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
final _scrollCtrl = ScrollController();
|
final _scrollCtrl = ScrollController();
|
||||||
final _focusNode = FocusNode();
|
final _focusNode = FocusNode();
|
||||||
final _scaffoldKey = GlobalKey<ScaffoldState>();
|
final _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||||
double? _drawerDragStartX;
|
|
||||||
String? _pickedImagePath;
|
String? _pickedImagePath;
|
||||||
int _lastMsgCount = 0;
|
|
||||||
Timer? _notificationTimer;
|
Timer? _notificationTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
WidgetsBinding.instance.addPostFrameCallback(
|
_scheduleScrollToLatest();
|
||||||
(_) => ref.invalidate(notificationUnreadCountProvider),
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
);
|
_checkDueNotifications();
|
||||||
|
_refreshTodayHealthIfCached();
|
||||||
|
});
|
||||||
_notificationTimer = Timer.periodic(
|
_notificationTimer = Timer.periodic(
|
||||||
const Duration(minutes: 2),
|
const Duration(minutes: 2),
|
||||||
(_) => ref.invalidate(notificationUnreadCountProvider),
|
(_) => _checkDueNotifications(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
ref.invalidate(notificationUnreadCountProvider);
|
_checkDueNotifications();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeMetrics() {
|
|
||||||
// 键盘动画期间每帧都会回调,让列表底部始终贴住输入区上沿
|
|
||||||
if (!_focusNode.hasFocus) return;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!mounted || !_scrollCtrl.hasClients) return;
|
|
||||||
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
@@ -87,9 +78,28 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _refreshTodayHealthIfCached() {
|
||||||
|
if (!ref.read(todayHealthSnapshotProvider).hasValue) return;
|
||||||
|
ref.invalidate(latestHealthProvider);
|
||||||
|
ref.invalidate(medicationReminderProvider);
|
||||||
|
ref.invalidate(currentExercisePlanProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleScrollToLatest() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_scrollCtrl.hasClients) return;
|
||||||
|
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
|
||||||
|
|
||||||
|
// Lazy list items can refine the final extent after the first layout.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_scrollCtrl.hasClients) return;
|
||||||
|
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final chatState = ref.watch(chatProvider);
|
|
||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
final user = auth.user;
|
final user = auth.user;
|
||||||
|
|
||||||
@@ -112,63 +122,43 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
final currentCount = chatState.messages.length;
|
ref.listen<int>(chatProvider.select((state) => state.messages.length), (
|
||||||
if (currentCount > _lastMsgCount) {
|
previous,
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
current,
|
||||||
if (_scrollCtrl.hasClients) {
|
) {
|
||||||
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
|
if (current <= (previous ?? 0)) return;
|
||||||
}
|
_scheduleScrollToLatest();
|
||||||
});
|
});
|
||||||
}
|
|
||||||
_lastMsgCount = currentCount;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
key: _scaffoldKey,
|
key: _scaffoldKey,
|
||||||
backgroundColor: const Color(0xFFFFFCFF),
|
backgroundColor: const Color(0xFFFFFCFF),
|
||||||
drawer: const HealthDrawer(),
|
drawer: const HealthDrawer(),
|
||||||
drawerEnableOpenDragGesture: false,
|
drawerEnableOpenDragGesture: true,
|
||||||
body: AppBackground(
|
drawerDragStartBehavior: DragStartBehavior.down,
|
||||||
safeArea: true,
|
drawerEdgeDragWidth: MediaQuery.sizeOf(context).width * 0.8,
|
||||||
|
body: DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFF0EDFF), Color(0xFFE6ECFF)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(user),
|
_buildHeader(user),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Stack(
|
child: RepaintBoundary(
|
||||||
children: [
|
child: _HomeMessages(scrollCtrl: _scrollCtrl),
|
||||||
RepaintBoundary(
|
|
||||||
child: ChatMessagesView(
|
|
||||||
scrollCtrl: _scrollCtrl,
|
|
||||||
messages: chatState.messages,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: MediaQuery.sizeOf(context).width * 0.8,
|
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.translucent,
|
|
||||||
onHorizontalDragStart: (details) {
|
|
||||||
_drawerDragStartX = details.globalPosition.dx;
|
|
||||||
},
|
|
||||||
onHorizontalDragUpdate: (details) {
|
|
||||||
final startX = _drawerDragStartX;
|
|
||||||
if (startX == null) return;
|
|
||||||
if (details.globalPosition.dx - startX < 28) return;
|
|
||||||
_drawerDragStartX = null;
|
|
||||||
_scaffoldKey.currentState?.openDrawer();
|
|
||||||
},
|
|
||||||
onHorizontalDragEnd: (_) => _drawerDragStartX = null,
|
|
||||||
onHorizontalDragCancel: () => _drawerDragStartX = null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildBottomBar(context),
|
_buildBottomBar(context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,20 +168,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
: '用户';
|
: '用户';
|
||||||
final unreadCount = ref.watch(notificationUnreadCountProvider).value ?? 0;
|
final unreadCount = ref.watch(notificationUnreadCountProvider).value ?? 0;
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
|
padding: const EdgeInsets.fromLTRB(14, 8, 14, 6),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.34),
|
|
||||||
border: Border(
|
|
||||||
bottom: BorderSide(color: Colors.white.withValues(alpha: 0.62)),
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0xFF101828).withValues(alpha: 0.025),
|
|
||||||
blurRadius: 16,
|
|
||||||
offset: const Offset(0, 6),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Builder(
|
Builder(
|
||||||
@@ -202,7 +179,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -274,34 +251,38 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
.read(chatProvider.notifier)
|
.read(chatProvider.notifier)
|
||||||
.triggerAgent(agent, visual.label),
|
.triggerAgent(agent, visual.label),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? null : Colors.transparent,
|
||||||
|
gradient: selected ? AppColors.actionOutlineGradient : null,
|
||||||
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(1.2),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 11.8,
|
||||||
|
vertical: 7.8,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: AppRadius.pillBorder,
|
||||||
border: Border.all(
|
|
||||||
color: selected
|
|
||||||
? AppColors.auraIndigo.withValues(alpha: 0.42)
|
|
||||||
: const Color(0xFFD5DAE4),
|
|
||||||
width: selected ? 1.4 : 1.1,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 16,
|
width: 18,
|
||||||
height: 16,
|
height: 18,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: visual.gradient,
|
gradient: visual.gradient,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: Icon(visual.icon, size: 11, color: Colors.white),
|
child: Icon(visual.icon, size: 13, color: Colors.white),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
visual.label,
|
visual.label,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
@@ -309,6 +290,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -327,7 +309,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
ActiveAgent.health => fromModule('记数据', AppModuleVisuals.health),
|
ActiveAgent.health => fromModule('记数据', AppModuleVisuals.health),
|
||||||
ActiveAgent.diet => fromModule('拍饮食', AppModuleVisuals.diet),
|
ActiveAgent.diet => fromModule('拍饮食', AppModuleVisuals.diet),
|
||||||
ActiveAgent.medication => fromModule('药管家', AppModuleVisuals.medication),
|
ActiveAgent.medication => fromModule('药管家', AppModuleVisuals.medication),
|
||||||
ActiveAgent.report => fromModule('报告分析', AppModuleVisuals.report),
|
ActiveAgent.report => fromModule('报告', AppModuleVisuals.report),
|
||||||
ActiveAgent.exercise => fromModule('运动', AppModuleVisuals.exercise),
|
ActiveAgent.exercise => fromModule('运动', AppModuleVisuals.exercise),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
@@ -413,17 +395,39 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
Widget _buildInputBar() {
|
Widget _buildInputBar() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
|
||||||
child: Row(
|
child: Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
padding: const EdgeInsets.fromLTRB(6, 5, 6, 5),
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
_RoundToolButton(
|
color: Colors.white,
|
||||||
icon: LucideIcons.plus,
|
borderRadius: AppRadius.pillBorder,
|
||||||
onTap: () => _showAttachmentPicker(context),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
onTap: () => _showAttachmentPicker(context),
|
||||||
|
child: Ink(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: AppColors.primaryGradient,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
LucideIcons.plus,
|
||||||
|
size: 21,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxHeight: 118),
|
constraints: const BoxConstraints(maxHeight: 112),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _textCtrl,
|
controller: _textCtrl,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
@@ -433,55 +437,46 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
),
|
),
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
textInputAction: TextInputAction.newline,
|
textInputAction: TextInputAction.newline,
|
||||||
decoration: InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: '输入你想说的...',
|
hintText: '输入你想说的...',
|
||||||
filled: true,
|
filled: false,
|
||||||
fillColor: Colors.white,
|
isDense: true,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 4,
|
||||||
vertical: 11,
|
vertical: 12,
|
||||||
),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(22),
|
|
||||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(22),
|
|
||||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(22),
|
|
||||||
borderSide: const BorderSide(
|
|
||||||
color: AppColors.auraIndigo,
|
|
||||||
width: 1.2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
border: InputBorder.none,
|
||||||
|
enabledBorder: InputBorder.none,
|
||||||
|
focusedBorder: InputBorder.none,
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _sendMessage(),
|
onSubmitted: (_) => _sendMessage(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 6),
|
||||||
GestureDetector(
|
Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
onTap: _sendMessage,
|
onTap: _sendMessage,
|
||||||
child: Container(
|
child: Ink(
|
||||||
width: 42,
|
width: 38,
|
||||||
height: 42,
|
height: 38,
|
||||||
margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 2),
|
decoration: const BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.primaryGradient,
|
gradient: AppColors.primaryGradient,
|
||||||
borderRadius: BorderRadius.circular(21),
|
shape: BoxShape.circle,
|
||||||
boxShadow: AppColors.buttonShadow,
|
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
LucideIcons.send,
|
LucideIcons.send,
|
||||||
size: 19,
|
size: 18,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,23 +525,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
title: const Text('上传 PDF'),
|
title: const Text('上传 PDF'),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
_dismissKeyboard();
|
await _pickPdf();
|
||||||
final result = await FilePicker.platform.pickFiles(
|
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: ['pdf'],
|
|
||||||
withData: false,
|
|
||||||
);
|
|
||||||
if (result == null || result.files.isEmpty) return;
|
|
||||||
final pdfFile = result.files.first;
|
|
||||||
final path = pdfFile.path;
|
|
||||||
if (path == null || path.isEmpty) return;
|
|
||||||
// 上传 + 让 AI 看 PDF 内容
|
|
||||||
await ref
|
|
||||||
.read(chatProvider.notifier)
|
|
||||||
.sendPdf(path, pdfFile.name, _textCtrl.text.trim());
|
|
||||||
_textCtrl.clear();
|
|
||||||
_dismissKeyboard();
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -576,6 +555,34 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pickPdf() async {
|
||||||
|
_dismissKeyboard();
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['pdf'],
|
||||||
|
withData: false,
|
||||||
|
);
|
||||||
|
if (result == null || result.files.isEmpty) return;
|
||||||
|
final pdfFile = result.files.first;
|
||||||
|
final path = pdfFile.path;
|
||||||
|
if (path == null || path.isEmpty) return;
|
||||||
|
await ref
|
||||||
|
.read(chatProvider.notifier)
|
||||||
|
.sendPdf(path, pdfFile.name, _textCtrl.text.trim());
|
||||||
|
_textCtrl.clear();
|
||||||
|
_dismissKeyboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkDueNotifications() async {
|
||||||
|
try {
|
||||||
|
await ref.read(inAppNotificationServiceProvider).checkDue();
|
||||||
|
} catch (_) {
|
||||||
|
// Network failures should not block the home page.
|
||||||
|
} finally {
|
||||||
|
ref.invalidate(notificationUnreadCountProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pickFoodImage(ImageSource source) async {
|
Future<void> _pickFoodImage(ImageSource source) async {
|
||||||
final picked = await ImagePicker().pickImage(
|
final picked = await ImagePicker().pickImage(
|
||||||
source: source,
|
source: source,
|
||||||
@@ -592,6 +599,18 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _HomeMessages extends ConsumerWidget {
|
||||||
|
final ScrollController scrollCtrl;
|
||||||
|
|
||||||
|
const _HomeMessages({required this.scrollCtrl});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final messages = ref.watch(chatProvider.select((state) => state.messages));
|
||||||
|
return ChatMessagesView(scrollCtrl: scrollCtrl, messages: messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _HeaderIconButton extends StatelessWidget {
|
class _HeaderIconButton extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
@@ -610,8 +629,8 @@ class _HeaderIconButton extends StatelessWidget {
|
|||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 38,
|
width: 44,
|
||||||
height: 38,
|
height: 44,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.surfaceGradient,
|
gradient: AppColors.surfaceGradient,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
@@ -648,28 +667,3 @@ class _HeaderIconButton extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RoundToolButton extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
const _RoundToolButton({required this.icon, required this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
width: 42,
|
|
||||||
height: 42,
|
|
||||||
margin: const EdgeInsets.only(bottom: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.surfaceGradient,
|
|
||||||
borderRadius: BorderRadius.circular(21),
|
|
||||||
border: Border.all(color: AppColors.borderLight),
|
|
||||||
boxShadow: AppColors.cardShadowLight,
|
|
||||||
),
|
|
||||||
child: Icon(icon, size: 21, color: AppColors.primaryDark),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,50 +13,8 @@ import '../../../providers/data_providers.dart';
|
|||||||
import '../../../widgets/ai_content.dart';
|
import '../../../widgets/ai_content.dart';
|
||||||
import '../../../widgets/app_toast.dart';
|
import '../../../widgets/app_toast.dart';
|
||||||
|
|
||||||
/// 卡片入场动画包装(从下往上滑入 + 淡入)
|
ChatMessage messageAtDisplayIndex(List<ChatMessage> messages, int index) =>
|
||||||
class _AnimatedCardEntry extends StatefulWidget {
|
messages[index];
|
||||||
final Widget child;
|
|
||||||
const _AnimatedCardEntry({required this.child});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_AnimatedCardEntry> createState() => _AnimatedCardEntryState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AnimatedCardEntryState extends State<_AnimatedCardEntry>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late final _ctrl = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 600),
|
|
||||||
);
|
|
||||||
late final _slide = Tween<Offset>(
|
|
||||||
begin: const Offset(0, 0.12),
|
|
||||||
end: Offset.zero,
|
|
||||||
).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
|
|
||||||
late final _fade = Tween<double>(
|
|
||||||
begin: 0.0,
|
|
||||||
end: 1.0,
|
|
||||||
).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_ctrl.forward();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_ctrl.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return FadeTransition(
|
|
||||||
opacity: _fade,
|
|
||||||
child: SlideTransition(position: _slide, child: widget.child),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 对话消息列表
|
/// 对话消息列表
|
||||||
class ChatMessagesView extends ConsumerWidget {
|
class ChatMessagesView extends ConsumerWidget {
|
||||||
@@ -71,7 +29,9 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final chatState = ref.watch(chatProvider);
|
final streaming = ref.watch(
|
||||||
|
chatProvider.select((state) => (state.isStreaming, state.thinkingText)),
|
||||||
|
);
|
||||||
|
|
||||||
if (messages.isEmpty) {
|
if (messages.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
@@ -105,12 +65,21 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
controller: scrollCtrl,
|
controller: scrollCtrl,
|
||||||
reverse: false,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
cacheExtent: 180,
|
||||||
itemCount: messages.length,
|
itemCount: messages.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final msg = messages[index];
|
final msg = messageAtDisplayIndex(messages, index);
|
||||||
return _buildMessageContent(context, ref, msg, chatState);
|
return KeyedSubtree(
|
||||||
|
key: ValueKey('chat-message-${msg.id}'),
|
||||||
|
child: _buildMessageContent(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
msg,
|
||||||
|
streaming.$1,
|
||||||
|
streaming.$2 ?? '',
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -121,7 +90,8 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
ChatMessage msg,
|
ChatMessage msg,
|
||||||
ChatState chatState,
|
bool isStreaming,
|
||||||
|
String thinkingText,
|
||||||
) {
|
) {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case MessageType.agentWelcome:
|
case MessageType.agentWelcome:
|
||||||
@@ -134,12 +104,10 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
case MessageType.dataConfirm:
|
case MessageType.dataConfirm:
|
||||||
return _buildDataConfirmCard(context, ref, msg);
|
return _buildDataConfirmCard(context, ref, msg);
|
||||||
default:
|
default:
|
||||||
if (!msg.isUser &&
|
if (!msg.isUser && isStreaming && msg.content.trim().isEmpty) {
|
||||||
chatState.isStreaming &&
|
return _buildThinkingBubble(context, thinkingText);
|
||||||
msg.content.trim().isEmpty) {
|
|
||||||
return _buildThinkingBubble(context, chatState.thinkingText);
|
|
||||||
}
|
}
|
||||||
return _buildTextBubble(context, ref, msg, chatState);
|
return _buildTextBubble(context, ref, msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,8 +127,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
final agentColors = _agentColors(agent);
|
final agentColors = _agentColors(agent);
|
||||||
final artworkPath = _agentArtworkPath(agent);
|
final artworkPath = _agentArtworkPath(agent);
|
||||||
|
|
||||||
return _AnimatedCardEntry(
|
return Align(
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
@@ -185,7 +152,6 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// ── 标题区域 ──
|
|
||||||
Stack(
|
Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
@@ -269,10 +235,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(info.$3, style: AppTextStyles.summarySubtitle),
|
||||||
info.$3,
|
|
||||||
style: AppTextStyles.summarySubtitle,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -281,16 +244,12 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── 插画展示 ──
|
|
||||||
Image.asset(
|
Image.asset(
|
||||||
artworkPath,
|
artworkPath,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 206,
|
height: 206,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── AI问诊引导 ──
|
|
||||||
if (agent == ActiveAgent.consultation) ...[
|
if (agent == ActiveAgent.consultation) ...[
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Padding(
|
Padding(
|
||||||
@@ -303,7 +262,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'在下方输入框描述您的症状,AI医生将为您提供专业建议',
|
'小脉会陪您一步步梳理症状。请先告诉我哪里不舒服,或说说您现在最担心的问题。',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -313,7 +272,6 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
if (actions.isNotEmpty) ...[
|
if (actions.isNotEmpty) ...[
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Padding(
|
Padding(
|
||||||
@@ -336,8 +294,6 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// ── 底部提示 ──
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||||
@@ -378,7 +334,6 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +350,8 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
} else if (a.action == 'pickFoodCamera' ||
|
} else if (a.action == 'pickFoodCamera' ||
|
||||||
a.action == 'pickFoodGallery') {
|
a.action == 'pickFoodGallery') {
|
||||||
ref.read(dietActionProvider.notifier).trigger(a.action!);
|
ref.read(dietActionProvider.notifier).trigger(a.action!);
|
||||||
|
} else if (a.action == 'openReportUpload') {
|
||||||
|
pushRoute(ref, 'reports', params: {'openUpload': 'true'});
|
||||||
} else if (a.route != null) {
|
} else if (a.route != null) {
|
||||||
if (a.route == 'camera' || a.route == 'gallery') {
|
if (a.route == 'camera' || a.route == 'gallery') {
|
||||||
ref.read(cameraActionProvider.notifier).trigger(a.route!);
|
ref.read(cameraActionProvider.notifier).trigger(a.route!);
|
||||||
@@ -473,6 +430,11 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
backendType != 'exercise');
|
backendType != 'exercise');
|
||||||
final isExercise = backendType == 'exercise';
|
final isExercise = backendType == 'exercise';
|
||||||
final isHealth = !isMedication && !isExercise;
|
final isHealth = !isMedication && !isExercise;
|
||||||
|
final visual = isMedication
|
||||||
|
? AppModuleVisuals.medication
|
||||||
|
: isExercise
|
||||||
|
? AppModuleVisuals.exercise
|
||||||
|
: AppModuleVisuals.health;
|
||||||
|
|
||||||
// 根据类型获取显示字段
|
// 根据类型获取显示字段
|
||||||
String title = '数据确认';
|
String title = '数据确认';
|
||||||
@@ -512,7 +474,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
} else if (isExercise) {
|
} else if (isExercise) {
|
||||||
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
|
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
|
||||||
title = '运动计划确认';
|
title = '运动计划确认';
|
||||||
titleIcon = LucideIcons.footprints;
|
titleIcon = AppModuleVisuals.exercise.icon;
|
||||||
final exName =
|
final exName =
|
||||||
(meta['value'] ??
|
(meta['value'] ??
|
||||||
meta['name'] ??
|
meta['name'] ??
|
||||||
@@ -574,8 +536,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return _AnimatedCardEntry(
|
return Align(
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
@@ -585,7 +546,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF6366F1).withValues(alpha: 0.10),
|
color: visual.color.withValues(alpha: 0.10),
|
||||||
blurRadius: 28,
|
blurRadius: 28,
|
||||||
offset: const Offset(0, 14),
|
offset: const Offset(0, 14),
|
||||||
),
|
),
|
||||||
@@ -604,11 +565,11 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 18),
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 18),
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFFFFFFF), Color(0xFFF7F4FF)],
|
colors: [Colors.white, visual.lightColor],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -618,11 +579,11 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
width: 56,
|
width: 56,
|
||||||
height: 56,
|
height: 56,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.actionOutlineGradient,
|
gradient: visual.gradient,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.auraIndigo.withValues(alpha: 0.18),
|
color: visual.color.withValues(alpha: 0.18),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
offset: const Offset(0, 4),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
@@ -674,7 +635,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(1.4),
|
padding: const EdgeInsets.all(1.4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.actionOutlineGradient,
|
gradient: visual.gradient,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -691,7 +652,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
width: 68,
|
width: 68,
|
||||||
height: 68,
|
height: 68,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.lightGradient,
|
color: visual.lightColor,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -699,14 +660,14 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
? Icon(
|
? Icon(
|
||||||
_getMetricIconData(backendType),
|
_getMetricIconData(backendType),
|
||||||
size: 34,
|
size: 34,
|
||||||
color: AppColors.primaryDark,
|
color: visual.color,
|
||||||
)
|
)
|
||||||
: Icon(
|
: Icon(
|
||||||
isMedication
|
isMedication
|
||||||
? Icons.medication_liquid_outlined
|
? Icons.medication_liquid_outlined
|
||||||
: LucideIcons.footprints,
|
: AppModuleVisuals.exercise.icon,
|
||||||
size: 36,
|
size: 36,
|
||||||
color: AppColors.iconColor,
|
color: visual.color,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -745,7 +706,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
color: abnormal
|
color: abnormal
|
||||||
? AppColors.error
|
? AppColors.error
|
||||||
: AppColors.iconColor,
|
: visual.color,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -828,9 +789,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
height: 1,
|
height: 1,
|
||||||
color: AppColors.borderLight,
|
color: AppColors.borderLight,
|
||||||
margin: const EdgeInsets.symmetric(
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
horizontal: 16,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
_buildFieldRow(fields[i]),
|
_buildFieldRow(fields[i]),
|
||||||
],
|
],
|
||||||
@@ -849,11 +808,11 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
height: 56,
|
height: 56,
|
||||||
padding: const EdgeInsets.all(1.4),
|
padding: const EdgeInsets.all(1.4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.actionOutlineGradient,
|
gradient: visual.gradient,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.auraIndigo.withValues(alpha: 0.18),
|
color: visual.color.withValues(alpha: 0.18),
|
||||||
blurRadius: 18,
|
blurRadius: 18,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 8),
|
||||||
),
|
),
|
||||||
@@ -913,7 +872,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.actionOutlineGradient,
|
gradient: visual.gradient,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
@@ -942,7 +901,6 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,27 +941,10 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.all(1.4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.actionOutlineGradient,
|
|
||||||
borderRadius: const BorderRadius.only(
|
|
||||||
topLeft: Radius.circular(4),
|
|
||||||
topRight: Radius.circular(20),
|
|
||||||
bottomLeft: Radius.circular(20),
|
|
||||||
bottomRight: Radius.circular(20),
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: AppTheme.primary.withAlpha(12),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 3),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: AppColors.cardBackground,
|
color: Color(0xFFF8F8FF),
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.only(
|
||||||
topLeft: Radius.circular(3),
|
topLeft: Radius.circular(3),
|
||||||
topRight: Radius.circular(18.6),
|
topRight: Radius.circular(18.6),
|
||||||
@@ -1043,7 +984,6 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
ChatMessage msg,
|
ChatMessage msg,
|
||||||
ChatState? chatState,
|
|
||||||
) {
|
) {
|
||||||
final isUser = msg.isUser;
|
final isUser = msg.isUser;
|
||||||
final imageUrl = msg.metadata?['imageUrl'] as String?;
|
final imageUrl = msg.metadata?['imageUrl'] as String?;
|
||||||
@@ -1061,29 +1001,11 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxWidth: MediaQuery.sizeOf(context).width * 0.82,
|
maxWidth: MediaQuery.sizeOf(context).width * 0.82,
|
||||||
),
|
),
|
||||||
padding: EdgeInsets.all(isUser ? 0 : 1.4),
|
padding: EdgeInsets.zero,
|
||||||
decoration: isUser
|
|
||||||
? null
|
|
||||||
: BoxDecoration(
|
|
||||||
gradient: AppColors.actionOutlineGradient,
|
|
||||||
borderRadius: const BorderRadius.only(
|
|
||||||
topLeft: Radius.circular(4),
|
|
||||||
topRight: Radius.circular(20),
|
|
||||||
bottomLeft: Radius.circular(20),
|
|
||||||
bottomRight: Radius.circular(20),
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: AppTheme.primary.withAlpha(12),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 3),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isUser ? AppTheme.primary : AppColors.cardBackground,
|
color: isUser ? const Color(0xFF5F56FF) : const Color(0xFFF8F8FF),
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.only(
|
||||||
topLeft: Radius.circular(isUser ? 18.6 : 3),
|
topLeft: Radius.circular(isUser ? 18.6 : 3),
|
||||||
topRight: Radius.circular(isUser ? 3 : 18.6),
|
topRight: Radius.circular(isUser ? 3 : 18.6),
|
||||||
@@ -1186,31 +1108,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (!isUser && msg.content.isNotEmpty)
|
if (!isUser && msg.content.isNotEmpty) const AiGeneratedNote(),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 10),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const CircleAvatar(
|
|
||||||
radius: 10,
|
|
||||||
backgroundColor: AppTheme.primaryLight,
|
|
||||||
child: Icon(
|
|
||||||
Icons.chat_bubble_outline,
|
|
||||||
size: 17,
|
|
||||||
color: AppTheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
const Text(
|
|
||||||
'内容由 AI 生成',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: AppColors.textHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1268,7 +1166,6 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// 处理 AI 回复里的 markdown 链接点击:
|
/// 处理 AI 回复里的 markdown 链接点击:
|
||||||
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
|
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
|
||||||
/// - app://report → 跳到报告列表(用户可在那里上传新报告)
|
/// - app://report → 跳到报告列表(用户可在那里上传新报告)
|
||||||
@@ -1425,10 +1322,14 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
ActiveAgent.consultation => (
|
ActiveAgent.consultation => (
|
||||||
LucideIcons.messageCircle,
|
LucideIcons.messageCircle,
|
||||||
'AI问诊',
|
'AI问诊',
|
||||||
'AI智能问诊,描述症状获取建议',
|
'现在由小脉为您进行预问诊,您可以放心说出身体的不适和疑问',
|
||||||
),
|
),
|
||||||
ActiveAgent.report => (LucideIcons.fileText, '报告分析', '上传体检报告,AI 辅助解读'),
|
ActiveAgent.report => (LucideIcons.fileText, '报告分析', '上传体检报告,AI 辅助解读'),
|
||||||
ActiveAgent.exercise => (LucideIcons.footprints, '运动', '制定运动计划,打卡记录进度'),
|
ActiveAgent.exercise => (
|
||||||
|
Icons.directions_run_outlined,
|
||||||
|
'运动',
|
||||||
|
'制定运动计划,打卡记录进度',
|
||||||
|
),
|
||||||
_ => (Icons.forum_outlined, 'AI 助手', '血管病患者的 AI 健康管理助手'),
|
_ => (Icons.forum_outlined, 'AI 助手', '血管病患者的 AI 健康管理助手'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1437,7 +1338,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
ActiveAgent.health => '直接说出您的症状或数据,如"血压130/85,心率72"',
|
ActiveAgent.health => '直接说出您的症状或数据,如"血压130/85,心率72"',
|
||||||
ActiveAgent.diet => '拍照或描述您吃了什么,如"早餐吃了两个鸡蛋一杯牛奶"',
|
ActiveAgent.diet => '拍照或描述您吃了什么,如"早餐吃了两个鸡蛋一杯牛奶"',
|
||||||
ActiveAgent.medication => '说出药品名称和用法,如"每天早晚各一片阿司匹林"',
|
ActiveAgent.medication => '说出药品名称和用法,如"每天早晚各一片阿司匹林"',
|
||||||
ActiveAgent.consultation => '描述您的症状和不适感,如"最近总是头晕胸闷"',
|
ActiveAgent.consultation => '小脉会根据您的回答逐步追问,并给出观察或就医建议',
|
||||||
ActiveAgent.report => '上传您的检查报告,AI 将自动提取指标并解读',
|
ActiveAgent.report => '上传您的检查报告,AI 将自动提取指标并解读',
|
||||||
ActiveAgent.exercise => '说出运动计划,如"每天散步30分钟坚持一周"',
|
ActiveAgent.exercise => '说出运动计划,如"每天散步30分钟坚持一周"',
|
||||||
_ => '直接描述您的需求,AI 会自动为您记录和分析',
|
_ => '直接描述您的需求,AI 会自动为您记录和分析',
|
||||||
@@ -1463,14 +1364,106 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTaskCardInChat(BuildContext context, WidgetRef ref) {
|
Widget _buildTaskCardInChat(BuildContext context, WidgetRef ref) {
|
||||||
final health = ref.watch(latestHealthProvider);
|
final snapshotAsync = ref.watch(todayHealthSnapshotProvider);
|
||||||
final reminders = ref.watch(medicationReminderProvider);
|
final snapshot = snapshotAsync.value;
|
||||||
return health.when(
|
if (snapshot == null) {
|
||||||
data: (data) => _taskCardBubble(context, ref, data, reminders),
|
return _taskCardPlaceholder(
|
||||||
loading: () =>
|
snapshotAsync.hasError ? '今日健康暂时无法获取' : '正在获取今日健康',
|
||||||
_taskCardBubble(context, ref, {}, const AsyncValue.loading()),
|
isError: snapshotAsync.hasError,
|
||||||
error: (_, e) =>
|
onRetry: () => ref.invalidate(todayHealthSnapshotProvider),
|
||||||
_taskCardBubble(context, ref, {}, const AsyncValue.loading()),
|
);
|
||||||
|
}
|
||||||
|
return _taskCardBubble(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
snapshot.health,
|
||||||
|
AsyncValue.data(snapshot.reminders),
|
||||||
|
AsyncValue.data(snapshot.exercisePlan),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _taskCardPlaceholder(
|
||||||
|
String message, {
|
||||||
|
required bool isError,
|
||||||
|
required VoidCallback onRetry,
|
||||||
|
}) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
boxShadow: AppColors.cardShadow,
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 13, 16, 11),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'今日健康',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${now.month}月${now.day}日',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
InkWell(
|
||||||
|
onTap: isError ? onRetry : null,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 11),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox.square(
|
||||||
|
dimension: 28,
|
||||||
|
child: isError
|
||||||
|
? const Icon(
|
||||||
|
LucideIcons.refreshCw,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
)
|
||||||
|
: const CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
isError ? '$message,点击重试' : message,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1479,6 +1472,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
Map<String, dynamic> healthData,
|
Map<String, dynamic> healthData,
|
||||||
AsyncValue<List<Map<String, dynamic>>> reminders,
|
AsyncValue<List<Map<String, dynamic>>> reminders,
|
||||||
|
AsyncValue<Map<String, dynamic>?> exercisePlan,
|
||||||
) {
|
) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final tasks = <Widget>[];
|
final tasks = <Widget>[];
|
||||||
@@ -1504,27 +1498,58 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
wtText == null;
|
wtText == null;
|
||||||
|
|
||||||
// ── 多指标异常检测 ──
|
// ── 多指标异常检测 ──
|
||||||
|
// 优先使用后端保存时计算出的 IsAbnormal,兜底规则必须和后端 HealthRecordRules 保持一致。
|
||||||
final abnormals = <String>[];
|
final abnormals = <String>[];
|
||||||
if (bp is Map && bp['systolic'] is int) {
|
final bpAbnormal = _recordIsAbnormal(
|
||||||
final s = bp['systolic'] as int;
|
bp,
|
||||||
final d = bp['diastolic'] is int ? bp['diastolic'] as int : 0;
|
fallback: () {
|
||||||
if (s >= 140 || d >= 90) abnormals.add('血压 $s/$d 偏高');
|
if (bp is! Map) return false;
|
||||||
|
final s = _numValue(bp['systolic'] ?? bp['Systolic']);
|
||||||
|
final d = _numValue(bp['diastolic'] ?? bp['Diastolic']);
|
||||||
|
return s != null &&
|
||||||
|
d != null &&
|
||||||
|
(s >= 140 || d >= 90 || s <= 89 || d <= 59);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (bpAbnormal && bp is Map) {
|
||||||
|
final s = _numValue(bp['systolic'] ?? bp['Systolic']);
|
||||||
|
final d = _numValue(bp['diastolic'] ?? bp['Diastolic']);
|
||||||
|
abnormals.add(
|
||||||
|
'血压 ${s?.toStringAsFixed(0) ?? '--'}/${d?.toStringAsFixed(0) ?? '--'} 异常',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (hr is Map && hr['value'] is num) {
|
final hrAbnormal = _recordIsAbnormal(
|
||||||
final v = (hr['value'] as num).toDouble();
|
hr,
|
||||||
if (v > 100) {
|
fallback: () {
|
||||||
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快');
|
final v = _numValue(hr is Map ? hr['value'] ?? hr['Value'] : null);
|
||||||
} else if (v < 60) {
|
return v != null && (v > 100 || v < 60);
|
||||||
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢');
|
},
|
||||||
|
);
|
||||||
|
if (hrAbnormal && hr is Map) {
|
||||||
|
final v = _numValue(hr['value'] ?? hr['Value']);
|
||||||
|
abnormals.add('心率 ${v?.toStringAsFixed(0) ?? '--'} 异常');
|
||||||
}
|
}
|
||||||
|
final bsAbnormal = _recordIsAbnormal(
|
||||||
|
bs,
|
||||||
|
fallback: () {
|
||||||
|
final v = _numValue(bs is Map ? bs['value'] ?? bs['Value'] : null);
|
||||||
|
return v != null && (v > 6.1 || v < 3.9);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (bsAbnormal && bs is Map) {
|
||||||
|
final v = _numValue(bs['value'] ?? bs['Value']);
|
||||||
|
abnormals.add('血糖 ${v?.toStringAsFixed(1) ?? '--'} 异常');
|
||||||
}
|
}
|
||||||
if (bs is Map && bs['value'] is num) {
|
final boAbnormal = _recordIsAbnormal(
|
||||||
final v = (bs['value'] as num).toDouble();
|
bo,
|
||||||
if (v > 6.1) abnormals.add('血糖 ${v.toStringAsFixed(1)} 偏高');
|
fallback: () {
|
||||||
}
|
final v = _numValue(bo is Map ? bo['value'] ?? bo['Value'] : null);
|
||||||
if (bo is Map && bo['value'] is num) {
|
return v != null && v <= 94;
|
||||||
final v = (bo['value'] as num).toDouble();
|
},
|
||||||
if (v < 95) abnormals.add('血氧 ${v.toStringAsFixed(0)}% 偏低');
|
);
|
||||||
|
if (boAbnormal && bo is Map) {
|
||||||
|
final v = _numValue(bo['value'] ?? bo['Value']);
|
||||||
|
abnormals.add('血氧 ${v?.toStringAsFixed(0) ?? '--'}% 异常');
|
||||||
}
|
}
|
||||||
final hasAbnormal = abnormals.isNotEmpty;
|
final hasAbnormal = abnormals.isNotEmpty;
|
||||||
|
|
||||||
@@ -1541,12 +1566,12 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
LucideIcons.triangleAlert,
|
AppModuleVisuals.healthOverviewIcon,
|
||||||
'健康指标',
|
'健康指标',
|
||||||
trailing: trailing,
|
trailing: trailing,
|
||||||
status: 'warning',
|
status: 'warning',
|
||||||
iconColor: healthIconColor,
|
iconColor: AppColors.warning,
|
||||||
iconBg: healthIconBg,
|
iconBg: AppColors.warningLight,
|
||||||
onTap: () => pushRoute(ref, 'trend'),
|
onTap: () => pushRoute(ref, 'trend'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1555,7 +1580,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
LucideIcons.circleCheck,
|
AppModuleVisuals.healthOverviewIcon,
|
||||||
'健康指标',
|
'健康指标',
|
||||||
trailing: '指标正常',
|
trailing: '指标正常',
|
||||||
status: 'done',
|
status: 'done',
|
||||||
@@ -1569,7 +1594,6 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
// ── 2. 运动 ──
|
// ── 2. 运动 ──
|
||||||
final exIconColor = AppModuleVisuals.exercise.color;
|
final exIconColor = AppModuleVisuals.exercise.color;
|
||||||
final exIconBg = AppModuleVisuals.exercise.lightColor;
|
final exIconBg = AppModuleVisuals.exercise.lightColor;
|
||||||
final exercisePlan = ref.watch(currentExercisePlanProvider);
|
|
||||||
var hasExercise = false;
|
var hasExercise = false;
|
||||||
exercisePlan.when(
|
exercisePlan.when(
|
||||||
data: (plan) {
|
data: (plan) {
|
||||||
@@ -1594,7 +1618,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
LucideIcons.footprints,
|
AppModuleVisuals.exercise.icon,
|
||||||
'$name $dur分钟',
|
'$name $dur分钟',
|
||||||
status: done
|
status: done
|
||||||
? 'done'
|
? 'done'
|
||||||
@@ -1612,7 +1636,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
LucideIcons.footprints,
|
AppModuleVisuals.exercise.icon,
|
||||||
'运动',
|
'运动',
|
||||||
trailing: '正在加载运动计划',
|
trailing: '正在加载运动计划',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
@@ -1627,7 +1651,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
LucideIcons.footprints,
|
AppModuleVisuals.exercise.icon,
|
||||||
'运动',
|
'运动',
|
||||||
trailing: '运动计划加载失败',
|
trailing: '运动计划加载失败',
|
||||||
status: 'overdue',
|
status: 'overdue',
|
||||||
@@ -1642,7 +1666,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
LucideIcons.footprints,
|
AppModuleVisuals.exercise.icon,
|
||||||
'运动',
|
'运动',
|
||||||
trailing: '暂无今日运动计划',
|
trailing: '暂无今日运动计划',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
@@ -1749,7 +1773,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
padding: const EdgeInsets.fromLTRB(16, 13, 16, 11),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.centerLeft,
|
begin: Alignment.centerLeft,
|
||||||
@@ -1760,26 +1784,13 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.6),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.health_and_safety,
|
|
||||||
size: 16,
|
|
||||||
color: AppColors.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
const Text(
|
||||||
'今日健康',
|
'今日健康',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.15,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@@ -1794,14 +1805,14 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.fromLTRB(14, 5, 14, 7),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (tasks.isEmpty)
|
if (tasks.isEmpty)
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
Icons.check_circle,
|
LucideIcons.circleCheck,
|
||||||
'今日暂无待办',
|
'今日暂无待办',
|
||||||
status: 'done',
|
status: 'done',
|
||||||
)
|
)
|
||||||
@@ -1832,34 +1843,36 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
'overdue': AppTheme.error,
|
'overdue': AppTheme.error,
|
||||||
};
|
};
|
||||||
final icons = {
|
final icons = {
|
||||||
'done': Icons.check_circle,
|
'done': LucideIcons.circleCheck,
|
||||||
'warning': Icons.warning,
|
'warning': LucideIcons.triangleAlert,
|
||||||
'pending': Icons.circle_outlined,
|
'pending': LucideIcons.circle,
|
||||||
'overdue': Icons.error,
|
'overdue': LucideIcons.circleAlert,
|
||||||
};
|
};
|
||||||
final isOverdue = status == 'overdue';
|
|
||||||
final ic = iconColor ?? AppTheme.primary;
|
final ic = iconColor ?? AppTheme.primary;
|
||||||
final ibg = iconBg ?? AppTheme.primaryLight;
|
final ibg = iconBg ?? AppTheme.primaryLight;
|
||||||
|
final textColor = switch (status) {
|
||||||
|
'overdue' => AppColors.error,
|
||||||
|
'warning' => AppColors.warning,
|
||||||
|
_ => AppColors.textPrimary,
|
||||||
|
};
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||||
child: GestureDetector(
|
child: Material(
|
||||||
behavior: HitTestBehavior.opaque,
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
borderRadius: AppRadius.smBorder,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
child: Padding(
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 5),
|
||||||
color: isOverdue ? const Color(0xFFFFEBEE) : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 30,
|
width: 32,
|
||||||
height: 30,
|
height: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: ibg,
|
color: ibg,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: AppRadius.smBorder,
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 18, color: ic),
|
child: Icon(icon, size: 18, color: ic),
|
||||||
),
|
),
|
||||||
@@ -1867,23 +1880,50 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
trailing ?? label,
|
trailing ?? label,
|
||||||
style: const TextStyle(
|
maxLines: 1,
|
||||||
fontSize: 16,
|
overflow: TextOverflow.ellipsis,
|
||||||
color: AppColors.textPrimary,
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: status == 'overdue' || status == 'warning'
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.w500,
|
||||||
|
color: textColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(
|
Icon(
|
||||||
icons[status] ?? Icons.circle_outlined,
|
icons[status] ?? Icons.circle_outlined,
|
||||||
size: 21,
|
size: 20,
|
||||||
color: colors[status] ?? Colors.grey,
|
color: colors[status] ?? Colors.grey,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _recordIsAbnormal(dynamic record, {required bool Function() fallback}) {
|
||||||
|
if (record is Map) {
|
||||||
|
final raw =
|
||||||
|
record['isAbnormal'] ?? record['IsAbnormal'] ?? record['abnormal'];
|
||||||
|
if (raw is bool) return raw;
|
||||||
|
if (raw is String) {
|
||||||
|
final normalized = raw.toLowerCase();
|
||||||
|
if (normalized == 'true') return true;
|
||||||
|
if (normalized == 'false') return false;
|
||||||
|
}
|
||||||
|
if (raw is num) return raw != 0;
|
||||||
|
}
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
double? _numValue(dynamic value) {
|
||||||
|
if (value is num) return value.toDouble();
|
||||||
|
if (value is String) return double.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AgentMark extends StatelessWidget {
|
class _AgentMark extends StatelessWidget {
|
||||||
@@ -1906,7 +1946,7 @@ class _AgentMark extends StatelessWidget {
|
|||||||
? Alignment.bottomCenter
|
? Alignment.bottomCenter
|
||||||
: Alignment.bottomRight,
|
: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(colors.verticalGradient ? 18 : 20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: colors.accent.withValues(alpha: 0.18),
|
color: colors.accent.withValues(alpha: 0.18),
|
||||||
@@ -2012,7 +2052,7 @@ final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
|||||||
ActiveAgent.health: [
|
ActiveAgent.health: [
|
||||||
_AgentAction(
|
_AgentAction(
|
||||||
label: '健康概览',
|
label: '健康概览',
|
||||||
icon: Icons.trending_up,
|
icon: AppModuleVisuals.healthOverviewIcon,
|
||||||
isWide: true,
|
isWide: true,
|
||||||
route: 'trend',
|
route: 'trend',
|
||||||
),
|
),
|
||||||
@@ -2052,10 +2092,10 @@ final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
|||||||
label: '上传报告',
|
label: '上传报告',
|
||||||
icon: Icons.upload_file_outlined,
|
icon: Icons.upload_file_outlined,
|
||||||
isWide: true,
|
isWide: true,
|
||||||
route: 'reports',
|
action: 'openReportUpload',
|
||||||
),
|
),
|
||||||
_AgentAction(
|
_AgentAction(
|
||||||
label: '查看历史',
|
label: '查看报告',
|
||||||
icon: Icons.history_outlined,
|
icon: Icons.history_outlined,
|
||||||
isWide: true,
|
isWide: true,
|
||||||
route: 'reports',
|
route: 'reports',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user