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,37 +96,68 @@ Future<DateTime?> showAppDatePicker(
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
Expanded(
|
||||||
selectionOverlay: null,
|
child: _wrapPicker(
|
||||||
scrollController: yearCtrl,
|
CupertinoPicker(
|
||||||
itemExtent: 40,
|
selectionOverlay: null,
|
||||||
onSelectedItemChanged: (i) {
|
scrollController: yearCtrl,
|
||||||
y = minDate.year + i;
|
itemExtent: 40,
|
||||||
clampDay();
|
onSelectedItemChanged: (i) {
|
||||||
final maxDay = daysInMonth(y, m);
|
y = minDate.year + i;
|
||||||
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
|
clampDay();
|
||||||
},
|
final maxDay = daysInMonth(y, m);
|
||||||
children: [for (var i = minDate.year; i <= maxDate.year; i++) Center(child: Text('$i', style: _pickerItemStyle))],
|
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
|
||||||
))),
|
},
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
children: [
|
||||||
selectionOverlay: null,
|
for (var i = minDate.year; i <= maxDate.year; i++)
|
||||||
scrollController: monthCtrl,
|
Center(child: Text('$i', style: _pickerItemStyle)),
|
||||||
itemExtent: 40,
|
],
|
||||||
onSelectedItemChanged: (i) {
|
),
|
||||||
m = i + 1;
|
),
|
||||||
clampDay();
|
),
|
||||||
final maxDay = daysInMonth(y, m);
|
Expanded(
|
||||||
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
|
child: _wrapPicker(
|
||||||
},
|
CupertinoPicker(
|
||||||
children: [for (var i = 1; i <= 12; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
selectionOverlay: null,
|
||||||
))),
|
scrollController: monthCtrl,
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
itemExtent: 40,
|
||||||
selectionOverlay: null,
|
onSelectedItemChanged: (i) {
|
||||||
scrollController: dayCtrl,
|
m = i + 1;
|
||||||
itemExtent: 40,
|
clampDay();
|
||||||
onSelectedItemChanged: (i) => d = i + 1,
|
final maxDay = daysInMonth(y, m);
|
||||||
children: [for (var i = 1; i <= daysInMonth(y, m); i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
|
selectionOverlay: null,
|
||||||
|
scrollController: dayCtrl,
|
||||||
|
itemExtent: 40,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -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(
|
||||||
selectionOverlay: null,
|
flex: 2,
|
||||||
scrollController: hourCtrl,
|
child: _wrapPicker(
|
||||||
itemExtent: 40,
|
CupertinoPicker(
|
||||||
onSelectedItemChanged: (i) => hour = i,
|
selectionOverlay: null,
|
||||||
children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
scrollController: hourCtrl,
|
||||||
))),
|
itemExtent: 40,
|
||||||
Expanded(flex: 2, child: _wrapPicker(CupertinoPicker(
|
onSelectedItemChanged: (i) => hour = i,
|
||||||
selectionOverlay: null,
|
children: [
|
||||||
scrollController: minuteCtrl,
|
for (var i = 0; i <= 23; i++)
|
||||||
itemExtent: 40,
|
Center(
|
||||||
onSelectedItemChanged: (i) => minute = i,
|
child: Text(
|
||||||
children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
i.toString().padLeft(2, '0'),
|
||||||
))),
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
|
selectionOverlay: null,
|
||||||
|
scrollController: minuteCtrl,
|
||||||
|
itemExtent: 40,
|
||||||
|
onSelectedItemChanged: (i) => minute = i,
|
||||||
|
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(
|
||||||
selectionOverlay: null,
|
child: _wrapPicker(
|
||||||
scrollController: yearCtrl,
|
CupertinoPicker(
|
||||||
itemExtent: 40,
|
selectionOverlay: null,
|
||||||
onSelectedItemChanged: (i) { y = minDate.year + i; clampDay(); },
|
scrollController: yearCtrl,
|
||||||
children: [for (var i = minDate.year; i <= maxDate.year; i++) Center(child: Text('$i', style: _pickerItemStyle))],
|
itemExtent: 40,
|
||||||
))),
|
onSelectedItemChanged: (i) {
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
y = minDate.year + i;
|
||||||
selectionOverlay: null,
|
clampDay();
|
||||||
scrollController: monthCtrl,
|
},
|
||||||
itemExtent: 40,
|
children: [
|
||||||
onSelectedItemChanged: (i) { m = i + 1; clampDay(); },
|
for (var i = minDate.year; i <= maxDate.year; i++)
|
||||||
children: [for (var i = 1; i <= 12; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
Center(child: Text('$i', style: _pickerItemStyle)),
|
||||||
))),
|
],
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
),
|
||||||
selectionOverlay: null,
|
),
|
||||||
scrollController: dayCtrl,
|
),
|
||||||
itemExtent: 40,
|
Expanded(
|
||||||
onSelectedItemChanged: (i) => d = i + 1,
|
child: _wrapPicker(
|
||||||
children: [for (var i = 1; i <= daysInMonth(y, m); i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
CupertinoPicker(
|
||||||
))),
|
selectionOverlay: null,
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
scrollController: monthCtrl,
|
||||||
selectionOverlay: null,
|
itemExtent: 40,
|
||||||
scrollController: hourCtrl,
|
onSelectedItemChanged: (i) {
|
||||||
itemExtent: 40,
|
m = i + 1;
|
||||||
onSelectedItemChanged: (i) => h = i,
|
clampDay();
|
||||||
children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
},
|
||||||
))),
|
children: [
|
||||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
for (var i = 1; i <= 12; i++)
|
||||||
selectionOverlay: null,
|
Center(
|
||||||
scrollController: minuteCtrl,
|
child: Text(
|
||||||
itemExtent: 40,
|
i.toString().padLeft(2, '0'),
|
||||||
onSelectedItemChanged: (i) => min = i,
|
style: _pickerItemStyle,
|
||||||
children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
|
),
|
||||||
))),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
|
selectionOverlay: null,
|
||||||
|
scrollController: dayCtrl,
|
||||||
|
itemExtent: 40,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
|
selectionOverlay: null,
|
||||||
|
scrollController: hourCtrl,
|
||||||
|
itemExtent: 40,
|
||||||
|
onSelectedItemChanged: (i) => h = i,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i <= 23; i++)
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
i.toString().padLeft(2, '0'),
|
||||||
|
style: _pickerItemStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _wrapPicker(
|
||||||
|
CupertinoPicker(
|
||||||
|
selectionOverlay: null,
|
||||||
|
scrollController: minuteCtrl,
|
||||||
|
itemExtent: 40,
|
||||||
|
onSelectedItemChanged: (i) => min = i,
|
||||||
|
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(
|
||||||
selectionOverlay: null,
|
CupertinoPicker(
|
||||||
scrollController: FixedExtentScrollController(initialItem: selected - min),
|
selectionOverlay: null,
|
||||||
itemExtent: 40,
|
scrollController: FixedExtentScrollController(
|
||||||
onSelectedItemChanged: (i) => selected = i + min,
|
initialItem: selected - min,
|
||||||
children: [
|
),
|
||||||
for (var i = min; i <= max; i++)
|
itemExtent: 40,
|
||||||
Center(child: Text(label.isEmpty ? '$i' : '$i$label', style: _pickerItemStyle)),
|
onSelectedItemChanged: (i) => selected = i + min,
|
||||||
],
|
children: [
|
||||||
)),
|
for (var i = min; i <= max; i++)
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
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('privacy');
|
||||||
|
},
|
||||||
|
child: AppGradientText(
|
||||||
|
'《隐私政策》',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.55,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -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,137 +388,146 @@ 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(
|
||||||
child: LayoutBuilder(
|
duration: const Duration(milliseconds: 180),
|
||||||
builder: (context, constraints) {
|
curve: Curves.easeOutCubic,
|
||||||
return SingleChildScrollView(
|
padding: EdgeInsets.only(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 24),
|
bottom: MediaQuery.viewInsetsOf(context).bottom,
|
||||||
child: ConstrainedBox(
|
),
|
||||||
constraints: BoxConstraints(
|
child: SafeArea(
|
||||||
minHeight: (constraints.maxHeight - 52).clamp(
|
child: LayoutBuilder(
|
||||||
0.0,
|
builder: (context, constraints) {
|
||||||
double.infinity,
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 22, 20, 24),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
minHeight: (constraints.maxHeight - 52).clamp(
|
||||||
|
0.0,
|
||||||
|
double.infinity,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
child: Center(
|
||||||
child: Center(
|
child: ConstrainedBox(
|
||||||
child: ConstrainedBox(
|
constraints: const BoxConstraints(maxWidth: 430),
|
||||||
constraints: const BoxConstraints(maxWidth: 430),
|
child: Column(
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
children: [
|
||||||
children: [
|
const _LoginHero(),
|
||||||
const _LoginHero(),
|
const SizedBox(height: 22),
|
||||||
const SizedBox(height: 28),
|
_LoginFormCard(
|
||||||
_LoginFormCard(
|
child: Column(
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
if (_successMsg != null)
|
||||||
if (_successMsg != null)
|
_NoticeBanner(
|
||||||
_NoticeBanner(
|
text: _successMsg!,
|
||||||
text: _successMsg!,
|
success: true,
|
||||||
success: true,
|
|
||||||
),
|
|
||||||
if (!_isLogin) ...[
|
|
||||||
_DoctorSelector(
|
|
||||||
name: _selectedDoctorName,
|
|
||||||
onTap: _showDoctorPicker,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_TextField(
|
|
||||||
_nameCtrl,
|
|
||||||
'姓名',
|
|
||||||
TextInputType.name,
|
|
||||||
20,
|
|
||||||
icon: Icons.person_outline_rounded,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
|
||||||
_TextField(
|
|
||||||
_phoneCtrl,
|
|
||||||
'手机号',
|
|
||||||
TextInputType.phone,
|
|
||||||
11,
|
|
||||||
icon: Icons.local_phone_rounded,
|
|
||||||
gradientIcon: true,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _TextField(
|
|
||||||
_codeCtrl,
|
|
||||||
'验证码',
|
|
||||||
TextInputType.number,
|
|
||||||
6,
|
|
||||||
icon: Icons.sms_rounded,
|
|
||||||
gradientIcon: true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
if (!_isLogin) ...[
|
||||||
_SmsButton(
|
_DoctorSelector(
|
||||||
sending: _sending,
|
name: _selectedDoctorName,
|
||||||
countdown: _countdown,
|
onTap: _showDoctorPicker,
|
||||||
onTap: (_countdown > 0 || _sending)
|
),
|
||||||
? null
|
const SizedBox(height: 12),
|
||||||
: _sendSms,
|
_TextField(
|
||||||
|
_nameCtrl,
|
||||||
|
'姓名',
|
||||||
|
TextInputType.name,
|
||||||
|
20,
|
||||||
|
icon: LucideIcons.userRound,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
_TextField(
|
||||||
|
_phoneCtrl,
|
||||||
|
'手机号',
|
||||||
|
TextInputType.phone,
|
||||||
|
11,
|
||||||
|
icon: LucideIcons.phone,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _TextField(
|
||||||
|
_codeCtrl,
|
||||||
|
'验证码',
|
||||||
|
TextInputType.number,
|
||||||
|
6,
|
||||||
|
icon: LucideIcons.messageSquareText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_SmsButton(
|
||||||
|
sending: _sending,
|
||||||
|
countdown: _countdown,
|
||||||
|
onTap: (_countdown > 0 || _sending)
|
||||||
|
? null
|
||||||
|
: _sendSms,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_Agreement(
|
||||||
|
agreed: _agreed,
|
||||||
|
onTap: () =>
|
||||||
|
setState(() => _agreed = !_agreed),
|
||||||
|
onTermsTap: () =>
|
||||||
|
_openStaticText('terms'),
|
||||||
|
onPrivacyTap: () =>
|
||||||
|
_openStaticText('privacy'),
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_NoticeBanner(
|
||||||
|
text: _error!,
|
||||||
|
success: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
const SizedBox(height: 22),
|
||||||
const SizedBox(height: 14),
|
_PrimaryButton(
|
||||||
_Agreement(
|
loading: _loading,
|
||||||
agreed: _agreed,
|
label: _isLogin ? '登录' : '注册',
|
||||||
onTap: () =>
|
onTap: _loading ? null : _submit,
|
||||||
setState(() => _agreed = !_agreed),
|
|
||||||
),
|
|
||||||
if (_error != null) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_NoticeBanner(
|
|
||||||
text: _error!,
|
|
||||||
success: false,
|
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 22),
|
GestureDetector(
|
||||||
_PrimaryButton(
|
onTap: () => setState(() {
|
||||||
loading: _loading,
|
_isLogin = !_isLogin;
|
||||||
label: _isLogin ? '登录' : '注册',
|
_error = null;
|
||||||
onTap: _loading ? null : _submit,
|
_successMsg = null;
|
||||||
),
|
}),
|
||||||
const SizedBox(height: 16),
|
child: Center(
|
||||||
GestureDetector(
|
child: AppGradientText(
|
||||||
onTap: () => setState(() {
|
_isLogin ? '没有账号?去注册' : '已有账号?去登录',
|
||||||
_isLogin = !_isLogin;
|
style: const TextStyle(
|
||||||
_error = null;
|
fontSize: 14,
|
||||||
_successMsg = null;
|
fontWeight: FontWeight.w800,
|
||||||
}),
|
),
|
||||||
child: Text(
|
),
|
||||||
_isLogin ? '没有账号?去注册' : '已有账号?去登录',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: AppColors.primary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -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(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
Container(
|
children: [
|
||||||
|
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(
|
||||||
children: [
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
TextSpan(
|
children: [
|
||||||
text: '已阅读并同意',
|
GestureDetector(
|
||||||
style: TextStyle(fontSize: 12, color: AppColors.textHint),
|
onTap: onTap,
|
||||||
),
|
behavior: HitTestBehavior.opaque,
|
||||||
TextSpan(
|
child: const Text('已阅读并同意', style: textStyle),
|
||||||
text: '《服务协议》',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
GestureDetector(
|
||||||
|
onTap: onTermsTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: const AppGradientText('《服务协议》', style: linkStyle),
|
||||||
|
),
|
||||||
|
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;
|
||||||
Permission.bluetoothScan,
|
try {
|
||||||
Permission.bluetoothConnect,
|
statuses = await [
|
||||||
].request();
|
Permission.bluetoothScan,
|
||||||
|
Permission.bluetoothConnect,
|
||||||
|
].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,54 +657,61 @@ class _DeviceSection extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return ClipRRect(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
borderRadius: AppRadius.lgBorder,
|
||||||
children: [
|
child: ColoredBox(
|
||||||
Padding(
|
color: Colors.white,
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
child: Column(
|
||||||
child: Row(
|
children: [
|
||||||
children: [
|
Padding(
|
||||||
Container(
|
padding: const EdgeInsets.fromLTRB(14, 13, 14, 10),
|
||||||
width: 34,
|
child: Row(
|
||||||
height: 34,
|
children: [
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: _deviceVisual.lightColor,
|
width: 34,
|
||||||
borderRadius: BorderRadius.circular(11),
|
height: 34,
|
||||||
border: Border.all(color: AppColors.borderLight),
|
decoration: BoxDecoration(
|
||||||
),
|
color: _deviceVisual.lightColor,
|
||||||
child: Icon(type.icon, size: 19, color: _deviceVisual.color),
|
borderRadius: AppRadius.smBorder,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
type.icon,
|
||||||
|
size: 19,
|
||||||
|
color: _deviceVisual.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
type.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${devices.length} 台',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
type.label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'${devices.length}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: AppColors.textHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (final device in devices)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
|
||||||
child: _BoundDeviceTile(
|
|
||||||
device: device,
|
|
||||||
connected: connectedDeviceId == device.id,
|
|
||||||
onUnbind: () => onUnbind(device),
|
|
||||||
),
|
),
|
||||||
),
|
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]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -549,102 +719,123 @@ class _DeviceSection extends StatelessWidget {
|
|||||||
class _BoundDeviceTile extends StatelessWidget {
|
class _BoundDeviceTile extends StatelessWidget {
|
||||||
final BoundBleDevice device;
|
final BoundBleDevice device;
|
||||||
final bool connected;
|
final bool connected;
|
||||||
|
final bool busy;
|
||||||
|
final bool showDivider;
|
||||||
final VoidCallback onUnbind;
|
final VoidCallback onUnbind;
|
||||||
|
|
||||||
const _BoundDeviceTile({
|
const _BoundDeviceTile({
|
||||||
required this.device,
|
required this.device,
|
||||||
required this.connected,
|
required this.connected,
|
||||||
|
required this.busy,
|
||||||
|
required this.showDivider,
|
||||||
required this.onUnbind,
|
required this.onUnbind,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final accent = connected ? AppColors.success : AppColors.textHint;
|
final accent = connected ? AppColors.successText : AppColors.textHint;
|
||||||
|
final availability = deviceSyncAvailabilityLabel(device.type);
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 15, 10, 15),
|
padding: const EdgeInsets.only(left: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: connected
|
||||||
borderRadius: BorderRadius.circular(18),
|
? AppColors.successLight.withValues(alpha: 0.45)
|
||||||
border: Border.all(
|
: Colors.white,
|
||||||
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
AnimatedContainer(
|
AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
width: 50,
|
width: 42,
|
||||||
height: 50,
|
height: 42,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: connected
|
color: connected ? AppColors.successLight : AppColors.deviceLight,
|
||||||
? AppColors.successLight
|
borderRadius: AppRadius.smBorder,
|
||||||
: const Color(0xFFF8FAFC),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: AppColors.borderLight),
|
|
||||||
),
|
),
|
||||||
child: Icon(device.type.icon, color: accent, size: 25),
|
child: Icon(device.type.icon, color: accent, size: 22),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
constraints: const BoxConstraints(minHeight: 70),
|
||||||
children: [
|
padding: const EdgeInsets.fromLTRB(0, 12, 6, 12),
|
||||||
Text(
|
decoration: BoxDecoration(
|
||||||
device.name,
|
border: showDivider
|
||||||
maxLines: 1,
|
? const Border(
|
||||||
overflow: TextOverflow.ellipsis,
|
bottom: BorderSide(
|
||||||
style: const TextStyle(
|
color: AppColors.divider,
|
||||||
fontSize: 18,
|
width: 0.7,
|
||||||
fontWeight: FontWeight.w800,
|
),
|
||||||
color: AppColors.textPrimary,
|
)
|
||||||
|
: 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)
|
||||||
const SizedBox(height: 4),
|
Container(
|
||||||
Text(
|
margin: const EdgeInsets.only(right: 2),
|
||||||
'最近同步:${_formatLastSync(device.lastSyncAt)}',
|
padding: const EdgeInsets.symmetric(
|
||||||
style: const TextStyle(
|
horizontal: 9,
|
||||||
fontSize: 13,
|
vertical: 5,
|
||||||
fontWeight: FontWeight.w700,
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.successLight,
|
||||||
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'已连接',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.successText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '解绑设备',
|
||||||
|
onPressed: busy ? null : onUnbind,
|
||||||
|
icon: const Icon(Icons.delete_outline_rounded, size: 21),
|
||||||
color: AppColors.textHint,
|
color: AppColors.textHint,
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (connected)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.only(right: 4),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.successLight,
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'已连接',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
height: 1,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: AppColors.success,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
tooltip: '解绑设备',
|
|
||||||
onPressed: connected ? null : onUnbind,
|
|
||||||
icon: const Icon(Icons.delete_outline_rounded),
|
|
||||||
color: connected ? AppColors.textHint : AppColors.error,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -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: '点击右上角添加设备',
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: const Color(0xFFEFF6FF),
|
);
|
||||||
borderRadius: BorderRadius.circular(22),
|
}
|
||||||
),
|
}
|
||||||
child: const Icon(
|
|
||||||
Icons.bluetooth_disabled_rounded,
|
class _SyncStatusBar extends StatelessWidget {
|
||||||
color: Color(0xFF2563EB),
|
final String message;
|
||||||
size: 36,
|
final bool active;
|
||||||
),
|
final Future<void> Function()? onRetry;
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
const _SyncStatusBar({
|
||||||
const Text(
|
required this.message,
|
||||||
'还没有绑定设备',
|
required this.active,
|
||||||
style: TextStyle(
|
required this.onRetry,
|
||||||
fontSize: 19,
|
});
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: AppColors.textPrimary,
|
@override
|
||||||
),
|
Widget build(BuildContext context) => Container(
|
||||||
),
|
padding: const EdgeInsets.fromLTRB(14, 11, 8, 11),
|
||||||
const SizedBox(height: 8),
|
decoration: BoxDecoration(
|
||||||
const Text(
|
color: active ? AppColors.deviceLight : Colors.white,
|
||||||
'右上角添加设备',
|
borderRadius: AppRadius.mdBorder,
|
||||||
textAlign: TextAlign.center,
|
border: Border.all(color: AppColors.borderLight),
|
||||||
style: TextStyle(
|
),
|
||||||
fontSize: 14,
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
active ? Icons.sync_rounded : Icons.info_outline_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: active ? AppColors.device : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w700,
|
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(
|
||||||
connecting:
|
children: [
|
||||||
_connectingId == result.device.remoteId.toString(),
|
for (var index = 0; index < _results.length; index++)
|
||||||
onConnect: () => _connectBindAndSync(result),
|
_DeviceResultTile(
|
||||||
);
|
result: _results[index],
|
||||||
},
|
connecting:
|
||||||
|
_connectingId ==
|
||||||
|
_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,74 +437,93 @@ 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(
|
Expanded(
|
||||||
child: Column(
|
child: Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
constraints: const BoxConstraints(minHeight: 70),
|
||||||
children: [
|
padding: const EdgeInsets.fromLTRB(0, 12, 12, 12),
|
||||||
Text(
|
decoration: BoxDecoration(
|
||||||
type?.label ?? '健康设备',
|
border: showDivider
|
||||||
style: const TextStyle(
|
? const Border(
|
||||||
fontSize: 18,
|
bottom: BorderSide(
|
||||||
fontWeight: FontWeight.w800,
|
color: AppColors.divider,
|
||||||
color: AppColors.textPrimary,
|
width: 0.7,
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
const SizedBox(height: 4),
|
: null,
|
||||||
Text(
|
),
|
||||||
name,
|
child: Row(
|
||||||
maxLines: 1,
|
children: [
|
||||||
overflow: TextOverflow.ellipsis,
|
Expanded(
|
||||||
style: const TextStyle(
|
child: Column(
|
||||||
fontSize: 14,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
color: AppColors.textHint,
|
children: [
|
||||||
),
|
Text(
|
||||||
),
|
type?.label ?? '健康设备',
|
||||||
],
|
style: const TextStyle(
|
||||||
),
|
fontSize: 17,
|
||||||
),
|
fontWeight: FontWeight.w800,
|
||||||
const SizedBox(width: 10),
|
color: AppColors.textPrimary,
|
||||||
SizedBox(
|
),
|
||||||
height: 38,
|
),
|
||||||
child: FilledButton(
|
const SizedBox(height: 4),
|
||||||
onPressed: connecting ? null : onConnect,
|
Text(
|
||||||
style: FilledButton.styleFrom(
|
name,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
maxLines: 1,
|
||||||
shape: RoundedRectangleBorder(
|
overflow: TextOverflow.ellipsis,
|
||||||
borderRadius: BorderRadius.circular(10),
|
style: const TextStyle(
|
||||||
),
|
fontSize: 13,
|
||||||
textStyle: const TextStyle(
|
color: AppColors.textHint,
|
||||||
fontSize: 14,
|
),
|
||||||
fontWeight: FontWeight.w800,
|
),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: connecting ? null : onConnect,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
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(
|
||||||
|
borderRadius: AppRadius.smBorder,
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(connecting ? '连接中' : '连接'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Text(connecting ? '连接中' : '连接'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -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)
|
||||||
final surgery = item as Map;
|
.map((item) {
|
||||||
final date = surgery['date']?.toString() ?? '';
|
final surgery = item as Map;
|
||||||
return '${surgery['type'] ?? ''}${date.isEmpty ? '' : ' $date'}';
|
final date = surgery['date']?.toString() ?? '';
|
||||||
}).join(';'),
|
return '${surgery['type'] ?? ''}${date.isEmpty ? '' : ' $date'}';
|
||||||
|
})
|
||||||
|
.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,8 +298,12 @@ 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('查看原始报告'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
|
|||||||
@@ -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) {
|
);
|
||||||
AppToast.show(context, '已删除', type: AppToastType.success);
|
if (context.mounted) {
|
||||||
}
|
AppToast.show(
|
||||||
} catch (_) {
|
context,
|
||||||
if (context.mounted) {
|
deleted ? '已删除' : '删除失败,请稍后重试',
|
||||||
AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error);
|
type: deleted ? AppToastType.success : 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,61 +122,41 @@ 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,
|
||||||
child: Column(
|
body: DecoratedBox(
|
||||||
children: [
|
decoration: const BoxDecoration(
|
||||||
_buildHeader(user),
|
gradient: LinearGradient(
|
||||||
Expanded(
|
begin: Alignment.topLeft,
|
||||||
child: Stack(
|
end: Alignment.bottomRight,
|
||||||
children: [
|
colors: [Color(0xFFF0EDFF), Color(0xFFE6ECFF)],
|
||||||
RepaintBoundary(
|
),
|
||||||
child: ChatMessagesView(
|
),
|
||||||
scrollCtrl: _scrollCtrl,
|
child: SafeArea(
|
||||||
messages: chatState.messages,
|
child: Column(
|
||||||
),
|
children: [
|
||||||
),
|
_buildHeader(user),
|
||||||
Positioned(
|
Expanded(
|
||||||
left: 0,
|
child: RepaintBoundary(
|
||||||
top: 0,
|
child: _HomeMessages(scrollCtrl: _scrollCtrl),
|
||||||
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,39 +251,44 @@ 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(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: selected ? null : Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(999),
|
gradient: selected ? AppColors.actionOutlineGradient : null,
|
||||||
border: Border.all(
|
borderRadius: AppRadius.pillBorder,
|
||||||
color: selected
|
|
||||||
? AppColors.auraIndigo.withValues(alpha: 0.42)
|
|
||||||
: const Color(0xFFD5DAE4),
|
|
||||||
width: selected ? 1.4 : 1.1,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
padding: const EdgeInsets.all(1.2),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Container(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(
|
||||||
Container(
|
horizontal: 11.8,
|
||||||
width: 16,
|
vertical: 7.8,
|
||||||
height: 16,
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: visual.gradient,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: visual.gradient,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Icon(visual.icon, size: 13, color: Colors.white),
|
||||||
),
|
),
|
||||||
child: Icon(visual.icon, size: 11, color: Colors.white),
|
const SizedBox(width: 6),
|
||||||
),
|
Text(
|
||||||
const SizedBox(width: 6),
|
visual.label,
|
||||||
Text(
|
style: const TextStyle(
|
||||||
visual.label,
|
fontSize: 15,
|
||||||
style: const TextStyle(
|
fontWeight: FontWeight.w700,
|
||||||
fontSize: 14,
|
color: AppColors.textPrimary,
|
||||||
fontWeight: FontWeight.w700,
|
),
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -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,74 +395,87 @@ 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),
|
),
|
||||||
),
|
child: Row(
|
||||||
const SizedBox(width: 8),
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
Expanded(
|
children: [
|
||||||
child: ConstrainedBox(
|
Material(
|
||||||
constraints: const BoxConstraints(maxHeight: 118),
|
color: Colors.transparent,
|
||||||
child: TextField(
|
child: InkWell(
|
||||||
controller: _textCtrl,
|
customBorder: const CircleBorder(),
|
||||||
focusNode: _focusNode,
|
onTap: () => _showAttachmentPicker(context),
|
||||||
style: const TextStyle(
|
child: Ink(
|
||||||
fontSize: 15,
|
width: 38,
|
||||||
color: AppColors.textPrimary,
|
height: 38,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: AppColors.primaryGradient,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
LucideIcons.plus,
|
||||||
|
size: 21,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
maxLines: null,
|
),
|
||||||
textInputAction: TextInputAction.newline,
|
),
|
||||||
decoration: InputDecoration(
|
const SizedBox(width: 6),
|
||||||
hintText: '输入你想说的...',
|
Expanded(
|
||||||
filled: true,
|
child: ConstrainedBox(
|
||||||
fillColor: Colors.white,
|
constraints: const BoxConstraints(maxHeight: 112),
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
child: TextField(
|
||||||
horizontal: 16,
|
controller: _textCtrl,
|
||||||
vertical: 11,
|
focusNode: _focusNode,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
maxLines: null,
|
||||||
borderRadius: BorderRadius.circular(22),
|
textInputAction: TextInputAction.newline,
|
||||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
decoration: const InputDecoration(
|
||||||
),
|
hintText: '输入你想说的...',
|
||||||
enabledBorder: OutlineInputBorder(
|
filled: false,
|
||||||
borderRadius: BorderRadius.circular(22),
|
isDense: true,
|
||||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
contentPadding: EdgeInsets.symmetric(
|
||||||
),
|
horizontal: 4,
|
||||||
focusedBorder: OutlineInputBorder(
|
vertical: 12,
|
||||||
borderRadius: BorderRadius.circular(22),
|
|
||||||
borderSide: const BorderSide(
|
|
||||||
color: AppColors.auraIndigo,
|
|
||||||
width: 1.2,
|
|
||||||
),
|
),
|
||||||
|
border: InputBorder.none,
|
||||||
|
enabledBorder: InputBorder.none,
|
||||||
|
focusedBorder: InputBorder.none,
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
onTap: _sendMessage,
|
||||||
|
child: Ink(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: AppColors.primaryGradient,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
LucideIcons.send,
|
||||||
|
size: 18,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _sendMessage(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
GestureDetector(
|
|
||||||
onTap: _sendMessage,
|
|
||||||
child: Container(
|
|
||||||
width: 42,
|
|
||||||
height: 42,
|
|
||||||
margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.primaryGradient,
|
|
||||||
borderRadius: BorderRadius.circular(21),
|
|
||||||
boxShadow: AppColors.buttonShadow,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
LucideIcons.send,
|
|
||||||
size: 19,
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user