Compare commits
7 Commits
sccsbc
...
ae94ced2d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae94ced2d5 | ||
|
|
e1f4a4b91f | ||
|
|
6e5d3e64cd | ||
|
|
528859686f | ||
|
|
fade61ac21 | ||
|
|
e654c1e0cc | ||
|
|
335a3e6440 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,6 +46,7 @@ backend/src/Health.WebApi/data-protection-keys/
|
|||||||
*.txt
|
*.txt
|
||||||
*.jpg
|
*.jpg
|
||||||
*.png
|
*.png
|
||||||
|
!backend/src/Health.WebApi/wwwroot/app-icon.png
|
||||||
!health_app/assets/branding/*.png
|
!health_app/assets/branding/*.png
|
||||||
!health_app/android/app/src/main/res/drawable/launch_brand.png
|
!health_app/android/app/src/main/res/drawable/launch_brand.png
|
||||||
!health_app/android/app/src/main/res/drawable-v21/launch_brand.png
|
!health_app/android/app/src/main/res/drawable-v21/launch_brand.png
|
||||||
|
|||||||
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/2026-07-18-ui/00-current.xml
Normal file
1
audit/2026-07-18-ui/00-current.xml
Normal file
File diff suppressed because one or more lines are too long
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 轮;发现危险信号时立即停止普通追问并优先给出就医提醒
|
||||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||||
- 回复语气温暖、专业
|
- 回复语气温和、清楚、专业,避免假装医生或作确定诊断
|
||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,25 +16,32 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
|||||||
|
|
||||||
public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
|
public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(command.Phone)) return Error(40001, "手机号不能为空");
|
var phone = command.Phone.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40002, "姓名不能为空");
|
var name = command.Name.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(phone)) return Error(40001, "手机号不能为空");
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) return Error(40002, "姓名不能为空");
|
||||||
|
if (await _db.Users.AnyAsync(x => x.Phone == phone, ct) ||
|
||||||
|
await _db.Doctors.AnyAsync(x => x.Phone == phone, ct))
|
||||||
|
return Error(40003, "该手机号已被其他账号使用");
|
||||||
|
|
||||||
var doctor = new Doctor
|
var doctor = new Doctor
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), Name = command.Name, Title = command.Title, Department = command.Department,
|
Id = Guid.NewGuid(), Name = name, Title = command.Title?.Trim(), Department = command.Department?.Trim(),
|
||||||
Phone = command.Phone, ProfessionalDirection = command.ProfessionalDirection, IsActive = true, CreatedAt = DateTime.UtcNow,
|
Phone = phone, ProfessionalDirection = command.ProfessionalDirection?.Trim(), IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), Phone = phone, Role = "Doctor", Name = name,
|
||||||
|
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
_db.Doctors.Add(doctor);
|
_db.Doctors.Add(doctor);
|
||||||
if (!await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct))
|
_db.Users.Add(user);
|
||||||
|
_db.DoctorProfiles.Add(new DoctorProfile
|
||||||
{
|
{
|
||||||
var user = new User { Id = Guid.NewGuid(), Phone = command.Phone, Role = "Doctor", Name = command.Name, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = name,
|
||||||
_db.Users.Add(user);
|
Title = doctor.Title, Department = doctor.Department, IsActive = true,
|
||||||
_db.DoctorProfiles.Add(new DoctorProfile
|
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||||
{
|
});
|
||||||
Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = command.Name,
|
|
||||||
Title = command.Title, Department = command.Department, IsActive = true,
|
|
||||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return Ok(new { doctor.Id, doctor.Name });
|
return Ok(new { doctor.Id, doctor.Name });
|
||||||
}
|
}
|
||||||
@@ -43,11 +50,43 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
|||||||
{
|
{
|
||||||
var doctor = await _db.Doctors.FindAsync([id], ct);
|
var doctor = await _db.Doctors.FindAsync([id], ct);
|
||||||
if (doctor == null) return Error(404, "医生不存在");
|
if (doctor == null) return Error(404, "医生不存在");
|
||||||
if (command.Name != null) doctor.Name = command.Name;
|
var profile = await _db.DoctorProfiles.Include(x => x.User)
|
||||||
if (command.Title != null) doctor.Title = command.Title;
|
.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
|
||||||
if (command.Department != null) doctor.Department = command.Department;
|
if (profile == null) return Error(40901, "医生登录资料不完整,请联系技术人员修复");
|
||||||
if (command.Phone != null) doctor.Phone = command.Phone;
|
|
||||||
if (command.ProfessionalDirection != null) doctor.ProfessionalDirection = command.ProfessionalDirection;
|
var user = profile.User;
|
||||||
|
if (command.Phone != null)
|
||||||
|
{
|
||||||
|
var phone = command.Phone.Trim();
|
||||||
|
if (phone.Length == 0) return Error(40001, "手机号不能为空");
|
||||||
|
if (await _db.Users.AnyAsync(x => x.Id != user.Id && x.Phone == phone, ct) ||
|
||||||
|
await _db.Doctors.AnyAsync(x => x.Id != id && x.Phone == phone, ct))
|
||||||
|
return Error(40003, "该手机号已被其他账号使用");
|
||||||
|
doctor.Phone = phone;
|
||||||
|
user.Phone = phone;
|
||||||
|
}
|
||||||
|
if (command.Name != null)
|
||||||
|
{
|
||||||
|
var name = command.Name.Trim();
|
||||||
|
if (name.Length == 0) return Error(40002, "姓名不能为空");
|
||||||
|
doctor.Name = name;
|
||||||
|
user.Name = name;
|
||||||
|
profile.Name = name;
|
||||||
|
}
|
||||||
|
if (command.Title != null)
|
||||||
|
{
|
||||||
|
doctor.Title = command.Title.Trim();
|
||||||
|
profile.Title = doctor.Title;
|
||||||
|
}
|
||||||
|
if (command.Department != null)
|
||||||
|
{
|
||||||
|
doctor.Department = command.Department.Trim();
|
||||||
|
profile.Department = doctor.Department;
|
||||||
|
}
|
||||||
|
if (command.ProfessionalDirection != null)
|
||||||
|
doctor.ProfessionalDirection = command.ProfessionalDirection.Trim();
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
profile.UpdatedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return Ok(new { doctor.Id });
|
return Ok(new { doctor.Id });
|
||||||
}
|
}
|
||||||
@@ -56,7 +95,11 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
|||||||
{
|
{
|
||||||
var doctor = await _db.Doctors.FindAsync([id], ct);
|
var doctor = await _db.Doctors.FindAsync([id], ct);
|
||||||
if (doctor == null) return Error(404, "医生不存在");
|
if (doctor == null) return Error(404, "医生不存在");
|
||||||
|
var profile = await _db.DoctorProfiles.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
|
||||||
|
if (profile == null) return Error(40901, "医生登录资料不完整,请联系技术人员修复");
|
||||||
doctor.IsActive = !doctor.IsActive;
|
doctor.IsActive = !doctor.IsActive;
|
||||||
|
profile.IsActive = doctor.IsActive;
|
||||||
|
profile.UpdatedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return Ok(new { doctor.Id, doctor.IsActive });
|
return Ok(new { doctor.Id, doctor.IsActive });
|
||||||
}
|
}
|
||||||
@@ -65,7 +108,22 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
|||||||
{
|
{
|
||||||
var doctor = await _db.Doctors.FindAsync([id], ct);
|
var doctor = await _db.Doctors.FindAsync([id], ct);
|
||||||
if (doctor == null) return Error(404, "医生不存在");
|
if (doctor == null) return Error(404, "医生不存在");
|
||||||
await _db.Users.Where(x => x.DoctorId == id).ExecuteUpdateAsync(s => s.SetProperty(x => x.DoctorId, (Guid?)null), ct);
|
|
||||||
|
if (await _db.Users.AnyAsync(x => x.Role == "User" && x.DoctorId == id, ct))
|
||||||
|
return Error(40004, "该医生仍有绑定患者,请先停用,不能直接删除");
|
||||||
|
if (await _db.Consultations.AnyAsync(x => x.DoctorId == id, ct))
|
||||||
|
return Error(40005, "该医生已有问诊记录,只能停用,不能删除");
|
||||||
|
|
||||||
|
var profile = await _db.DoctorProfiles.Include(x => x.User)
|
||||||
|
.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
|
||||||
|
if (profile != null)
|
||||||
|
{
|
||||||
|
var user = profile.User;
|
||||||
|
var refreshTokens = await _db.RefreshTokens.Where(x => x.UserId == user.Id).ToListAsync(ct);
|
||||||
|
_db.RefreshTokens.RemoveRange(refreshTokens);
|
||||||
|
_db.DoctorProfiles.Remove(profile);
|
||||||
|
_db.Users.Remove(user);
|
||||||
|
}
|
||||||
_db.Doctors.Remove(doctor);
|
_db.Doctors.Remove(doctor);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return Ok(new { success = true });
|
return Ok(new { success = true });
|
||||||
@@ -77,7 +135,9 @@ public sealed class AdminService(AppDbContext db) : IAdminService
|
|||||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
var query = _db.Users.AsNoTracking().Where(x => x.Role == "User");
|
var query = _db.Users.AsNoTracking().Where(x => x.Role == "User");
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
query = query.Where(x => (x.Name != null && x.Name.Contains(search)) || x.Phone.Contains(search));
|
query = query.Where(x =>
|
||||||
|
(x.Name != null && x.Name.Contains(search)) ||
|
||||||
|
(x.Phone != null && x.Phone.Contains(search)));
|
||||||
var total = await query.CountAsync(ct);
|
var total = await query.CountAsync(ct);
|
||||||
var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
.Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null })
|
.Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null })
|
||||||
|
|||||||
@@ -113,9 +113,13 @@ public sealed class AuthService(
|
|||||||
{
|
{
|
||||||
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return Error(40005, "Apple 登录验证失败,请重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 查找已有用户
|
// 2. 查找已有用户
|
||||||
|
|||||||
1473
backend/src/Health.Infrastructure/Data/Migrations/20260713022401_AddPushAndDndPreferences.Designer.cs
generated
Normal file
1473
backend/src/Health.Infrastructure/Data/Migrations/20260713022401_AddPushAndDndPreferences.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPushAndDndPreferences : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "DndEnabled",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DndEndMinutes",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DndStartMinutes",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "PushEnabled",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DndEnabled",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DndEndMinutes",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DndStartMinutes",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PushEnabled",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1477
backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
generated
Normal file
1477
backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddHealthArchiveSurgeryStatus : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SurgeryHistoryStatus",
|
||||||
|
table: "HealthArchives",
|
||||||
|
type: "character varying(8)",
|
||||||
|
maxLength: 8,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SurgeryHistoryStatus",
|
||||||
|
table: "HealthArchives");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -486,6 +486,10 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
b.Property<DateOnly?>("SurgeryDate")
|
b.Property<DateOnly?>("SurgeryDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("SurgeryHistoryStatus")
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
b.Property<string>("SurgeryType")
|
b.Property<string>("SurgeryType")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -673,6 +677,15 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("AbnormalAlert")
|
b.Property<bool>("AbnormalAlert")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("DndEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("DndEndMinutes")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("DndStartMinutes")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<bool>("DoctorReply")
|
b.Property<bool>("DoctorReply")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -700,6 +713,9 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("MedicationReminder")
|
b.Property<bool>("MedicationReminder")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PushEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
b.Property<DateTime>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
|||||||
builder.Entity<HealthArchive>(e =>
|
builder.Entity<HealthArchive>(e =>
|
||||||
{
|
{
|
||||||
e.HasIndex(a => a.UserId).IsUnique();
|
e.HasIndex(a => a.UserId).IsUnique();
|
||||||
|
e.Property(a => a.SurgeryHistoryStatus).HasMaxLength(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Entity<HealthArchiveSurgery>(e =>
|
builder.Entity<HealthArchiveSurgery>(e =>
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
using Health.Application.Notifications;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Notifications;
|
||||||
|
|
||||||
|
public sealed class EfReminderCatchUpService(AppDbContext db) : IReminderCatchUpService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db = db;
|
||||||
|
|
||||||
|
public async Task<ReminderCatchUpResult> CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pref = await GetPreferenceAsync(userId, ct);
|
||||||
|
if (!pref.PushEnabled) return new ReminderCatchUpResult(0);
|
||||||
|
|
||||||
|
var beijingNow = utcNow.AddHours(8);
|
||||||
|
var today = DateOnly.FromDateTime(beijingNow);
|
||||||
|
var todayStartUtc = beijingNow.Date.AddHours(-8);
|
||||||
|
var todayEndUtc = todayStartUtc.AddDays(1);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
created += await CreateHealthRecordReminderAsync(userId, pref, beijingNow, todayStartUtc, todayEndUtc, ct);
|
||||||
|
created += await CreateMedicationRemindersAsync(userId, pref, beijingNow, today, todayStartUtc, todayEndUtc, ct);
|
||||||
|
created += await CreateExerciseRemindersAsync(userId, pref, beijingNow, today, ct);
|
||||||
|
created += await CreateFollowUpRemindersAsync(userId, pref, beijingNow, ct);
|
||||||
|
|
||||||
|
if (created > 0)
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new ReminderCatchUpResult(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<NotificationPreference> GetPreferenceAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pref = await _db.NotificationPreferences.FirstOrDefaultAsync(x => x.UserId == userId, ct);
|
||||||
|
if (pref != null) return pref;
|
||||||
|
|
||||||
|
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
|
||||||
|
await _db.NotificationPreferences.AddAsync(pref, ct);
|
||||||
|
return pref;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateHealthRecordReminderAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
DateTime todayStartUtc,
|
||||||
|
DateTime todayEndUtc,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!pref.HealthRecordReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var window = HealthRecordWindow(beijingNow);
|
||||||
|
if (window == null) return 0;
|
||||||
|
|
||||||
|
var recordedTypes = await _db.HealthRecords.AsNoTracking()
|
||||||
|
.Where(r => r.UserId == userId && r.RecordedAt >= todayStartUtc && r.RecordedAt < todayEndUtc)
|
||||||
|
.Select(r => r.MetricType)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var missing = EnabledHealthMetrics(pref)
|
||||||
|
.Where(x => !recordedTypes.Contains(x.Type))
|
||||||
|
.ToList();
|
||||||
|
if (missing.Count == 0) return 0;
|
||||||
|
|
||||||
|
var date = DateOnly.FromDateTime(beijingNow);
|
||||||
|
var sourceId = StableGuid($"health_record|{userId:N}|{date:yyyyMMdd}|{window}");
|
||||||
|
return await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"health_record_reminder",
|
||||||
|
window == "evening" ? "今日健康指标还未完成" : "记得录入今日健康指标",
|
||||||
|
$"建议录入:{string.Join("、", missing.Select(x => x.Label))}",
|
||||||
|
"info",
|
||||||
|
"health",
|
||||||
|
null,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateMedicationRemindersAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
DateOnly today,
|
||||||
|
DateTime todayStartUtc,
|
||||||
|
DateTime todayEndUtc,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!pref.MedicationReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var now = TimeOnly.FromDateTime(beijingNow);
|
||||||
|
var earliest = now.AddHours(-4);
|
||||||
|
var medications = await _db.Medications.AsNoTracking()
|
||||||
|
.Where(m => m.UserId == userId && m.IsActive)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
foreach (var medication in medications.Where(m => IsMedicationActiveOn(m, today) && ShouldDoseToday(m, today)))
|
||||||
|
{
|
||||||
|
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInTimeWindow(t, earliest, now)))
|
||||||
|
{
|
||||||
|
var logged = await _db.MedicationLogs.AsNoTracking().AnyAsync(l =>
|
||||||
|
l.UserId == userId &&
|
||||||
|
l.MedicationId == medication.Id &&
|
||||||
|
l.ScheduledTime == scheduledTime &&
|
||||||
|
l.CreatedAt >= todayStartUtc &&
|
||||||
|
l.CreatedAt < todayEndUtc, ct);
|
||||||
|
if (logged) continue;
|
||||||
|
|
||||||
|
var sourceId = StableGuid($"medication|{userId:N}|{medication.Id:N}|{today:yyyyMMdd}|{scheduledTime:HHmm}");
|
||||||
|
var details = string.Join(" ", new[] { medication.Dosage, scheduledTime.ToString("HH:mm") }
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
created += await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"MedicationReminder",
|
||||||
|
"用药时间到了",
|
||||||
|
$"请按计划服用{medication.Name}{(details.Length > 0 ? $"({details})" : "")},服用后记得打卡。",
|
||||||
|
"info",
|
||||||
|
"medication",
|
||||||
|
medication.Id.ToString(),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateExerciseRemindersAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var now = TimeOnly.FromDateTime(beijingNow);
|
||||||
|
var dueItems = await _db.ExercisePlanItems.AsNoTracking()
|
||||||
|
.Include(x => x.Plan)
|
||||||
|
.Where(x => x.Plan.UserId == userId &&
|
||||||
|
x.ScheduledDate == today &&
|
||||||
|
!x.IsCompleted &&
|
||||||
|
!x.IsRestDay &&
|
||||||
|
x.Plan.ReminderTime <= now)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
foreach (var item in dueItems)
|
||||||
|
{
|
||||||
|
var sourceId = StableGuid($"exercise|{userId:N}|{item.Id:N}|{today:yyyyMMdd}");
|
||||||
|
created += await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"ExerciseReminder",
|
||||||
|
"今日运动计划",
|
||||||
|
$"今天安排了{item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。",
|
||||||
|
"info",
|
||||||
|
"exercise",
|
||||||
|
item.PlanId.ToString(),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateFollowUpRemindersAsync(
|
||||||
|
Guid userId,
|
||||||
|
NotificationPreference pref,
|
||||||
|
DateTime beijingNow,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!pref.FollowUpReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||||
|
var nowUtc = beijingNow.AddHours(-8);
|
||||||
|
var upcomingEndUtc = nowUtc.AddHours(24);
|
||||||
|
var followUps = await _db.FollowUps.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId && x.ScheduledAt >= nowUtc && x.ScheduledAt <= upcomingEndUtc)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
foreach (var followUp in followUps)
|
||||||
|
{
|
||||||
|
var sourceId = StableGuid($"followup|{userId:N}|{followUp.Id:N}|24h");
|
||||||
|
created += await AddNotificationIfMissingAsync(
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
"follow_up_reminder",
|
||||||
|
"复查提醒",
|
||||||
|
$"{followUp.Title} 即将到期,请提前安排时间。",
|
||||||
|
"info",
|
||||||
|
"followup",
|
||||||
|
followUp.Id.ToString(),
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> AddNotificationIfMissingAsync(
|
||||||
|
Guid userId,
|
||||||
|
Guid sourceId,
|
||||||
|
string type,
|
||||||
|
string title,
|
||||||
|
string message,
|
||||||
|
string severity,
|
||||||
|
string? actionType,
|
||||||
|
string? actionTargetId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await _db.UserNotifications.AsNoTracking().AnyAsync(x => x.SourceId == sourceId, ct))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
await _db.UserNotifications.AddAsync(new UserNotification
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
SourceId = sourceId,
|
||||||
|
Type = type,
|
||||||
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
Severity = severity,
|
||||||
|
ActionType = actionType,
|
||||||
|
ActionTargetId = actionTargetId,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
}, ct);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? HealthRecordWindow(DateTime beijingNow)
|
||||||
|
{
|
||||||
|
var minutes = beijingNow.Hour * 60 + beijingNow.Minute;
|
||||||
|
if (minutes is >= 7 * 60 and <= 10 * 60 + 30) return "morning";
|
||||||
|
if (minutes is >= 11 * 60 + 30 and <= 14 * 60) return "noon";
|
||||||
|
if (minutes is >= 18 * 60 and <= 21 * 60 + 30) return "evening";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<(HealthMetricType Type, string Label)> EnabledHealthMetrics(NotificationPreference pref)
|
||||||
|
{
|
||||||
|
var result = new List<(HealthMetricType, string)>();
|
||||||
|
if (pref.HealthRecordReminderBloodPressure) result.Add((HealthMetricType.BloodPressure, "血压"));
|
||||||
|
if (pref.HealthRecordReminderHeartRate) result.Add((HealthMetricType.HeartRate, "心率"));
|
||||||
|
if (pref.HealthRecordReminderGlucose) result.Add((HealthMetricType.Glucose, "血糖"));
|
||||||
|
if (pref.HealthRecordReminderSpO2) result.Add((HealthMetricType.SpO2, "血氧"));
|
||||||
|
if (pref.HealthRecordReminderWeight) result.Add((HealthMetricType.Weight, "体重"));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInDnd(NotificationPreference pref, DateTime beijingNow)
|
||||||
|
{
|
||||||
|
if (!pref.DndEnabled) return false;
|
||||||
|
var minuteOfDay = beijingNow.Hour * 60 + beijingNow.Minute;
|
||||||
|
return pref.DndStartMinutes <= pref.DndEndMinutes
|
||||||
|
? minuteOfDay >= pref.DndStartMinutes && minuteOfDay < pref.DndEndMinutes
|
||||||
|
: minuteOfDay >= pref.DndStartMinutes || minuteOfDay < pref.DndEndMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsMedicationActiveOn(Medication medication, DateOnly date) =>
|
||||||
|
(medication.StartDate == null || medication.StartDate <= date) &&
|
||||||
|
(medication.EndDate == null || medication.EndDate >= date);
|
||||||
|
|
||||||
|
private static bool ShouldDoseToday(Medication medication, DateOnly today)
|
||||||
|
{
|
||||||
|
if (medication.Frequency is MedicationFrequency.Daily
|
||||||
|
or MedicationFrequency.TwiceDaily
|
||||||
|
or MedicationFrequency.ThreeTimesDaily
|
||||||
|
or MedicationFrequency.AsNeeded)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var startDate = medication.StartDate ?? today;
|
||||||
|
if (medication.Frequency == MedicationFrequency.EveryOtherDay)
|
||||||
|
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
|
||||||
|
if (medication.Frequency == MedicationFrequency.Weekly)
|
||||||
|
return today.DayOfWeek == startDate.DayOfWeek;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInTimeWindow(TimeOnly value, TimeOnly start, TimeOnly end) =>
|
||||||
|
end >= start ? value >= start && value <= end : value >= start || value <= end;
|
||||||
|
|
||||||
|
private static Guid StableGuid(string key)
|
||||||
|
{
|
||||||
|
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
|
||||||
|
return new Guid(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ public sealed class EfUserNotificationProducer(AppDbContext db) : IUserNotificat
|
|||||||
NotificationMessage message,
|
NotificationMessage message,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var pushEnabled = await _db.NotificationPreferences.AsNoTracking()
|
||||||
|
.Where(x => x.UserId == userId)
|
||||||
|
.Select(x => (bool?)x.PushEnabled)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (pushEnabled is false) return false;
|
||||||
|
|
||||||
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct))
|
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -67,7 +73,7 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
|
|||||||
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
|
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
|
||||||
.ExecuteUpdateAsync(setters => setters
|
.ExecuteUpdateAsync(setters => setters
|
||||||
.SetProperty(x => x.Status, "Failed")
|
.SetProperty(x => x.Status, "Failed")
|
||||||
.SetProperty(x => x.LastError, "通知任务执行超时且已达到最大重试次数")
|
.SetProperty(x => x.LastError, "Notification task timed out and reached max retry count")
|
||||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,59 +87,89 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
|
|||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
if (candidate == null) return false;
|
if (candidate == null) return false;
|
||||||
|
|
||||||
var claimed = await _db.NotificationOutbox
|
var task = await _db.NotificationOutbox
|
||||||
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
|
.FirstOrDefaultAsync(x => x.Id == candidate.Id && x.Status == "Pending", ct);
|
||||||
.ExecuteUpdateAsync(setters => setters
|
if (task == null) return true;
|
||||||
.SetProperty(x => x.Status, "Processing")
|
|
||||||
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
|
task.Status = "Processing";
|
||||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
task.Attempts += 1;
|
||||||
if (claimed == 0) return true;
|
task.UpdatedAt = now;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var message = JsonSerializer.Deserialize<NotificationMessage>(candidate.Payload)
|
var message = JsonSerializer.Deserialize<NotificationMessage>(task.Payload)
|
||||||
?? throw new InvalidOperationException("通知消息内容为空");
|
?? throw new InvalidOperationException("Notification payload is empty");
|
||||||
|
|
||||||
if (!await _db.UserNotifications.AsNoTracking()
|
var preference = await _db.NotificationPreferences.AsNoTracking()
|
||||||
.AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct))
|
.FirstOrDefaultAsync(x => x.UserId == task.UserId, ct);
|
||||||
|
var deliver = preference == null || ShouldDeliver(preference, message.Type, DateTime.UtcNow.AddHours(8));
|
||||||
|
|
||||||
|
if (deliver && !await _db.UserNotifications.AsNoTracking()
|
||||||
|
.AnyAsync(x => x.SourceId == task.SourceTaskId, ct))
|
||||||
{
|
{
|
||||||
await _db.UserNotifications.AddAsync(new UserNotification
|
await _db.UserNotifications.AddAsync(new UserNotification
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
UserId = candidate.UserId,
|
UserId = task.UserId,
|
||||||
SourceId = candidate.SourceTaskId,
|
SourceId = task.SourceTaskId,
|
||||||
Type = message.Type,
|
Type = message.Type,
|
||||||
Title = message.Title,
|
Title = message.Title,
|
||||||
Message = message.Message,
|
Message = message.Message,
|
||||||
Severity = message.Severity,
|
Severity = message.Severity,
|
||||||
ActionType = message.ActionType,
|
ActionType = message.ActionType,
|
||||||
ActionTargetId = message.ActionTargetId,
|
ActionTargetId = message.ActionTargetId,
|
||||||
CreatedAt = candidate.CreatedAt,
|
CreatedAt = task.CreatedAt,
|
||||||
}, ct);
|
}, ct);
|
||||||
// Persist the inbox item first. If completion fails, stale-task
|
|
||||||
// recovery can safely retry because SourceId is idempotent.
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
|
task.Status = "Completed";
|
||||||
.ExecuteUpdateAsync(setters => setters
|
task.LastError = null;
|
||||||
.SetProperty(x => x.Status, "Completed")
|
task.UpdatedAt = DateTime.UtcNow;
|
||||||
.SetProperty(x => x.LastError, (string?)null)
|
await _db.SaveChangesAsync(ct);
|
||||||
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var attempts = candidate.Attempts + 1;
|
task.Status = task.Attempts >= MaxAttempts ? "Failed" : "Pending";
|
||||||
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
|
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
|
||||||
.ExecuteUpdateAsync(setters => setters
|
task.LastError = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
|
||||||
.SetProperty(x => x.Status, attempts >= MaxAttempts ? "Failed" : "Pending")
|
task.UpdatedAt = DateTime.UtcNow;
|
||||||
.SetProperty(x => x.AvailableAt,
|
await _db.SaveChangesAsync(ct);
|
||||||
DateTime.UtcNow.AddSeconds(Math.Pow(2, attempts) * 5))
|
|
||||||
.SetProperty(x => x.LastError,
|
|
||||||
ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message)
|
|
||||||
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool ShouldDeliver(NotificationPreference preference, string type, DateTime nowCst)
|
||||||
|
{
|
||||||
|
if (!preference.PushEnabled) return false;
|
||||||
|
|
||||||
|
var minuteOfDay = nowCst.Hour * 60 + nowCst.Minute;
|
||||||
|
var inDnd = preference.DndEnabled && (preference.DndStartMinutes <= preference.DndEndMinutes
|
||||||
|
? minuteOfDay >= preference.DndStartMinutes && minuteOfDay < preference.DndEndMinutes
|
||||||
|
: minuteOfDay >= preference.DndStartMinutes || minuteOfDay < preference.DndEndMinutes);
|
||||||
|
if (inDnd) return false;
|
||||||
|
|
||||||
|
return NormalizeType(type) switch
|
||||||
|
{
|
||||||
|
"medication_reminder" => preference.MedicationReminder,
|
||||||
|
"exercise_reminder" => true,
|
||||||
|
"follow_up_reminder" => preference.FollowUpReminder,
|
||||||
|
"doctor_reply" => preference.DoctorReply,
|
||||||
|
"abnormal_alert" => preference.AbnormalAlert,
|
||||||
|
"health_record_reminder" => preference.HealthRecordReminder,
|
||||||
|
"health_metric_alert" => preference.AbnormalAlert,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeType(string type) => type switch
|
||||||
|
{
|
||||||
|
"MedicationReminder" => "medication_reminder",
|
||||||
|
"ExerciseReminder" => "exercise_reminder",
|
||||||
|
"HealthMetricAlert" => "health_metric_alert",
|
||||||
|
"FollowUpReminder" => "follow_up_reminder",
|
||||||
|
_ => type.ToLowerInvariant(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,44 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
|
|||||||
public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
|
public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
|
||||||
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
||||||
|
|
||||||
|
public async Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var fileUrls = new List<string>();
|
||||||
|
|
||||||
|
var userAvatar = await _db.Users
|
||||||
|
.Where(u => u.Id == userId)
|
||||||
|
.Select(u => u.AvatarUrl)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
AddIfPresent(fileUrls, userAvatar);
|
||||||
|
|
||||||
|
var doctorProfile = await _db.DoctorProfiles
|
||||||
|
.Where(p => p.UserId == userId)
|
||||||
|
.Select(p => new { p.DoctorId, p.AvatarUrl })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
AddIfPresent(fileUrls, doctorProfile?.AvatarUrl);
|
||||||
|
|
||||||
|
if (doctorProfile?.DoctorId != null)
|
||||||
|
{
|
||||||
|
var doctorAvatar = await _db.Doctors
|
||||||
|
.Where(d => d.Id == doctorProfile.DoctorId)
|
||||||
|
.Select(d => d.AvatarUrl)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
AddIfPresent(fileUrls, doctorAvatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileUrls.AddRange(await _db.Reports
|
||||||
|
.Where(r => r.UserId == userId && r.FileUrl != "")
|
||||||
|
.Select(r => r.FileUrl)
|
||||||
|
.ToListAsync(ct));
|
||||||
|
|
||||||
|
var metadata = await _db.ConversationMessages
|
||||||
|
.Where(m => m.Conversation!.UserId == userId && m.MetadataJson != null && m.MetadataJson != "")
|
||||||
|
.Select(m => m.MetadataJson!)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new AccountFileReferences(fileUrls.Distinct().ToList(), metadata);
|
||||||
|
}
|
||||||
|
|
||||||
public Task SaveChangesAsync(CancellationToken ct) =>
|
public Task SaveChangesAsync(CancellationToken ct) =>
|
||||||
_db.SaveChangesAsync(ct);
|
_db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
@@ -50,4 +88,9 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
|
|||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void AddIfPresent(List<string> values, string? value)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value)) values.Add(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Health.Application.Users;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Users;
|
||||||
|
|
||||||
|
public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCleanup
|
||||||
|
{
|
||||||
|
private readonly string _uploadsRoot = Path.GetFullPath(uploadsRoot);
|
||||||
|
|
||||||
|
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var fileUrls = new HashSet<string>(references.FileUrls, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var metadataJson in references.ConversationMetadataJson)
|
||||||
|
AddMetadataUrls(fileUrls, metadataJson);
|
||||||
|
|
||||||
|
foreach (var fileUrl in fileUrls)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var localPath = ResolveLocalPath(fileUrl);
|
||||||
|
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var userDirectory = Path.Combine(_uploadsRoot, "users", userId.ToString("N"));
|
||||||
|
if (Directory.Exists(userDirectory)) Directory.Delete(userDirectory, recursive: true);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddMetadataUrls(HashSet<string> fileUrls, string metadataJson)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(metadataJson);
|
||||||
|
AddStringProperty(document.RootElement, "imageUrl", fileUrls);
|
||||||
|
AddStringProperty(document.RootElement, "pdfUrl", fileUrls);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// 旧消息元数据可能不是合法 JSON;不影响其余账号数据注销。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddStringProperty(JsonElement root, string propertyName, HashSet<string> fileUrls)
|
||||||
|
{
|
||||||
|
if (root.ValueKind != JsonValueKind.Object ||
|
||||||
|
!root.TryGetProperty(propertyName, out var property) ||
|
||||||
|
property.ValueKind != JsonValueKind.String)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var value = property.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(value)) fileUrls.Add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ResolveLocalPath(string fileUrl)
|
||||||
|
{
|
||||||
|
var urlPath = fileUrl;
|
||||||
|
if (Uri.TryCreate(fileUrl, UriKind.Absolute, out var absoluteUri))
|
||||||
|
urlPath = absoluteUri.AbsolutePath;
|
||||||
|
|
||||||
|
var uploadsIndex = urlPath.IndexOf("/uploads/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (uploadsIndex < 0) return null;
|
||||||
|
|
||||||
|
string relativePath;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
relativePath = Uri.UnescapeDataString(urlPath[(uploadsIndex + "/uploads/".Length)..]);
|
||||||
|
}
|
||||||
|
catch (UriFormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryIndex = relativePath.IndexOfAny(['?', '#']);
|
||||||
|
if (queryIndex >= 0) relativePath = relativePath[..queryIndex];
|
||||||
|
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||||
|
|
||||||
|
var fullPath = Path.GetFullPath(Path.Combine(_uploadsRoot, relativePath));
|
||||||
|
var rootPrefix = _uploadsRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||||
|
+ Path.DirectorySeparatorChar;
|
||||||
|
return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,107 +1,66 @@
|
|||||||
using Health.Application.Notifications;
|
using Health.Application.Notifications;
|
||||||
using Health.Domain.Enums;
|
|
||||||
|
|
||||||
namespace Health.WebApi.BackgroundServices;
|
namespace Health.WebApi.BackgroundServices;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户,
|
/// Produces in-app catch-up reminders for health records.
|
||||||
/// 对未录入指标且开关开启的用户推送提醒。
|
/// The actual reminder rules live in IReminderCatchUpService so foreground
|
||||||
/// 中国时区 UTC+8。
|
/// app checks and background scans stay consistent.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HealthRecordReminderService(
|
public sealed class HealthRecordReminderService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
private static readonly int[] ReminderHoursCst = [9, 13, 19];
|
||||||
private readonly ILogger<HealthRecordReminderService> _logger = logger;
|
|
||||||
|
|
||||||
// 北京时间 9:00 和 12:00 触发
|
|
||||||
private static readonly int[] ReminderHoursCst = [9, 12];
|
|
||||||
|
|
||||||
private DateTime _lastFired = DateTime.MinValue;
|
private DateTime _lastFired = DateTime.MinValue;
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("健康录入提醒服务已启动");
|
logger.LogInformation("Health record in-app reminder service started");
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var nowCst = DateTime.UtcNow.AddHours(8);
|
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||||
// 当前小时是否在触发列表,且今天该小时还没触发过
|
if (ReminderHoursCst.Contains(nowCst.Hour) &&
|
||||||
if (ReminderHoursCst.Contains(nowCst.Hour)
|
(_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
||||||
&& (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
|
||||||
{
|
{
|
||||||
await ScanAndPushAsync(nowCst, stoppingToken);
|
await CheckUsersAsync(DateTime.UtcNow, stoppingToken);
|
||||||
_lastFired = nowCst;
|
_lastFired = nowCst;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
catch (Exception ex) { _logger.LogError(ex, "健康录入提醒扫描异常"); }
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Health record in-app reminder scan failed");
|
||||||
|
}
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ScanAndPushAsync(DateTime nowCst, CancellationToken ct)
|
private async Task CheckUsersAsync(DateTime utcNow, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
var producer = scope.ServiceProvider.GetRequiredService<IUserNotificationProducer>();
|
var catchUp = scope.ServiceProvider.GetRequiredService<IReminderCatchUpService>();
|
||||||
|
|
||||||
var todayStart = nowCst.Date.AddHours(-8); // 北京 0:00 转 UTC
|
var userIds = await db.NotificationPreferences.AsNoTracking()
|
||||||
var todayEnd = todayStart.AddDays(1);
|
.Where(x => x.PushEnabled && x.HealthRecordReminder)
|
||||||
|
.Select(x => x.UserId)
|
||||||
// 取所有启用了健康录入提醒的用户偏好
|
|
||||||
var prefs = await db.NotificationPreferences
|
|
||||||
.Where(p => p.HealthRecordReminder)
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
foreach (var pref in prefs)
|
var created = 0;
|
||||||
|
foreach (var userId in userIds)
|
||||||
{
|
{
|
||||||
// 该用户当天已录入的指标
|
var result = await catchUp.CheckDueAsync(userId, utcNow, ct);
|
||||||
var recordedTypes = await db.HealthRecords
|
created += result.CreatedCount;
|
||||||
.Where(r => r.UserId == pref.UserId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd)
|
|
||||||
.Select(r => r.MetricType)
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
// 哪些指标启用开关但当天没录入
|
|
||||||
var missing = new List<(HealthMetricType type, bool enabled, string label)>
|
|
||||||
{
|
|
||||||
(HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"),
|
|
||||||
(HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"),
|
|
||||||
(HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"),
|
|
||||||
(HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"),
|
|
||||||
(HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"),
|
|
||||||
};
|
|
||||||
|
|
||||||
var missingLabels = missing
|
|
||||||
.Where(m => m.enabled && !recordedTypes.Contains(m.type))
|
|
||||||
.Select(m => m.label)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (missingLabels.Count == 0) continue;
|
|
||||||
|
|
||||||
// 每天每个时段每个用户只推一次——SourceTaskId 用 userId + 日期 + 小时
|
|
||||||
var sourceId = MakeSourceId(pref.UserId, nowCst);
|
|
||||||
var summary = string.Join("、", missingLabels);
|
|
||||||
|
|
||||||
await producer.EnqueueAsync(pref.UserId, sourceId, new NotificationMessage(
|
|
||||||
Type: "health_record_reminder",
|
|
||||||
Title: nowCst.Hour < 12 ? "早安,记得录入今日健康数据" : "下午好,今日健康数据还未录入",
|
|
||||||
Message: $"建议录入:{summary}",
|
|
||||||
Severity: "info",
|
|
||||||
ActionType: "health",
|
|
||||||
ActionTargetId: null), ct);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static Guid MakeSourceId(Guid userId, DateTime nowCst)
|
if (created > 0)
|
||||||
{
|
logger.LogInformation("Created {Count} in-app catch-up reminders", created);
|
||||||
// 通过哈希构造稳定的 Guid,保证同一用户同一时段不重复
|
|
||||||
var key = $"hrr|{userId:N}|{nowCst:yyyyMMddHH}";
|
|
||||||
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
|
|
||||||
return new Guid(bytes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,8 +321,12 @@ public static class AiChatEndpoints
|
|||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
||||||
|
|
||||||
await conversations.DeleteAsync(userId.Value, id, ct);
|
var deleted = await conversations.DeleteAsync(userId.Value, id, ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return deleted
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Json(
|
||||||
|
new { code = 40401, data = (object?)null, message = "对话不存在" },
|
||||||
|
statusCode: StatusCodes.Status404NotFound);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 一键清空当前用户的全部对话
|
// 一键清空当前用户的全部对话
|
||||||
@@ -335,6 +339,49 @@ public static class AiChatEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 饮食分析页专用建议:不创建会话,不保存提示词或回复到历史记录。
|
||||||
|
app.MapPost("/api/ai/diet-commentary", async (
|
||||||
|
DietCommentaryRequest request,
|
||||||
|
HttpContext http,
|
||||||
|
DeepSeekClient llmClient,
|
||||||
|
IPatientContextService patientContexts,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == null)
|
||||||
|
return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
|
||||||
|
|
||||||
|
var foods = request.Foods?
|
||||||
|
.Where(food => !string.IsNullOrWhiteSpace(food.Name) &&
|
||||||
|
!string.IsNullOrWhiteSpace(food.Portion) &&
|
||||||
|
food.Calories > 0)
|
||||||
|
.Take(20)
|
||||||
|
.Select(food => new DietCommentaryFood(food.Name, food.Portion, food.Calories))
|
||||||
|
.ToList() ?? [];
|
||||||
|
if (foods.Count == 0)
|
||||||
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "请先完善食物信息" });
|
||||||
|
|
||||||
|
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
|
||||||
|
var foodDescription = DietCommentaryPolicy.BuildFoodDescription(foods);
|
||||||
|
var response = await llmClient.ChatAsync(
|
||||||
|
[
|
||||||
|
new ChatMessage
|
||||||
|
{
|
||||||
|
Role = "system",
|
||||||
|
Content = DietCommentaryPolicy.SystemPrompt + "\n\n当前用户健康档案:\n" + patientContext,
|
||||||
|
},
|
||||||
|
new ChatMessage { Role = "user", Content = "本餐食物:" + foodDescription },
|
||||||
|
],
|
||||||
|
maxTokens: 300,
|
||||||
|
temperature: 0.3f,
|
||||||
|
ct: ct);
|
||||||
|
var commentary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(commentary))
|
||||||
|
return Results.Ok(new { code = 50001, data = (object?)null, message = "饮食建议生成失败" });
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = new { commentary }, message = (string?)null });
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapPost("/api/ai/analyze-food-image", async (
|
app.MapPost("/api/ai/analyze-food-image", async (
|
||||||
HttpRequest httpRequest,
|
HttpRequest httpRequest,
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
@@ -623,7 +670,7 @@ public static class AiChatEndpoints
|
|||||||
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
|
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
|
||||||
break;
|
break;
|
||||||
case "glucose":
|
case "glucose":
|
||||||
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v >= 7.0m || v <= 3.8m);
|
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v > 6.1m || v < 3.9m);
|
||||||
break;
|
break;
|
||||||
case "spo2":
|
case "spo2":
|
||||||
AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94);
|
AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94);
|
||||||
@@ -818,3 +865,6 @@ public static class AiChatEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record DietCommentaryFoodRequest(string Name, string Portion, int Calories);
|
||||||
|
public sealed record DietCommentaryRequest(IReadOnlyList<DietCommentaryFoodRequest>? Foods);
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ public static class DietEndpoints
|
|||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
await diets.DeleteAsync(userId, id, ct);
|
var deleted = await diets.DeleteAsync(userId, id, ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return deleted
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "饮食记录不存在" });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ public static class DoctorEndpoints
|
|||||||
return profile?.DoctorId;
|
return profile?.DoctorId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static (DateTime StartUtc, DateTime EndUtc) GetBeijingDayRange(DateTime utcNow)
|
||||||
|
{
|
||||||
|
var beijingNow = utcNow.ToUniversalTime().AddHours(8);
|
||||||
|
var startUtc = new DateTime(
|
||||||
|
beijingNow.Year,
|
||||||
|
beijingNow.Month,
|
||||||
|
beijingNow.Day,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
DateTimeKind.Utc).AddHours(-8);
|
||||||
|
return (startUtc, startUtc.AddDays(1));
|
||||||
|
}
|
||||||
|
|
||||||
public static void MapDoctorEndpoints(this WebApplication app)
|
public static void MapDoctorEndpoints(this WebApplication app)
|
||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/doctor").RequireAuthorization();
|
var group = app.MapGroup("/api/doctor").RequireAuthorization();
|
||||||
@@ -56,8 +70,7 @@ public static class DoctorEndpoints
|
|||||||
var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId);
|
var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId);
|
||||||
var activeConsultations = await db.Consultations.CountAsync(c => c.User.DoctorId == doctorId && c.Status != ConsultationStatus.Closed);
|
var activeConsultations = await db.Consultations.CountAsync(c => c.User.DoctorId == doctorId && c.Status != ConsultationStatus.Closed);
|
||||||
var pendingReports = await db.Reports.CountAsync(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor);
|
var pendingReports = await db.Reports.CountAsync(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor);
|
||||||
var todayStart = DateTime.UtcNow.Date;
|
var (todayStart, todayEnd) = GetBeijingDayRange(DateTime.UtcNow);
|
||||||
var todayEnd = todayStart.AddDays(1);
|
|
||||||
var todayFollowUps = await db.FollowUps.CountAsync(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming);
|
var todayFollowUps = await db.FollowUps.CountAsync(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming);
|
||||||
|
|
||||||
// 待办列表
|
// 待办列表
|
||||||
@@ -92,6 +105,7 @@ public static class DoctorEndpoints
|
|||||||
doctorHospital = profile.Hospital,
|
doctorHospital = profile.Hospital,
|
||||||
doctorAvatar = profile.AvatarUrl,
|
doctorAvatar = profile.AvatarUrl,
|
||||||
doctorOnline = profile.IsOnline,
|
doctorOnline = profile.IsOnline,
|
||||||
|
doctorActive = profile.IsActive,
|
||||||
stats = new { totalPatients, activeConsultations, pendingReports, todayFollowUps },
|
stats = new { totalPatients, activeConsultations, pendingReports, todayFollowUps },
|
||||||
pendingConsultations,
|
pendingConsultations,
|
||||||
pendingReports = pendingReportList,
|
pendingReports = pendingReportList,
|
||||||
@@ -112,7 +126,7 @@ public static class DoctorEndpoints
|
|||||||
{
|
{
|
||||||
query = query.Where(u =>
|
query = query.Where(u =>
|
||||||
(u.Name != null && u.Name.Contains(search)) ||
|
(u.Name != null && u.Name.Contains(search)) ||
|
||||||
u.Phone.Contains(search));
|
(u.Phone != null && u.Phone.Contains(search)));
|
||||||
}
|
}
|
||||||
var total = await query.CountAsync();
|
var total = await query.CountAsync();
|
||||||
var patients = await query.OrderByDescending(u => u.CreatedAt)
|
var patients = await query.OrderByDescending(u => u.CreatedAt)
|
||||||
@@ -190,7 +204,8 @@ public static class DoctorEndpoints
|
|||||||
user.HealthArchive.Allergies,
|
user.HealthArchive.Allergies,
|
||||||
user.HealthArchive.DietRestrictions,
|
user.HealthArchive.DietRestrictions,
|
||||||
user.HealthArchive.ChronicDiseases,
|
user.HealthArchive.ChronicDiseases,
|
||||||
user.HealthArchive.FamilyHistory
|
user.HealthArchive.FamilyHistory,
|
||||||
|
user.HealthArchive.SurgeryHistoryStatus
|
||||||
},
|
},
|
||||||
latestRecords,
|
latestRecords,
|
||||||
trendRecords,
|
trendRecords,
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ public static class ExerciseEndpoints
|
|||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
await exercises.DeleteAsync(userId, id, ct);
|
var deleted = await exercises.DeleteAsync(userId, id, ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return deleted
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "运动计划不存在" });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ public static class FileEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/files").RequireAuthorization();
|
var group = app.MapGroup("/api/files").RequireAuthorization();
|
||||||
|
|
||||||
group.MapPost("/upload", async (HttpRequest request, CancellationToken ct) =>
|
group.MapPost("/upload", async (HttpContext http, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
|
var request = http.Request;
|
||||||
if (!request.HasFormContentType)
|
if (!request.HasFormContentType)
|
||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "需要 multipart/form-data" });
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "需要 multipart/form-data" });
|
||||||
|
|
||||||
@@ -23,7 +24,12 @@ public static class FileEndpoints
|
|||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" });
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" });
|
||||||
|
|
||||||
var results = new List<object>();
|
var results = new List<object>();
|
||||||
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
var userDirectoryName = GetUserId(http).ToString("N");
|
||||||
|
var uploadsDir = Path.Combine(
|
||||||
|
Directory.GetCurrentDirectory(),
|
||||||
|
"uploads",
|
||||||
|
"users",
|
||||||
|
userDirectoryName);
|
||||||
Directory.CreateDirectory(uploadsDir);
|
Directory.CreateDirectory(uploadsDir);
|
||||||
|
|
||||||
foreach (var file in files)
|
foreach (var file in files)
|
||||||
@@ -48,7 +54,7 @@ public static class FileEndpoints
|
|||||||
id = fileId,
|
id = fileId,
|
||||||
name = file.FileName,
|
name = file.FileName,
|
||||||
size = file.Length,
|
size = file.Length,
|
||||||
url = $"/uploads/{storedName}",
|
url = $"/uploads/users/{userDirectoryName}/{storedName}",
|
||||||
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
|
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -56,4 +62,9 @@ public static class FileEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id)
|
||||||
|
? id
|
||||||
|
: Guid.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ public static class HealthEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = new { id }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { id }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapPost("/batch", async (
|
||||||
|
IReadOnlyList<CreateHealthRecordRequest> requests,
|
||||||
|
HttpContext http,
|
||||||
|
IHealthRecordService healthRecords,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var ids = await healthRecords.CreateManyAsync(
|
||||||
|
userId,
|
||||||
|
requests.Select(request => request.ToServiceRequest()).ToList(),
|
||||||
|
ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { ids }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
group.MapPut("/{id:guid}", async (
|
group.MapPut("/{id:guid}", async (
|
||||||
Guid id,
|
Guid id,
|
||||||
CreateHealthRecordRequest req,
|
CreateHealthRecordRequest req,
|
||||||
|
|||||||
@@ -36,8 +36,10 @@ public static class MedicationEndpoints
|
|||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
await medications.DeleteAsync(userId, id, ct);
|
var deleted = await medications.DeleteAsync(userId, id, ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return deleted
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "用药记录不存在" });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
@@ -75,8 +77,10 @@ public static class MedicationEndpoints
|
|||||||
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
|
var cancelled = await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return cancelled
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "未找到对应的服药打卡记录" });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,14 +114,19 @@ public static class MedicationEndpoints
|
|||||||
if (root.TryGetProperty("frequency", out var f) && Enum.TryParse<MedicationFrequency>(f.GetString(), out var freq))
|
if (root.TryGetProperty("frequency", out var f) && Enum.TryParse<MedicationFrequency>(f.GetString(), out var freq))
|
||||||
frequency = freq;
|
frequency = freq;
|
||||||
|
|
||||||
|
var hasEndDate = root.TryGetProperty("endDate", out var ed);
|
||||||
|
var clearEndDate = hasEndDate &&
|
||||||
|
(ed.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(ed.GetString()));
|
||||||
|
|
||||||
return new MedicationPatchRequest(
|
return new MedicationPatchRequest(
|
||||||
root.TryGetProperty("name", out var n) ? n.GetString() : null,
|
root.TryGetProperty("name", out var n) ? n.GetString() : null,
|
||||||
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
||||||
frequency,
|
frequency,
|
||||||
root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null,
|
root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null,
|
||||||
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
|
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
|
||||||
root.TryGetProperty("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
hasEndDate && !clearEndDate && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
||||||
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null);
|
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null,
|
||||||
|
clearEndDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<TimeOnly> ReadTimes(JsonElement root, string propertyName)
|
private static List<TimeOnly> ReadTimes(JsonElement root, string propertyName)
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ public static class NotificationEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapPost("/check-due", async (HttpContext http, IReminderCatchUpService reminders, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||||
|
var result = await reminders.CheckDueAsync(userId, DateTime.UtcNow, ct);
|
||||||
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
@@ -90,6 +98,10 @@ public static class NotificationEndpoints
|
|||||||
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
|
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
|
||||||
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value;
|
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value;
|
||||||
if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value;
|
if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value;
|
||||||
|
if (body.PushEnabled.HasValue) pref.PushEnabled = body.PushEnabled.Value;
|
||||||
|
if (body.DndEnabled.HasValue) pref.DndEnabled = body.DndEnabled.Value;
|
||||||
|
if (body.DndStartMinutes is >= 0 and < 1440) pref.DndStartMinutes = body.DndStartMinutes.Value;
|
||||||
|
if (body.DndEndMinutes is >= 0 and < 1440) pref.DndEndMinutes = body.DndEndMinutes.Value;
|
||||||
if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value;
|
if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value;
|
||||||
if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
|
if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
|
||||||
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
|
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
|
||||||
@@ -100,7 +112,7 @@ public static class NotificationEndpoints
|
|||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
// 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00
|
// 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00
|
||||||
if (body.HealthRecordReminder is true && pref.HealthRecordReminder)
|
if (body.HealthRecordReminder is true && pref.PushEnabled && pref.HealthRecordReminder)
|
||||||
{
|
{
|
||||||
var nowCst = DateTime.UtcNow.AddHours(8);
|
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||||
var todayStart = nowCst.Date.AddHours(-8);
|
var todayStart = nowCst.Date.AddHours(-8);
|
||||||
@@ -153,6 +165,10 @@ public static class NotificationEndpoints
|
|||||||
followUpReminder = pref.FollowUpReminder,
|
followUpReminder = pref.FollowUpReminder,
|
||||||
doctorReply = pref.DoctorReply,
|
doctorReply = pref.DoctorReply,
|
||||||
abnormalAlert = pref.AbnormalAlert,
|
abnormalAlert = pref.AbnormalAlert,
|
||||||
|
pushEnabled = pref.PushEnabled,
|
||||||
|
dndEnabled = pref.DndEnabled,
|
||||||
|
dndStartMinutes = pref.DndStartMinutes,
|
||||||
|
dndEndMinutes = pref.DndEndMinutes,
|
||||||
healthRecordReminder = pref.HealthRecordReminder,
|
healthRecordReminder = pref.HealthRecordReminder,
|
||||||
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
|
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
|
||||||
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
|
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
|
||||||
@@ -167,6 +183,10 @@ public sealed record NotificationPrefsUpdateDto(
|
|||||||
bool? FollowUpReminder,
|
bool? FollowUpReminder,
|
||||||
bool? DoctorReply,
|
bool? DoctorReply,
|
||||||
bool? AbnormalAlert,
|
bool? AbnormalAlert,
|
||||||
|
bool? PushEnabled,
|
||||||
|
bool? DndEnabled,
|
||||||
|
int? DndStartMinutes,
|
||||||
|
int? DndEndMinutes,
|
||||||
bool? HealthRecordReminder,
|
bool? HealthRecordReminder,
|
||||||
bool? HealthRecordReminderBloodPressure,
|
bool? HealthRecordReminderBloodPressure,
|
||||||
bool? HealthRecordReminderHeartRate,
|
bool? HealthRecordReminderHeartRate,
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ public static class UserEndpoints
|
|||||||
req.DietRestrictions,
|
req.DietRestrictions,
|
||||||
req.ChronicDiseases,
|
req.ChronicDiseases,
|
||||||
req.FamilyHistory,
|
req.FamilyHistory,
|
||||||
surgeries),
|
surgeries,
|
||||||
|
req.SurgeryHistoryStatus),
|
||||||
ct);
|
ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
@@ -70,6 +71,7 @@ public sealed record UpdateArchiveRequest(
|
|||||||
string? Diagnosis, string? SurgeryType, string? SurgeryDate,
|
string? Diagnosis, string? SurgeryType, string? SurgeryDate,
|
||||||
List<string>? Allergies, List<string>? DietRestrictions,
|
List<string>? Allergies, List<string>? DietRestrictions,
|
||||||
List<string>? ChronicDiseases, string? FamilyHistory,
|
List<string>? ChronicDiseases, string? FamilyHistory,
|
||||||
List<UpdateArchiveSurgeryRequest>? Surgeries);
|
List<UpdateArchiveSurgeryRequest>? Surgeries,
|
||||||
|
string? SurgeryHistoryStatus);
|
||||||
|
|
||||||
public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date);
|
public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date);
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ builder.Services.AddScoped<IInAppNotificationService, InAppNotificationService>(
|
|||||||
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
|
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
|
||||||
builder.Services.AddScoped<IUserNotificationProducer, EfUserNotificationProducer>();
|
builder.Services.AddScoped<IUserNotificationProducer, EfUserNotificationProducer>();
|
||||||
builder.Services.AddScoped<INotificationOutboxProcessor, EfNotificationOutboxProcessor>();
|
builder.Services.AddScoped<INotificationOutboxProcessor, EfNotificationOutboxProcessor>();
|
||||||
|
builder.Services.AddScoped<IReminderCatchUpService, EfReminderCatchUpService>();
|
||||||
builder.Services.AddScoped<IReportService, ReportService>();
|
builder.Services.AddScoped<IReportService, ReportService>();
|
||||||
builder.Services.AddScoped<IReportRepository, EfReportRepository>();
|
builder.Services.AddScoped<IReportRepository, EfReportRepository>();
|
||||||
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
|
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
|
||||||
@@ -150,6 +151,8 @@ builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
|
|||||||
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
|
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
|
||||||
builder.Services.AddScoped<IUserService, UserService>();
|
builder.Services.AddScoped<IUserService, UserService>();
|
||||||
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
|
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
|
||||||
|
builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup(
|
||||||
|
Path.Combine(Directory.GetCurrentDirectory(), "uploads")));
|
||||||
|
|
||||||
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)----
|
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)----
|
||||||
builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Warning",
|
"Default": "Warning",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
backend/src/Health.WebApi/wwwroot/app-icon.png
Normal file
BIN
backend/src/Health.WebApi/wwwroot/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -28,7 +28,7 @@
|
|||||||
<p>请您在注册、登录和使用本应用前仔细阅读本政策。若您不同意本政策内容,请停止注册或使用相关服务。</p>
|
<p>请您在注册、登录和使用本应用前仔细阅读本政策。若您不同意本政策内容,请停止注册或使用相关服务。</p>
|
||||||
|
|
||||||
<h2>一、我们收集的信息</h2>
|
<h2>一、我们收集的信息</h2>
|
||||||
<p>为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划、在线医生咨询和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:</p>
|
<p>为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:</p>
|
||||||
|
|
||||||
<h3>1. 账号与身份信息</h3>
|
<h3>1. 账号与身份信息</h3>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
<h3>4. 蓝牙设备信息</h3>
|
<h3>4. 蓝牙设备信息</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前代码中已实现血压计数据同步,设备模型中预留了血糖仪、体重秤、血氧仪类型,但当前实际支持的数据解析以血压计为主。</li>
|
<li>当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前实际支持以采用标准蓝牙血压服务的血压计为主;其他设备类型将在功能正式开放后另行说明。</li>
|
||||||
<li>我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间,以及用于避免重复同步的短期记录指纹。</li>
|
<li>我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间,以及用于避免重复同步的短期记录指纹。</li>
|
||||||
<li>通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。</li>
|
<li>通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<p>如您不同意本协议,请停止注册或使用本应用。</p>
|
<p>如您不同意本协议,请停止注册或使用本应用。</p>
|
||||||
|
|
||||||
<h2>一、服务内容</h2>
|
<h2>一、服务内容</h2>
|
||||||
<p>小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释、在线医生咨询、随访和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。</p>
|
<p>小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。</p>
|
||||||
<p>本应用当前可能包含以下服务:</p>
|
<p>本应用当前可能包含以下服务:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>健康数据管理:记录和展示血压、心率、血糖、血氧、体重等指标。</li>
|
<li>健康数据管理:记录和展示血压、心率、血糖、血氧、体重等指标。</li>
|
||||||
@@ -37,7 +37,6 @@
|
|||||||
<li>饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。</li>
|
<li>饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。</li>
|
||||||
<li>AI 健康咨询:根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。</li>
|
<li>AI 健康咨询:根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。</li>
|
||||||
<li>用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。</li>
|
<li>用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。</li>
|
||||||
<li>在线医生咨询:在开通范围内与医生或相关服务人员进行消息沟通。</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>二、账号注册与使用</h2>
|
<h2>二、账号注册与使用</h2>
|
||||||
@@ -56,7 +55,6 @@
|
|||||||
<li>检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。</li>
|
<li>检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。</li>
|
||||||
<li>饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。</li>
|
<li>饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。</li>
|
||||||
<li>用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。</li>
|
<li>用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。</li>
|
||||||
<li>医生咨询、随访和报告审核等服务,应结合线下诊疗意见综合判断。</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p>如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。</p>
|
<p>如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。</p>
|
||||||
|
|
||||||
|
|||||||
120
backend/tests/Health.Tests/account_deletion_tests.cs
Normal file
120
backend/tests/Health.Tests/account_deletion_tests.cs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
using Health.Application.Users;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Infrastructure.Users;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class AccountDeletionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task LocalCleanup_DeletesOwnedUploadsWithoutTouchingOtherFiles()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), $"health-account-delete-{Guid.NewGuid():N}");
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var userDirectory = Path.Combine(root, "users", userId.ToString("N"));
|
||||||
|
var reportPath = Path.Combine(root, "reports", "owned-report.pdf");
|
||||||
|
var chatImagePath = Path.Combine(root, "owned-chat.jpg");
|
||||||
|
var otherUserPath = Path.Combine(root, "users", Guid.NewGuid().ToString("N"), "keep.jpg");
|
||||||
|
var outsidePath = Path.Combine(Path.GetDirectoryName(root)!, "outside-account-file.txt");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(userDirectory);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(otherUserPath)!);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(userDirectory, "unlinked-upload.jpg"), "owned");
|
||||||
|
await File.WriteAllTextAsync(reportPath, "owned");
|
||||||
|
await File.WriteAllTextAsync(chatImagePath, "owned");
|
||||||
|
await File.WriteAllTextAsync(otherUserPath, "other");
|
||||||
|
await File.WriteAllTextAsync(outsidePath, "outside");
|
||||||
|
|
||||||
|
var cleanup = new LocalAccountFileCleanup(root);
|
||||||
|
var references = new AccountFileReferences(
|
||||||
|
["/uploads/reports/owned-report.pdf", "/uploads/../outside-account-file.txt"],
|
||||||
|
["{\"imageUrl\":\"/uploads/owned-chat.jpg\"}"]);
|
||||||
|
|
||||||
|
await cleanup.DeleteAsync(userId, references, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(Directory.Exists(userDirectory));
|
||||||
|
Assert.False(File.Exists(reportPath));
|
||||||
|
Assert.False(File.Exists(chatImagePath));
|
||||||
|
Assert.True(File.Exists(otherUserPath));
|
||||||
|
Assert.True(File.Exists(outsidePath));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||||
|
if (File.Exists(outsidePath)) File.Delete(outsidePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UserService_CleansFilesBeforeDeletingDatabaseData()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var references = new AccountFileReferences(["/uploads/reports/report.pdf"], []);
|
||||||
|
var repository = new FakeUserRepository(references, calls);
|
||||||
|
var cleanup = new FakeAccountFileCleanup(calls);
|
||||||
|
var service = new UserService(repository, cleanup);
|
||||||
|
|
||||||
|
await service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(["references", "files", "database"], calls);
|
||||||
|
Assert.Same(references, cleanup.ReceivedReferences);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UserService_DoesNotDeleteDatabaseWhenFileCleanupFails()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var repository = new FakeUserRepository(new AccountFileReferences([], []), calls);
|
||||||
|
var service = new UserService(repository, new FailingAccountFileCleanup(calls));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<IOException>(() =>
|
||||||
|
service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Equal(["references", "files"], calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeUserRepository(
|
||||||
|
AccountFileReferences references,
|
||||||
|
List<string> calls) : IUserRepository
|
||||||
|
{
|
||||||
|
public Task<User?> GetAsync(Guid userId, CancellationToken ct) => Task.FromResult<User?>(null);
|
||||||
|
|
||||||
|
public Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
calls.Add("references");
|
||||||
|
return Task.FromResult(references);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task DeleteAccountDataAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
calls.Add("database");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeAccountFileCleanup(List<string> calls) : IAccountFileCleanup
|
||||||
|
{
|
||||||
|
public AccountFileReferences? ReceivedReferences { get; private set; }
|
||||||
|
|
||||||
|
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||||
|
{
|
||||||
|
calls.Add("files");
|
||||||
|
ReceivedReferences = references;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FailingAccountFileCleanup(List<string> calls) : IAccountFileCleanup
|
||||||
|
{
|
||||||
|
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||||
|
{
|
||||||
|
calls.Add("files");
|
||||||
|
throw new IOException("file is locked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
backend/tests/Health.Tests/admin_service_tests.cs
Normal file
191
backend/tests/Health.Tests/admin_service_tests.cs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
using Health.Application.Admin;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Infrastructure.Admin;
|
||||||
|
using Health.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class AdminServiceTests
|
||||||
|
{
|
||||||
|
private static AppDbContext CreateDbContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new AppDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddDoctor_CreatesDoctorLoginAndProfileTogether()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var service = new AdminService(db);
|
||||||
|
|
||||||
|
var result = await service.AddDoctorAsync(
|
||||||
|
new AddDoctorCommand("13800138001", "王医生", "主任医师", "心内科", "高血压"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Code);
|
||||||
|
var doctor = await db.Doctors.SingleAsync();
|
||||||
|
var user = await db.Users.SingleAsync();
|
||||||
|
var profile = await db.DoctorProfiles.SingleAsync();
|
||||||
|
Assert.Equal("Doctor", user.Role);
|
||||||
|
Assert.Equal(doctor.Id, profile.DoctorId);
|
||||||
|
Assert.Equal(user.Id, profile.UserId);
|
||||||
|
Assert.Equal(doctor.Name, user.Name);
|
||||||
|
Assert.Equal(doctor.Name, profile.Name);
|
||||||
|
Assert.Equal(doctor.Title, profile.Title);
|
||||||
|
Assert.Equal(doctor.Department, profile.Department);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddDoctor_RejectsAPhoneAlreadyUsedByAnyAccount()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
db.Users.Add(new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Phone = "13800138002",
|
||||||
|
Role = "User",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new AdminService(db);
|
||||||
|
|
||||||
|
var result = await service.AddDoctorAsync(
|
||||||
|
new AddDoctorCommand("13800138002", "李医生", null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotEqual(0, result.Code);
|
||||||
|
Assert.Empty(db.Doctors);
|
||||||
|
Assert.Empty(db.DoctorProfiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateDoctor_SynchronizesDoctorLoginAndProfile()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var (doctor, user, profile) = await SeedDoctorAsync(db);
|
||||||
|
var service = new AdminService(db);
|
||||||
|
|
||||||
|
var result = await service.UpdateDoctorAsync(
|
||||||
|
doctor.Id,
|
||||||
|
new UpdateDoctorCommand("13800138999", "新姓名", "副主任医师", "神经内科", "脑血管"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Code);
|
||||||
|
Assert.Equal("13800138999", doctor.Phone);
|
||||||
|
Assert.Equal("13800138999", user.Phone);
|
||||||
|
Assert.Equal("新姓名", doctor.Name);
|
||||||
|
Assert.Equal("新姓名", user.Name);
|
||||||
|
Assert.Equal("新姓名", profile.Name);
|
||||||
|
Assert.Equal("副主任医师", profile.Title);
|
||||||
|
Assert.Equal("神经内科", profile.Department);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ToggleDoctor_SynchronizesAvailabilityButKeepsLoginEnabled()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var (doctor, user, profile) = await SeedDoctorAsync(db);
|
||||||
|
var service = new AdminService(db);
|
||||||
|
|
||||||
|
var result = await service.ToggleDoctorAsync(doctor.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Code);
|
||||||
|
Assert.False(doctor.IsActive);
|
||||||
|
Assert.False(profile.IsActive);
|
||||||
|
Assert.Equal("Doctor", user.Role);
|
||||||
|
Assert.NotNull(await db.Users.FindAsync(user.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteDoctor_IsRejectedWhilePatientsAreBound()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var (doctor, user, profile) = await SeedDoctorAsync(db);
|
||||||
|
db.Users.Add(new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Phone = "13800138088",
|
||||||
|
Name = "患者",
|
||||||
|
Role = "User",
|
||||||
|
DoctorId = doctor.Id,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new AdminService(db);
|
||||||
|
|
||||||
|
var result = await service.DeleteDoctorAsync(doctor.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotEqual(0, result.Code);
|
||||||
|
Assert.NotNull(await db.Doctors.FindAsync(doctor.Id));
|
||||||
|
Assert.NotNull(await db.Users.FindAsync(user.Id));
|
||||||
|
Assert.NotNull(await db.DoctorProfiles.FindAsync(profile.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteUnusedDoctor_RemovesLoginProfileAndRefreshTokens()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var (doctor, user, _) = await SeedDoctorAsync(db);
|
||||||
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = user.Id,
|
||||||
|
Token = "refresh-token",
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(1),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var service = new AdminService(db);
|
||||||
|
|
||||||
|
var result = await service.DeleteDoctorAsync(doctor.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, result.Code);
|
||||||
|
Assert.Empty(db.Doctors);
|
||||||
|
Assert.Empty(db.DoctorProfiles);
|
||||||
|
Assert.Empty(db.Users);
|
||||||
|
Assert.Empty(db.RefreshTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(Doctor Doctor, User User, DoctorProfile Profile)> SeedDoctorAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
var doctor = new Doctor
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Phone = "13800138003",
|
||||||
|
Name = "原姓名",
|
||||||
|
Title = "医师",
|
||||||
|
Department = "内科",
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Phone = doctor.Phone,
|
||||||
|
Name = doctor.Name,
|
||||||
|
Role = "Doctor",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var profile = new DoctorProfile
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
DoctorId = doctor.Id,
|
||||||
|
UserId = user.Id,
|
||||||
|
Name = doctor.Name,
|
||||||
|
Title = doctor.Title,
|
||||||
|
Department = doctor.Department,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.AddRange(doctor, user, profile);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return (doctor, user, profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
219
docs/HANDOFF-2026-07-17.md
Normal file
219
docs/HANDOFF-2026-07-17.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# 小脉健康项目交接报告(2026-07-17)
|
||||||
|
|
||||||
|
## 1. 本轮工作范围
|
||||||
|
|
||||||
|
本轮工作集中在以下三项:
|
||||||
|
|
||||||
|
1. 对医生端和管理员端进行患者端风格的轻量统一。
|
||||||
|
2. 检查并修复医生、登录账号、医生资料之间的同步逻辑。
|
||||||
|
3. 进行源码级逻辑检查和小范围自动化验证。
|
||||||
|
|
||||||
|
本轮没有生成 APK、没有进行真机或截图测试、没有启动 Web API、没有部署、没有推送远程代码,也没有修改真实数据库数据。
|
||||||
|
|
||||||
|
## 2. 医生端与管理员端 UI 改造
|
||||||
|
|
||||||
|
### 2.1 共用视觉层
|
||||||
|
|
||||||
|
新增后台共用展示组件:
|
||||||
|
|
||||||
|
- `health_app/lib/widgets/backoffice_ui.dart`
|
||||||
|
- `health_app/lib/utils/backoffice_formatters.dart`
|
||||||
|
- `health_app/lib/providers/backoffice_refresh_providers.dart`
|
||||||
|
|
||||||
|
统一内容包括:
|
||||||
|
|
||||||
|
- 使用患者端现有的紫蓝渐变和浅色页面背景。
|
||||||
|
- 使用白色圆角卡片、轻边框和统一阴影。
|
||||||
|
- 统一加载、空数据、加载失败和重试状态。
|
||||||
|
- 统一管理员端、医生端侧边栏的选中态。
|
||||||
|
- 对空姓名头像和不完整时间字符串进行安全展示。
|
||||||
|
|
||||||
|
### 2.2 医生端
|
||||||
|
|
||||||
|
已覆盖的主要页面:
|
||||||
|
|
||||||
|
- 工作台
|
||||||
|
- 患者列表与患者详情
|
||||||
|
- 问诊列表
|
||||||
|
- 报告列表与报告详情
|
||||||
|
- 随访列表与新增/编辑随访
|
||||||
|
- 医生资料
|
||||||
|
- 医生设置
|
||||||
|
- 医生侧边栏
|
||||||
|
|
||||||
|
已修复的页面问题:
|
||||||
|
|
||||||
|
- “新建随访”以前会被路由器当成缺少 ID 的详情页,现在可以正常进入新建模式。
|
||||||
|
- 新增或编辑随访后,返回列表会自动刷新。
|
||||||
|
- 随访和报告日期不再直接强制截取 16 个字符,避免空值或短字符串导致 `RangeError`。
|
||||||
|
- 空患者姓名不再因为直接读取第一个字符而导致崩溃。
|
||||||
|
- 患者列表加载失败后不再反复自动请求。
|
||||||
|
- 医生资料页面重建时不再反复覆盖用户正在编辑的输入内容。
|
||||||
|
- 停用医生登录后,工作台显示“账号已停用,新患者暂时无法选择您”的提示。
|
||||||
|
|
||||||
|
### 2.3 管理员端
|
||||||
|
|
||||||
|
已覆盖的主要页面:
|
||||||
|
|
||||||
|
- 医生管理
|
||||||
|
- 患者管理
|
||||||
|
- 新增医生
|
||||||
|
- 编辑医生
|
||||||
|
- 管理员侧边栏
|
||||||
|
|
||||||
|
已修复的页面问题:
|
||||||
|
|
||||||
|
- 管理员接口失败时不再无限显示加载状态。
|
||||||
|
- 医生列表增加“编辑”入口,复用新增医生表单。
|
||||||
|
- 新增或编辑医生后,返回列表会自动刷新。
|
||||||
|
- 停用和删除失败时显示后端返回的具体原因,不再静默失败。
|
||||||
|
- 删除确认文案改为安全规则:有绑定患者或问诊记录时只能停用,不能删除。
|
||||||
|
- 停用医生在管理员列表中继续显示“已停用”状态。
|
||||||
|
|
||||||
|
## 3. 医生账号三层数据同步
|
||||||
|
|
||||||
|
医生相关数据由以下三部分组成:
|
||||||
|
|
||||||
|
- `Doctor`:患者选择、医生展示和业务归属。
|
||||||
|
- `User(Role=Doctor)`:医生登录账号。
|
||||||
|
- `DoctorProfile`:医生端资料和医生实体关联。
|
||||||
|
|
||||||
|
本轮修改了 `backend/src/Health.Infrastructure/Admin/AdminService.cs`,规则如下。
|
||||||
|
|
||||||
|
### 3.1 新增医生
|
||||||
|
|
||||||
|
- 一次性创建 `Doctor`、医生登录 `User` 和 `DoctorProfile`。
|
||||||
|
- 三者使用相同的手机号和姓名,并建立正确 ID 关联。
|
||||||
|
- 手机号已被患者、医生或其他账号使用时拒绝新增。
|
||||||
|
|
||||||
|
### 3.2 编辑医生
|
||||||
|
|
||||||
|
- 手机号同步更新 `Doctor` 和登录 `User`。
|
||||||
|
- 姓名同步更新 `Doctor`、登录 `User` 和 `DoctorProfile`。
|
||||||
|
- 职称、科室同步更新 `Doctor` 和 `DoctorProfile`。
|
||||||
|
- 专业方向更新到 `Doctor`。
|
||||||
|
- 修改手机号前检查其他账号和医生是否已经占用。
|
||||||
|
- 医生登录资料缺失时拒绝局部修改,避免继续扩大不一致数据。
|
||||||
|
|
||||||
|
### 3.3 停用医生
|
||||||
|
|
||||||
|
- 同步更新 `Doctor.IsActive` 和 `DoctorProfile.IsActive`。
|
||||||
|
- 医生登录账号保持 `Role=Doctor`,仍可正常登录并服务已绑定患者。
|
||||||
|
- 患者与医生的原有绑定关系保持不变。
|
||||||
|
- 公开医生列表原本已经只查询 `Doctor.IsActive == true`,因此新患者注册时不会再看到已停用医生。
|
||||||
|
- 注册接口原本已经再次校验医生必须有效且启用。
|
||||||
|
|
||||||
|
### 3.4 删除医生
|
||||||
|
|
||||||
|
- 医生仍有绑定患者时拒绝删除。
|
||||||
|
- 医生已有问诊记录时拒绝删除。
|
||||||
|
- 无绑定患者和问诊记录时,删除 `Doctor`、对应登录 `User`、`DoctorProfile` 和刷新令牌。
|
||||||
|
- 有历史数据的医生应使用“停用”,不应通过物理删除清理。
|
||||||
|
|
||||||
|
## 4. 自动化验证
|
||||||
|
|
||||||
|
本轮最终验证结果:
|
||||||
|
|
||||||
|
- `flutter analyze`:通过,零问题。
|
||||||
|
- 后端 `Health.Tests`:56 项通过,0 项失败,0 项跳过。
|
||||||
|
- 医生/管理员 UI、路由和刷新相关测试:通过。
|
||||||
|
|
||||||
|
新增或更新的测试包括:
|
||||||
|
|
||||||
|
- `backend/tests/Health.Tests/admin_service_tests.cs`
|
||||||
|
- `health_app/test/backoffice_ui_test.dart`
|
||||||
|
- `health_app/test/backoffice_formatters_test.dart`
|
||||||
|
- `health_app/test/backoffice_refresh_test.dart`
|
||||||
|
- `health_app/test/app_router_test.dart`
|
||||||
|
|
||||||
|
说明:`dotnet test` 会自动编译测试程序集,但本轮没有执行发布构建,没有生成 Android APK/AAB,也没有执行 iOS 构建。
|
||||||
|
|
||||||
|
## 5. 数据库核对结果
|
||||||
|
|
||||||
|
本轮只进行了只读连接检查,没有修改数据库。
|
||||||
|
|
||||||
|
- 项目本地配置指向 `localhost:5432 / health_manager`。
|
||||||
|
- 本机 PostgreSQL 没有运行,因此无法读取本地实际数据。
|
||||||
|
- 开发 API `http://10.4.237.12:5000` 当前无法连接。
|
||||||
|
- 正式 API `https://erpapi.datalumina.cn/xiaomai/api/doctors` 可以访问,但公开接口返回的启用医生数量为 0。
|
||||||
|
- 交接资料显示正式数据库和备份方案尚未最终配置。
|
||||||
|
|
||||||
|
因此目前无法确认数据库中是否已经存在以下旧数据问题:
|
||||||
|
|
||||||
|
- `Doctor` 存在但没有医生登录 `User`。
|
||||||
|
- `DoctorProfile` 缺失或关联错误。
|
||||||
|
- 三层手机号、姓名或启用状态不一致。
|
||||||
|
- 重复医生手机号。
|
||||||
|
- 患者 `DoctorId` 指向不存在的医生。
|
||||||
|
|
||||||
|
新代码可以防止以后继续产生这些不一致,但不会自动修改已有数据。获得生产 PostgreSQL 只读连接或实际数据库备份后,应单独执行数据核对。
|
||||||
|
|
||||||
|
## 6. 仍未解决的高优先级问题
|
||||||
|
|
||||||
|
### P0:固定管理员验证码
|
||||||
|
|
||||||
|
`backend/src/Health.Infrastructure/Auth/AuthService.cs` 仍保留固定管理员手机号和固定验证码 `000000`。上线前必须移除,管理员需要使用受控的创建和认证方式。
|
||||||
|
|
||||||
|
### P0:短信服务仍是开发实现
|
||||||
|
|
||||||
|
`backend/src/Health.Infrastructure/Services/sms_service.cs` 只向服务端控制台输出验证码,没有接入真实短信服务。生产环境不会把验证码返回 App,因此普通用户无法正常接收验证码。
|
||||||
|
|
||||||
|
### P0:问诊 SignalR Hub 未鉴权
|
||||||
|
|
||||||
|
`backend/src/Health.WebApi/Hubs/ConsultationHub.cs` 没有强制 JWT 身份验证,也没有校验用户或医生是否属于指定问诊。调用者可以传入 `senderType` 和 `senderName`,存在加入其他问诊、伪造医生消息和写入数据库的风险。
|
||||||
|
|
||||||
|
### P1:医生聊天错误复用患者聊天流程
|
||||||
|
|
||||||
|
医生问诊列表进入的 `DoctorChatPage` 复用了患者端 `consultationChatProvider`:
|
||||||
|
|
||||||
|
- 把问诊 ID 当成医生 ID。
|
||||||
|
- 调用患者配额接口。
|
||||||
|
- 尝试创建新问诊。
|
||||||
|
- 发送消息时使用患者身份。
|
||||||
|
- 医生需要回复的 `WaitingDoctor` 状态反而不能发送。
|
||||||
|
|
||||||
|
医生实时问诊目前不能视为可用功能。需要拆分独立医生聊天 Provider 和页面,并与 Hub 鉴权一起处理。
|
||||||
|
|
||||||
|
### P1:AI SSE 鉴权和上传文件隐私
|
||||||
|
|
||||||
|
此前检查发现:
|
||||||
|
|
||||||
|
- AI SSE 接口允许从 query token 中直接读取 JWT claims,没有完整验证签名、签发者、受众和有效期。
|
||||||
|
- 上传的医疗图片和报告通过 `/uploads` 静态公开访问。
|
||||||
|
- 附件本地路径解析缺少完整的目录边界和文件归属校验。
|
||||||
|
- CORS 当前允许任意来源并携带凭据。
|
||||||
|
|
||||||
|
这些问题应在正式上线前统一修复。
|
||||||
|
|
||||||
|
### P1:iOS 蓝牙流程仍需修复和真机验证
|
||||||
|
|
||||||
|
此前检查发现 iOS 页面仍请求 Android 风格蓝牙权限,并调用仅 Android 支持的 `FlutterBluePlus.turnOn()`。iOS Pods 也需要在 macOS 上重新安装并验证。当前 Windows 环境不能证明 iOS 构建和真机蓝牙可用。
|
||||||
|
|
||||||
|
## 7. Apple 医疗硬件审核准备
|
||||||
|
|
||||||
|
如果首版明确只支持欧姆龙 J735,建议准备:
|
||||||
|
|
||||||
|
- J735 当前有效的医疗器械注册或批准材料。
|
||||||
|
- 小脉健康 App 与 J735 的联调测试报告。
|
||||||
|
- 真实 iPhone、J735 和当前版本 App 的完整配对及测量演示视频。
|
||||||
|
- App、审核说明、兼容设备清单和宣传文案全部明确首版支持范围。
|
||||||
|
|
||||||
|
注册证只能证明血压计本身合规,不能替代 App 与硬件联调测试报告和演示视频。
|
||||||
|
|
||||||
|
## 8. 建议后续顺序
|
||||||
|
|
||||||
|
1. 修复固定管理员验证码并接入正式短信服务。
|
||||||
|
2. 拆分医生聊天流程并为 SignalR Hub 增加 JWT、角色和问诊归属校验。
|
||||||
|
3. 修复 AI SSE 鉴权、上传文件访问控制、路径边界和 CORS。
|
||||||
|
4. 获得真实数据库只读连接,核对并修复旧医生账号数据。
|
||||||
|
5. 在 macOS 和真实 iPhone 上修复并验证 iOS 蓝牙流程。
|
||||||
|
6. 准备 J735 监管材料、联调报告和真实设备演示视频。
|
||||||
|
7. 完成正式服务器、数据库、短信、对象存储、域名和 HTTPS 配置后,再进入发布构建和应用商店提交。
|
||||||
|
|
||||||
|
## 9. 当前操作边界
|
||||||
|
|
||||||
|
- 本轮代码修改和本交接报告均未进行新的 Git 提交或远程推送。
|
||||||
|
- 没有执行数据库迁移。
|
||||||
|
- 没有写入、更新或删除真实数据库数据。
|
||||||
|
- 没有生成或上传发布安装包。
|
||||||
|
- 没有启动需要额外关闭的前端或后端常驻服务。
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# UI System First Pass Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Build the first pass of the app-wide UI system and migrate the highest-impact frontend surfaces to the agreed typography, color, icon, layout, button, feedback, and AI content rules.
|
|
||||||
|
|
||||||
**Architecture:** Centralize design decisions in token and common widget files, then migrate pages by replacing local hardcoded visuals with shared module visuals and components. Keep this pass focused on visible consistency and maintainability without adding the full accessibility-mode feature yet.
|
|
||||||
|
|
||||||
**Tech Stack:** Flutter, Riverpod, shadcn_ui Lucide icons, flutter_markdown.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Design Tokens And Common Widgets
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/core/app_colors.dart`
|
|
||||||
- Modify: `health_app/lib/core/app_theme.dart`
|
|
||||||
- Create: `health_app/lib/core/app_design_tokens.dart`
|
|
||||||
- Create: `health_app/lib/core/app_module_visuals.dart`
|
|
||||||
- Create: `health_app/lib/widgets/app_buttons.dart`
|
|
||||||
- Create: `health_app/lib/widgets/app_status_badge.dart`
|
|
||||||
- Create: `health_app/lib/widgets/app_toast.dart`
|
|
||||||
- Create: `health_app/lib/widgets/ai_content.dart`
|
|
||||||
|
|
||||||
- [ ] Add typography, spacing, radius, shadow, module color, and module icon tokens.
|
|
||||||
- [ ] Add reusable gradient-outline button and module-aware buttons.
|
|
||||||
- [ ] Add top-center toast helper for light feedback.
|
|
||||||
- [ ] Add shared AI Markdown renderer and plain text cleaner.
|
|
||||||
- [ ] Run `flutter analyze`.
|
|
||||||
|
|
||||||
### Task 2: AI Conversation And Cards
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/pages/home/widgets/chat_messages_view.dart`
|
|
||||||
- Modify: `health_app/lib/pages/home/home_page.dart`
|
|
||||||
|
|
||||||
- [ ] Apply shared AI content renderer to AI bubbles.
|
|
||||||
- [ ] Normalize user bubble, AI bubble, attachments, AI generated note, and Markdown typography.
|
|
||||||
- [ ] Update intelligent agent visuals to use shared module icons and colors, including `dumbbell` for exercise and `pill` for medication.
|
|
||||||
- [ ] Preserve gradient-outline white-background buttons in agent welcome cards.
|
|
||||||
- [ ] Keep today health card gradient outline and ensure empty medication windows still show a light status.
|
|
||||||
- [ ] Run `flutter analyze lib/pages/home/widgets/chat_messages_view.dart lib/pages/home/home_page.dart`.
|
|
||||||
|
|
||||||
### Task 3: Sidebar
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/widgets/health_drawer.dart`
|
|
||||||
|
|
||||||
- [ ] Keep current structure: user header, health dashboard, common functions, conversation records.
|
|
||||||
- [ ] Convert user header to no-frame layout with settings on the right.
|
|
||||||
- [ ] Keep health dashboard as a light panel.
|
|
||||||
- [ ] Convert common functions to a two-column light matrix without heavy per-item cards.
|
|
||||||
- [ ] Keep conversation records as lightweight rows with summary left and date right.
|
|
||||||
- [ ] Run `flutter analyze lib/widgets/health_drawer.dart`.
|
|
||||||
|
|
||||||
### Task 4: Core Management Pages
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/widgets/enterprise_widgets.dart`
|
|
||||||
- Modify: `health_app/lib/pages/medication/medication_list_page.dart`
|
|
||||||
- Modify: `health_app/lib/pages/medication/medication_checkin_page.dart`
|
|
||||||
- Modify: `health_app/lib/pages/remaining_pages.dart`
|
|
||||||
- Modify: `health_app/lib/pages/report/report_pages.dart`
|
|
||||||
- Modify: `health_app/lib/pages/report/ai_analysis_page.dart`
|
|
||||||
- Modify: `health_app/lib/pages/notifications/notification_center_page.dart`
|
|
||||||
|
|
||||||
- [ ] Update summary panels to avoid repeating page titles and reduce icon emphasis.
|
|
||||||
- [ ] Apply module colors and Lucide icons to medication, exercise, reports, and notifications.
|
|
||||||
- [ ] Apply shared AI content renderer to report AI text to prevent visible raw Markdown markers such as `**`.
|
|
||||||
- [ ] Normalize list title/subtitle/tag sizes and row heights.
|
|
||||||
- [ ] Run targeted `flutter analyze` on modified files.
|
|
||||||
|
|
||||||
### Task 5: Profile, Health Archive, Diet, Device
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/pages/profile/profile_page.dart`
|
|
||||||
- Modify: `health_app/lib/pages/remaining_pages.dart`
|
|
||||||
- Modify: `health_app/lib/pages/diet/diet_capture_page.dart`
|
|
||||||
- Modify: `health_app/lib/pages/device/device_management_page.dart`
|
|
||||||
- Modify: `health_app/lib/widgets/ble_sync_dialog.dart`
|
|
||||||
|
|
||||||
- [ ] Use two-column short-field layout where appropriate in profile/health archive surfaces already in scope.
|
|
||||||
- [ ] Apply module tokens to diet and device surfaces.
|
|
||||||
- [ ] Improve Bluetooth/device panels with shared radius, typography, and module colors.
|
|
||||||
- [ ] Replace obvious bottom SnackBar style feedback in touched flows with top-center toast where low-risk.
|
|
||||||
- [ ] Run targeted `flutter analyze` on modified files.
|
|
||||||
|
|
||||||
### Task 6: Full Verification
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Test: `health_app`
|
|
||||||
|
|
||||||
- [ ] Run `flutter analyze`.
|
|
||||||
- [ ] If available and practical, run a smoke test or build command for the Flutter app.
|
|
||||||
- [ ] Summarize changed files and any deferred surfaces.
|
|
||||||
|
|
||||||
@@ -1,688 +0,0 @@
|
|||||||
# Apple Sign-In 集成实施方案
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** 为健康管家 App 集成 Sign in with Apple,让 iOS 用户可以通过 Apple ID 一键登录/注册。
|
|
||||||
|
|
||||||
**Architecture:** 后端新增 `POST /api/auth/apple-login` 端点,验证 Apple 返回的 identityToken(JWT),查找或创建 User 记录(无需手机号),返回 JWT token。前端添加 `sign_in_with_apple` Flutter 包,在登录页放置 Apple 登录按钮。
|
|
||||||
|
|
||||||
**Tech Stack:** Flutter 3.44 + Dart 3.12 | C# .NET 10 + EF Core + PostgreSQL | sign_in_with_apple 包 | Apple RSA 公钥验证
|
|
||||||
|
|
||||||
**设计决策:**
|
|
||||||
- User.Phone 改为可空(Apple 用户无手机号),PostgreSQL 对多个 NULL 值不违反唯一索引
|
|
||||||
- 新增 `AppleUserId` 字段(可空字符串 + 唯一索引)标识 Apple 用户
|
|
||||||
- Apple 登录首次无需绑定医生(doctorId 可为空),用户可在个人中心后续设置
|
|
||||||
- Apple identityToken 验证:缓存 Apple 公钥,本地验签,验证 audience/issuer/有效期
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: 后端 — User 实体 + DB 迁移
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `backend/src/Health.Domain/Entities/user.cs`
|
|
||||||
- Modify: `backend/src/Health.Infrastructure/Data/app_db_context.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: User 实体添加 AppleUserId 字段,Phone 改为可空**
|
|
||||||
|
|
||||||
`backend/src/Health.Domain/Entities/user.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace Health.Domain.Entities;
|
|
||||||
|
|
||||||
public sealed class User
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public string? Phone { get; set; } // 改为可空,Apple 用户无手机号
|
|
||||||
public string? AppleUserId { get; set; } // Apple 唯一标识
|
|
||||||
public string Role { get; set; } = "User";
|
|
||||||
public Guid? DoctorId { get; set; }
|
|
||||||
public string? Name { get; set; }
|
|
||||||
// ... 其余字段不变
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: AppDbContext 添加 AppleUserId 配置**
|
|
||||||
|
|
||||||
`backend/src/Health.Infrastructure/Data/app_db_context.cs` 的 User 配置段:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
builder.Entity<User>(e =>
|
|
||||||
{
|
|
||||||
e.HasIndex(u => u.Phone).IsUnique();
|
|
||||||
e.HasIndex(u => u.AppleUserId).IsUnique(); // 新增
|
|
||||||
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: 验证编译**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: 后端 — Apple JWT 验证服务
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `backend/src/Health.Infrastructure/Services/apple_token_validator.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 创建 AppleTokenValidator**
|
|
||||||
|
|
||||||
`backend/src/Health.Infrastructure/Services/apple_token_validator.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
|
|
||||||
namespace Health.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Apple IdentityToken 验证:下载公钥、验签、检查声明
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AppleTokenValidator
|
|
||||||
{
|
|
||||||
private const string AppleKeysUrl = "https://appleid.apple.com/auth/keys";
|
|
||||||
private const string AppleIssuer = "https://appleid.apple.com";
|
|
||||||
private static readonly HttpClient _http = new();
|
|
||||||
private static List<JsonWebKey>? _cachedKeys;
|
|
||||||
private static DateTime _keysExpiry = DateTime.MinValue;
|
|
||||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
|
||||||
|
|
||||||
private readonly string _clientId;
|
|
||||||
|
|
||||||
public AppleTokenValidator(IConfiguration config)
|
|
||||||
{
|
|
||||||
_clientId = config["APPLE_CLIENT_ID"] ?? "com.datalumina.YYA";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 验证 Apple identityToken,返回 sub(用户唯一标识)
|
|
||||||
/// </summary>
|
|
||||||
public async Task<string> ValidateAsync(string identityToken, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var handler = new JwtSecurityTokenHandler();
|
|
||||||
// 先解码 header 获取 kid
|
|
||||||
var rawToken = handler.ReadJwtToken(identityToken);
|
|
||||||
var kid = rawToken.Header.Kid;
|
|
||||||
|
|
||||||
var keys = await GetKeysAsync(ct);
|
|
||||||
var key = keys.FirstOrDefault(k => k.KeyId == kid)
|
|
||||||
?? throw new SecurityTokenException($"No matching key for kid: {kid}");
|
|
||||||
|
|
||||||
var validationParams = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidIssuer = AppleIssuer,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidAudiences = [_clientId, "com.datalumina.YYA.signin"],
|
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
IssuerSigningKey = key,
|
|
||||||
ClockSkew = TimeSpan.FromMinutes(1),
|
|
||||||
};
|
|
||||||
|
|
||||||
var principal = handler.ValidateToken(identityToken, validationParams, out _);
|
|
||||||
var sub = principal.FindFirst("sub")?.Value;
|
|
||||||
if (string.IsNullOrWhiteSpace(sub))
|
|
||||||
throw new SecurityTokenException("Missing 'sub' claim in Apple identityToken");
|
|
||||||
|
|
||||||
return sub;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<List<JsonWebKey>> GetKeysAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
|
||||||
|
|
||||||
await _lock.WaitAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
|
||||||
|
|
||||||
var response = await _http.GetStringAsync(AppleKeysUrl, ct);
|
|
||||||
var jwks = new JsonWebKeySet(response);
|
|
||||||
_cachedKeys = jwks.Keys.ToList();
|
|
||||||
_keysExpiry = DateTime.UtcNow.AddHours(6); // 缓存 6 小时
|
|
||||||
return _cachedKeys;
|
|
||||||
}
|
|
||||||
finally { _lock.Release(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: 注册服务到 DI**
|
|
||||||
|
|
||||||
在 `backend/src/Health.WebApi/Program.cs` 的 services 注册区添加:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
builder.Services.AddSingleton<AppleTokenValidator>();
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: 验证编译**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: 后端 — AuthService 添加 Apple 登录
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `backend/src/Health.Application/Auth/AuthContracts.cs`
|
|
||||||
- Modify: `backend/src/Health.Infrastructure/Auth/AuthService.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加 AppleLoginCommand DTO 和接口方法**
|
|
||||||
|
|
||||||
`backend/src/Health.Application/Auth/AuthContracts.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace Health.Application.Auth;
|
|
||||||
|
|
||||||
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
|
||||||
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
|
||||||
public sealed record AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name); // 新增
|
|
||||||
|
|
||||||
public interface IAuthService
|
|
||||||
{
|
|
||||||
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
|
||||||
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
|
||||||
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
|
||||||
Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct); // 新增
|
|
||||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
|
||||||
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: AuthService 实现 AppleLoginAsync**
|
|
||||||
|
|
||||||
`backend/src/Health.Infrastructure/Auth/AuthService.cs`:
|
|
||||||
|
|
||||||
在类的顶部依赖注入区添加 `AppleTokenValidator`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed class AuthService(
|
|
||||||
AppDbContext db,
|
|
||||||
JwtProvider jwt,
|
|
||||||
SmsService sms,
|
|
||||||
AppleTokenValidator appleValidator // 新增
|
|
||||||
) : IAuthService
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _db = db;
|
|
||||||
private readonly JwtProvider _jwt = jwt;
|
|
||||||
private readonly SmsService _sms = sms;
|
|
||||||
private readonly AppleTokenValidator _appleValidator = appleValidator;
|
|
||||||
// ... 现有代码不变
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `LogoutAsync` 方法之后添加新方法,并修改 `AddTokens` 的 phone 参数类型为 `string?`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
|
|
||||||
{
|
|
||||||
// 1. 验证 Apple identityToken
|
|
||||||
string appleUserId;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 查找已有用户
|
|
||||||
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
|
|
||||||
if (existingUser != null)
|
|
||||||
{
|
|
||||||
// 已有用户 → 直接登录
|
|
||||||
existingUser.UpdatedAt = DateTime.UtcNow;
|
|
||||||
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
|
|
||||||
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return new AuthResult(0, new
|
|
||||||
{
|
|
||||||
tokens.accessToken, tokens.refreshToken,
|
|
||||||
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
|
|
||||||
var newUser = new User
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Phone = null, // Apple 用户未绑定手机
|
|
||||||
AppleUserId = appleUserId,
|
|
||||||
Role = "User",
|
|
||||||
Name = command.Name,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
UpdatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
_db.Users.Add(newUser);
|
|
||||||
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
|
|
||||||
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
|
|
||||||
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
|
|
||||||
await _db.SaveChangesAsync(ct);
|
|
||||||
return new AuthResult(0, new
|
|
||||||
{
|
|
||||||
newTokens.accessToken, newTokens.refreshToken,
|
|
||||||
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
同时修改 `AddTokens` 方法签名,phone 改为可空:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
|
|
||||||
{
|
|
||||||
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
|
|
||||||
var refresh = _jwt.GenerateRefreshToken();
|
|
||||||
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
|
||||||
return (access, refresh);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**说明**: `AddTokens` 的 phone 参数改为 `string?`,`GenerateAccessToken` 接收到空字符串(Apple 用户无需手机号)。`RefreshAsync` 和 `RegisterAsync` 中现有的 `AddTokens` 调用无需修改(传入 `string?` 的 `user.Phone` 自动匹配)。Admin 的 `AddTokens(AdminId, AdminPhone, "Admin")`(`AdminPhone` 是 `string` 常量)也无需修改。
|
|
||||||
|
|
||||||
- [ ] **Step 3: 验证编译**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: 后端 — 添加 Apple 登录端点
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加 AppleLoginRequest 和端点**
|
|
||||||
|
|
||||||
`backend/src/Health.WebApi/Endpoints/auth_endpoints.cs`:
|
|
||||||
|
|
||||||
在现有端点中添加:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// 在 MapAuthEndpoints 方法内、logout 端点之后添加:
|
|
||||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
|
||||||
```
|
|
||||||
|
|
||||||
在文件末尾添加 DTO:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
|
||||||
```
|
|
||||||
|
|
||||||
文件最终结果为:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using Health.Application.Auth;
|
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
|
||||||
|
|
||||||
public static class AuthEndpoints
|
|
||||||
{
|
|
||||||
public static void MapAuthEndpoints(this WebApplication app)
|
|
||||||
{
|
|
||||||
app.MapPost("/api/auth/send-sms", async (SendSmsRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.SendCodeAsync(request.Phone, app.Environment.IsDevelopment(), ct)));
|
|
||||||
app.MapPost("/api/auth/register", async (RegisterRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
|
||||||
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
|
||||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
|
||||||
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
|
||||||
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
await auth.LogoutAsync(request.RefreshToken, ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IResult ToResult(AuthResult result) =>
|
|
||||||
Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record SendSmsRequest(string Phone);
|
|
||||||
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
|
||||||
public sealed record LoginRequest(string Phone, string SmsCode);
|
|
||||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
|
||||||
public sealed record RefreshRequest(string RefreshToken);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: 验证编译**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: 后端 — 重启并测试端点连通性
|
|
||||||
|
|
||||||
- [ ] **Step 1: 重启后端服务**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend/src/Health.WebApi && dotnet run &
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: 测试无 token 调用(预期返回 40005,token 无效)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -w "\nHTTP %{http_code}" -X POST "http://localhost:5000/api/auth/apple-login" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"identityToken":"invalid","name":"test"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: HTTP 200, code=40005, message 包含"验证失败"
|
|
||||||
|
|
||||||
- [ ] **Step 3: 测试真实场景**
|
|
||||||
|
|
||||||
此时无法用 curl 直接测(需要真实的 Apple identityToken)。稍后在 Task 8 中从 App 端联合验证。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: 前端 — 添加 sign_in_with_apple 依赖
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/pubspec.yaml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加依赖**
|
|
||||||
|
|
||||||
`health_app/pubspec.yaml`,在 dependencies 区添加:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Apple 登录
|
|
||||||
sign_in_with_apple: ^6.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: 安装依赖**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd health_app && flutter pub get
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: 前端 — AuthProvider 添加 Apple 登录方法
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/providers/auth_provider.dart`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加 appleLogin 方法**
|
|
||||||
|
|
||||||
`health_app/lib/providers/auth_provider.dart`,在 `login()` 方法之后、`logout()` 方法之前添加:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
/// Apple 登录
|
|
||||||
Future<String?> appleLogin(String identityToken, String? authorizationCode, String? name) async {
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiClientProvider);
|
|
||||||
final response = await api.post(
|
|
||||||
'/api/auth/apple-login',
|
|
||||||
data: {
|
|
||||||
'identityToken': identityToken,
|
|
||||||
if (authorizationCode != null) 'authorizationCode': authorizationCode,
|
|
||||||
if (name != null && name.isNotEmpty) 'name': name,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
final data = response.data['data'];
|
|
||||||
if (data == null) return response.data['message'] ?? 'Apple 登录失败';
|
|
||||||
|
|
||||||
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
|
||||||
final user = data['user'];
|
|
||||||
state = AuthState(
|
|
||||||
isLoggedIn: true,
|
|
||||||
isLoading: false,
|
|
||||||
user: UserInfo(
|
|
||||||
id: user['id'] ?? '',
|
|
||||||
phone: user['phone'] ?? '',
|
|
||||||
role: user['role'] ?? 'User',
|
|
||||||
name: user['name'],
|
|
||||||
avatarUrl: user['avatarUrl'],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
return 'Apple 登录失败: $e';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意**: `UserInfo` 的 `phone` 字段当前是 `required String`。Apple 用户可能无手机号。`user['phone']` 可能是 null。在 login() 方法中 Phone 断言 `phone: user['phone'] ?? ''` 已有兜底,appleLogin 保持一致。
|
|
||||||
|
|
||||||
- [ ] **Step 2: 编译检查**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd health_app && flutter analyze lib/providers/auth_provider.dart
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 8: 前端 — 登录页添加 Apple 登录按钮
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `health_app/lib/pages/auth/login_page.dart`
|
|
||||||
|
|
||||||
- [ ] **Step 1: 添加 Apple 登录按钮组件和点击逻辑**
|
|
||||||
|
|
||||||
`health_app/lib/pages/auth/login_page.dart`:
|
|
||||||
|
|
||||||
顶部添加 import:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `_LoginPageState` 类中添加 Apple 登录方法(在 `_startCountdown` 方法之后):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
Future<void> _appleSignIn() async {
|
|
||||||
if (_loading) return;
|
|
||||||
setState(() {
|
|
||||||
_loading = true;
|
|
||||||
_error = null;
|
|
||||||
_successMsg = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final credential = await SignInWithApple.getAppleIDCredential(
|
|
||||||
scopes: [
|
|
||||||
AppleIDAuthorizationScopes.fullName,
|
|
||||||
],
|
|
||||||
webAuthenticationOptions: WebAuthenticationOptions(
|
|
||||||
clientId: 'com.datalumina.YYA.signin',
|
|
||||||
redirectUri: Uri.parse('https://erpapi.datalumina.cn/xiaomai/api/auth/apple-login'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final identityToken = credential.identityToken;
|
|
||||||
if (identityToken == null) {
|
|
||||||
setState(() {
|
|
||||||
_loading = false;
|
|
||||||
_error = 'Apple 登录未返回身份信息,请重试';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final name = credential.givenName != null && credential.familyName != null
|
|
||||||
? '${credential.familyName}${credential.givenName}'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
final err = await ref.read(authProvider.notifier).appleLogin(
|
|
||||||
identityToken,
|
|
||||||
credential.authorizationCode,
|
|
||||||
name,
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _loading = false);
|
|
||||||
if (err != null) {
|
|
||||||
setState(() => _error = err);
|
|
||||||
} else {
|
|
||||||
_goHome();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_loading = false;
|
|
||||||
if (e is SignInWithAppleAuthorizationException) {
|
|
||||||
_error = 'Apple 登录已取消';
|
|
||||||
} else {
|
|
||||||
_error = 'Apple 登录失败: $e';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `_LoginFormCard` 的 children 中,在 `_PrimaryButton` 和"去注册"链接之间(`_PrimaryButton` 之后,`const SizedBox(height: 16)` 之前)插入 Apple 按钮:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
// --- Apple 登录按钮 ---
|
|
||||||
SignInWithAppleButton(
|
|
||||||
onPressed: _appleSignIn,
|
|
||||||
style: SignInWithAppleButtonStyle.whiteOutline,
|
|
||||||
height: 48,
|
|
||||||
cornerRadius: 14,
|
|
||||||
),
|
|
||||||
// --- 结束 ---
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
```
|
|
||||||
|
|
||||||
即在 build 方法中 `_LoginFormCard` child 的末尾(`_PrimaryButton` 之后、`GestureDetector` "去注册" 之前):
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// ... 上面是 _PrimaryButton 的 const SizedBox(height: 22) 和 _PrimaryButton 本身
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
SignInWithAppleButton(
|
|
||||||
onPressed: _appleSignIn,
|
|
||||||
style: SignInWithAppleButtonStyle.whiteOutline,
|
|
||||||
height: 48,
|
|
||||||
cornerRadius: 14,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => setState(() {
|
|
||||||
_isLogin = !_isLogin;
|
|
||||||
_error = null;
|
|
||||||
_successMsg = null;
|
|
||||||
}),
|
|
||||||
// ... 去注册/去登录 链接
|
|
||||||
),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 1 补充**: `_submit()` 方法中也需要加 `_loading` 检查,确保 Apple 按钮点击期间短信登录不响应。现有 `_submit` 已有 `setState(() => _loading = true)`,问题不大。
|
|
||||||
|
|
||||||
- [ ] **Step 2: 编译检查**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd health_app && flutter analyze lib/pages/auth/login_page.dart
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: 编译 iOS 验证无语法错误**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd health_app && flutter build ios --debug --no-codesign
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 9: iOS — Xcode 配置 Sign in with Apple
|
|
||||||
|
|
||||||
- [ ] **Step 1: 在 Xcode 中启用 Sign in with Apple Capability**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
open health_app/ios/Runner.xcworkspace
|
|
||||||
```
|
|
||||||
|
|
||||||
在 Xcode 中操作:
|
|
||||||
1. 选择 Runner target → Signing & Capabilities
|
|
||||||
2. 点击 "+ Capability" → 选择 "Sign in with Apple"
|
|
||||||
3. 确认 .entitlements 文件自动生成了 `com.apple.developer.applesignin` entitlement
|
|
||||||
|
|
||||||
这也会自动在 `project.pbxproj` 中写入相关配置,后续 git diff 可以看到变化。
|
|
||||||
|
|
||||||
- [ ] **Step 2: 验证 .entitlements 文件**
|
|
||||||
|
|
||||||
确认 `health_app/ios/Runner/Runner.entitlements` 包含:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<key>com.apple.developer.applesignin</key>
|
|
||||||
<array>
|
|
||||||
<string>Default</string>
|
|
||||||
</array>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 10: App Store Connect 配置
|
|
||||||
|
|
||||||
- [ ] **Step 1: 在 App Store Connect 中启用 Sign in with Apple**
|
|
||||||
|
|
||||||
1. 打开 [App Store Connect](https://appstoreconnect.apple.com)
|
|
||||||
2. 进入 App 记录 → App 隐私 → Sign in with Apple
|
|
||||||
3. 确认已为 `com.datalumina.YYA` 这个 App ID 启用了 Sign in with Apple capability
|
|
||||||
|
|
||||||
如果还未创建 App Store Connect 上的 App 记录,需要在 Certificates, Identifiers & Profiles 中为 App ID 手动勾选 "Sign in with Apple" capability。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 验证计划
|
|
||||||
|
|
||||||
**后端单元验证:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 后端编译通过
|
|
||||||
cd backend/src/Health.WebApi && dotnet build
|
|
||||||
|
|
||||||
# 2. 启动后端
|
|
||||||
cd backend/src/Health.WebApi && dotnet run
|
|
||||||
|
|
||||||
# 3. 测试 Apple 登录端点(无 token 场景)
|
|
||||||
curl -s -X POST "http://localhost:5000/api/auth/apple-login" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"identityToken":"test","name":"测试"}'
|
|
||||||
# 预期: HTTP 200, body 包含 code=40005
|
|
||||||
|
|
||||||
# 4. 测试现有发送短信端点未被影响
|
|
||||||
curl -s -X POST "http://localhost:5000/api/auth/send-sms" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"phone":"13800138000"}'
|
|
||||||
# 预期: HTTP 200, code=0
|
|
||||||
```
|
|
||||||
|
|
||||||
**前端验证:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 编译检查
|
|
||||||
cd health_app && flutter analyze
|
|
||||||
|
|
||||||
# 2. iOS 构建通过
|
|
||||||
flutter build ios --debug --no-codesign
|
|
||||||
|
|
||||||
# 3. 在模拟器上运行
|
|
||||||
flutter run -d "iPhone 17 Pro"
|
|
||||||
# 验证:登录页显示 Apple 登录按钮;点击后弹出 Apple ID 授权界面
|
|
||||||
```
|
|
||||||
|
|
||||||
**端到端验证(模拟器):**
|
|
||||||
1. 模拟器上启动 App → 进入登录页
|
|
||||||
2. 点击 Apple 登录按钮 → Apple 授权弹窗出现 → 选择"继续"
|
|
||||||
3. 后端验证 identityToken → 创建新用户或登录已有 → 返回 JWT → App 进入首页
|
|
||||||
4. 二次打开 App → 如果之前 Apple 登录成功 + refresh_token 有效 → 自动恢复登录态
|
|
||||||
|
|
||||||
**DB 验证:**
|
|
||||||
```sql
|
|
||||||
-- 确认 Apple 用户创建成功
|
|
||||||
SELECT id, phone, apple_user_id, name, role FROM "Users" WHERE apple_user_id IS NOT NULL;
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# 医生端与管理员端 UI 统一设计
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在不改变现有业务流程、接口协议和导航结构的前提下,使医生端与管理员端的全部现有页面统一到患者端当前的视觉语言,并在修改过程中进行源码级逻辑检查。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- 覆盖 `health_app/lib/pages/doctor/` 下全部现有页面。
|
||||||
|
- 覆盖 `health_app/lib/pages/admin/` 下全部现有页面。
|
||||||
|
- 覆盖医生端和管理员端侧边栏及其直接使用的共用展示组件。
|
||||||
|
- 复用患者端已有的 `AppColors`、主题、卡片阴影、圆角、渐变和页面背景。
|
||||||
|
- 检查前端状态处理、异步调用、空数据、错误反馈、导航和字段映射中的明显逻辑错误。
|
||||||
|
- 检查后端与医生端、管理员端直接相关的鉴权、数据归属、参数验证和异常处理。
|
||||||
|
|
||||||
|
## 不在范围内
|
||||||
|
|
||||||
|
- 不新增医生端或管理员端业务功能。
|
||||||
|
- 不修改 API 协议、数据库结构或现有权限规则,除非发现明确的高风险缺陷并经用户另行确认。
|
||||||
|
- 不重做患者端。
|
||||||
|
- 不截图、不做视觉回归平台、不进行真机测试或发布。
|
||||||
|
- 不为了视觉统一进行无关的大规模重构。
|
||||||
|
|
||||||
|
## 视觉方案
|
||||||
|
|
||||||
|
### 页面骨架
|
||||||
|
|
||||||
|
- 页面使用患者端的浅灰背景和统一安全区处理。
|
||||||
|
- 顶部标题栏保持轻量、透明或白色表面,标题、返回和菜单图标统一使用患者端文字颜色。
|
||||||
|
- 页面内容统一使用 16 像素水平边距;主要区块之间保持稳定的 12–16 像素间距。
|
||||||
|
|
||||||
|
### 卡片与操作
|
||||||
|
|
||||||
|
- 内容容器统一为白色圆角卡片,使用 `AppColors.borderLight` 和轻阴影。
|
||||||
|
- 主操作使用患者端紫蓝主渐变;次操作使用浅色表面和边框;危险操作使用现有错误色。
|
||||||
|
- 列表项、统计卡片、筛选控件、表单和弹窗使用同一套圆角、文字层级和状态色。
|
||||||
|
- 医疗业务的警告、成功、待处理和异常状态继续保持语义颜色,不用装饰色覆盖含义。
|
||||||
|
|
||||||
|
### 状态反馈
|
||||||
|
|
||||||
|
- 加载状态保留明确进度反馈,避免空白页面。
|
||||||
|
- 空状态说明当前没有数据,并保留用户可执行的下一步操作(如果原流程已有操作)。
|
||||||
|
- 错误状态显示可理解的中文信息和重试入口(如果原页面已有重载能力)。
|
||||||
|
- 长文本和动态数据使用弹性布局与省略处理,降低窄屏溢出风险。
|
||||||
|
|
||||||
|
## 组件边界
|
||||||
|
|
||||||
|
- 优先使用现有 `AppColors` 和 `AppTheme`,不建立第二套颜色系统。
|
||||||
|
- 仅在多个医生端和管理员端页面确实重复时,新增小型共用展示组件,例如页面标题、区块卡片、状态占位和标签。
|
||||||
|
- 共用组件只负责展示与交互外观,不持有业务状态、不直接调用服务。
|
||||||
|
- 页面继续通过现有 Riverpod Provider 和 Service 获取数据,避免视觉改造改变数据流。
|
||||||
|
|
||||||
|
## 逻辑检查原则
|
||||||
|
|
||||||
|
- UI 改造过程中检查 `mounted`、重复提交、加载状态复位、空集合、空字段、分页/筛选和导航返回后的刷新。
|
||||||
|
- 检查医生与管理员 API 是否要求正确角色,并验证用户或患者数据归属。
|
||||||
|
- 明确的低风险页面错误可随 UI 修改修正;涉及鉴权、删除、隐私、医疗数据或接口行为的缺陷先记录并向用户说明,不擅自改变规则。
|
||||||
|
- 已知的全局上线风险单独列入最终检查报告,不与本次视觉修改混在一起扩大范围。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 运行 `flutter analyze`。
|
||||||
|
- 运行受修改页面影响的现有相关测试;不新增复杂测试框架。
|
||||||
|
- 运行一次 Android 编译验证,确认 Flutter 与原生依赖能完成编译。
|
||||||
|
- 不运行截图测试、真机测试、iOS 构建或发布流程。
|
||||||
|
- 最终说明修改过的页面、发现的逻辑问题、验证结果和仍需人工确认的事项。
|
||||||
|
|
||||||
|
## 完成标准
|
||||||
|
|
||||||
|
- 医生端和管理员端全部现有页面在颜色、卡片、间距、按钮、文字层级和状态反馈上与患者端一致。
|
||||||
|
- 原有页面入口、导航、数据加载和操作流程不被视觉改造改变。
|
||||||
|
- 修改代码通过静态分析、相关测试和一次 Android 编译验证。
|
||||||
|
- 逻辑检查结果按严重程度清晰列出,高风险业务问题没有未经确认的行为变更。
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -7,7 +9,7 @@ plugins {
|
|||||||
|
|
||||||
// 加载正式签名配置
|
// 加载正式签名配置
|
||||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
val keystoreProperties = java.util.Properties()
|
val keystoreProperties = Properties()
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||||
}
|
}
|
||||||
@@ -38,10 +40,12 @@ android {
|
|||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
keyAlias = keystoreProperties["keyAlias"] as String?
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
keyPassword = keystoreProperties["keyPassword"] as String?
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
storeFile = keystoreProperties.getProperty("storeFile")?.let { path ->
|
||||||
storePassword = keystoreProperties["storePassword"] as String?
|
file(path)
|
||||||
|
}
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||||
|
|
||||||
<!-- BLE 蓝牙血压计 -->
|
<!-- BLE 蓝牙健康设备(当前实现血压计同步) -->
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||||
<application
|
<application
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
BIN
health_app/assets/branding/drawer_background_v2.png
Normal file
BIN
health_app/assets/branding/drawer_background_v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 834 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
BIN
health_app/assets/branding/login_background_v2.png
Normal file
BIN
health_app/assets/branding/login_background_v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -70,9 +70,9 @@
|
|||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = ""
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
|
|||||||
@@ -27,15 +27,22 @@
|
|||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
|
<string>需要使用蓝牙连接支持的血压计等健康设备,以同步用户主动测量的健康数据</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
<string>需要使用蓝牙连接血压计</string>
|
<string>需要使用蓝牙连接支持的血压计等健康设备</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
|
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
<string>需要保存图片到相册</string>
|
<string>需要保存图片到相册</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>需要访问相册以选取饮食照片、体检报告或个人头像</string>
|
<string>需要访问相册以选取饮食照片、体检报告或个人头像</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>用于在同一局域网内连接小脉健康服务,以完成登录、健康数据同步和文件上传</string>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ final appReadyProvider = Provider<bool>((ref) {
|
|||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
if (auth.isLoading) return false; // 还在判登录态 → 继续盖 Splash
|
if (auth.isLoading) return false; // 还在判登录态 → 继续盖 Splash
|
||||||
if (!auth.isLoggedIn) return true; // 未登录 → 就绪,露出登录页
|
if (!auth.isLoggedIn) return true; // 未登录 → 就绪,露出登录页
|
||||||
|
final currentRoute = ref.watch(routeStackProvider).last.name;
|
||||||
|
if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash
|
||||||
final role = auth.user?.role ?? 'User';
|
final role = auth.user?.role ?? 'User';
|
||||||
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
|
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
|
||||||
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
|
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
|
||||||
@@ -104,10 +106,15 @@ class _RootNavigator extends ConsumerWidget {
|
|||||||
final stack = ref.watch(routeStackProvider);
|
final stack = ref.watch(routeStackProvider);
|
||||||
final current = stack.last;
|
final current = stack.last;
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
|
final isPublicStaticText = current.name == 'staticText';
|
||||||
|
|
||||||
// 登录后自动跳转(在下一帧完成,无闪烁)
|
// 登录后自动跳转(在下一帧完成,无闪烁)
|
||||||
if (authState.isLoggedIn && current.name == 'login') {
|
if (authState.isLoggedIn && current.name == 'login') {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
ref.invalidate(latestHealthProvider);
|
||||||
|
ref.invalidate(medicationListProvider);
|
||||||
|
ref.invalidate(medicationReminderProvider);
|
||||||
|
ref.invalidate(currentExercisePlanProvider);
|
||||||
final role = authState.user?.role ?? 'User';
|
final role = authState.user?.role ?? 'User';
|
||||||
if (role == 'Admin') {
|
if (role == 'Admin') {
|
||||||
goRoute(ref, 'adminHome');
|
goRoute(ref, 'adminHome');
|
||||||
@@ -120,7 +127,8 @@ class _RootNavigator extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
if (!authState.isLoading &&
|
if (!authState.isLoading &&
|
||||||
!authState.isLoggedIn &&
|
!authState.isLoggedIn &&
|
||||||
current.name != 'login') {
|
current.name != 'login' &&
|
||||||
|
!isPublicStaticText) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
goRoute(ref, 'login');
|
goRoute(ref, 'login');
|
||||||
});
|
});
|
||||||
@@ -131,7 +139,26 @@ class _RootNavigator extends ConsumerWidget {
|
|||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop) popRoute(ref);
|
if (!didPop) popRoute(ref);
|
||||||
},
|
},
|
||||||
child: buildPage(current, ref),
|
child: Navigator(
|
||||||
|
pages: [
|
||||||
|
for (var index = 0; index < stack.length; index++)
|
||||||
|
MaterialPage<void>(
|
||||||
|
key: ValueKey(_pageKey(stack[index], index)),
|
||||||
|
name: stack[index].name,
|
||||||
|
child: buildPage(stack[index], ref),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onDidRemovePage: (_) {
|
||||||
|
if (ref.read(routeStackProvider).length > 1) popRoute(ref);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _pageKey(RouteInfo route, int index) {
|
||||||
|
final params = route.params.entries
|
||||||
|
.map((entry) => '${entry.key}=${entry.value}')
|
||||||
|
.join('&');
|
||||||
|
return '$index:${route.name}:$params';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kReleaseMode;
|
||||||
import 'local_database.dart';
|
import 'local_database.dart';
|
||||||
|
|
||||||
/// API 基础地址(生产模式)。本地开发可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
/// API 基础地址(生产模式)。本地开发可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
||||||
const String baseUrl = String.fromEnvironment(
|
const String baseUrl = String.fromEnvironment(
|
||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: 'https://erpapi.datalumina.cn/xiaomai',
|
defaultValue: kReleaseMode
|
||||||
|
? 'https://erpapi.datalumina.cn/xiaomai'
|
||||||
|
: 'http://10.4.159.130:5000',
|
||||||
);
|
);
|
||||||
|
|
||||||
class ApiException implements Exception {
|
class ApiException implements Exception {
|
||||||
|
|||||||
@@ -20,30 +20,30 @@ class AppColors {
|
|||||||
static const Color meadowAccent = Color(0xFFBAE6FD);
|
static const Color meadowAccent = Color(0xFFBAE6FD);
|
||||||
static const Color sageAccent = Color(0xFFE9D5FF);
|
static const Color sageAccent = Color(0xFFE9D5FF);
|
||||||
|
|
||||||
static const Color health = Color(0xFF3B82F6);
|
static const Color health = Color(0xFF2D9D8F);
|
||||||
static const Color healthLight = Color(0xFFEFF6FF);
|
static const Color healthLight = Color(0xFFECF8F5);
|
||||||
static const Color healthBorder = Color(0xFFBFDBFE);
|
static const Color healthBorder = Color(0xFFCBE9E2);
|
||||||
static const Color medication = Color(0xFF8B5CF6);
|
static const Color medication = Color(0xFF00B8D9);
|
||||||
static const Color medicationLight = Color(0xFFF5F3FF);
|
static const Color medicationLight = Color(0xFFEAFBFF);
|
||||||
static const Color medicationBorder = Color(0xFFDDD6FE);
|
static const Color medicationBorder = Color(0xFFB8F0FA);
|
||||||
static const Color exercise = Color(0xFF10B981);
|
static const Color exercise = Color(0xFF566FD4);
|
||||||
static const Color exerciseLight = Color(0xFFECFDF5);
|
static const Color exerciseLight = Color(0xFFF1F3FD);
|
||||||
static const Color exerciseBorder = Color(0xFFA7F3D0);
|
static const Color exerciseBorder = Color(0xFFD7DCF6);
|
||||||
static const Color report = Color(0xFF2563EB);
|
static const Color report = Color(0xFF6366F1);
|
||||||
static const Color reportLight = Color(0xFFEEF2FF);
|
static const Color reportLight = Color(0xFFEFFBFF);
|
||||||
static const Color reportBorder = Color(0xFFC7D2FE);
|
static const Color reportBorder = Color(0xFFBAE6FD);
|
||||||
static const Color diet = Color(0xFFF97316);
|
static const Color diet = Color(0xFFF7971E);
|
||||||
static const Color dietLight = Color(0xFFFFF7ED);
|
static const Color dietLight = Color(0xFFFFF8DB);
|
||||||
static const Color dietBorder = Color(0xFFFED7AA);
|
static const Color dietBorder = Color(0xFFFFE58A);
|
||||||
static const Color device = Color(0xFF06B6D4);
|
static const Color device = Color(0xFF2563EB);
|
||||||
static const Color deviceLight = Color(0xFFECFEFF);
|
static const Color deviceLight = Color(0xFFEFF6FF);
|
||||||
static const Color deviceBorder = Color(0xFFA5F3FC);
|
static const Color deviceBorder = Color(0xFFBFDBFE);
|
||||||
static const Color notification = Color(0xFF7C5CFF);
|
static const Color notification = Color(0xFF7C5CFF);
|
||||||
static const Color notificationLight = Color(0xFFF5F3FF);
|
static const Color notificationLight = Color(0xFFF5F3FF);
|
||||||
static const Color notificationBorder = Color(0xFFDDD6FE);
|
static const Color notificationBorder = Color(0xFFDDD6FE);
|
||||||
static const Color doctor = Color(0xFF0F766E);
|
static const Color doctor = Color(0xFFFFAFBD);
|
||||||
static const Color doctorLight = Color(0xFFF0FDFA);
|
static const Color doctorLight = Color(0xFFFFF3EF);
|
||||||
static const Color doctorBorder = Color(0xFF99F6E4);
|
static const Color doctorBorder = Color(0xFFFFD7CB);
|
||||||
static const Color calendar = Color(0xFF6366F1);
|
static const Color calendar = Color(0xFF6366F1);
|
||||||
static const Color calendarLight = Color(0xFFEEF2FF);
|
static const Color calendarLight = Color(0xFFEEF2FF);
|
||||||
static const Color calendarBorder = Color(0xFFC7D2FE);
|
static const Color calendarBorder = Color(0xFFC7D2FE);
|
||||||
@@ -51,7 +51,7 @@ class AppColors {
|
|||||||
static const Color followupLight = Color(0xFFFDF2F8);
|
static const Color followupLight = Color(0xFFFDF2F8);
|
||||||
static const Color followupBorder = Color(0xFFFBCFE8);
|
static const Color followupBorder = Color(0xFFFBCFE8);
|
||||||
|
|
||||||
static const Color background = Color(0xFFF8FAFF);
|
static const Color background = Color(0xFFF3F5F8);
|
||||||
static const Color backgroundSoft = Color(0xFFFFFFFF);
|
static const Color backgroundSoft = Color(0xFFFFFFFF);
|
||||||
static const Color cardBackground = Color(0xFFFFFFFF);
|
static const Color cardBackground = Color(0xFFFFFFFF);
|
||||||
static const Color cardInner = Color(0xFFF1F5F9);
|
static const Color cardInner = Color(0xFFF1F5F9);
|
||||||
@@ -70,10 +70,13 @@ class AppColors {
|
|||||||
|
|
||||||
static const Color success = Color(0xFF16A34A);
|
static const Color success = Color(0xFF16A34A);
|
||||||
static const Color successLight = Color(0xFFEAF8EF);
|
static const Color successLight = Color(0xFFEAF8EF);
|
||||||
|
static const Color successText = Color(0xFF15803D);
|
||||||
static const Color error = Color(0xFFEF4444);
|
static const Color error = Color(0xFFEF4444);
|
||||||
static const Color errorLight = Color(0xFFFEECEF);
|
static const Color errorLight = Color(0xFFFEECEF);
|
||||||
|
static const Color errorText = Color(0xFFDC2626);
|
||||||
static const Color warning = Color(0xFFF59E0B);
|
static const Color warning = Color(0xFFF59E0B);
|
||||||
static const Color warningLight = Color(0xFFFFF7E6);
|
static const Color warningLight = Color(0xFFFFF7E6);
|
||||||
|
static const Color warningText = Color(0xFFB45309);
|
||||||
static const Color infoLight = Color(0xFFEFF5FF);
|
static const Color infoLight = Color(0xFFEFF5FF);
|
||||||
|
|
||||||
static const LinearGradient primaryGradient = LinearGradient(
|
static const LinearGradient primaryGradient = LinearGradient(
|
||||||
@@ -157,45 +160,45 @@ class AppColors {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient doctorGradient = LinearGradient(
|
static const LinearGradient doctorGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.centerLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.centerRight,
|
||||||
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
|
colors: [Color(0xFFFFC3A0), Color(0xFFFFAFBD)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient healthGradient = LinearGradient(
|
static const LinearGradient healthGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.centerLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.centerRight,
|
||||||
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
colors: [Color(0xFF2FAE9B), Color(0xFF63BFAE)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient medicationGradient = LinearGradient(
|
static const LinearGradient medicationGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFA78BFA), Color(0xFF7C3AED)],
|
colors: [Color(0xFF4FACFE), Color(0xFF00F2FE)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient exerciseGradient = LinearGradient(
|
static const LinearGradient exerciseGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF34D399), Color(0xFF059669)],
|
colors: [Color(0xFF5B7CDE), Color(0xFF7569D9)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient reportGradient = LinearGradient(
|
static const LinearGradient reportGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
colors: [Color(0xFF38BDF8), Color(0xFF818CF8)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient dietGradient = LinearGradient(
|
static const LinearGradient dietGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFFB923C), Color(0xFFF97316)],
|
colors: [Color(0xFFF7971E), Color(0xFFFFD200)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient deviceGradient = LinearGradient(
|
static const LinearGradient deviceGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF22D3EE), Color(0xFF0891B2)],
|
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient notificationGradient = LinearGradient(
|
static const LinearGradient notificationGradient = LinearGradient(
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ class AppTextStyles {
|
|||||||
|
|
||||||
static const TextStyle appBarTitle = TextStyle(
|
static const TextStyle appBarTitle = TextStyle(
|
||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
static const TextStyle summaryTitle = TextStyle(
|
static const TextStyle summaryTitle = TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
);
|
);
|
||||||
@@ -95,13 +95,13 @@ class AppTextStyles {
|
|||||||
|
|
||||||
static const TextStyle sectionTitle = TextStyle(
|
static const TextStyle sectionTitle = TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
static const TextStyle listTitle = TextStyle(
|
static const TextStyle listTitle = TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.22,
|
height: 1.22,
|
||||||
);
|
);
|
||||||
@@ -122,7 +122,7 @@ class AppTextStyles {
|
|||||||
|
|
||||||
static const TextStyle formLabel = TextStyle(
|
static const TextStyle formLabel = TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -134,19 +134,19 @@ class AppTextStyles {
|
|||||||
|
|
||||||
static const TextStyle tag = TextStyle(
|
static const TextStyle tag = TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
height: 1.15,
|
height: 1.15,
|
||||||
);
|
);
|
||||||
|
|
||||||
static const TextStyle button = TextStyle(
|
static const TextStyle button = TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
);
|
);
|
||||||
|
|
||||||
static const TextStyle miniButton = TextStyle(
|
static const TextStyle miniButton = TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w600,
|
||||||
);
|
);
|
||||||
|
|
||||||
static const TextStyle chatBody = TextStyle(
|
static const TextStyle chatBody = TextStyle(
|
||||||
@@ -158,7 +158,7 @@ class AppTextStyles {
|
|||||||
static const TextStyle chatTitle = TextStyle(
|
static const TextStyle chatTitle = TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
height: 1.35,
|
height: 1.35,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -168,4 +168,3 @@ class AppTextStyles {
|
|||||||
height: 1.25,
|
height: 1.25,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class AppModuleVisual {
|
|||||||
class AppModuleVisuals {
|
class AppModuleVisuals {
|
||||||
AppModuleVisuals._();
|
AppModuleVisuals._();
|
||||||
|
|
||||||
|
static const healthOverviewIcon = Icons.trending_up;
|
||||||
|
|
||||||
static const ai = AppModuleVisual(
|
static const ai = AppModuleVisual(
|
||||||
module: AppModule.ai,
|
module: AppModule.ai,
|
||||||
label: 'AI',
|
label: 'AI',
|
||||||
@@ -73,7 +75,7 @@ class AppModuleVisuals {
|
|||||||
static const exercise = AppModuleVisual(
|
static const exercise = AppModuleVisual(
|
||||||
module: AppModule.exercise,
|
module: AppModule.exercise,
|
||||||
label: '运动',
|
label: '运动',
|
||||||
icon: LucideIcons.footprints,
|
icon: Icons.directions_run_outlined,
|
||||||
color: AppColors.exercise,
|
color: AppColors.exercise,
|
||||||
lightColor: AppColors.exerciseLight,
|
lightColor: AppColors.exerciseLight,
|
||||||
borderColor: AppColors.exerciseBorder,
|
borderColor: AppColors.exerciseBorder,
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import '../pages/settings/notification_prefs_page.dart';
|
|||||||
import '../pages/notifications/notification_center_page.dart';
|
import '../pages/notifications/notification_center_page.dart';
|
||||||
import '../pages/history/conversation_history_page.dart';
|
import '../pages/history/conversation_history_page.dart';
|
||||||
import '../pages/profile/profile_page.dart';
|
import '../pages/profile/profile_page.dart';
|
||||||
|
import '../pages/profile/profile_edit_page.dart';
|
||||||
import '../pages/diet/diet_capture_page.dart';
|
import '../pages/diet/diet_capture_page.dart';
|
||||||
|
import '../pages/exercise/exercise_plan_page.dart';
|
||||||
import '../pages/device/device_scan_page.dart';
|
import '../pages/device/device_scan_page.dart';
|
||||||
import '../pages/device/device_management_page.dart';
|
import '../pages/device/device_management_page.dart';
|
||||||
import '../pages/remaining_pages.dart';
|
import '../pages/remaining_pages.dart';
|
||||||
@@ -55,7 +57,14 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
case 'adminHome':
|
case 'adminHome':
|
||||||
return const AdminHomePage();
|
return const AdminHomePage();
|
||||||
case 'adminAddDoctor':
|
case 'adminAddDoctor':
|
||||||
return const AdminAddDoctorPage();
|
return AdminAddDoctorPage(
|
||||||
|
id: params['id'],
|
||||||
|
initialPhone: params['phone'] ?? '',
|
||||||
|
initialName: params['name'] ?? '',
|
||||||
|
initialTitle: params['title'] ?? '',
|
||||||
|
initialDepartment: params['department'] ?? '',
|
||||||
|
initialDirection: params['direction'] ?? '',
|
||||||
|
);
|
||||||
case 'trend':
|
case 'trend':
|
||||||
return TrendPage(
|
return TrendPage(
|
||||||
metricType: params['type']?.isNotEmpty == true ? params['type'] : null,
|
metricType: params['type']?.isNotEmpty == true ? params['type'] : null,
|
||||||
@@ -67,9 +76,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 +86,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 +100,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':
|
||||||
@@ -104,8 +116,7 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
final id = _requiredParam(params, 'id');
|
final id = _requiredParam(params, 'id');
|
||||||
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
|
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
|
||||||
case 'doctorFollowUpEdit':
|
case 'doctorFollowUpEdit':
|
||||||
final id = _requiredParam(params, 'id');
|
return DoctorFollowUpEditPage(id: params['id']);
|
||||||
return id == null ? _missingParamPage() : DoctorFollowUpEditPage(id: id);
|
|
||||||
case 'devices':
|
case 'devices':
|
||||||
return const DeviceManagementPage();
|
return const DeviceManagementPage();
|
||||||
case 'deviceScan':
|
case 'deviceScan':
|
||||||
|
|||||||
@@ -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,21 +663,21 @@ class AppTheme {
|
|||||||
surface: surface,
|
surface: surface,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
),
|
),
|
||||||
scaffoldBackgroundColor: Colors.transparent,
|
scaffoldBackgroundColor: AppColors.background,
|
||||||
splashColor: primary.withValues(alpha: 0.06),
|
splashColor: primary.withValues(alpha: 0.06),
|
||||||
highlightColor: Colors.transparent,
|
highlightColor: Colors.transparent,
|
||||||
hoverColor: primaryLight.withValues(alpha: 0.55),
|
hoverColor: primaryLight.withValues(alpha: 0.55),
|
||||||
fontFamily: null,
|
fontFamily: null,
|
||||||
|
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
backgroundColor: Colors.white.withValues(alpha: 0.9),
|
backgroundColor: Colors.white,
|
||||||
foregroundColor: text,
|
foregroundColor: text,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
scrolledUnderElevation: 0,
|
scrolledUnderElevation: 0,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
titleTextStyle: const TextStyle(
|
titleTextStyle: TextStyle(
|
||||||
fontSize: 17,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: text,
|
color: text,
|
||||||
),
|
),
|
||||||
@@ -546,7 +724,7 @@ class AppTheme {
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
minimumSize: const Size(double.infinity, 50),
|
minimumSize: const Size(double.infinity, 50),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg)),
|
||||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -575,12 +753,12 @@ class AppTheme {
|
|||||||
textTheme: const TextTheme(
|
textTheme: const TextTheme(
|
||||||
headlineLarge: TextStyle(
|
headlineLarge: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
color: text,
|
color: text,
|
||||||
),
|
),
|
||||||
titleLarge: TextStyle(
|
titleLarge: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w600,
|
||||||
color: text,
|
color: text,
|
||||||
),
|
),
|
||||||
titleMedium: TextStyle(
|
titleMedium: TextStyle(
|
||||||
|
|||||||
@@ -34,17 +34,19 @@ class LocalDatabase {
|
|||||||
|
|
||||||
Future<void> write(String key, String value) async {
|
Future<void> write(String key, String value) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
await db.insert(
|
await db.insert('kv_store', {
|
||||||
'kv_store',
|
'key': key,
|
||||||
{'key': key, 'value': value},
|
'value': value,
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> read(String key) async {
|
Future<String?> read(String key) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final result =
|
final result = await db.query(
|
||||||
await db.query('kv_store', where: 'key = ?', whereArgs: [key]);
|
'kv_store',
|
||||||
|
where: 'key = ?',
|
||||||
|
whereArgs: [key],
|
||||||
|
);
|
||||||
if (result.isEmpty) return null;
|
if (result.isEmpty) return null;
|
||||||
return result.first['value'] as String?;
|
return result.first['value'] as String?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ class RouteStackNotifier extends Notifier<List<RouteInfo>> {
|
|||||||
|
|
||||||
/// 路由栈 Provider
|
/// 路由栈 Provider
|
||||||
final routeStackProvider =
|
final routeStackProvider =
|
||||||
NotifierProvider<RouteStackNotifier, List<RouteInfo>>(RouteStackNotifier.new);
|
NotifierProvider<RouteStackNotifier, List<RouteInfo>>(
|
||||||
|
RouteStackNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
/// 当前路由
|
/// 当前路由
|
||||||
final currentRouteProvider = Provider<RouteInfo>((ref) {
|
final currentRouteProvider = Provider<RouteInfo>((ref) {
|
||||||
@@ -46,13 +48,21 @@ void _dismissKeyboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 跳转(替换整个栈)
|
/// 跳转(替换整个栈)
|
||||||
void goRoute(WidgetRef ref, String name, {Map<String, String> params = const {}}) {
|
void goRoute(
|
||||||
|
WidgetRef ref,
|
||||||
|
String name, {
|
||||||
|
Map<String, String> params = const {},
|
||||||
|
}) {
|
||||||
_dismissKeyboard();
|
_dismissKeyboard();
|
||||||
ref.read(routeStackProvider.notifier).replace(name, params: params);
|
ref.read(routeStackProvider.notifier).replace(name, params: params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 推入新页面
|
/// 推入新页面
|
||||||
void pushRoute(WidgetRef ref, String name, {Map<String, String> params = const {}}) {
|
void pushRoute(
|
||||||
|
WidgetRef ref,
|
||||||
|
String name, {
|
||||||
|
Map<String, String> params = const {},
|
||||||
|
}) {
|
||||||
_dismissKeyboard();
|
_dismissKeyboard();
|
||||||
ref.read(routeStackProvider.notifier).push(name, params: params);
|
ref.read(routeStackProvider.notifier).push(name, params: params);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,53 @@ 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 '../../providers/backoffice_refresh_providers.dart';
|
||||||
import '../../widgets/app_toast.dart';
|
import '../../widgets/app_toast.dart';
|
||||||
|
import '../../widgets/backoffice_ui.dart';
|
||||||
|
|
||||||
class AdminAddDoctorPage extends ConsumerStatefulWidget {
|
class AdminAddDoctorPage extends ConsumerStatefulWidget {
|
||||||
const AdminAddDoctorPage({super.key});
|
final String? id;
|
||||||
|
final String initialPhone;
|
||||||
|
final String initialName;
|
||||||
|
final String initialTitle;
|
||||||
|
final String initialDepartment;
|
||||||
|
final String initialDirection;
|
||||||
|
|
||||||
|
const AdminAddDoctorPage({
|
||||||
|
super.key,
|
||||||
|
this.id,
|
||||||
|
this.initialPhone = '',
|
||||||
|
this.initialName = '',
|
||||||
|
this.initialTitle = '',
|
||||||
|
this.initialDepartment = '',
|
||||||
|
this.initialDirection = '',
|
||||||
|
});
|
||||||
@override
|
@override
|
||||||
ConsumerState<AdminAddDoctorPage> createState() => _AdminAddDoctorPageState();
|
ConsumerState<AdminAddDoctorPage> createState() => _AdminAddDoctorPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
|
class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
|
||||||
final _phoneCtrl = TextEditingController();
|
late final TextEditingController _phoneCtrl;
|
||||||
final _nameCtrl = TextEditingController();
|
late final TextEditingController _nameCtrl;
|
||||||
final _titleCtrl = TextEditingController();
|
late final TextEditingController _titleCtrl;
|
||||||
final _deptCtrl = TextEditingController();
|
late final TextEditingController _deptCtrl;
|
||||||
final _directionCtrl = TextEditingController();
|
late final TextEditingController _directionCtrl;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
|
||||||
|
bool get _isEdit => widget.id?.isNotEmpty == true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_phoneCtrl = TextEditingController(text: widget.initialPhone);
|
||||||
|
_nameCtrl = TextEditingController(text: widget.initialName);
|
||||||
|
_titleCtrl = TextEditingController(text: widget.initialTitle);
|
||||||
|
_deptCtrl = TextEditingController(text: widget.initialDepartment);
|
||||||
|
_directionCtrl = TextEditingController(text: widget.initialDirection);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_phoneCtrl.dispose();
|
_phoneCtrl.dispose();
|
||||||
@@ -40,20 +70,35 @@ class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
|
|||||||
}
|
}
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
await ref.read(adminServiceProvider).addDoctor({
|
final data = {
|
||||||
'phone': _phoneCtrl.text.trim(),
|
'phone': _phoneCtrl.text.trim(),
|
||||||
'name': _nameCtrl.text.trim(),
|
'name': _nameCtrl.text.trim(),
|
||||||
'title': _titleCtrl.text.trim(),
|
'title': _titleCtrl.text.trim(),
|
||||||
'department': _deptCtrl.text.trim(),
|
'department': _deptCtrl.text.trim(),
|
||||||
'professionalDirection': _directionCtrl.text.trim(),
|
'professionalDirection': _directionCtrl.text.trim(),
|
||||||
});
|
};
|
||||||
|
if (_isEdit) {
|
||||||
|
await ref.read(adminServiceProvider).updateDoctor(widget.id!, data);
|
||||||
|
} else {
|
||||||
|
await ref.read(adminServiceProvider).addDoctor(data);
|
||||||
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
AppToast.show(context, '添加成功', type: AppToastType.success);
|
ref.invalidate(doctorListProvider);
|
||||||
|
ref.read(adminDoctorsRefreshSignalProvider.notifier).trigger();
|
||||||
|
AppToast.show(
|
||||||
|
context,
|
||||||
|
_isEdit ? '修改成功' : '添加成功',
|
||||||
|
type: AppToastType.success,
|
||||||
|
);
|
||||||
popRoute(ref);
|
popRoute(ref);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
AppToast.show(context, '添加失败: $e', type: AppToastType.error);
|
AppToast.show(
|
||||||
|
context,
|
||||||
|
'${_isEdit ? '修改' : '添加'}失败: $e',
|
||||||
|
type: AppToastType.error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _saving = false);
|
if (mounted) setState(() => _saving = false);
|
||||||
@@ -65,49 +110,54 @@ 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: Text(
|
||||||
'新增医生',
|
_isEdit ? '编辑医生' : '新增医生',
|
||||||
style: TextStyle(color: AppColors.textPrimary),
|
style: const TextStyle(color: AppColors.textPrimary),
|
||||||
),
|
),
|
||||||
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: BackofficeSurface(
|
||||||
padding: const EdgeInsets.all(20),
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
padding: const EdgeInsets.all(20),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
_buildField('手机号 *', _phoneCtrl, TextInputType.phone),
|
children: [
|
||||||
const SizedBox(height: 16),
|
_buildField('手机号 *', _phoneCtrl, TextInputType.phone),
|
||||||
_buildField('姓名 *', _nameCtrl, TextInputType.name),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
_buildField('姓名 *', _nameCtrl, TextInputType.name),
|
||||||
_buildField('职称', _titleCtrl, TextInputType.text),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
_buildField('职称', _titleCtrl, TextInputType.text),
|
||||||
_buildField('科室', _deptCtrl, TextInputType.text),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
_buildField('科室', _deptCtrl, TextInputType.text),
|
||||||
_buildField('专业方向', _directionCtrl, TextInputType.text),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 32),
|
_buildField('专业方向', _directionCtrl, TextInputType.text),
|
||||||
SizedBox(
|
const SizedBox(height: 32),
|
||||||
height: 50,
|
SizedBox(
|
||||||
child: ElevatedButton(
|
height: 50,
|
||||||
onPressed: _saving ? null : _save,
|
child: ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
onPressed: _saving ? null : _save,
|
||||||
backgroundColor: AppColors.primary,
|
style: ElevatedButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(
|
backgroundColor: AppColors.primary,
|
||||||
borderRadius: BorderRadius.circular(12),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
child: _saving
|
||||||
|
? const CircularProgressIndicator(color: Colors.white)
|
||||||
|
: Text(
|
||||||
|
_isEdit ? '保存修改' : '保存',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: _saving
|
|
||||||
? const CircularProgressIndicator(color: Colors.white)
|
|
||||||
: const Text(
|
|
||||||
'保存',
|
|
||||||
style: TextStyle(fontSize: 16, color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ 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;
|
||||||
|
import '../../providers/backoffice_refresh_providers.dart';
|
||||||
|
import '../../widgets/backoffice_ui.dart';
|
||||||
|
import '../../widgets/app_toast.dart';
|
||||||
|
|
||||||
class AdminDoctorsPage extends ConsumerStatefulWidget {
|
class AdminDoctorsPage extends ConsumerStatefulWidget {
|
||||||
const AdminDoctorsPage({super.key});
|
const AdminDoctorsPage({super.key});
|
||||||
@@ -13,6 +16,7 @@ class AdminDoctorsPage extends ConsumerStatefulWidget {
|
|||||||
class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
||||||
List<Map<String, dynamic>> _doctors = [];
|
List<Map<String, dynamic>> _doctors = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -21,25 +25,40 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
setState(() => _loading = true);
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
final res = await ref.read(adminServiceProvider).getDoctors();
|
final res = await ref.read(adminServiceProvider).getDoctors();
|
||||||
if (res['code'] == 0 && mounted) {
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
if (res['code'] == 0) {
|
||||||
_doctors = List<Map<String, dynamic>>.from(res['data'] ?? []);
|
_doctors = List<Map<String, dynamic>>.from(res['data'] ?? []);
|
||||||
|
} else {
|
||||||
|
_error = res['message']?.toString() ?? '医生列表加载失败';
|
||||||
|
}
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
|
_error = '医生列表加载失败';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (_) {
|
|
||||||
if (mounted) setState(() => _loading = false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleActive(String id) async {
|
Future<void> _toggleActive(String id) async {
|
||||||
try {
|
try {
|
||||||
await ref.read(adminServiceProvider).toggleDoctorActive(id);
|
await ref.read(adminServiceProvider).toggleDoctorActive(id);
|
||||||
_load();
|
await _load();
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
AppToast.show(context, '状态修改失败:$e', type: AppToastType.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _delete(String id) async {
|
Future<void> _delete(String id) async {
|
||||||
@@ -47,7 +66,7 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('确认删除'),
|
title: const Text('确认删除'),
|
||||||
content: const Text('删除后患者关联将被清除,确定吗?'),
|
content: const Text('只有没有绑定患者和问诊记录的医生才能删除;有历史数据的医生请使用“停用”。'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
@@ -64,13 +83,20 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
|||||||
if (ok == true) {
|
if (ok == true) {
|
||||||
try {
|
try {
|
||||||
await ref.read(adminServiceProvider).deleteDoctor(id);
|
await ref.read(adminServiceProvider).deleteDoctor(id);
|
||||||
_load();
|
await _load();
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
AppToast.show(context, '删除失败:$e', type: AppToastType.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
ref.listen(adminDoctorsRefreshSignalProvider, (previous, next) {
|
||||||
|
if (previous != null && previous != next) _load();
|
||||||
|
});
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.pageGrey,
|
backgroundColor: AppColors.pageGrey,
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
@@ -79,13 +105,14 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
|||||||
child: const Icon(Icons.add, color: Colors.white),
|
child: const Icon(Icons.add, color: Colors.white),
|
||||||
),
|
),
|
||||||
body: _loading
|
body: _loading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const BackofficeLoadingState(message: '正在加载医生')
|
||||||
|
: _error != null
|
||||||
|
? BackofficeErrorState(message: _error!, onRetry: _load)
|
||||||
: _doctors.isEmpty
|
: _doctors.isEmpty
|
||||||
? const Center(
|
? const BackofficeEmptyState(
|
||||||
child: Text(
|
icon: Icons.medical_services_outlined,
|
||||||
'暂无医生',
|
title: '暂无医生',
|
||||||
style: TextStyle(color: AppColors.textHint, fontSize: 16),
|
description: '点击右下角按钮添加第一位医生',
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -93,104 +120,114 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
|||||||
itemBuilder: (_, i) {
|
itemBuilder: (_, i) {
|
||||||
final d = _doctors[i];
|
final d = _doctors[i];
|
||||||
final active = d['isActive'] != false;
|
final active = d['isActive'] != false;
|
||||||
return Card(
|
return BackofficeSectionCard(
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
elevation: 0,
|
padding: const EdgeInsets.all(14),
|
||||||
shape: RoundedRectangleBorder(
|
child: Row(
|
||||||
borderRadius: BorderRadius.circular(14),
|
children: [
|
||||||
side: const BorderSide(color: AppColors.border),
|
CircleAvatar(
|
||||||
),
|
backgroundColor: active
|
||||||
child: Padding(
|
? AppColors.successLight
|
||||||
padding: const EdgeInsets.all(14),
|
: AppColors.background,
|
||||||
child: Row(
|
child: Icon(
|
||||||
children: [
|
Icons.local_hospital,
|
||||||
CircleAvatar(
|
size: 20,
|
||||||
backgroundColor: active
|
color: active
|
||||||
? AppColors.successLight
|
? AppColors.success
|
||||||
: AppColors.background,
|
: AppColors.textHint,
|
||||||
child: Icon(
|
|
||||||
Icons.local_hospital,
|
|
||||||
size: 20,
|
|
||||||
color: active
|
|
||||||
? AppColors.success
|
|
||||||
: AppColors.textHint,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
Expanded(
|
const SizedBox(width: 12),
|
||||||
child: Column(
|
Expanded(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Row(
|
children: [
|
||||||
children: [
|
Row(
|
||||||
Text(
|
children: [
|
||||||
d['name'] ?? '',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
if (!active)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 6,
|
|
||||||
vertical: 2,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.errorLight,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'已停用',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
color: AppColors.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${d['title'] ?? ''} 路 ${d['department'] ?? ''}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (d['professionalDirection'] != null)
|
|
||||||
Text(
|
Text(
|
||||||
d['professionalDirection']!,
|
d['name'] ?? '',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 16,
|
||||||
color: AppColors.textHint,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(width: 8),
|
||||||
),
|
if (!active)
|
||||||
),
|
Container(
|
||||||
PopupMenuButton<String>(
|
padding: const EdgeInsets.symmetric(
|
||||||
onSelected: (v) {
|
horizontal: 6,
|
||||||
if (v == 'toggle') _toggleActive(d['id']);
|
vertical: 2,
|
||||||
if (v == 'delete') _delete(d['id']);
|
),
|
||||||
},
|
decoration: BoxDecoration(
|
||||||
itemBuilder: (_) => [
|
color: AppColors.errorLight,
|
||||||
PopupMenuItem(
|
borderRadius: BorderRadius.circular(4),
|
||||||
value: 'toggle',
|
),
|
||||||
child: Text(active ? '停用' : '启用'),
|
child: const Text(
|
||||||
|
'已停用',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: AppColors.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const PopupMenuItem(
|
const SizedBox(height: 4),
|
||||||
value: 'delete',
|
Text(
|
||||||
child: Text(
|
'${d['title'] ?? ''} · ${d['department'] ?? ''}',
|
||||||
'删除',
|
style: const TextStyle(
|
||||||
style: TextStyle(color: AppColors.error),
|
fontSize: 13,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (d['professionalDirection'] != null)
|
||||||
|
Text(
|
||||||
|
d['professionalDirection']!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (v) {
|
||||||
|
if (v == 'edit') {
|
||||||
|
pushRoute(
|
||||||
|
ref,
|
||||||
|
'adminAddDoctor',
|
||||||
|
params: {
|
||||||
|
'id': d['id']?.toString() ?? '',
|
||||||
|
'phone': d['phone']?.toString() ?? '',
|
||||||
|
'name': d['name']?.toString() ?? '',
|
||||||
|
'title': d['title']?.toString() ?? '',
|
||||||
|
'department': d['department']?.toString() ?? '',
|
||||||
|
'direction':
|
||||||
|
d['professionalDirection']?.toString() ??
|
||||||
|
'',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (v == 'toggle') _toggleActive(d['id']);
|
||||||
|
if (v == 'delete') _delete(d['id']);
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'toggle',
|
||||||
|
child: Text(active ? '停用' : '启用'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Text(
|
||||||
|
'删除',
|
||||||
|
style: TextStyle(color: AppColors.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,36 +2,46 @@ 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 '../../widgets/admin_drawer.dart';
|
import '../../widgets/admin_drawer.dart';
|
||||||
|
import '../../widgets/backoffice_ui.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: Text(
|
||||||
|
page == 'patients' ? '患者管理' : '医生管理',
|
||||||
|
style: TextStyle(color: AppColors.textPrimary),
|
||||||
|
),
|
||||||
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||||
),
|
),
|
||||||
drawer: const AdminDrawer(),
|
drawer: const AdminDrawer(),
|
||||||
body: switch (page) {
|
body: BackofficeSurface(
|
||||||
'doctors' => const AdminDoctorsPage(),
|
child: switch (page) {
|
||||||
'patients' => const AdminPatientsPage(),
|
'doctors' => const AdminDoctorsPage(),
|
||||||
_ => const AdminDoctorsPage(),
|
'patients' => const AdminPatientsPage(),
|
||||||
},
|
_ => const AdminDoctorsPage(),
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +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 '../../providers/data_providers.dart' show adminServiceProvider;
|
import '../../providers/data_providers.dart' show adminServiceProvider;
|
||||||
|
import '../../utils/backoffice_formatters.dart';
|
||||||
|
import '../../widgets/backoffice_ui.dart';
|
||||||
|
|
||||||
class AdminPatientsPage extends ConsumerStatefulWidget {
|
class AdminPatientsPage extends ConsumerStatefulWidget {
|
||||||
const AdminPatientsPage({super.key});
|
const AdminPatientsPage({super.key});
|
||||||
@@ -16,6 +18,7 @@ class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
|
|||||||
int _page = 1;
|
int _page = 1;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
bool _loadingMore = false;
|
bool _loadingMore = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -35,13 +38,15 @@ class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
_patients = [];
|
_patients = [];
|
||||||
|
_error = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final res = await ref
|
final res = await ref
|
||||||
.read(adminServiceProvider)
|
.read(adminServiceProvider)
|
||||||
.getPatients(search: _searchCtrl.text.trim(), page: _page);
|
.getPatients(search: _searchCtrl.text.trim(), page: _page);
|
||||||
if (res['code'] == 0 && mounted) {
|
if (!mounted) return;
|
||||||
|
if (res['code'] == 0) {
|
||||||
final data = res['data'] as Map<String, dynamic>? ?? {};
|
final data = res['data'] as Map<String, dynamic>? ?? {};
|
||||||
setState(() {
|
setState(() {
|
||||||
_total = data['total'] ?? 0;
|
_total = data['total'] ?? 0;
|
||||||
@@ -54,12 +59,19 @@ class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
|
|||||||
_loading = false;
|
_loading = false;
|
||||||
_loadingMore = false;
|
_loadingMore = false;
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_loadingMore = false;
|
||||||
|
_error = res['message']?.toString() ?? '患者列表加载失败';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
_loadingMore = false;
|
_loadingMore = false;
|
||||||
|
_error = '患者列表加载失败';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,13 +135,14 @@ class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
|
|||||||
// 患者列表
|
// 患者列表
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _loading
|
child: _loading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const BackofficeLoadingState(message: '正在加载患者')
|
||||||
|
: _error != null
|
||||||
|
? BackofficeErrorState(message: _error!, onRetry: _load)
|
||||||
: _patients.isEmpty
|
: _patients.isEmpty
|
||||||
? const Center(
|
? const BackofficeEmptyState(
|
||||||
child: Text(
|
icon: Icons.people_outline,
|
||||||
'暂无患者',
|
title: '暂无患者',
|
||||||
style: TextStyle(color: AppColors.textHint, fontSize: 16),
|
description: '注册患者会显示在这里',
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: NotificationListener<ScrollNotification>(
|
: NotificationListener<ScrollNotification>(
|
||||||
onNotification: (n) {
|
onNotification: (n) {
|
||||||
@@ -152,20 +165,14 @@ class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final p = _patients[i];
|
final p = _patients[i];
|
||||||
return Card(
|
return BackofficeSectionCard(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
elevation: 0,
|
padding: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
side: const BorderSide(color: AppColors.border),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: AppColors.iconBg,
|
backgroundColor: AppColors.iconBg,
|
||||||
child: Text(
|
child: Text(
|
||||||
p['name']?.toString().isNotEmpty == true
|
backofficeInitial(p['name']),
|
||||||
? p['name']!.toString()[0]
|
|
||||||
: '?',
|
|
||||||
style: const TextStyle(color: AppColors.primary),
|
style: const TextStyle(color: AppColors.primary),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user