diff --git a/AGENTS.md b/AGENTS.md index 786d909..c3b0ff3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,9 +22,9 @@ | UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` | | 持久化 | SwiftData | 见 §5 数据模型 | | 图表 | Swift Charts | iOS 16+ 原生 | -| **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama | -| LLM | Qwen3-1.7B 4bit (HF: `mlx-community/Qwen3-1.7B-4bit`) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 | -| VL | Qwen2.5-VL-3B-Instruct 4bit (HF: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`) | ~2.0GB,负责拍照→结构化指标 | +| **AI 运行时(主)** | **MNN (alibaba) + Arm SME2 + CPU** | 挑战赛考核点:Qwen + MNN + SME2 端侧 CPU 推理。device-only(xcframework 见 `scripts/build-mnn-xcframework.sh`),A19/iPhone17 启用 SME2、A17 回退 NEON。经 `MNNLLMBridge`(ObjC++)→ `MNNBackend` | +| **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama | +| 模型 | **Qwen3.5-2B**(一个多模态模型,文本+视觉一肩挑) | 真机主用:`taobao-mnn/Qwen3.5-2B-MNN`(~1.2GB);MLX 兜底:`mlx-community/Qwen3.5-2B-4bit`(~1.7GB)。**已废弃**:Qwen3-1.7B / Qwen2.5-VL-3B / Qwen3-VL-4B(4B 实测过慢退回 2B) | | 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 | | Face ID | LocalAuthentication | | | Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 | @@ -38,13 +38,14 @@ ### 3.1 模块边界(强制) ``` -UI → CaptureService / AskService / TrendService → AIRuntime → MLX +UI → CaptureService / AskService / TrendService → AIRuntime → MNN / MLX ↓ Persistence ``` - **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。 -- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务,MLX 共享显存,并发会 OOM。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。 +- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务(模型共享内存/Metal 显存,并发会 OOM 被 jetsam 杀)。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。**真正落地**是 actor 内信号量闸门 `acquireGate()/releaseGate()`,所有占显存的重活(解码 + 模型加载)进入前先 await,且加载 VL 前先卸 LLM。 +- **引擎选择**:`InferenceEngine.current` 由偏好(`.auto`/`.mnn`/`.mlx`)+ 设备可用性解析,真机默认 `.mnn`(SME2/NEON),模拟器回退 `.mlx`。 - **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。 ### 3.2 VL pipeline(拍一张 = 一条流程) @@ -66,7 +67,7 @@ VL prompt 必须: ### 3.3 RAG(结构化检索,不做 embedding) **两段式调用**: -1. 用 Qwen3-1.7B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s +1. 用 Qwen3.5-2B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s 2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答 **第 1 步失败时**回退到"近 30 天全表扫描",不卡死。 @@ -84,7 +85,9 @@ VL prompt 必须: ## 4. 模型分发 - 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条 -- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有 +- **用户面只有一个模型**:Qwen3.5-2B-MNN(~1.2GB,`ModelKind.userFacing = [.mnnLLM]`)。多模态,文本+视觉全包,下载全部 / 就绪计数只算它 +- MLX 兜底版 Qwen3.5-2B-4bit(~1.7GB)仅模拟器与兜底用,不展示、不计入「下载全部」,但旁路导入仍可单独导 +- WiFi 提示必须有 - App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载" - `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用) @@ -259,7 +262,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算 | 周次 | 必交付 | |---|---| -| W1 末 / W2 当前 | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 | +| W1 末 / W2 当前 | 项目结构、跑通 Qwen3.5-2B(MLX/MNN)、首个 token 在设备吐出 | | W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) | | W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault | | W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) | diff --git a/CLAUDE.md b/CLAUDE.md index 5f213cb..2a8e67b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,8 +24,10 @@ | 图表 | Swift Charts | iOS 16+ 原生 | | **AI 运行时(主)** | **MNN (alibaba) + Arm SME2 + CPU** | 挑战赛考核点:Qwen + MNN + SME2 端侧 CPU 推理。device-only(xcframework 见 `scripts/build-mnn-xcframework.sh`),A19/iPhone17 启用 SME2、A17 回退 NEON。经 `MNNLLMBridge`(ObjC++)→ `MNNBackend` | | **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama | -| LLM | MNN 主(iPhone17+/SME2):Qwen3.5-2B(`taobao-mnn/Qwen3.5-2B-MNN`,~1.1GiB);MLX 兜底:Qwen3.5-2B-4bit | 文本生成、关键词抽取、趋势解读。4B 实测过慢已退回 2B | -| VL | Qwen3-VL-4B-Instruct 4bit (MLX `mlx-community/Qwen3-VL-4B-Instruct-4bit`) | 拍照→结构化指标。MNN VL 需 OMNI 构建,暂走 MLX | +| **统一模型(文本+视觉)** | **Qwen3.5-2B 多模态,一个模型全包** | 同一个 Qwen3.5-2B 同时做文本生成 / 关键词抽取 / 趋势解读 **和** 拍照→结构化指标。两种格式两种引擎,按设备选(见下两行)。代码:`ModelKind` | +| ├ MNN 主(iPhone17+/SME2) | `taobao-mnn/Qwen3.5-2B-MNN`(~1.1GiB,含 `visual.mnn`) | 挑战赛考核路径,真机默认。文本 + 图→文都走它。`ModelKind.mnnLLM`,唯一对用户暴露(`userFacing`) | +| └ MLX 兜底 / 模拟器 | `mlx-community/Qwen3.5-2B-4bit`(~1.7GB,多模态) | Metal GPU。走 `qwen3_5`,文本与 VL 复用同一模型。`ModelKind.llm`。4B 实测过慢已退回 2B | +| ~~VL(独立)~~ | ~~`mlx-community/Qwen3-VL-4B-Instruct-4bit`~~ **已废弃** | MLX VL 已改复用统一 Qwen3.5-2B 多模态;`ModelKind.vl` 仅保留枚举避免动穷举 switch,不再下载/展示 | | 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 | | Face ID | LocalAuthentication | | | Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 | @@ -39,13 +41,13 @@ ### 3.1 模块边界(强制) ``` -UI → CaptureService / AskService / TrendService → AIRuntime → MLX +UI → CaptureService / AskService / TrendService → AIRuntime → MNN(主) / MLX(兜底) ↓ Persistence ``` - **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。 -- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务,MLX 共享显存,并发会 OOM。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。 +- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务(`InferenceEngine` 选 MNN/SME2 主或 MLX/GPU 兜底,共享内存/显存,并发会 OOM)。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。 - **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。 ### 3.2 VL pipeline(拍一张 = 一条流程) @@ -67,7 +69,7 @@ VL prompt 必须: ### 3.3 RAG(结构化检索,不做 embedding) **两段式调用**: -1. 用 Qwen3-1.7B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s +1. 用统一 Qwen3.5-2B(MNN 主 / MLX 兜底)抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s 2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答 **第 1 步失败时**回退到"近 30 天全表扫描",不卡死。 @@ -85,7 +87,7 @@ VL prompt 必须: ## 4. 模型分发 - 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条 -- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有 +- **用户侧只下载统一模型 Qwen3.5-2B(MNN,~1.1GiB,含视觉)**——不再是 ~4GB 两模型。`ModelKind.userFacing = [.mnnLLM]`,「下载全部」/ 就绪计数只算它。MLX 兜底模型 `Qwen3.5-2B-4bit`(~1.7GB)仅模拟器 / 旁路导入用,不计入用户下载;`Qwen3-VL-4B` 已废弃,不再分发。WiFi 提示仍保留 - App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载" - `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用) @@ -250,7 +252,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算 3. **UI 不直接调 AIRuntime**——必须经过 Service 4. **AIRuntime 必须 actor 化**——禁止 class + lock 5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏 -6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2) +6. **新功能必须问"清单里有吗"**——清单外的功能(多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2)、**用药提醒**(记录 · 用药记录点药 → 复用自由提醒 `CustomReminder` / `CustomReminderEditSheet`,只到点提示,**仍不给剂量/频次建议**,守 §1 "不做剂量推荐") 7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来 8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架 @@ -260,7 +262,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算 | 周次 | 必交付 | |---|---| -| W1 末 / W2 当前 | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 | +| W1 末 / W2 当前 | 项目结构、跑通 Qwen3.5-2B(MLX/MNN)、首个 token 在设备吐出 | | W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) | | W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault | | W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) | diff --git a/docs/release/小红书文案.md b/docs/release/小红书文案.md new file mode 100644 index 0000000..20e2357 --- /dev/null +++ b/docs/release/小红书文案.md @@ -0,0 +1,137 @@ +# 康康 · 小红书发布文案(比赛评审用) + +> 使用说明: +> - `◻︎` 处填真机实测数字(打开 我的 → 模型管理 → 性能自检,截图同时把数字抄进来) +> - `#比赛官方话题#` 和 `@官方账号` 替换成组委会指定的话题和账号(评审通常按官方话题检索作品,**漏带话题可能查不到你的帖子**) +> - 主推版做主帖;技术版可隔 2~3 天发第二篇,小红书对"同一项目多角度连发"权重友好 +> - 发布时间建议:工作日 12:00–13:30 或 20:00–22:30 + +--- + +## 版本 A · 主推版(大众 + 评委兼顾) + +### 标题(三选一,均 ≤ 20 字) + +1. 体检报告拍一下,AI 解读不联网📱 +2. 我做了个不上传的健康 AI,飞行模式都能用 +3. 爸妈的体检报告,终于有 AI 肯"离线"看了 + +### 正文 + +体检报告上一堆↑↓箭头,看得懂的没几个; +想让 AI 帮忙解读,又得把化验单拍给云端—— +等于把自己最隐私的数据交出去了。 + +所以我做了「康康」:一个 **100% 本地推理** 的健康档案 App🍃 +所有 AI 都跑在 iPhone 自己的芯片上,**开飞行模式照样用**,数据一个字节都不出手机。 + +✅ 它能做什么👇 + +📷 **拍一张,报告变档案** +化验单/体检报告对着拍,OCR + 端侧大模型自动抽出每项指标、参考范围、偏高偏低,归档成可检索的电子档案。 + +📈 **趋势看得见** +血压、血糖、体重……长期指标自动画折线,AI 用大白话告诉你"这半年在变好还是变差"。 + +💬 **问它,它真的记得你** +"我去年尿酸多少?""最近三次血脂对比一下"——它从你自己的历史记录里检索回答,每句话都带引用,点一下能跳回原始报告。 + +🗣️ **嘴说就能记** +"昨晚头疼,睡得不好"——说一句,自动整理成日记;药盒扫一下,自动录入正在吃的药。 + +🏥 **看病前 30 秒** +一键生成给医生看的就诊摘要:近期症状 + 关键指标 + 用药过敏史,门诊不再大脑空白。 + +🔐 **隐私三件套** +系统级硬件加密 + Face ID 锁 + 永久删除。没有账号、没有云、没有"用户协议第 38 条"。 + +⚙️ 技术控看这里: +端侧跑的是 Qwen3.5 大模型,推理框架是阿里开源的 MNN,在 iPhone 17 上吃满了 Arm 最新的 SME2 矩阵指令——纯 CPU 解码 ◻︎ tok/s,锁屏界面实时显示生成速度,推理快到不像没联网😎 + +这是我参加 #比赛官方话题# 的参赛作品,从设计到代码一个人肝了六周。 +如果你也觉得"健康数据就该留在自己手机里",求个赞和收藏🙏 +有想要的功能评论区告诉我,下个版本安排! + +⚠️ 康康只做记录和科普式解读,不做诊断不替代医生,身体不舒服请及时就医。 + +### 话题标签 + +\#比赛官方话题# #端侧AI #本地大模型 #健康管理 #体检报告解读 #隐私保护 #iOS开发 #独立开发者 #AI应用 #数字健康 + +### 配图脚本(9 宫格) + +| # | 内容 | 备注 | +|---|------|------| +| 1 | 封面:手机展示首页 + 大字标题"体检报告 AI 解读,不联网" | 封面字要大,缩略图能读清 | +| 2 | 拍照识别报告全流程(拍摄→指标确认页) | 可两张拼一张 | +| 3 | 报告详情 C2:原图/解读/指标 三 Tab | 露出"对比上次"区块 | +| 4 | 趋势页折线图 + AI 一句话解读 | | +| 5 | AI 问答:带 [1][2] 引用 Pill 的回答 | 体现"检索自己的记录" | +| 6 | **控制中心飞行模式开启 + App 正常生成回答** 同屏 | 全帖最有说服力的一张 | +| 7 | 性能自检卡:SME2 标识 + prefill/decode tok/s | 评委重点看这张 | +| 8 | 锁屏 Live Activity 实时 tok/s | | +| 9 | 隐私设置页:Face ID + 永久删除 | | + +--- + +## 版本 B · 技术圈层版(隔 2~3 天发) + +### 标题(二选一) + +1. 在 iPhone 的 CPU 上,我把大模型跑到 ◻︎ tok/s +2. 不用 GPU,iPhone 17 纯 CPU 跑通 Qwen3.5🔥 + +### 正文 + +最近所有人都在卷云端大模型,我反着来: +把整套健康 AI——视觉识别、RAG 问答、趋势解读——全部塞进 iPhone 本地,**纯 CPU 推理**。 + +为什么是 CPU 不是 GPU? +因为 Arm 在新一代芯片里加了 SME2(可伸缩矩阵扩展):专为矩阵乘法设计的指令集,大模型推理的核心运算正好是它的主场。 + +我的技术栈👇 +🔹 模型:Qwen3.5-2B(多模态,一个模型同时干文本 + 看图识报告) +🔹 推理框架:MNN(阿里开源),iPhone 17/A19 走 SME2,老机型自动回退 NEON +🔹 兜底:MLX(Apple 官方,Metal GPU),双后端运行时无感切换 +🔹 应用层:SwiftUI + SwiftData,RAG 用结构化检索(意图抽取→按关键词查库→拼 prompt),不引入 embedding 模型,首响更快 + +实测数据(iPhone 17,可在 App 内"性能自检"复现): +⚡ prefill ◻︎ tok/s / decode ◻︎ tok/s +⚡ 拍一张化验单到出结构化指标:约 ◻︎ 秒 +⚡ 模型常驻互斥 + actor 串行闸门,长时间使用不 OOM + +几个有意思的坑: +1️⃣ MNN 默认 enable_thinking=true,模型疯狂输出 吃光 token 预算,要在 bridge 层 set_config 关掉 +2️⃣ 长文本逐行复读死循环——采样器默认不带 repetition penalty,MNN 要显式写进 mixed_samplers +3️⃣ LLM 和 VL 同时驻留必 jetsam,做了常驻互斥 + 推理优先级闸门(交互任务可插队后台预生成) + +做这个项目的初衷很简单:健康数据是最不该上云的数据。 +端侧推理已经到了"真能用"的拐点,这是我给 #比赛官方话题# 交的答卷。 + +代码细节/性能调优有兴趣的评论区聊👇 + +⚠️ App 仅做记录与科普式解读,不提供诊断建议。 + +### 话题标签 + +\#比赛官方话题# #端侧AI #MNN #Qwen #ArmSME2 #大模型推理 #iOS开发 #SwiftUI #独立开发者 #本地大模型 + +### 配图脚本 + +1. 封面:性能自检卡大图,tok/s 数字放大做封面字 +2. 架构图:UI → Service → AIRuntime → MNN(SME2)/MLX 双后端 +3. 飞行模式 + 流式生成同屏 +4. 锁屏 Live Activity tok/s +5. 拍照识别报告前后对比(原图 → 结构化指标) +6. Xcode/代码截图:MNNLLMBridge 或 actor 闸门片段(打码无关信息) +7. 老机型 NEON vs iPhone 17 SME2 速度对比(如有数据) + +--- + +## 发布贴士 + +1. **官方话题必带且放第一位**,正文里也 @官方账号 一次 +2. 封面图决定 80% 点击:大字 + 高对比,别用纯截图 +3. 发布后 1 小时内回评论(尤其问"怎么下载"的,回复"比赛 demo 阶段,关注我等上架"),互动率影响推荐量 +4. 不要写"治疗""诊断""疗效"等词,健康类内容平台审得严,现有文案已规避 +5. 主帖发出后把链接填进比赛报名系统/问卷(如果章程要求回填链接) diff --git a/康康/AI/Prompts/MedicationPrompts.swift b/康康/AI/Prompts/MedicationPrompts.swift index 470139f..3c023a9 100644 --- a/康康/AI/Prompts/MedicationPrompts.swift +++ b/康康/AI/Prompts/MedicationPrompts.swift @@ -7,42 +7,55 @@ import Foundation nonisolated enum MedicationPrompts { static func medicationsFromText(_ ocrText: String) -> String { + // ≤5 张合并 OCR,放宽到 2400(单张 1200 偏小,易把背面用法截掉)。 medicationsFromTextTemplate - .replacingOccurrences(of: "{{OCR_TEXT}}", with: VLPrompts.clipOCR(ocrText, limit: 1200)) + .replacingOccurrences(of: "{{OCR_TEXT}}", with: VLPrompts.clipOCR(ocrText, limit: 2400)) } private static let medicationsFromTextTemplate: String = #""" -你是药品包装识别助手。下面是对一张药盒、药品说明书或处方单做 OCR 得到的纯文本,可能有错字、换行混乱或无关噪声。 +你是药品包装识别助手。下面是对一种药品的多张照片(药盒正面/背面/说明书/处方单)做 OCR 得到的纯文本,各张之间用「---」分隔,可能有错字、换行混乱或无关噪声。 请从中提取药品信息,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。 JSON schema(严格): { "medications": [ { - "name": string, // 药品通用名或商品名,如 "缬沙坦胶囊" + "name": string, // 药品名,见下方「name 怎么填」 "strength": string, // 规格,如 "80mg"、"0.5g×24片";识别不出填 "" "usage": string // 用法用量,如 "每日一次,一次一粒";包装上没有就填 "" } ] } -规则: -- 只提取药品本身;"国药准字"批准文号、生产厂家、批号、有效期、条形码一律忽略。 -- 一张药盒通常只有 1 种药;处方单可能有多种,都要提取。 +name 怎么填(关键,别搞混): +- 药品名 = 通用名(化学/药典名),这是要填进 name 的主体。中文药名照中文写,英文药名(如 "Metformin"、"Amoxicillin")就照英文原样抄,不要翻译、不要丢。 +- 若包装上同时印有商品名/商标名(厂商起的牌子名,如 "代文""泰诺""Tylenol"),把它放在通用名后的括号里,例如 "缬沙坦胶囊(代文)"。只读到商品名、读不到通用名时,就直接用商品名当 name。 +- 生产厂家/公司名/品牌 LOGO 文字(如 "XX药业有限公司""诺华""拜耳")不是药名,一律不要当 name,也不要塞进括号。 + +通用规则: +- 只提取药品本身;"国药准字"批准文号、生产厂家、批号、有效期、条形码、贮藏、二维码一律忽略。 +- 多张照片通常是同一种药的不同面,合并成一条,不要因为来自不同照片就重复输出;处方单可能有多种药,才分多条。 - 不要发明药品。名称读不清的整条跳过;strength / usage 读不清就填 "",不要编造。 - 不要输出任何服药建议或剂量调整建议,只抄录包装上已有的文字。 - 同一药品只输出一次。 -示例 1(药盒): -输入 OCR 文本: 缬沙坦胶囊 80mg×7粒 国药准字H20103521 XX药业有限公司 +示例 1(药盒,含商品名 + 厂商): +输入 OCR 文本: 代文 缬沙坦胶囊 80mg×7粒 国药准字H20103521 北京诺华制药有限公司 输出: -{"medications":[{"name":"缬沙坦胶囊","strength":"80mg×7粒","usage":""}]} +{"medications":[{"name":"缬沙坦胶囊(代文)","strength":"80mg×7粒","usage":""}]} 示例 2(说明书含用法): 输入 OCR 文本: 二甲双胍缓释片 0.5g×30片 用法用量:口服,一次1片,一日2次,随餐服用 输出: {"medications":[{"name":"二甲双胍缓释片","strength":"0.5g×30片","usage":"口服,一次1片,一日2次,随餐服用"}]} +示例 3(英文药名,正反两张合并): +输入 OCR 文本: Amoxicillin Capsules 500mg GSK +--- +Dosage: Take one capsule three times daily +输出: +{"medications":[{"name":"Amoxicillin","strength":"500mg","usage":"Take one capsule three times daily"}]} + 现在请解析下面这段 OCR 文本,只输出 JSON。/no_think OCR 文本: diff --git a/康康/AI/Prompts/VLPrompts.swift b/康康/AI/Prompts/VLPrompts.swift index 43a2dcc..ff3751c 100644 --- a/康康/AI/Prompts/VLPrompts.swift +++ b/康康/AI/Prompts/VLPrompts.swift @@ -112,6 +112,53 @@ JSON schema(严格): {"title":"春季年度体检","type":"checkup","report_date":"2026-04-12","institution":"协和医院","page_count":1,"summary":"血脂偏高、其他正常","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.12,0.31,0.76,0.07]},{"name":"谷丙转氨酶","value":"32","unit":"U/L","range":"9 - 50","status":"normal","source_page":1,"source_box":[0.12,0.39,0.76,0.07]},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal","source_page":1,"source_box":[0.12,0.47,0.76,0.07]}]} {{OCR_SECTION}} 现在请识别图片并输出 JSON: +"""# + + // MARK: - 报告归档 · 轻量 meta(只抽日期/机构/类型/标题,不识别指标) + + /// 报告归档新链路:只保存原图,**不逐项识别指标**(逐项多模态识别在 2B 上易 OOM / 卡死)。 + /// 用 Vision OCR 出纯文本后,交文本 LLM 只抽报告级 meta —— 输出极小(~50 token),快且稳。 + /// 识别不到就留空,UI 用占位(今天 / 空机构),用户可手填。 + static func reportMetaFromText(_ ocrText: String, today: Date = .now) -> String { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + let todayStr = f.string(from: today) + return reportMetaTemplate + .replacingOccurrences(of: "{{TODAY}}", with: todayStr) + .replacingOccurrences(of: "{{OCR_TEXT}}", with: clipOCR(ocrText, limit: 1500)) + } + + private static let reportMetaTemplate: String = #""" +你是体检/化验报告归档助手。下面是对一份报告做 OCR 得到的纯文本,可能有错字、错位、噪声。 +请只提取这份报告的「元信息」,**不要提取任何具体指标/数值**。只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。 + +今天的日期是 {{TODAY}}。 + +JSON schema(严格): +{ + "title": string, // 报告抬头,如 "春季年度体检";读不出就填 "" + "type": "checkup" | "lab" | "imaging" | "prescription" | "other", + "report_date": "YYYY-MM-DD", // 报告/采样/体检日期;实在读不出就填 "" + "institution": string // 医院/体检机构名;读不出就填 "" +} + +规则: +- 只输出上面 4 个字段,绝不输出 indicators / 数值 / 参考范围。 +- type:化验单→"lab";体检套餐→"checkup";影像(B超/CT/X光/MRI)→"imaging";处方→"prescription";拿不准→"other"。 +- 日期挑「报告日期 / 检查日期 / 采样日期」其一,统一成 YYYY-MM-DD;只有年月就补 -01;读不出填 ""。 +- institution 取医院/体检中心全称,去掉「检验科/报告单」等栏目词;读不出填 ""。 +- 不要编造;读不出的字段填 ""。 + +示例 OCR 文本: +协和医院体检中心 健康体检报告 姓名:张三 体检日期:2026-04-12 低密度脂蛋白 3.84 ↑ ... +输出: +{"title":"健康体检报告","type":"checkup","report_date":"2026-04-12","institution":"协和医院体检中心"} + +现在请解析下面这段 OCR 文本,只输出 JSON。/no_think + +OCR 文本: +{{OCR_TEXT}} """# // MARK: - 局部小框识别(指标速记) diff --git a/康康/App/KangkangApp.swift b/康康/App/KangkangApp.swift index 318116e..1a0570d 100644 --- a/康康/App/KangkangApp.swift +++ b/康康/App/KangkangApp.swift @@ -24,6 +24,7 @@ struct KangkangApp: App { CustomMonitorMetric.self, HealthExport.self, CustomReminder.self, + Medication.self, ]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) // 建库后给 store 文件补 .completeUnlessOpen 保护(§6),两条创建路径共用。 diff --git a/康康/DesignSystem/VaultImage.swift b/康康/DesignSystem/VaultImage.swift new file mode 100644 index 0000000..5db01d0 --- /dev/null +++ b/康康/DesignSystem/VaultImage.swift @@ -0,0 +1,47 @@ +import SwiftUI + +/// 从加密 Vault 异步加载并降采样显示原图的通用组件。 +/// +/// 替代「在 body 里 `try? FileVault.shared.loadImage(...)` 同步读盘 + 全量解码」的旧写法, +/// 解决两个真实问题: +/// 1. **OOM**:全分辨率位图(4000×3000 ≈ 48MB)进内存,翻几页就触发 jetsam。这里按 `maxPixel` +/// 降采样,缩略图几百 KB,全屏图几 MB。 +/// 2. **主线程卡顿**:读盘 + JPEG 解码在主线程会掉帧。这里放到后台线程,主线程只拿结果绘制。 +/// +/// 区分「加载中」与「读取失败」两态:加载中显示中性占位,只有真正失败才显示「原图无法读取」, +/// 不会一打开就闪一下吓人的错误文案。`content` 拿到 `UIImage`(而非 `Image`), +/// 方便需要 `image.size` 的调用方(如证据高亮 overlay)按真实宽高比定位。 +struct VaultImage: View { + let relativePath: String + /// 降采样目标最大边(像素)。缩略图给 ~400,全屏查看器给 ~2000。 + var maxPixel: CGFloat = 1024 + + @ViewBuilder var content: (UIImage) -> Content + /// 占位回调,`isLoading == true` 表示仍在加载,`false` 表示加载完成但失败。 + @ViewBuilder var placeholder: (_ isLoading: Bool) -> Placeholder + + @State private var image: UIImage? + @State private var loading = true + + var body: some View { + Group { + if let image { + content(image) + } else { + placeholder(loading) + } + } + // id 变了(TabView 翻到新页 / 行复用换 asset)就重新加载;同一身份重渲染不会重复读盘。 + .task(id: relativePath) { + loading = true + let path = relativePath + let mp = maxPixel + let loaded = await Task.detached(priority: .userInitiated) { + try? FileVault.shared.loadDownsampledImage(relativePath: path, maxPixelSize: mp) + }.value + guard !Task.isCancelled else { return } + image = loaded + loading = false + } + } +} diff --git a/康康/Features/Archive/ArchiveListView.swift b/康康/Features/Archive/ArchiveListView.swift index 8805617..df598b2 100644 --- a/康康/Features/Archive/ArchiveListView.swift +++ b/康康/Features/Archive/ArchiveListView.swift @@ -23,9 +23,12 @@ struct ArchiveListView: View { @Query(sort: \MetricReminder.updatedAt, order: .reverse) private var metricReminders: [MetricReminder] + @Query(sort: \Medication.updatedAt, order: .reverse) + private var medications: [Medication] + /// 记录页内的 push 目的地。用单个 `navigationDestination(item:)` 驱动—— /// 多个 `navigationDestination(isPresented:)` 并存时 SwiftUI 行为未定义(会误触发)。 - private enum Route: Hashable { case exports, reminders } + private enum Route: Hashable { case exports, reminders, medicationLibrary } @State private var filter: TimelineKind? = nil @State private var endingSymptom: Symptom? @@ -33,57 +36,73 @@ struct ArchiveListView: View { @State private var selectedGroup: IndicatorGroup? @State private var route: Route? + /// 顶部搜索:点放大镜展开搜索框,按条目标题(指标/报告/症状/日记名)实时过滤,与分类 chip 叠加。 + @State private var searching = false + @State private var query = "" + @MainActor private var allEntries: [TimelineEntry] { let mapped = - TimelineEntry.from(indicators: indicators) + + TimelineEntry.aggregatedIndicators(indicators) + reports.map(TimelineEntry.from(report:)) + diaries.map(TimelineEntry.from(diary:)) + symptoms.map(TimelineEntry.from(symptom:)) - let filtered = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped - return filtered.sorted { $0.date > $1.date } + let byKind = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped + let q = query.trimmingCharacters(in: .whitespaces) + let byQuery = q.isEmpty ? byKind : byKind.filter { $0.title.localizedCaseInsensitiveContains(q) } + return byQuery.sorted { $0.date > $1.date } } - private var grouped: [(section: DateSection, items: [TimelineEntry])] { - TimelineGrouping.group(allEntries) - } - - private var totalCount: Int { allEntries.count } - var body: some View { NavigationStack { content .navigationDestination(item: $route) { route in switch route { - case .exports: HealthExportListView() - case .reminders: RemindersListView() + case .exports: HealthExportListView() + case .reminders: RemindersListView() + case .medicationLibrary: MedicationLibraryView() } } } } private var content: some View { - VStack(alignment: .leading, spacing: 0) { - header + // 聚合(含血压配对 O(m²))+ 分类/搜索过滤在一次 body 内只算一次。原先 .isEmpty、分组、 + // 计数各调一遍 allEntries,等于全表聚合三次;搜索时每次按键都翻三倍,这里收敛成一次。 + let entries = allEntries + let groups = TimelineGrouping.group(entries) + return VStack(alignment: .leading, spacing: 0) { + header(total: entries.count) .padding(.horizontal, 20) .padding(.top, 8) .padding(.bottom, 14) if reminderTotal > 0 { reminderBoard + .padding(.horizontal, 20) + .padding(.bottom, 10) + } + + // 药品库入口:始终显示——它是「管理常用药」的浏览/管理目的地,空库时也要能找到来添加。 + medicationBoard + .padding(.horizontal, 20) + .padding(.bottom, 14) + + filterChips + .padding(.bottom, searching ? 10 : 14) + + if searching { + searchField .padding(.horizontal, 20) .padding(.bottom, 14) } - filterChips - .padding(.bottom, 14) - - if allEntries.isEmpty { + if entries.isEmpty { emptyState } else { ScrollView(showsIndicators: false) { LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) { - ForEach(grouped, id: \.section) { group in + ForEach(groups, id: \.section) { group in Section { VStack(spacing: 10) { ForEach(group.items) { entry in @@ -149,12 +168,12 @@ struct ArchiveListView: View { diaries: diaries, symptoms: symptoms) } - private var header: some View { + private func header(total: Int) -> some View { HStack(alignment: .lastTextBaseline) { Text("记录") .font(.tjTitle(26)) .foregroundStyle(Tj.Palette.text) - Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount) 条")) + Text(total == 0 ? "" : String(appLoc: "\(total) 条")) .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Spacer() @@ -173,9 +192,57 @@ struct ArchiveListView: View { } .buttonStyle(.plain) } + searchToggle } } + private var searchToggle: some View { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + searching.toggle() + if !searching { query = "" } + } + } label: { + Image(systemName: searching ? "xmark" : "magnifyingglass") + .font(.tjScaled( 14, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .frame(width: 32, height: 32) + .background(Circle().fill(Tj.Palette.sand2)) + } + .buttonStyle(.plain) + .accessibilityLabel(searching ? String(appLoc: "关闭搜索") : String(appLoc: "搜索记录")) + } + + private var searchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.tjScaled( 13)) + .foregroundStyle(Tj.Palette.text3) + TextField(String(appLoc: "搜索指标 / 报告 / 症状名"), text: $query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .foregroundStyle(Tj.Palette.text) + .tint(Tj.Palette.ink) + if !query.isEmpty { + Button { query = "" } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Tj.Palette.text3) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: 1) + ) + } + // MARK: - 提醒任务汇总卡 /// 两类提醒(自由 + 指标记录)合计,含已关闭。 @@ -241,6 +308,58 @@ struct ArchiveListView: View { .buttonStyle(.plain) } + // MARK: - 药品库入口卡 + + /// 主标题:空库「药品库」,有药「药品库 · N 种常用药」。 + private var medicationCountLabel: String { + medications.isEmpty + ? String(appLoc: "药品库") + : String(appLoc: "药品库 · \(medications.count) 种常用药") + } + + /// 副标题:空库给引导文案;有药取前 3 个药名预览(药名是用户数据,不本地化)。 + private var medicationPreviewLine: String { + if medications.isEmpty { return String(appLoc: "拍药盒或手动添加常用药") } + let names = medications.prefix(3).map(\.name).joined(separator: " · ") + return medications.count > 3 ? names + " …" : names + } + + /// 点击进药品库(MedicationLibraryView,push 形态)统一管理;卡片本身只展示。 + private var medicationBoard: some View { + Button { route = .medicationLibrary } label: { + HStack(spacing: 12) { + ZStack { + Circle().fill(medications.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft) + Image(systemName: "pills.fill") + .font(.tjScaled( 16)) + .foregroundStyle(medications.isEmpty ? Tj.Palette.text3 : Tj.Palette.ink) + } + .frame(width: 36, height: 36) + + VStack(alignment: .leading, spacing: 2) { + Text(medicationCountLabel) + .font(.tjScaled( 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .lineLimit(1) + Text(medicationPreviewLine) + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text3) + .lineLimit(1) + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.tjScaled( 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(14) + .contentShape(Rectangle()) + .tjCard() + } + .buttonStyle(.plain) + } + private var filterChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { @@ -291,13 +410,19 @@ struct ArchiveListView: View { } private var emptyState: some View { - VStack(spacing: 14) { + let q = query.trimmingCharacters(in: .whitespaces) + let isSearchMiss = !q.isEmpty + return VStack(spacing: 14) { Spacer() - TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始")) + TjPlaceholder(label: isSearchMiss + ? String(appLoc: "没有匹配「\(q)」的记录") + : String(appLoc: "还没有任何记录\n点底部 + 号开始")) .frame(width: 240, height: 140) - Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录")) - .font(.tjScaled( 13)) - .foregroundStyle(Tj.Palette.text3) + if !isSearchMiss { + Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录")) + .font(.tjScaled( 13)) + .foregroundStyle(Tj.Palette.text3) + } Spacer() } .frame(maxWidth: .infinity) diff --git a/康康/Features/Capture/CaptureReviewForm.swift b/康康/Features/Capture/CaptureReviewForm.swift index 9984172..7277ef8 100644 --- a/康康/Features/Capture/CaptureReviewForm.swift +++ b/康康/Features/Capture/CaptureReviewForm.swift @@ -8,9 +8,12 @@ struct CaptureReviewForm: View { @State var parsed: ParsedReport let assets: [FileVault.SavedAsset] let warning: String? + /// 归档模式:只存原图 + 基本信息(标题/类型/日期/机构),隐藏指标区与摘要。 + /// 报告归档不再逐项识别(逐项多模态在 2B 上易 OOM 卡死),见 CaptureService.extractReportMeta。 + var metaOnly: Bool = false let onSave: (ParsedReport) -> Void let onCancel: () -> Void - /// 「重新识别」回调。assets 为空(写图失败)时传 nil,banner 上不显示该按钮。 + /// 「重新识别信息」回调。assets 为空(写图失败)时传 nil,banner 上不显示该按钮。 var onReanalyze: (() -> Void)? = nil var body: some View { @@ -23,7 +26,9 @@ struct CaptureReviewForm: View { pageThumbnails } metaSection - indicatorSection + if !metaOnly { + indicatorSection + } Spacer(minLength: 8) actions } @@ -68,20 +73,26 @@ struct CaptureReviewForm: View { private var pageThumbnails: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)")) + if metaOnly { + Text("原图已加密保存,详情页随时可翻看放大。系统只识别报告日期与机构作为标签,不逐项录入数值。") + .font(.tjScaled( 11)) + .foregroundStyle(Tj.Palette.text3) + .fixedSize(horizontal: false, vertical: true) + } ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in - if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) { - Image(uiImage: img) - .resizable() - .scaledToFill() - .frame(width: 84, height: 110) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(Tj.Palette.line, lineWidth: 1) - ) + VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in + Image(uiImage: img).resizable().scaledToFill() + } placeholder: { _ in + Tj.Palette.paper } + .frame(width: 84, height: 110) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: 1) + ) } } } @@ -117,9 +128,11 @@ struct CaptureReviewForm: View { labeledField(String(appLoc: "机构(可选)")) { TextField("如:协和医院", text: $parsed.institution) } - labeledField(String(appLoc: "摘要(可选)")) { - TextField("一句话总结", text: $parsed.summary, axis: .vertical) - .lineLimit(1...3) + if !metaOnly { + labeledField(String(appLoc: "摘要(可选)")) { + TextField("一句话总结", text: $parsed.summary, axis: .vertical) + .lineLimit(1...3) + } } } .padding(12) diff --git a/康康/Features/Capture/UnifiedCaptureFlow.swift b/康康/Features/Capture/UnifiedCaptureFlow.swift index 4dff4bb..96d23ca 100644 --- a/康康/Features/Capture/UnifiedCaptureFlow.swift +++ b/康康/Features/Capture/UnifiedCaptureFlow.swift @@ -62,7 +62,7 @@ struct UnifiedCaptureFlow: View { switch phase { case .idle: return String(appLoc: "拍摄报告") case .analyzing: return String(appLoc: "本地识别中…") - case .editing: return String(appLoc: "核对识别结果") + case .editing: return String(appLoc: "核对报告信息") } } @@ -86,6 +86,7 @@ struct UnifiedCaptureFlow: View { parsed: parsed, assets: assets, warning: warning, + metaOnly: true, // 归档只存原图 + meta,不逐项识别(§见 CaptureService.extractReportMeta) onSave: { final in saveAll(parsed: final, assets: assets) }, onCancel: cancelAll, onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) } @@ -152,9 +153,7 @@ struct UnifiedCaptureFlow: View { phase = .analyzing(images: images, assets: nil) let timeout = analyzeTimeoutSeconds analyzeTask = Task { - // Step 1: 先把图写进 Vault。 - // 在 UI 这一层写,而不是塞进 CaptureService.analyze —— 这样取消/失败回退时, - // assets 已经在 phase 里,cancelAll 能清理孤儿,editingFallback 也不必再补写。 + // Step 1: 先把图写进 Vault(归档的核心价值就是「把原图存下来」,先保证它)。 let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) } // 极端情况:用户在写图过程中按了「取消」,View 已 dismiss、cancelAll 看到的 // phase 还是 .analyzing(_, nil),清不到这批刚写完的图 — 这里手动收尾。 @@ -167,7 +166,7 @@ struct UnifiedCaptureFlow: View { phase = .editing( parsed: .empty(), assets: [], - warning: String(appLoc: "图片保存失败,手动录入并保留文本") + warning: String(appLoc: "图片保存失败,请重试") ) } return @@ -179,49 +178,40 @@ struct UnifiedCaptureFlow: View { } } - // Step 2: VL 推理(timeout 哨兵到点 cancel 父任务,VLSession 在下一个 token break)。 + // Step 2: 轻量 meta 提取(OCR + 文本 LLM,只抽日期/机构/类型/标题)。 + // 不再跑多模态逐项识别 —— 那在 2B 上又慢又会 OOM 卡死。watchdog 到点 cancel。 let watchdog = Task { try? await Task.sleep(for: .seconds(timeout)) analyzeTask?.cancel() } defer { watchdog.cancel() } - do { - let parsed = try await CaptureService.shared.reanalyze(assets: assets) - if Task.isCancelled { - await editingFallback(assets: assets, - msg: String(appLoc: "识别超时(>\(timeout)s),先手动录入")) - return - } + let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets) + if Task.isCancelled { await MainActor.run { - phase = .editing( - parsed: parsed, - assets: assets, - warning: parsed.isEmpty ? String(appLoc: "识别没有读出指标,请手动补充") : nil - ) + phase = .editing(parsed: .empty(), assets: assets, + warning: String(appLoc: "识别超时,已保存原图,请手动填写信息")) } - } catch let CaptureError.parseFailed(msg) { - await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)")) - } catch let CaptureError.inferenceFailed(msg) { - await editingFallback(assets: assets, - msg: Task.isCancelled - ? String(appLoc: "识别超时(>\(timeout)s),先手动录入") - : String(appLoc: "推理失败:\(msg)")) - } catch CaptureError.modelNotReady { - await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪,先手动录入")) - } catch { - await editingFallback(assets: assets, - msg: String(appLoc: "未知错误:\(error.localizedDescription)")) + return + } + await MainActor.run { + phase = .editing( + parsed: meta, + assets: assets, + warning: recognized ? nil + : String(appLoc: "未能自动识别报告信息,已保存原图,可手动填写日期 / 机构") + ) } } } - /// 「重新识别」:复用已存 assets,不再写图,只重跑 VL。 + /// 「重新识别信息」:复用已存 assets,不再写图,只重跑一次轻量 meta 提取。 private func reanalyze(assets: [FileVault.SavedAsset]) { analyzeTask?.cancel() - // 这里没有原始 UIImage,AnalyzingView 显示首张缩略图即可 + // 这里没有原始 UIImage,AnalyzingView 只把首张缩略图模糊后当背景,降采样到 600px 足够, + // 避免为「重新识别」把整页全分辨率原图(数十 MB)载进内存。 let thumbnails: [UIImage] = assets.compactMap { - try? FileVault.shared.loadImage(relativePath: $0.relativePath) + try? FileVault.shared.loadDownsampledImage(relativePath: $0.relativePath, maxPixelSize: 600) } phase = .analyzing(images: thumbnails, assets: assets) let timeout = analyzeTimeoutSeconds @@ -232,40 +222,19 @@ struct UnifiedCaptureFlow: View { } defer { watchdog.cancel() } - do { - let parsed = try await CaptureService.shared.reanalyze(assets: assets) - if Task.isCancelled { - await editingFallback(assets: assets, - msg: String(appLoc: "识别超时(>\(timeout)s),保留旧编辑")) - return - } + let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets) + if Task.isCancelled { await MainActor.run { - phase = .editing( - parsed: parsed, - assets: assets, - warning: parsed.isEmpty ? String(appLoc: "重新识别没有读出新指标") : nil - ) + phase = .editing(parsed: .empty(), assets: assets, + warning: String(appLoc: "识别超时,已保留原图")) } - } catch CaptureError.modelNotReady { - await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪")) - } catch let CaptureError.parseFailed(msg) { - await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)")) - } catch let CaptureError.inferenceFailed(msg) { - await editingFallback(assets: assets, - msg: Task.isCancelled - ? String(appLoc: "识别超时(>\(timeout)s)") - : String(appLoc: "推理失败:\(msg)")) - } catch { - await editingFallback(assets: assets, - msg: String(appLoc: "未知错误:\(error.localizedDescription)")) + return + } + await MainActor.run { + phase = .editing(parsed: meta, assets: assets, + warning: recognized ? nil + : String(appLoc: "未能自动识别报告信息,可手动填写")) } - } - } - - /// reanalyze 失败时回到 editing,保留 assets 但清空 parsed。 - private func editingFallback(assets: [FileVault.SavedAsset], msg: String) async { - await MainActor.run { - phase = .editing(parsed: .empty(), assets: assets, warning: msg) } } diff --git a/康康/Features/Diary/DiaryQuickSheet.swift b/康康/Features/Diary/DiaryQuickSheet.swift index f88f7f1..736d2fc 100644 --- a/康康/Features/Diary/DiaryQuickSheet.swift +++ b/康康/Features/Diary/DiaryQuickSheet.swift @@ -11,8 +11,10 @@ struct DiaryQuickSheet: View { @State private var content: String = "" @State private var createdAt: Date = .now - /// 「拍药盒」分支:全屏扫描流程,确认后存为带「用药」tag 的日记。 + /// 「拍药盒」分支:全屏扫描流程,识别后入药品库。 @State private var showMedicationScan = false + /// 「用药」分支:记一次服用(选药 + 剂量 + 时间),存为带「用药」tag 的日记。 + @State private var showMedicationLog = false /// 「记症状」分支:嵌套弹出 SymptomStartSheet(自带保存/取消,关闭后回到本页)。 @State private var showSymptomStart = false @@ -98,14 +100,20 @@ struct DiaryQuickSheet: View { .padding(.horizontal, 20) .padding(.bottom, 10) - // 入口三选一:写日记(本页)/ 拍药盒(存「用药」日记)/ 记症状(SymptomStartSheet) - HStack(spacing: 10) { + // 入口四选(2×2):写日记(本页)/ 用药(MedicationLogSheet,记剂量+时间)/ + // 拍药盒(识别入药品库)/ 记症状(SymptomStartSheet)。 + LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10)], spacing: 10) { modeCard(icon: "pencil", title: String(appLoc: "写日记"), subtitle: String(appLoc: "文字或语音"), active: true) { contentFocused = true } - modeCard(icon: "pills.fill", title: String(appLoc: "拍药盒"), - subtitle: String(appLoc: "识别用药"), active: false) { + modeCard(icon: "pills.fill", title: String(appLoc: "用药"), + subtitle: String(appLoc: "记剂量与时间"), active: false) { + showMedicationLog = true + } + modeCard(icon: "camera.viewfinder", title: String(appLoc: "拍药盒"), + subtitle: String(appLoc: "识别入药品库"), active: false) { showMedicationScan = true } modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"), @@ -252,9 +260,9 @@ struct DiaryQuickSheet: View { .presentationCornerRadius(Tj.Radius.xl) .fullScreenCover(isPresented: $showMedicationScan) { MedicationScanFlow( - onSave: { entries in - // 落库:「用药」日记(进记录时间线)+ 同步个人资料·当前用药。 - MedicationArchiver.archive(entries: entries, in: ctx) + onSave: { meds, images in + // 识别后入药品库(含原图),不再写日记。服用流水走「写日记 · 用药」模式。 + MedicationArchiver.archive(medications: meds, images: images, in: ctx) dismiss() }, onClose: { showMedicationScan = false } @@ -264,6 +272,10 @@ struct DiaryQuickSheet: View { // 嵌套 sheet:症状表单自带保存/取消;取消回到日记,不强行关闭。 SymptomStartSheet() } + .sheet(isPresented: $showMedicationLog) { + // 嵌套 sheet:用药记录表单自带保存/取消;保存后回到日记(不强行关闭)。 + MedicationLogSheet() + } .onDisappear { suggestTask?.cancel() voiceFlowTask?.cancel() diff --git a/康康/Features/Diary/MedicationLogSheet.swift b/康康/Features/Diary/MedicationLogSheet.swift new file mode 100644 index 0000000..0f8fbe5 --- /dev/null +++ b/康康/Features/Diary/MedicationLogSheet.swift @@ -0,0 +1,133 @@ +import SwiftUI +import SwiftData + +/// 「写日记 · 用药」:记一次服用流水 —— 选药(药品库或手输)+ 剂量 + 时间 +/// → 带 `DiaryEntry.medicationTag` 的日记,进「记录」时间线的「用药」分类。 +/// +/// 与药品库(`Medication`,master 清单)分层:这里是「某次吃了多少、什么时候吃的」。 +/// 嵌套 sheet,自带保存 / 取消(同 `SymptomStartSheet`),关闭后回到写日记页。 +struct MedicationLogSheet: View { + @Environment(\.modelContext) private var ctx + @Environment(\.dismiss) private var dismiss + + @Query(sort: \Medication.updatedAt, order: .reverse) + private var library: [Medication] + + /// 选中的药品库药;手输模式为 nil。 + @State private var selectedMed: Medication? + /// 手输药名(药品库为空,或想记不在库里的药)。与 selectedMed 互斥。 + @State private var manualName = "" + @State private var dosage = "" + @State private var takenAt: Date = .now + + private var resolvedName: String { + (selectedMed?.name ?? manualName).trimmingCharacters(in: .whitespacesAndNewlines) + } + private var canSave: Bool { !resolvedName.isEmpty } + + var body: some View { + NavigationStack { + Form { + Section { + if library.isEmpty { + TextField(String(appLoc: "药名,如:缬沙坦胶囊"), text: $manualName) + .foregroundStyle(Tj.Palette.text) + } else { + ForEach(library) { m in + Button { select(m) } label: { medRow(m) } + .buttonStyle(.plain) + } + HStack(spacing: 8) { + Image(systemName: "pencil") + .foregroundStyle(Tj.Palette.text3) + TextField(String(appLoc: "或手动输入药名"), text: $manualName) + .foregroundStyle(Tj.Palette.text) + .onChange(of: manualName) { _, v in + if !v.trimmingCharacters(in: .whitespaces).isEmpty { + selectedMed = nil + } + } + } + } + } header: { + Text("吃了哪个药") + } footer: { + if library.isEmpty { + Text("药品库还没有药,可在「记录 · 药品库」拍药盒或手动添加。这里直接手输也行。") + } + } + + Section { + TextField(String(appLoc: "剂量,如:1 片 / 80mg"), text: $dosage) + .foregroundStyle(Tj.Palette.text) + } header: { + Text("剂量") + } + + Section { + DatePicker(String(appLoc: "时间"), selection: $takenAt, in: ...Date.now) + } header: { + Text("时间") + } + } + .scrollContentBackground(.hidden) + .background(Tj.Palette.sand.ignoresSafeArea()) + .navigationTitle("记录用药") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(String(appLoc: "取消")) { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button(String(appLoc: "保存")) { save() } + .fontWeight(.semibold) + .disabled(!canSave) + } + } + } + } + + private func medRow(_ m: Medication) -> some View { + let on = selectedMed === m + return HStack(spacing: 10) { + Image(systemName: on ? "checkmark.circle.fill" : "circle") + .foregroundStyle(on ? Tj.Palette.ink : Tj.Palette.text3) + VStack(alignment: .leading, spacing: 2) { + Text(m.name) + .foregroundStyle(Tj.Palette.text) + if !m.detailLine.isEmpty { + Text(m.detailLine) + .font(.tjScaled( 11)) + .foregroundStyle(Tj.Palette.text3) + } + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + + private func select(_ m: Medication) { + selectedMed = m + manualName = "" + } + + private func save() { + guard canSave else { return } + // content 单行:「药名 [规格] · 剂量」。剂量进正文,时间用 createdAt 承载。 + // 与 TimelineEntry.firstLine / TimelineEntryDetailView.medicationLines 单行解析兼容。 + var line = resolvedName + if let s = selectedMed?.strength, !s.isEmpty { line += " \(s)" } + let dose = dosage.trimmingCharacters(in: .whitespacesAndNewlines) + if !dose.isEmpty { line += " · \(dose)" } + + let entry = DiaryEntry(content: line, createdAt: takenAt, tags: [DiaryEntry.medicationTag]) + ctx.insert(entry) + try? ctx.save() + dismiss() + } +} + +#Preview { + MedicationLogSheet() + .modelContainer(for: [Medication.self, DiaryEntry.self, Asset.self], inMemory: true) +} diff --git a/康康/Features/Home/HomeView.swift b/康康/Features/Home/HomeView.swift index d9d577f..525db43 100644 --- a/康康/Features/Home/HomeView.swift +++ b/康康/Features/Home/HomeView.swift @@ -18,21 +18,19 @@ struct HomeView: View { /// 点「最近记录」某行 → 打开只读详情 sheet(与档案库 C1 同款交互)。 @State private var selectedEntry: TimelineEntry? + /// 点指标行 → 打开同类聚合详情(历次翻页 + 趋势,与档案库 C1 同款)。 + @State private var selectedGroup: IndicatorGroup? @MainActor private var recentEntries: [TimelineEntry] { let all = - TimelineEntry.from(indicators: indicators) + + TimelineEntry.aggregatedIndicators(indicators) + reports.map(TimelineEntry.from(report:)) + diaries.map(TimelineEntry.from(diary:)) + symptoms.map(TimelineEntry.from(symptom:)) return all.sorted { $0.date > $1.date }.prefix(6).map { $0 } } - private var recentGrouped: [(section: DateSection, items: [TimelineEntry])] { - TimelineGrouping.group(recentEntries) - } - var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { @@ -65,6 +63,9 @@ struct HomeView: View { TimelineEntryDetailView(detail: d) } } + .sheet(item: $selectedGroup) { group in + IndicatorSeriesDetailView(group: group) + } } private var greeting: some View { @@ -100,7 +101,10 @@ struct HomeView: View { } private var recentSection: some View { - VStack(alignment: .leading, spacing: 10) { + // 聚合(含血压配对 O(m²))在一次 body 内只算一次,再派生分组,避免 .isEmpty 与分组各算一遍。 + let entries = recentEntries + let groups = TimelineGrouping.group(entries) + return VStack(alignment: .leading, spacing: 10) { HStack(alignment: .lastTextBaseline) { Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text) Spacer() @@ -112,11 +116,11 @@ struct HomeView: View { .buttonStyle(.plain) } - if recentEntries.isEmpty { + if entries.isEmpty { emptyRecent } else { VStack(alignment: .leading, spacing: 14) { - ForEach(recentGrouped, id: \.section) { group in + ForEach(groups, id: \.section) { group in VStack(alignment: .leading, spacing: 8) { Text(group.section.label) .font(.tjScaled( 11, weight: .semibold)) @@ -125,12 +129,16 @@ struct HomeView: View { VStack(spacing: 10) { ForEach(group.items) { entry in Button { - if TimelineDetail.resolve( + // 指标 → 同类聚合详情(历次 + 趋势);其余 → 只读详情。与档案库 C1 一致。 + guard let d = TimelineDetail.resolve( for: entry, indicators: indicators, reports: reports, diaries: diaries, symptoms: symptoms - ) != nil { - selectedEntry = entry + ) else { return } + switch d { + case .indicator(let i): selectedGroup = IndicatorGroup.of(i) + case .bloodPressure(let sys, _): selectedGroup = IndicatorGroup.of(sys) + default: selectedEntry = entry } } label: { TimelineRow(entry: entry) diff --git a/康康/Features/Indicator/IndicatorQuickSheet.swift b/康康/Features/Indicator/IndicatorQuickSheet.swift index a0a0f9c..9b5bd72 100644 --- a/康康/Features/Indicator/IndicatorQuickSheet.swift +++ b/康康/Features/Indicator/IndicatorQuickSheet.swift @@ -31,6 +31,18 @@ struct IndicatorQuickSheet: View { /// nil 时(如 Preview)不显示拍照按钮。 var onRequestCamera: (() -> Void)? = nil + /// 从已有指标「再记一条」时的预选目标。nil = 正常空白新建。 + /// seriesKey 命中 MonitorMetric / CustomMonitorMetric → 预选对应预设(保留进趋势 + 自动判异常); + /// 否则按 name/unit/range 走自由输入预填。数值一律留空,由用户填新读数。 + var prefill: Prefill? = nil + + struct Prefill: Equatable { + var seriesKey: String? + var name: String = "" + var unit: String = "" + var range: String = "" + } + @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss @Query private var profiles: [UserProfile] @@ -69,6 +81,32 @@ struct IndicatorQuickSheet: View { // 隐藏管理 sheet 触发态 @State private var showHiddenSheet: Bool = false + // 「再记一条」预选只应用一次 + @State private var didApplyPrefill = false + + // 顶部搜索:点放大镜展开搜索框,按名实时过滤长期监测预设 / 自定义指标 / 化验项快捷。 + @State private var searchingMetrics = false + @State private var metricQuery = "" + + private var isSearchingMetrics: Bool { + !metricQuery.trimmingCharacters(in: .whitespaces).isEmpty + } + private var filteredMonitorMetrics: [MonitorMetric] { + let q = metricQuery.trimmingCharacters(in: .whitespaces) + guard !q.isEmpty else { return visibleMonitorMetrics } + return visibleMonitorMetrics.filter { $0.displayName.localizedCaseInsensitiveContains(q) } + } + private var filteredCustomMetrics: [CustomMonitorMetric] { + let q = metricQuery.trimmingCharacters(in: .whitespaces) + guard !q.isEmpty else { return customMetrics } + return customMetrics.filter { $0.name.localizedCaseInsensitiveContains(q) } + } + private var filteredLabPresets: [IndicatorPreset] { + let q = metricQuery.trimmingCharacters(in: .whitespaces) + guard !q.isEmpty else { return labPresets } + return labPresets.filter { $0.name.localizedCaseInsensitiveContains(q) } + } + private static var defaultReminderTime: Date { Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now } @@ -137,6 +175,7 @@ struct IndicatorQuickSheet: View { footer } + .onAppear { applyPrefillIfNeeded() } .task(id: longTermKey) { hydrateReminder() } .background( Tj.Palette.sand @@ -161,19 +200,64 @@ struct IndicatorQuickSheet: View { } private var header: some View { - HStack { - Text("记录指标") - .font(.tjH2()) - .foregroundStyle(Tj.Palette.text) - Spacer() - Text("本地处理 · 永不上传") - .font(.tjScaled( 12)) - .foregroundStyle(Tj.Palette.text3) + VStack(spacing: 12) { + HStack(spacing: 10) { + Text("记录指标") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + Spacer() + Text("本地处理 · 永不上传") + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text3) + searchToggle + } + if searchingMetrics { searchField } } .padding(.horizontal, 20) .padding(.bottom, 16) } + private var searchToggle: some View { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + searchingMetrics.toggle() + if !searchingMetrics { metricQuery = "" } + } + } label: { + Image(systemName: searchingMetrics ? "xmark" : "magnifyingglass") + .font(.tjScaled( 14, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .frame(width: 32, height: 32) + .background(Circle().fill(Tj.Palette.sand2)) + } + .buttonStyle(.plain) + .accessibilityLabel(searchingMetrics ? String(appLoc: "关闭搜索") : String(appLoc: "搜索指标")) + } + + private var searchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.tjScaled( 13)) + .foregroundStyle(Tj.Palette.text3) + TextField(String(appLoc: "搜索指标名"), text: $metricQuery) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .foregroundStyle(Tj.Palette.text) + .tint(Tj.Palette.ink) + if !metricQuery.isEmpty { + Button { metricQuery = "" } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Tj.Palette.text3) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(fieldBg) + .overlay(fieldBorder) + } + /// 顶部「拍照识别」入口:并入原「指标速记」。点后由 RootView 切到相机 VL 流程。 @ViewBuilder private var cameraEntrySection: some View { @@ -241,13 +325,19 @@ struct IndicatorQuickSheet: View { } let columns = [GridItem(.flexible()), GridItem(.flexible())] LazyVGrid(columns: columns, spacing: 8) { - ForEach(visibleMonitorMetrics) { m in + ForEach(filteredMonitorMetrics) { m in monitorTile(m) } - ForEach(customMetrics) { cm in + ForEach(filteredCustomMetrics) { cm in customTile(cm) } - addCustomTile + // 搜索态下不显示「自定义(新建)」格,聚焦过滤结果。 + if !isSearchingMetrics { addCustomTile } + } + if isSearchingMetrics, filteredMonitorMetrics.isEmpty, filteredCustomMetrics.isEmpty { + Text("没有匹配的长期监测指标") + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text3) } } .sheet(isPresented: $showHiddenSheet) { @@ -386,14 +476,18 @@ struct IndicatorQuickSheet: View { } } + @ViewBuilder private var labPresetSection: some View { - VStack(alignment: .leading, spacing: 8) { - sectionLabel(String(appLoc: "化验项快捷(不进趋势)")) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(labPresets) { p in - chip(p.name, selected: selectedLabPreset == p) { - applyLab(p) + // 搜索且化验项无匹配:整段隐藏(避免只剩一个空标题)。 + if !(isSearchingMetrics && filteredLabPresets.isEmpty) { + VStack(alignment: .leading, spacing: 8) { + sectionLabel(String(appLoc: "化验项快捷(不进趋势)")) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(filteredLabPresets) { p in + chip(p.name, selected: selectedLabPreset == p) { + applyLab(p) + } } } } @@ -941,6 +1035,29 @@ struct IndicatorQuickSheet: View { // MARK: - apply preset + /// 「再记一条」预选:seriesKey 命中长期监测预设 / 自定义指标则选中对应卡片(进趋势 + 自动判异常), + /// 否则按 name/unit/range 走自由输入。数值不预填——让用户填新读数。只应用一次。 + private func applyPrefillIfNeeded() { + guard !didApplyPrefill, let p = prefill else { return } + didApplyPrefill = true + if let key = p.seriesKey { + if let m = MonitorMetric.allCases.first(where: { metric in + metric.fields.contains { $0.seriesKey == key } + }) { + applyMonitor(m) + return + } + if let cm = customMetrics.first(where: { $0.seriesKey == key }) { + applyCustom(cm) + return + } + } + // 无 seriesKey 或未匹配预设(化验项 / 报告 / 自由指标):自由输入预填,不带 seriesKey,不进趋势。 + name = p.name + unit = p.unit + range = p.range + } + private func applyMonitor(_ m: MonitorMetric) { if selectedMonitor == m { // 取消选择 diff --git a/康康/Features/Indicator/RecordAnotherButton.swift b/康康/Features/Indicator/RecordAnotherButton.swift new file mode 100644 index 0000000..f665e46 --- /dev/null +++ b/康康/Features/Indicator/RecordAnotherButton.swift @@ -0,0 +1,39 @@ +import SwiftUI + +extension IndicatorQuickSheet.Prefill { + /// 从一条已有指标推断「再记一条」的预选目标: + /// seriesKey 命中长期监测 / 自定义指标则预选对应预设(进趋势 + 自动判异常),否则按 name/unit/range 自由预填。 + init(indicator i: Indicator) { + self.init(seriesKey: i.seriesKey, name: i.name, unit: i.unit, range: i.range) + } +} + +/// 指标详情 / 同类聚合详情底部「再记一条」按钮:打开预选同款指标的录入表单(数值留空,由用户填新读数)。 +/// 自带弹窗状态,`TimelineEntryDetailView` 与 `IndicatorSeriesDetailView` 共用同一组件。 +struct RecordAnotherButton: View { + /// 按钮文案里显示的指标名(如「空腹血糖」「血压」)。 + let name: String + /// 打开录入表单时的预选目标。 + let prefill: IndicatorQuickSheet.Prefill + + @State private var showSheet = false + + var body: some View { + Button { showSheet = true } label: { + Label(String(appLoc: "再记一条「\(name)」"), systemImage: "plus.circle.fill") + .font(.tjScaled( 13, weight: .semibold)) + .foregroundStyle(Tj.Palette.ink) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(Tj.Palette.leaf.opacity(0.16)) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .sheet(isPresented: $showSheet) { + IndicatorQuickSheet(prefill: prefill) + } + } +} diff --git a/康康/Features/Me/CustomReminderEditSheet.swift b/康康/Features/Me/CustomReminderEditSheet.swift index 22fc00d..18186a2 100644 --- a/康康/Features/Me/CustomReminderEditSheet.swift +++ b/康康/Features/Me/CustomReminderEditSheet.swift @@ -10,13 +10,20 @@ struct CustomReminderEditSheet: View { /// nil = 新建模式。 let reminder: CustomReminder? + /// 新建模式下的预填(如从「用药记录」点药进来,预填「吃药:药名」+ 用法备注)。 + /// 编辑模式(reminder != nil)忽略。 + private let prefillTitle: String + private let prefillNote: String @State private var title = "" @State private var note = "" @State private var pickedTime: Date = .now - @State private var frequency: CustomReminder.Frequency = .daily + /// 多选频率(每日/每周/每月/每年 可同时勾选)。 + @State private var frequencies: Set = [.daily] @State private var weekdays: Set = Set(1...7) - @State private var dayOfMonth = 1 + /// 每月多选日期(1...31)。 + @State private var monthDays: Set = [1] + @State private var dayOfMonth = 1 // 仅「每年」用 @State private var month = 1 @State private var hydrated = false @State private var showAuthDeniedAlert = false @@ -24,8 +31,10 @@ struct CustomReminderEditSheet: View { /// 常用时间快捷预设(时, 分):早 / 午 / 傍晚 / 睡前。 private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)] - init(reminder: CustomReminder? = nil) { + init(reminder: CustomReminder? = nil, prefillTitle: String = "", prefillNote: String = "") { self.reminder = reminder + self.prefillTitle = prefillTitle + self.prefillNote = prefillNote } private var isEditing: Bool { reminder != nil } @@ -33,8 +42,9 @@ struct CustomReminderEditSheet: View { title.trimmingCharacters(in: .whitespacesAndNewlines) } private var canSave: Bool { - guard !trimmedTitle.isEmpty else { return false } - if frequency == .weekly { return !weekdays.isEmpty } + guard !trimmedTitle.isEmpty, !frequencies.isEmpty else { return false } + if frequencies.contains(.weekly) && weekdays.isEmpty { return false } + if frequencies.contains(.monthly) && monthDays.isEmpty { return false } return true } @@ -51,18 +61,12 @@ struct CustomReminderEditSheet: View { } Section { - Picker(String(appLoc: "重复"), selection: $frequency) { - Text(String(appLoc: "每日")).tag(CustomReminder.Frequency.daily) - Text(String(appLoc: "每周")).tag(CustomReminder.Frequency.weekly) - Text(String(appLoc: "每月")).tag(CustomReminder.Frequency.monthly) - Text(String(appLoc: "每年")).tag(CustomReminder.Frequency.yearly) - } - .pickerStyle(.segmented) - .listRowBackground(Color.clear) - + frequencyChips frequencyDetail } header: { Text("重复") + } footer: { + Text("可多选:如同时勾选「每周一三五」+「每月1日」,两种节奏都会提醒。") } Section { @@ -109,23 +113,60 @@ struct CustomReminderEditSheet: View { } } - // MARK: - 频率子控件 + // MARK: - 频率多选 chip + + private static let freqOrder: [CustomReminder.Frequency] = [.daily, .weekly, .monthly, .yearly] + + private func freqLabel(_ f: CustomReminder.Frequency) -> String { + switch f { + case .daily: return String(appLoc: "每日") + case .weekly: return String(appLoc: "每周") + case .monthly: return String(appLoc: "每月") + case .yearly: return String(appLoc: "每年") + } + } + + private var frequencyChips: some View { + HStack(spacing: 8) { + ForEach(Self.freqOrder, id: \.self) { f in + let on = frequencies.contains(f) + Button { + if on { frequencies.remove(f) } else { frequencies.insert(f) } + } label: { + Text(freqLabel(f)) + .font(.tjScaled( 13, weight: on ? .semibold : .regular)) + .foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text) + .frame(maxWidth: .infinity, minHeight: 32) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(on ? Tj.Palette.ink : Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1) + ) + } + .buttonStyle(.plain) + } + } + .listRowBackground(Color.clear) + } + + // MARK: - 频率子控件(随勾选项展开,可同时出现多组) @ViewBuilder private var frequencyDetail: some View { - switch frequency { - case .daily: - EmptyView() - case .weekly: + if frequencies.contains(.weekly) { + subCaption(String(appLoc: "每周 · 选星期几")) weekdayRow - case .monthly: - Picker(String(appLoc: "日期"), selection: $dayOfMonth) { - ForEach(1...31, id: \.self) { d in - Text(String(appLoc: "\(d)日")).tag(d) - } - } - if dayOfMonth >= 29 { skipHint } - case .yearly: + } + if frequencies.contains(.monthly) { + subCaption(String(appLoc: "每月 · 选日期(可多选)")) + monthDayGrid + if monthDays.contains(where: { $0 >= 29 }) { skipHint } + } + if frequencies.contains(.yearly) { + subCaption(String(appLoc: "每年 · 选月/日")) Picker(String(appLoc: "月份"), selection: $month) { ForEach(1...12, id: \.self) { mo in Text(String(appLoc: "\(mo)月")).tag(mo) @@ -140,6 +181,41 @@ struct CustomReminderEditSheet: View { } } + private func subCaption(_ text: String) -> some View { + Text(text) + .font(.tjScaled( 11, weight: .semibold)) + .foregroundStyle(Tj.Palette.text3) + .frame(maxWidth: .infinity, alignment: .leading) + .listRowBackground(Color.clear) + } + + /// 每月日期多选网格(1...31,7 列)。 + private var monthDayGrid: some View { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 7), spacing: 6) { + ForEach(1...31, id: \.self) { d in + let on = monthDays.contains(d) + Button { + if on { monthDays.remove(d) } else { monthDays.insert(d) } + } label: { + Text("\(d)") + .font(.tjScaled( 12, weight: on ? .semibold : .regular)) + .foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text) + .frame(maxWidth: .infinity, minHeight: 30) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(on ? Tj.Palette.ink : Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1) + ) + } + .buttonStyle(.plain) + } + } + .listRowBackground(Color.clear) + } + private var skipHint: some View { Text(String(appLoc: "部分月份无此日,该月将跳过")) .font(.tjScaled( 11)) @@ -229,13 +305,18 @@ struct CustomReminderEditSheet: View { if let r = reminder { title = r.title note = r.note - frequency = r.frequency + frequencies = r.frequencies weekdays = Set(r.weekdays) + monthDays = Set(r.monthlyDays) dayOfMonth = r.dayOfMonth month = r.month pickedTime = Calendar.current.date( bySettingHour: r.hour, minute: r.minute, second: 0, of: .now ) ?? .now + } else { + // 新建模式:从调用方带进来的预填(药名 / 用法)。 + title = prefillTitle + note = prefillNote } } @@ -245,6 +326,7 @@ struct CustomReminderEditSheet: View { let hour = cal.component(.hour, from: pickedTime) let minute = cal.component(.minute, from: pickedTime) let sortedDays = weekdays.sorted() + let sortedMonthDays = monthDays.sorted() let target: CustomReminder if let r = reminder { @@ -253,8 +335,9 @@ struct CustomReminderEditSheet: View { r.hour = hour r.minute = minute r.weekdays = sortedDays - r.frequency = frequency - r.dayOfMonth = dayOfMonth + r.frequencies = frequencies // 写 frequenciesRaw(+ 代表 frequencyRaw) + r.monthlyDays = sortedMonthDays // 写 monthDays + r.dayOfMonth = dayOfMonth // 仅每年用 r.month = month r.updatedAt = .now target = r @@ -265,10 +348,11 @@ struct CustomReminderEditSheet: View { hour: hour, minute: minute, weekdays: sortedDays, - frequency: frequency, dayOfMonth: dayOfMonth, month: month ) + new.frequencies = frequencies + new.monthlyDays = sortedMonthDays ctx.insert(new) target = new } diff --git a/康康/Features/Me/MeView.swift b/康康/Features/Me/MeView.swift index f8941e6..cf6c493 100644 --- a/康康/Features/Me/MeView.swift +++ b/康康/Features/Me/MeView.swift @@ -282,6 +282,6 @@ struct MeView: View { .modelContainer(for: [ UserProfile.self, Indicator.self, Report.self, DiaryEntry.self, Asset.self, ChatTurn.self, Symptom.self, MetricReminder.self, - CustomMonitorMetric.self, + CustomMonitorMetric.self, Medication.self, ], inMemory: true) } diff --git a/康康/Features/Profile/MedicationLibraryView.swift b/康康/Features/Profile/MedicationLibraryView.swift new file mode 100644 index 0000000..ea50564 --- /dev/null +++ b/康康/Features/Profile/MedicationLibraryView.swift @@ -0,0 +1,383 @@ +import SwiftUI +import SwiftData + +/// 「记录 · 药品库」管理页:我有哪些药的 master 清单(药名 / 规格 / 用法 / 原图)。 +/// 拍药盒识别或手动录入入库;某次服用流水另走「写日记 · 用药」(带 `DiaryEntry.medicationTag` 的日记,含剂量 + 时间)。 +/// 列表 / 增删改范式照搬 `CustomMetricsListView`;编辑表单照搬 `CustomReminderEditSheet`。 +struct MedicationLibraryView: View { + @Environment(\.modelContext) private var ctx + @Environment(\.dismiss) private var dismiss + + @Query(sort: \Medication.updatedAt, order: .reverse) + private var medications: [Medication] + + /// sheet 形态(从「记录」拉起)补「完成」按钮;push 形态不补,靠返回键。 + var presentedAsSheet: Bool = false + + @State private var editingTarget: MedicationEditTarget? + @State private var showScan = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + hintBanner + if medications.isEmpty { + emptyState + } else { + ForEach(medications) { m in + Button { + editingTarget = MedicationEditTarget(medication: m) + } label: { + row(m) + } + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 32) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + .navigationTitle("药品库") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if presentedAsSheet { + ToolbarItem(placement: .topBarLeading) { + Button(String(appLoc: "完成")) { dismiss() } + } + } + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 16) { + Button { showScan = true } label: { + Image(systemName: "camera") + .font(.tjScaled( 16, weight: .semibold)) + } + .accessibilityLabel(String(appLoc: "拍药盒添加")) + Button { editingTarget = MedicationEditTarget(medication: nil) } label: { + Image(systemName: "plus") + .font(.tjScaled( 16, weight: .semibold)) + } + .accessibilityLabel(String(appLoc: "手动添加")) + } + } + } + .sheet(item: $editingTarget) { target in + MedicationEditSheet(existing: target.medication) + } + .fullScreenCover(isPresented: $showScan) { + // 拍药盒 → 本地 OCR + LLM 识别 → 核对 → 入药品库(含原图)。 + MedicationScanFlow( + onSave: { meds, images in + MedicationArchiver.archive(medications: meds, images: images, in: ctx) + }, + onClose: { showScan = false } + ) + } + } + + // MARK: - subviews + + private var hintBanner: some View { + HStack(spacing: 10) { + Image(systemName: "info.circle.fill") + .foregroundStyle(Tj.Palette.text3) + Text("药品库是你的常用药清单。记录某次服用请到「写日记 · 用药」,可填剂量和时间。") + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text2) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.sand2.opacity(0.5)) + ) + } + + private var emptyState: some View { + VStack(spacing: 14) { + Spacer(minLength: 40) + TjPlaceholder(label: String(appLoc: "药品库还是空的")) + .frame(width: 220, height: 130) + Text("右上角拍药盒或 + 手动添加") + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text3) + Spacer() + } + .frame(maxWidth: .infinity) + } + + private func row(_ m: Medication) -> some View { + HStack(spacing: 12) { + ZStack { + Circle().fill(Tj.Palette.leafSoft) + Image(systemName: "pills.fill") + .font(.tjScaled( 17, weight: .medium)) + .foregroundStyle(Tj.Palette.ink) + } + .frame(width: 40, height: 40) + + VStack(alignment: .leading, spacing: 3) { + Text(m.name) + .font(.tjScaled( 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .lineLimit(1) + if !m.detailLine.isEmpty { + Text(m.detailLine) + .font(.tjScaled( 11)) + .foregroundStyle(Tj.Palette.text3) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + + if !m.assets.isEmpty { + Text("📷 \(m.assets.count)") + .font(.tjScaled( 11)) + .foregroundStyle(Tj.Palette.text3) + } + Image(systemName: "chevron.right") + .font(.tjScaled( 11, weight: .medium)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + ) + } +} + +/// `medication == nil` → 新建;否则编辑。`id` 用 UUID 让同一对象重开 sheet 也能刷新。 +private struct MedicationEditTarget: Identifiable { + let id = UUID() + let medication: Medication? +} + +/// 药品库的新建 / 编辑表单(范式同 `CustomReminderEditSheet`:本地 @State 暂存,保存才写库)。 +private struct MedicationEditSheet: View { + @Environment(\.modelContext) private var ctx + @Environment(\.dismiss) private var dismiss + + /// nil = 新建模式。 + let existing: Medication? + + @State private var name = "" + @State private var strength = "" + @State private var usage = "" + @State private var note = "" + @State private var hydrated = false + /// 点缩略图全屏查看的起始页;nil = 未打开查看器。 + @State private var viewerStart: PhotoIndex? + + private var isEditing: Bool { existing != nil } + private var canSave: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + NavigationStack { + Form { + if let m = existing, !m.assets.isEmpty { + Section { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(Array(m.assets.enumerated()), id: \.offset) { idx, asset in + Button { + viewerStart = PhotoIndex(index: idx) + } label: { + MedicationAssetThumb(asset: asset) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + } header: { + Text(String(appLoc: "原图\(m.assets.count)张")) + } footer: { + Text("点图片可放大查看。原图均存在本机加密目录,不上传。") + } + } + + Section { + TextField(String(appLoc: "药名,如:缬沙坦胶囊"), text: $name) + .foregroundStyle(Tj.Palette.text) + TextField(String(appLoc: "规格,如:80mg×7粒"), text: $strength) + .foregroundStyle(Tj.Palette.text2) + TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $usage) + .foregroundStyle(Tj.Palette.text2) + } footer: { + Text("仅作清单记录,不提供任何用药或剂量建议。") + } + + Section { + TextField(String(appLoc: "备注(可选)"), text: $note, axis: .vertical) + .lineLimit(1...3) + .foregroundStyle(Tj.Palette.text2) + } + + if isEditing { + Section { + Button(role: .destructive) { deleteMedication() } label: { + Label(String(appLoc: "从药品库删除"), systemImage: "trash") + } + } + } + } + .scrollContentBackground(.hidden) + .background(Tj.Palette.sand.ignoresSafeArea()) + .navigationTitle(isEditing ? String(appLoc: "编辑药品") : String(appLoc: "添加药品")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(String(appLoc: "取消")) { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button(String(appLoc: "保存")) { save() } + .fontWeight(.semibold) + .disabled(!canSave) + } + } + .onAppear(perform: hydrate) + .fullScreenCover(item: $viewerStart) { start in + if let m = existing { + MedicationPhotoViewer(assets: m.assets, startIndex: start.index) + } + } + } + } + + private func hydrate() { + guard !hydrated else { return } + hydrated = true + if let m = existing { + name = m.name + strength = m.strength + usage = m.usage + note = m.note ?? "" + } + } + + private func save() { + guard canSave else { return } + let n = name.trimmingCharacters(in: .whitespacesAndNewlines) + let s = strength.trimmingCharacters(in: .whitespacesAndNewlines) + let u = usage.trimmingCharacters(in: .whitespacesAndNewlines) + let nt = note.trimmingCharacters(in: .whitespacesAndNewlines) + if let m = existing { + m.name = n + m.strength = s + m.usage = u + m.note = nt.isEmpty ? nil : nt + m.updatedAt = .now + } else { + let med = Medication(name: n, strength: s, usage: u, note: nt.isEmpty ? nil : nt) + ctx.insert(med) + } + try? ctx.save() + dismiss() + } + + private func deleteMedication() { + guard let m = existing else { return } + // 先删 Vault 里的 JPEG(cascade 只删 Asset 记录,文件要手动 unlink,§6 永久删除)。 + for a in m.assets { + try? FileVault.shared.remove(relativePath: a.relativePath) + } + ctx.delete(m) + try? ctx.save() + dismiss() + } +} + +// MARK: - 原图查看 + +/// 全屏查看器的起始页载体(`.fullScreenCover(item:)` 需 Identifiable)。 +private struct PhotoIndex: Identifiable { + let id = UUID() + let index: Int +} + +/// 药品库行内 / 编辑表单里的方形缩略图。原图从加密 Vault 同步读取(数量少,与 EvidenceImagePage 同款)。 +private struct MedicationAssetThumb: View { + let asset: Asset + + var body: some View { + VaultImage(relativePath: asset.relativePath, maxPixel: 500) { img in + Image(uiImage: img).resizable().scaledToFill() + } placeholder: { isLoading in + if isLoading { + Tj.Palette.paper + } else { + TjPlaceholder(label: String(appLoc: "原图无法读取")) + } + } + .frame(width: 150, height: 150) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + ) + } +} + +/// 全屏翻页查看药品原图(看清药盒小字)。 +private struct MedicationPhotoViewer: View { + @Environment(\.dismiss) private var dismiss + let assets: [Asset] + @State private var selection: Int + + init(assets: [Asset], startIndex: Int) { + self.assets = assets + _selection = State(initialValue: min(max(startIndex, 0), max(assets.count - 1, 0))) + } + + var body: some View { + ZStack(alignment: .topTrailing) { + Color.black.ignoresSafeArea() + + TabView(selection: $selection) { + ForEach(Array(assets.enumerated()), id: \.offset) { idx, asset in + VaultImage(relativePath: asset.relativePath, maxPixel: 2000) { img in + Image(uiImage: img).resizable().scaledToFit() + } placeholder: { isLoading in + if isLoading { + ProgressView().tint(.white) + } else { + TjPlaceholder(label: String(appLoc: "原图无法读取")) + } + } + .tag(idx) + } + } + .tabViewStyle(.page(indexDisplayMode: assets.count > 1 ? .automatic : .never)) + .ignoresSafeArea() + + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.tjScaled( 16, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(Circle().fill(.black.opacity(0.4))) + } + .padding(.trailing, 18) + .padding(.top, 14) + } + } +} + +#Preview { + NavigationStack { + MedicationLibraryView(presentedAsSheet: true) + } + .modelContainer(for: [Medication.self, Asset.self], inMemory: true) +} diff --git a/康康/Features/Profile/MedicationScanFlow.swift b/康康/Features/Profile/MedicationScanFlow.swift index 064a586..490069e 100644 --- a/康康/Features/Profile/MedicationScanFlow.swift +++ b/康康/Features/Profile/MedicationScanFlow.swift @@ -2,28 +2,41 @@ import SwiftUI import SwiftData import UIKit -/// 「拍药盒入档」流程:拍药盒/说明书 → Vision OCR → LLM 结构化 → 核对 → 落库。 -/// 入口:「+ 新建 · 健康日记 · 拍药盒」与「我的 · 个人资料 · 当前用药」。 -/// 两个入口确认后都走 `MedicationArchiver`:记一条「用药」日记(进记录时间线)+ 同步当前用药。 -/// 只识别入档,不做用药提醒/剂量建议(§1)。 +/// 「拍药盒入库」流程:拍药盒/说明书(最多 5 张,选一张识别)→ Vision OCR → LLM 结构化 → 核对 → 存入药品库(连同原图)。 +/// 入口:「记录 · 药品库」与「记录 · 健康日记 · 拍药盒」。 +/// 两个入口确认后都走 `MedicationArchiver`:每条药建一个 `Medication`(挂原图),不写日记、不写当前用药。 +/// 服用流水改由「写日记 · 用药」生成带 `medicationTag` 的 DiaryEntry。只识别入库,不做用药提醒/剂量建议(§1)。 /// -/// 状态机(与 QuickRegionCaptureFlow 同构): +/// 状态机: /// ``` -/// idle(相机/相册) → recognizing(OCR + LLM) → confirm(核对可编辑) → onSave → 关闭 -/// │ 失败/没读出 ──────► confirm(空行 + 警示文案,手动补) +/// idle(相机/相册) ─拍到第1张→ collecting(复看:删/继续拍≤5/选一张/开始识别) +/// │ 开始识别 +/// ▼ +/// recognizing(选中单张 OCR + LLM) ─→ confirm(核对一种药) ─onSave→ 关闭 +/// │ 失败/没读出 ───────────────► confirm(空行 + 警示) /// ``` struct MedicationScanFlow: View { - /// 用户确认后回传条目文本(非空,如 "缬沙坦胶囊 80mg · 一日一次")。落库由调用方做。 - let onSave: ([String]) -> Void + /// 用户确认后回传(结构化药品, 原图)。入库由调用方做(走 MedicationArchiver.archive(medications:))。 + let onSave: ([ParsedMedication], [UIImage]) -> Void let onClose: () -> Void + /// 一种药最多关联 5 张原图(正面/背面/说明书…)。 + static let maxImages = 5 + @State private var phase: Phase = .idle + /// 已拍/已选的原图,跨 collecting → recognizing → confirm 一直留着,确认时全部作为该药原图落库。 + @State private var images: [UIImage] = [] + /// 识别用的照片索引(在多张里单选一张)。一次只记一种药 → 只 OCR 这一张;删图时校正。 + @State private var recognizeIndex = 0 + /// 在 collecting 复看页「继续拍/继续选」时弹相机或相册。 + @State private var showMoreCapture = false /// 识别任务句柄:识别中点「取消」要能立刻中断,不留后台推理。 @State private var recognitionTask: Task? enum Phase { case idle - case recognizing(image: UIImage) + case collecting + case recognizing case confirm(items: [EditableMedication], warning: String?) } @@ -35,6 +48,8 @@ struct MedicationScanFlow: View { var include: Bool = true } + private var remainingSlots: Int { max(0, Self.maxImages - images.count) } + var body: some View { content .background(Tj.Palette.sand.ignoresSafeArea()) @@ -45,10 +60,14 @@ struct MedicationScanFlow: View { switch phase { case .idle: // 不整体 ignoresSafeArea:相机内部已全屏黑底,忽略安全区会让「取消」顶进灵动岛。 - captureEntry + initialCaptureEntry - case .recognizing(let image): - recognizingView(image: image) + case .collecting: + collectingView + .fullScreenCover(isPresented: $showMoreCapture) { moreCaptureSheet } + + case .recognizing: + recognizingView case .confirm(let items, let warning): NavigationStack { @@ -56,7 +75,7 @@ struct MedicationScanFlow: View { items: items, warning: warning, onSave: { saveItems($0) }, - onRetake: { phase = .idle } + onRetake: { images = []; phase = .idle } ) .navigationTitle("核对药品") .navigationBarTitleDisplayMode(.inline) @@ -72,31 +91,160 @@ struct MedicationScanFlow: View { // MARK: - 入口:拍照(真机)/ 相册(模拟器) + /// 首张:进入即拍/选。拿到第一张就转 collecting 复看。 @ViewBuilder - private var captureEntry: some View { + private var initialCaptureEntry: some View { #if targetEnvironment(simulator) PhotoPickerSheet( - onFinish: { images in - if let first = images.first { startRecognition(first) } else { onClose() } + onFinish: { picked in + appendImages(picked) + if images.isEmpty { onClose() } else { phase = .collecting } }, onCancel: onClose ) #else SingleShotCameraView( - onCapture: { startRecognition($0) }, + onCapture: { appendImages([$0]); phase = .collecting }, onCancel: onClose ) #endif } - private func recognizingView(image: UIImage) -> some View { + /// collecting 复看页里「继续拍/继续选」弹出的二次采集。 + @ViewBuilder + private var moreCaptureSheet: some View { + #if targetEnvironment(simulator) + PhotoPickerSheet( + onFinish: { picked in appendImages(picked); showMoreCapture = false }, + onCancel: { showMoreCapture = false } + ) + #else + SingleShotCameraView( + onCapture: { appendImages([$0]); showMoreCapture = false }, + onCancel: { showMoreCapture = false } + ) + #endif + } + + private func appendImages(_ new: [UIImage]) { + guard remainingSlots > 0 else { return } + images.append(contentsOf: new.prefix(remainingSlots)) + } + + // MARK: - 复看(已拍 N 张:删 / 继续拍 / 开始识别) + + private var collectingView: some View { + VStack(spacing: 0) { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 12)], spacing: 12) { + ForEach(Array(images.enumerated()), id: \.offset) { idx, img in + let isPick = idx == recognizeIndex + ZStack(alignment: .topTrailing) { + Image(uiImage: img) + .resizable() + .scaledToFill() + .frame(width: 96, height: 96) + .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(isPick ? Tj.Palette.ink : Color.clear, lineWidth: 3) + ) + .overlay(alignment: .bottomLeading) { + if isPick { + Text("识别此张") + .font(.tjScaled(10, weight: .semibold)) + .foregroundStyle(Tj.Palette.paper) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(Tj.Palette.ink)) + .padding(5) + } + } + // 点图把它选为「识别用」那张(单选)。 + .onTapGesture { recognizeIndex = idx } + Button { + images.remove(at: idx) + // 校正识别索引:删选中前面的图要左移;删到越界则收回末尾。 + if images.isEmpty { + recognizeIndex = 0 + phase = .idle + } else if idx < recognizeIndex { + recognizeIndex -= 1 + } else if recognizeIndex >= images.count { + recognizeIndex = images.count - 1 + } + } label: { + Image(systemName: "xmark.circle.fill") + .font(.tjScaled(20)) + .foregroundStyle(.white, .black.opacity(0.5)) + .padding(4) + } + .buttonStyle(.plain) + } + } + + if remainingSlots > 0 { + Button { showMoreCapture = true } label: { + VStack(spacing: 6) { + Image(systemName: "plus") + .font(.tjScaled(22, weight: .medium)) + Text("继续拍") + .font(.tjScaled(12)) + } + .foregroundStyle(Tj.Palette.text2) + .frame(width: 96, height: 96) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(Tj.Palette.line, style: StrokeStyle(lineWidth: 1, dash: [4])) + ) + } + .buttonStyle(.plain) + } + } + .padding(18) + } + + VStack(spacing: 8) { + Text("已拍 \(images.count)/\(Self.maxImages) 张 · 可拍正面、背面、说明书") + .font(.tjScaled(12)) + .foregroundStyle(Tj.Palette.text3) + if images.count > 1 { + Text("点照片选「识别此张」· 一次记一种药") + .font(.tjScaled(11)) + .foregroundStyle(Tj.Palette.ink) + } + Text("照片与文字均不离开设备") + .font(.tjScaled(11)) + .foregroundStyle(Tj.Palette.text3) + + Button { + startRecognition() + } label: { + Text("开始识别") + .frame(maxWidth: .infinity) + } + .buttonStyle(TjPrimaryButton()) + .disabled(images.isEmpty) + .opacity(images.isEmpty ? 0.4 : 1) + } + .padding(.horizontal, 18) + .padding(.bottom, 12) + } + .overlay(alignment: .topLeading) { + flowCancelButton { onClose() } + } + } + + private var recognizingView: some View { VStack(spacing: 18) { - Image(uiImage: image) - .resizable() - .scaledToFit() - .frame(maxHeight: 320) - .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)) - .padding(.horizontal, 24) + if images.indices.contains(recognizeIndex) { + Image(uiImage: images[recognizeIndex]) + .resizable() + .scaledToFit() + .frame(maxHeight: 320) + .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)) + .padding(.horizontal, 24) + } ProgressView().tint(Tj.Palette.ink) Text("正在本地识别药品…") .font(.tjScaled(14)) @@ -108,31 +256,37 @@ struct MedicationScanFlow: View { .frame(maxWidth: .infinity, maxHeight: .infinity) // 识别中也要能退出,不能让用户干等(§3.2 不卡死) .overlay(alignment: .topLeading) { - Button { + flowCancelButton { recognitionTask?.cancel() onClose() - } label: { - Text("取消") - .font(.tjScaled( 16, weight: .medium)) - .foregroundStyle(Tj.Palette.text) - .padding(.horizontal, 18) - .frame(minHeight: 44) - .background(Capsule().fill(Tj.Palette.paper)) - .overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1)) - .contentShape(Capsule()) } - .buttonStyle(.plain) - .padding(.leading, 16) - .padding(.top, 8) } } - // MARK: - 识别(整图 OCR → LLM 结构化) + private func flowCancelButton(_ action: @escaping () -> Void) -> some View { + Button(action: action) { + Text("取消") + .font(.tjScaled(16, weight: .medium)) + .foregroundStyle(Tj.Palette.text) + .padding(.horizontal, 18) + .frame(minHeight: 44) + .background(Capsule().fill(Tj.Palette.paper)) + .overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .padding(.leading, 16) + .padding(.top, 8) + } - private func startRecognition(_ image: UIImage) { - phase = .recognizing(image: image) + // MARK: - 识别(选中单张 OCR → LLM 结构化) + + private func startRecognition() { + guard images.indices.contains(recognizeIndex) else { return } + phase = .recognizing + let target = images[recognizeIndex] recognitionTask = Task { - let (items, warning) = await recognize(image) + let (items, warning) = await recognize(target) guard !Task.isCancelled else { return } // 识别中点了取消:不再回写 phase await MainActor.run { // 全失败也不卡死:给一条空行让用户手填(§3.2 失败回退红线)。 @@ -148,13 +302,15 @@ struct MedicationScanFlow: View { private func recognize(_ image: UIImage) async -> (items: [EditableMedication], warning: String?) { do { - let text = try await OCRService.recognizeText(in: image) - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { + // 一次只识别选中的这一张 → 一种药。 + let text = (try? await OCRService.recognizeText(in: image))? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if text.isEmpty { return ([], String(appLoc: "没识别到文字,拍清楚一点再试")) } - let parsed = try await MedicationScanService.shared.recognizeMedications(fromOCRText: trimmed) - let items = parsed.map { + let parsed = try await MedicationScanService.shared.recognizeMedications(fromOCRText: text) + // 一次一种药:即使识别出多条,也只取第一条。 + let items = parsed.prefix(1).map { EditableMedication(name: $0.name, strength: $0.strength, usage: $0.usage) } return (items, items.isEmpty ? String(appLoc: "没读出药品,可以手动填写") : nil) @@ -172,34 +328,56 @@ struct MedicationScanFlow: View { // MARK: - 保存 private func saveItems(_ items: [EditableMedication]) { - let entries = items + let meds = items .filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty } .map { - ParsedMedication(name: $0.name, strength: $0.strength, usage: $0.usage).entryText + ParsedMedication(name: $0.name.trimmingCharacters(in: .whitespaces), + strength: $0.strength.trimmingCharacters(in: .whitespaces), + usage: $0.usage.trimmingCharacters(in: .whitespaces)) } - onSave(entries) + // 确认后入药品库(连同原图)。空条目则不入库,由调用方据数组是否为空决定。 + onSave(meds, images) onClose() } } -// MARK: - 统一落库(MainActor,SwiftData 写主上下文必须由 View 侧持有的 ctx 来做,§3.1) +// MARK: - 入药品库(MainActor,SwiftData 写主上下文必须由 View 侧持有的 ctx 来做,§3.1) -/// 拍药盒确认后的统一落库,两个入口共用: -/// 1. 记一条带「用药」tag 的 DiaryEntry → 出现在「记录」时间线的「用药」分类 -/// 2. 同步到 UserProfile.currentMedications(去重)→ AI 解读 / 身体档案 prompt 背景 +/// 拍药盒确认后入药品库,两个入口(药品库页、写日记 · 拍药盒)共用: +/// 每条药建一个 `Medication`(挂原图),按 name+strength 软去重;**不写日记、不写 currentMedications**。 +/// 服用流水改由「写日记 · 用药」生成带 `DiaryEntry.medicationTag` 的日记。 @MainActor enum MedicationArchiver { - static func archive(entries: [String], in ctx: ModelContext) { - guard !entries.isEmpty else { return } - let diary = DiaryEntry(content: entries.joined(separator: "\n"), - tags: [DiaryEntry.medicationTag]) - ctx.insert(diary) + static func archive(medications: [ParsedMedication], images: [UIImage] = [], in ctx: ModelContext) { + guard !medications.isEmpty else { return } - let profile = UserProfileStore.loadOrCreate(in: ctx) - for entry in entries where !profile.currentMedications.contains(entry) { - profile.currentMedications.append(entry) + // 原图写加密 Vault(§5/§6:落 Application Support/Vault,目录级硬件加密)。 + // 多药共享同批原图时只挂「第一条新建的药」,避免同一 JPEG 被多个 Asset 引用、 + // 删一条 cascade 误删另一条还在用的文件。 + let savedAssets = images + .prefix(MedicationScanFlow.maxImages) + .compactMap { try? FileVault.shared.writeJPEG($0) } + + let existing = (try? ctx.fetch(FetchDescriptor())) ?? [] + var attachedImages = false + for m in medications { + // 软去重:同 name+strength 已在库则只补用法 / 刷新时间,不重复建。 + if let dup = existing.first(where: { $0.name == m.name && $0.strength == m.strength }) { + if dup.usage.isEmpty, !m.usage.isEmpty { dup.usage = m.usage } + dup.updatedAt = .now + continue + } + let med = Medication(name: m.name, strength: m.strength, usage: m.usage) + if !attachedImages { + for s in savedAssets { + let asset = Asset(relativePath: s.relativePath, bytes: s.bytes) + ctx.insert(asset) + med.assets.append(asset) + } + attachedImages = true + } + ctx.insert(med) } - profile.updatedAt = .now try? ctx.save() } } @@ -231,13 +409,8 @@ private struct MedicationConfirmView: View { ForEach($items) { $item in Section { - HStack { - TextField(String(appLoc: "药品名,如:缬沙坦胶囊"), text: $item.name) - .foregroundStyle(Tj.Palette.text) - Toggle("", isOn: $item.include) - .labelsHidden() - .tint(Tj.Palette.ink) - } + TextField(String(appLoc: "药品名,如:缬沙坦胶囊"), text: $item.name) + .foregroundStyle(Tj.Palette.text) TextField(String(appLoc: "规格,如:80mg×7粒"), text: $item.strength) .foregroundStyle(Tj.Palette.text2) TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $item.usage) @@ -246,12 +419,6 @@ private struct MedicationConfirmView: View { } Section { - Button { - items.append(.init(name: "", strength: "", usage: "")) - } label: { - Label("再加一种", systemImage: "plus.circle") - .foregroundStyle(Tj.Palette.ink) - } Button { onRetake() } label: { @@ -259,7 +426,7 @@ private struct MedicationConfirmView: View { .foregroundStyle(Tj.Palette.ink) } } footer: { - Text("将记入健康日记(记录页可查),并同步到「当前用药」供 AI 解读参考。不提供任何用药建议。") + Text("一次记一种药,多张照片都会作为这种药的原图存入药品库,供查看与 AI 解读参考。不提供任何用药建议。") } } .scrollContentBackground(.hidden) @@ -267,7 +434,7 @@ private struct MedicationConfirmView: View { Button { onSave(items) } label: { - Text("保存用药记录") + Text("存入药品库") .frame(maxWidth: .infinity) } .buttonStyle(TjPrimaryButton()) @@ -281,5 +448,5 @@ private struct MedicationConfirmView: View { } #Preview { - MedicationScanFlow(onSave: { print($0) }, onClose: {}) + MedicationScanFlow(onSave: { _, _ in }, onClose: {}) } diff --git a/康康/Features/Profile/ProfileEditView.swift b/康康/Features/Profile/ProfileEditView.swift index 1774ed5..472fa80 100644 --- a/康康/Features/Profile/ProfileEditView.swift +++ b/康康/Features/Profile/ProfileEditView.swift @@ -38,7 +38,6 @@ private struct ProfileEditForm: View { @State private var healthImportDraft: HealthProfileImportDraft? @State private var healthImportError: String? @State private var isImportingHealthProfile = false - @State private var showMedicationScan = false var body: some View { Form { @@ -88,9 +87,6 @@ private struct ProfileEditForm: View { items: $profile.allergies) StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"), items: $profile.familyHistory) - StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"), - items: $profile.currentMedications, - onScan: { showMedicationScan = true }) } .navigationTitle("个人资料") .navigationBarTitleDisplayMode(.inline) @@ -100,16 +96,6 @@ private struct ProfileEditForm: View { profile.updatedAt = .now try? ctx.save() } - .fullScreenCover(isPresented: $showMedicationScan) { - // 拍药盒 → 本地 OCR + LLM 识别 → 核对 → 统一落库: - // 记一条「用药」日记(进记录时间线)+ 同步当前用药(去重)。 - MedicationScanFlow( - onSave: { entries in - MedicationArchiver.archive(entries: entries, in: ctx) - }, - onClose: { showMedicationScan = false } - ) - } .sheet(item: $healthImportDraft) { draft in HealthProfileImportPreviewSheet( draft: draft, @@ -468,27 +454,10 @@ private struct StringListSection: View { let title: String let placeholder: String @Binding var items: [String] - /// 非 nil 时在节内显示「拍药盒自动识别」入口(目前仅「当前用药」用)。 - var onScan: (() -> Void)? = nil @State private var newInput = "" var body: some View { Section(title) { - if let onScan { - Button(action: onScan) { - HStack(spacing: 10) { - Image(systemName: "camera.viewfinder") - .foregroundStyle(Tj.Palette.ink) - VStack(alignment: .leading, spacing: 2) { - Text("拍药盒自动识别") - .foregroundStyle(Tj.Palette.text) - Text("拍药盒或说明书,本地识别药名与规格") - .font(.tjScaled( 12)) - .foregroundStyle(Tj.Palette.text3) - } - } - } - } ForEach(items, id: \.self) { item in HStack { Text(item) diff --git a/康康/Features/Record/RecordSheet.swift b/康康/Features/Record/RecordSheet.swift index 3d3eeac..49c6619 100644 --- a/康康/Features/Record/RecordSheet.swift +++ b/康康/Features/Record/RecordSheet.swift @@ -1,13 +1,15 @@ import SwiftUI enum RecordKind: String, Identifiable, CaseIterable { - case quick, indicator, healthExport, archive, diary, symptom, reminder + case quick, indicator, healthExport, archive, diary, symptom, reminder, medicationLibrary var id: String { rawValue } /// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。 /// 注:`.quick`(指标速记)已并入 `.indicator`(记录指标)内的「拍照识别」; - /// `.symptom`(记录症状)与拍药盒一起并入 `.diary`(健康日记)顶部三选一,不再单列。 - static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive] + /// `.symptom`(记录症状)与拍药盒一起并入 `.diary`(健康日记)顶部三选一,不再单列; + /// `.medicationLibrary`(药品库)是浏览/管理目的地,主入口在「记录」Tab 顶部板卡, + /// 这里垫底保留一个快捷方式(创建动作在前,药品库作为「去管理」入口排最后)。 + static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive, .medicationLibrary] /// 健康日记行的功能提示 pill(代替 subtitle,让"症状/药盒在日记里"一眼可见)。 /// 计算属性:每次按当前语言解析,语言切换即时更新(同 ProfileEditView 的 presets 约定)。 @@ -24,6 +26,7 @@ enum RecordKind: String, Identifiable, CaseIterable { case .diary: return String(appLoc: "健康日记") case .symptom: return String(appLoc: "记录症状") case .reminder: return String(appLoc: "开启一个提醒") + case .medicationLibrary: return String(appLoc: "药品库") } } var subtitle: String { @@ -35,6 +38,7 @@ enum RecordKind: String, Identifiable, CaseIterable { case .diary: return String(appLoc: "写日记或拍药盒记录用药 · 可让 AI 辅助") case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束") case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒") + case .medicationLibrary: return String(appLoc: "管理常用药清单 · 拍药盒或手动添加") } } var icon: String { @@ -46,6 +50,7 @@ enum RecordKind: String, Identifiable, CaseIterable { case .diary: return "heart.text.square" case .symptom: return "waveform.path.ecg" case .reminder: return "bell.badge" + case .medicationLibrary: return "pills.fill" } } var accent: Color { @@ -57,6 +62,7 @@ enum RecordKind: String, Identifiable, CaseIterable { case .diary: return Tj.Palette.leaf case .symptom: return Tj.Palette.amber case .reminder: return Tj.Palette.leaf + case .medicationLibrary: return Tj.Palette.ink } } } @@ -83,7 +89,7 @@ struct RecordSheet: View { } .padding(.bottom, 14) - // ScrollView 包裹:6 个入口在小屏固定 detent 下可能溢出,滚动确保都能触达。 + // ScrollView 包裹:入口在小屏固定 detent 下可能溢出,滚动确保都能触达。 ScrollView { VStack(spacing: 10) { ForEach(RecordKind.displayOrder) { kind in diff --git a/康康/Features/Timeline/IndicatorSeriesDetailView.swift b/康康/Features/Timeline/IndicatorSeriesDetailView.swift index da63d0a..1e26f0f 100644 --- a/康康/Features/Timeline/IndicatorSeriesDetailView.swift +++ b/康康/Features/Timeline/IndicatorSeriesDetailView.swift @@ -140,6 +140,7 @@ struct IndicatorSeriesDetailView: View { } else { pages pager + recordAnotherRow if bucket != nil { trendButton } } } @@ -311,6 +312,30 @@ struct IndicatorSeriesDetailView: View { .disabled(!enabled) } + // MARK: - 再记一条(与指标详情共用 RecordAnotherButton 组件) + + /// 按当前翻到的那一页指标预选「再记一条」:血压走双字段,其余按 name/unit/range/seriesKey。 + @ViewBuilder + private var recordAnotherRow: some View { + if records.indices.contains(currentIndex) { + switch records[currentIndex] { + case .single(let i): + RecordAnotherButton(name: i.name, prefill: .init(indicator: i)) + .padding(.horizontal, 20) + .padding(.bottom, bucket == nil ? 20 : 10) + case .bp(let sys, _): + RecordAnotherButton( + name: String(appLoc: "血压"), + prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic", + name: String(appLoc: "血压"), + unit: "mmHg", range: sys.range) + ) + .padding(.horizontal, 20) + .padding(.bottom, bucket == nil ? 20 : 10) + } + } + } + // MARK: - 趋势 / 删除 private var trendButton: some View { diff --git a/康康/Features/Timeline/TimelineEntry.swift b/康康/Features/Timeline/TimelineEntry.swift index c83ab9d..c07d7c1 100644 --- a/康康/Features/Timeline/TimelineEntry.swift +++ b/康康/Features/Timeline/TimelineEntry.swift @@ -42,10 +42,12 @@ struct TimelineEntry: Identifiable, Hashable { let kind: TimelineKind let date: Date let title: String - let subtitle: String + var subtitle: String let trailing: String? let trailingIsAlert: Bool let isOngoing: Bool + /// 同名指标聚合后的累计次数(>1 时副标题附「共 N 次」)。非聚合条目恒为 1。 + var aggregateCount: Int = 1 static func from(indicator i: Indicator) -> TimelineEntry { TimelineEntry( @@ -87,6 +89,34 @@ struct TimelineEntry: Identifiable, Hashable { return entries } + /// 「记录」列表 / 首页最近记录用:把同名(同类组)指标聚合成一条,代表取最新一次, + /// 附带该组累计次数(`aggregateCount`,>1 时副标题缀「共 N 次」)。 + /// 点代表条目跳 `IndicatorSeriesDetailView` 看历次。分组口径与聚合详情 / 趋势一致 + /// (`IndicatorGroup`):血压(bp.*)并一组、有 seriesKey 的按 key、无 seriesKey 的按 name+unit 归一。 + static func aggregatedIndicators(_ indicators: [Indicator]) -> [TimelineEntry] { + var order: [String] = [] + var groups: [String: [Indicator]] = [:] + for i in indicators { + let key = IndicatorGroup.of(i).id + if groups[key] == nil { order.append(key) } + groups[key, default: []].append(i) + } + return order.compactMap { key -> TimelineEntry? in + guard let members = groups[key] else { return nil } + // 该组逐条条目(血压已合并 sys/dia),取最新一条作代表。 + guard var rep = from(indicators: members).max(by: { $0.date < $1.date }) else { return nil } + // 次数:血压按测量次数(bp.systolic 条数),其余按成员条数。 + let count = key == IndicatorGroup.bloodPressure.id + ? members.filter { $0.seriesKey == "bp.systolic" }.count + : members.count + rep.aggregateCount = count + if count > 1 { + rep.subtitle += " · " + String(appLoc: "共 \(count) 次") + } + return rep + } + } + private static func mergedBP(systolic sys: Indicator, diastolic dia: Indicator) -> TimelineEntry { let abnormal = sys.status != .normal || dia.status != .normal // 方向箭头按实际 status 给:两值同向才标 ↑/↓;一高一低只标红不给方向 diff --git a/康康/Features/Timeline/TimelineEntryDetailView.swift b/康康/Features/Timeline/TimelineEntryDetailView.swift index c150c1e..c4edd1e 100644 --- a/康康/Features/Timeline/TimelineEntryDetailView.swift +++ b/康康/Features/Timeline/TimelineEntryDetailView.swift @@ -54,6 +54,27 @@ struct TimelineEntryDetailView: View { @State private var showDeleteConfirm = false @State private var evidenceTarget: Indicator? + @State private var reminderPrefill: ReminderPrefill? + + /// 「用药记录」点药 → 预填吃药提醒表单用的载体。 + private struct ReminderPrefill: Identifiable { + let id = UUID() + let title: String + let note: String + } + + /// 报告详情「查看原图」起始页载体。 + @State private var reportPhotoStart: ReportPhotoPage? + private struct ReportPhotoPage: Identifiable { + let id = UUID() + let index: Int + } + + /// 当前详情若是报告则取出,供「查看原图」用。 + private var reportEntry: Report? { + if case .report(let r) = detail { return r } + return nil + } var body: some View { VStack(spacing: 0) { @@ -84,6 +105,15 @@ struct TimelineEntryDetailView: View { EvidenceImagePreview(report: report, indicator: indicator) } } + .sheet(item: $reminderPrefill) { prefill in + // 复用自由提醒表单(每天/每周/每月/每年 + 时间点;一日多次就再建一条)。 + CustomReminderEditSheet(prefillTitle: prefill.title, prefillNote: prefill.note) + } + .sheet(item: $reportPhotoStart) { start in + if let r = reportEntry { + ReportImagesViewer(assets: r.assets, startIndex: start.index) + } + } } // MARK: - 删除(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6) @@ -120,6 +150,10 @@ struct TimelineEntryDetailView: View { for p in paths { try? FileVault.shared.remove(relativePath: p) } ctx.delete(r) case .diary(let d): + // 拍药盒日记可能挂原图;cascade 删 Asset 记录,Vault 里的 JPEG 要手动 unlink。 + for p in Set(d.assets.map(\.relativePath)) { + try? FileVault.shared.remove(relativePath: p) + } ctx.delete(d) case .symptom(let s): ctx.delete(s) @@ -167,7 +201,7 @@ struct TimelineEntryDetailView: View { case .indicator: return String(appLoc: "指标详情") case .bloodPressure: return String(appLoc: "血压详情") case .report: return String(appLoc: "报告详情") - case .diary: return String(appLoc: "日记详情") + case .diary(let d): return d.isMedicationLog ? String(appLoc: "用药详情") : String(appLoc: "日记详情") case .symptom: return String(appLoc: "症状详情") } } @@ -186,28 +220,31 @@ struct TimelineEntryDetailView: View { // MARK: - 指标 private func indicatorBody(_ i: Indicator) -> some View { - card { - HStack(alignment: .firstTextBaseline) { - Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text) - Spacer() - statusChip(i.status) - } - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(i.value) - .font(.tjScaled( 30, weight: .bold, design: .rounded)) - .foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick) - if !i.unit.isEmpty { - Text(i.unit).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3) + VStack(alignment: .leading, spacing: 16) { + card { + HStack(alignment: .firstTextBaseline) { + Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text) + Spacer() + statusChip(i.status) } + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(i.value) + .font(.tjScaled( 30, weight: .bold, design: .rounded)) + .foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick) + if !i.unit.isEmpty { + Text(i.unit).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3) + } + } + divider + if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) } + field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt)) + field(String(appLoc: "来源"), i.report?.title ?? i.source.label) + if let report = i.report { + evidenceButton(for: i, assets: report.assets) + } + if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) } } - divider - if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) } - field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt)) - field(String(appLoc: "来源"), i.report?.title ?? i.source.label) - if let report = i.report { - evidenceButton(for: i, assets: report.assets) - } - if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) } + RecordAnotherButton(name: i.name, prefill: .init(indicator: i)) } } @@ -217,21 +254,28 @@ struct TimelineEntryDetailView: View { let combined: IndicatorStatus = sys.status != .normal ? sys.status : (dia?.status ?? .normal) - return card { - HStack(alignment: .firstTextBaseline) { - Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text) - Spacer() - statusChip(combined) + return VStack(alignment: .leading, spacing: 16) { + card { + HStack(alignment: .firstTextBaseline) { + Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text) + Spacer() + statusChip(combined) + } + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("\(sys.value)/\(dia?.value ?? "—")") + .font(.tjScaled( 30, weight: .bold, design: .rounded)) + .foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick) + Text("mmHg").font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3) + } + divider + if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) } + field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt)) } - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text("\(sys.value)/\(dia?.value ?? "—")") - .font(.tjScaled( 30, weight: .bold, design: .rounded)) - .foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick) - Text("mmHg").font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3) - } - divider - if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) } - field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt)) + // 血压走双字段:seriesKey 用 bp.systolic 反查到 MonitorMetric.bloodPressure。 + RecordAnotherButton(name: String(appLoc: "血压"), + prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic", + name: String(appLoc: "血压"), + unit: "mmHg", range: sys.range)) } } @@ -248,16 +292,16 @@ struct TimelineEntryDetailView: View { TjBadge(text: r.type.label, style: .neutral) Text(Self.dateText(r.reportDate)) .font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) - if !r.assets.isEmpty { - Text(String(appLoc: "原图\(r.assets.count)张")) - .font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) - } } if let inst = r.institution, !inst.isEmpty { field(String(appLoc: "机构"), inst) } } + if !r.assets.isEmpty { + reportPhotosCard(r.assets) + } + ReportSummaryCard(report: r) if !r.indicators.isEmpty { @@ -286,26 +330,146 @@ struct TimelineEntryDetailView: View { } } - // MARK: - 日记 - - private func diaryBody(_ d: DiaryEntry) -> some View { - VStack(alignment: .leading, spacing: 16) { - card { - Text(Self.dateTimeText(d.createdAt)) - .font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) - Text(d.content) - .font(.tjScaled( 15)) - .foregroundStyle(Tj.Palette.text) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - if !d.tags.isEmpty { - field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " ")) + /// 报告原图卡:可点缩略图 → 全屏翻页查看。归档只存图时,这是看原图的唯一入口,必须独立于指标存在。 + private func reportPhotosCard(_ assets: [Asset]) -> some View { + card { + HStack { + Text(String(appLoc: "原图\(assets.count)张")) + .font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2) + Spacer() + Text(String(appLoc: "点图放大")).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3) + } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(Array(assets.enumerated()), id: \.offset) { idx, asset in + Button { + reportPhotoStart = ReportPhotoPage(index: idx) + } label: { + reportThumb(asset) + } + .buttonStyle(.plain) + } } } } } + private func reportThumb(_ asset: Asset) -> some View { + VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in + Image(uiImage: img).resizable().scaledToFill() + } placeholder: { isLoading in + if isLoading { + Tj.Palette.paper + } else { + TjPlaceholder(label: String(appLoc: "原图无法读取")) + } + } + .frame(width: 96, height: 120) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: 1) + ) + } + + // MARK: - 日记 + + @ViewBuilder + private func diaryBody(_ d: DiaryEntry) -> some View { + if d.isMedicationLog { + medicationBody(d) + } else { + VStack(alignment: .leading, spacing: 16) { + card { + Text(Self.dateTimeText(d.createdAt)) + .font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) + Text(d.content) + .font(.tjScaled( 15)) + .foregroundStyle(Tj.Palette.text) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + if !d.tags.isEmpty { + field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " ")) + } + } + } + } + } + + // MARK: - 用药使用记录(展示药名/剂量/时间 + 设置提醒) + + /// 用药使用记录(带「用药」tag 的日记):展示「药名 [规格] · 剂量」+ 时间,下方「设置提醒」。 + /// 只到点提示,不做剂量/频次建议(CLAUDE.md §1、§10)。 + private func medicationBody(_ d: DiaryEntry) -> some View { + let lines = Self.medicationLines(d.content) + return VStack(alignment: .leading, spacing: 16) { + card { + Text(Self.dateTimeText(d.createdAt)) + .font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) + if lines.isEmpty { + Text(d.content) + .font(.tjScaled( 15)).foregroundStyle(Tj.Palette.text) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } else { + ForEach(Array(lines.enumerated()), id: \.offset) { idx, line in + if idx > 0 { divider } + Text(line) + .font(.tjScaled( 15)).foregroundStyle(Tj.Palette.text) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + medicationActionRow(d) + + Text("「设置提醒」只到点提示,不提供任何用药或剂量建议。") + .font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + } + + /// 单动作:设置提醒(复用自由提醒表单,预填药名 + 用法)。只到点提示,不做剂量建议。 + private func medicationActionRow(_ d: DiaryEntry) -> some View { + HStack(spacing: 10) { + medAction(title: String(appLoc: "设置提醒"), icon: "bell.badge") { + let lines = Self.medicationLines(d.content) + if lines.count <= 1 { + let f = Self.medicationReminderFields(forLine: lines.first ?? d.content) + reminderPrefill = ReminderPrefill(title: f.title, note: f.note) + } else { + // 多种药:一个提醒涵盖,药名清单进备注,用户据此自定时间/频率。 + reminderPrefill = ReminderPrefill(title: String(appLoc: "服药提醒"), + note: lines.joined(separator: "\n")) + } + } + } + } + + private func medAction(title: String, icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 6) { + Image(systemName: icon).font(.tjScaled( 18, weight: .medium)) + Text(title).font(.tjScaled( 12, weight: .semibold)) + } + .foregroundStyle(Tj.Palette.ink) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(Tj.Palette.amber.opacity(0.14)) + ) + .contentShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)) + } + .buttonStyle(.plain) + } + // MARK: - 症状 private func symptomBody(_ s: Symptom) -> some View { @@ -412,6 +576,76 @@ struct TimelineEntryDetailView: View { private nonisolated static func dateText(_ d: Date) -> String { d.formatted(.dateTime.year().month().day()) } + + // MARK: - 用药行解析(纯函数,便于单测) + + /// 把用药日记 content 按换行拆成单行药品,去掉空白行与首尾空格。 + nonisolated static func medicationLines(_ content: String) -> [String] { + content.split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + /// 从一行药品文本(如「缬沙坦胶囊 80mg · 一日一次」)派生吃药提醒预填: + /// 标题 =「吃药:<药名+规格>」,备注 = 用法(" · " 之后部分,供用户据此选时间/频率)。 + nonisolated static func medicationReminderFields(forLine line: String) -> (title: String, note: String) { + let parts = line.components(separatedBy: " · ") + let head = (parts.first ?? line).trimmingCharacters(in: .whitespaces) + let usage = parts.count > 1 + ? parts.dropFirst().joined(separator: " · ").trimmingCharacters(in: .whitespaces) + : "" + let name = head.isEmpty ? line.trimmingCharacters(in: .whitespaces) : head + return (title: String(appLoc: "吃药:") + name, note: usage) + } +} + +/// 报告原图浏览(纯翻页看图,无指标高亮)。归档只存图的报告也能随时调取查看。 +private struct ReportImagesViewer: View { + @Environment(\.dismiss) private var dismiss + let assets: [Asset] + @State private var selection: Int + + init(assets: [Asset], startIndex: Int) { + self.assets = assets + _selection = State(initialValue: min(max(startIndex, 0), max(assets.count - 1, 0))) + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.tjScaled( 16, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .frame(width: 32, height: 32) + .background(Circle().fill(Tj.Palette.sand2)) + } + Text("原图 · 第 \(selection + 1)/\(assets.count) 页") + .font(.tjScaled( 14, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background(Tj.Palette.sand) + .overlay(alignment: .bottom) { + Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) + } + + TabView(selection: $selection) { + ForEach(Array(assets.enumerated()), id: \.offset) { index, asset in + EvidenceImagePage(asset: asset, highlight: nil) + .tag(index) + .padding(16) + } + } + .tabViewStyle(.page(indexDisplayMode: assets.count > 1 ? .automatic : .never)) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .presentationBackground(Tj.Palette.sand) + } } /// 原图证据预览(翻页 + 高亮框)。指标详情与同类聚合详情共用,故为模块内可见。 @@ -479,19 +713,16 @@ private struct EvidenceImagePage: View { let asset: Asset let highlight: CGRect? - private var image: UIImage? { - try? FileVault.shared.loadImage(relativePath: asset.relativePath) - } - var body: some View { GeometryReader { geo in - if let image { + VaultImage(relativePath: asset.relativePath, maxPixel: 2000) { image in ZStack { Image(uiImage: image) .resizable() .scaledToFit() .frame(width: geo.size.width, height: geo.size.height) if let highlight { + // 降采样保持原始宽高比,imageSize 仅用于算 letterbox 比例,定位不受影响。 EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight) } } @@ -502,9 +733,14 @@ private struct EvidenceImagePage: View { RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) ) - } else { - TjPlaceholder(label: String(appLoc: "原图无法读取")) - .frame(width: geo.size.width, height: geo.size.height) + } placeholder: { isLoading in + if isLoading { + ProgressView() + .frame(width: geo.size.width, height: geo.size.height) + } else { + TjPlaceholder(label: String(appLoc: "原图无法读取")) + .frame(width: geo.size.width, height: geo.size.height) + } } } } diff --git a/康康/Features/Trends/TrendsView.swift b/康康/Features/Trends/TrendsView.swift index da487bd..a08b57b 100644 --- a/康康/Features/Trends/TrendsView.swift +++ b/康康/Features/Trends/TrendsView.swift @@ -12,6 +12,10 @@ struct TrendsView: View { private var profile: UserProfile? { profiles.first } + /// 顶部搜索:点放大镜展开搜索框,按指标名(bucket.title)实时过滤两段列表。 + @State private var searching = false + @State private var query = "" + private var seriesBuckets: [SeriesBucket] { SeriesBucket.build(from: indicators, profile: profile, @@ -25,6 +29,14 @@ struct TrendsView: View { seriesBuckets.filter { $0.kind == .lab } } + private func filtered(_ buckets: [SeriesBucket]) -> [SeriesBucket] { + let q = query.trimmingCharacters(in: .whitespaces) + guard !q.isEmpty else { return buckets } + return buckets.filter { $0.title.localizedCaseInsensitiveContains(q) } + } + private var filteredMonitor: [SeriesBucket] { filtered(monitorBuckets) } + private var filteredLab: [SeriesBucket] { filtered(labBuckets) } + var body: some View { NavigationStack { ScrollView(showsIndicators: false) { @@ -32,12 +44,14 @@ struct TrendsView: View { header.padding(.top, 4) if seriesBuckets.isEmpty { emptyState + } else if filteredMonitor.isEmpty && filteredLab.isEmpty { + noMatchState } else { - if !monitorBuckets.isEmpty { - section(title: String(appLoc: "长期监测"), buckets: monitorBuckets) + if !filteredMonitor.isEmpty { + section(title: String(appLoc: "长期监测"), buckets: filteredMonitor) } - if !labBuckets.isEmpty { - section(title: String(appLoc: "化验指标趋势"), buckets: labBuckets) + if !filteredLab.isEmpty { + section(title: String(appLoc: "化验指标趋势"), buckets: filteredLab) } } } @@ -51,9 +65,73 @@ struct TrendsView: View { } private var header: some View { - Text("趋势") - .font(.tjTitle(26)) - .foregroundStyle(Tj.Palette.text) + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .lastTextBaseline) { + Text("趋势") + .font(.tjTitle(26)) + .foregroundStyle(Tj.Palette.text) + Spacer() + searchToggle + } + if searching { searchField } + } + } + + private var searchToggle: some View { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + searching.toggle() + if !searching { query = "" } + } + } label: { + Image(systemName: searching ? "xmark" : "magnifyingglass") + .font(.tjScaled( 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .frame(width: 36, height: 36) + .background(Circle().fill(Tj.Palette.sand2)) + } + .buttonStyle(.plain) + .accessibilityLabel(searching ? String(appLoc: "关闭搜索") : String(appLoc: "搜索指标")) + } + + private var searchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.tjScaled( 13)) + .foregroundStyle(Tj.Palette.text3) + TextField(String(appLoc: "搜索指标名"), text: $query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .foregroundStyle(Tj.Palette.text) + .tint(Tj.Palette.ink) + if !query.isEmpty { + Button { query = "" } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Tj.Palette.text3) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: 1) + ) + } + + private var noMatchState: some View { + VStack(spacing: 12) { + TjPlaceholder(label: String(appLoc: "没有匹配「\(query)」的指标")) + .frame(height: 120) + .frame(maxWidth: 260) + } + .frame(maxWidth: .infinity) + .padding(.top, 60) } private func section(title: String, buckets: [SeriesBucket]) -> some View { diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings index cacc157..087c347 100644 --- a/康康/Localizable.xcstrings +++ b/康康/Localizable.xcstrings @@ -189,6 +189,9 @@ } } } + }, + "「设置提醒」只到点提示,不提供任何用药或剂量建议。" : { + }, "/" : { @@ -909,6 +912,9 @@ } } } + }, + "📷 %lld" : { + }, "1 项偏低" : { "extractionState" : "stale", @@ -1497,6 +1503,7 @@ } }, "VL 模型未就绪" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1519,6 +1526,7 @@ } }, "VL 模型未就绪,先手动录入" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1541,6 +1549,7 @@ } }, "VL 输出无法解析:%@" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1628,6 +1637,9 @@ } } } + }, + "一次记一种药,多张照片都会作为这种药的原图存入药品库,供查看与 AI 解读参考。不提供任何用药建议。" : { + }, "三" : { "localizations" : { @@ -2031,6 +2043,9 @@ } } } + }, + "仅作清单记录,不提供任何用药或剂量建议。" : { + }, "仅供参考,不构成医疗建议" : { "extractionState" : "stale", @@ -2214,6 +2229,9 @@ } } } + }, + "从药品库删除" : { + }, "任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。" : { "localizations" : { @@ -2841,9 +2859,6 @@ } } } - }, - "保存用药记录" : { - }, "偏低" : { "localizations" : { @@ -3195,6 +3210,9 @@ } } } + }, + "共 %lld 次" : { + }, "共 %lld 页" : { "localizations" : { @@ -3306,6 +3324,9 @@ } } } + }, + "关闭搜索" : { + }, "其他" : { "localizations" : { @@ -3350,9 +3371,6 @@ } } } - }, - "再加一种" : { - }, "再拍一项" : { "extractionState" : "stale", @@ -3376,6 +3394,9 @@ } } } + }, + "再记一条「%@」" : { + }, "再说一次" : { @@ -3680,6 +3701,12 @@ } } } + }, + "剂量" : { + + }, + "剂量,如:1 片 / 80mg" : { + }, "前往设置" : { @@ -3913,6 +3940,16 @@ } } }, + "原图 · 第 %lld/%lld 页" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "原图 · 第 %1$lld/%2$lld 页" + } + } + } + }, "原图%lld张" : { }, @@ -3937,6 +3974,9 @@ } } } + }, + "原图已加密保存,详情页随时可翻看放大。系统只识别报告日期与机构作为标签,不逐项录入数值。" : { + }, "原图无法读取" : { @@ -4125,6 +4165,9 @@ }, "只读取生日、性别、身高、血型" : { + }, + "可多选:如同时勾选「每周一三五」+「每月1日」,两种节奏都会提醒。" : { + }, "可选开启 Face ID 启动锁,进一步保护隐私。" : { "localizations" : { @@ -4169,6 +4212,15 @@ } } } + }, + "右上角拍药盒或 + 手动添加" : { + + }, + "吃了哪个药" : { + + }, + "吃药:" : { + }, "各引擎实测对比" : { @@ -4378,6 +4430,7 @@ } }, "图片保存失败,手动录入并保留文本" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4398,6 +4451,9 @@ } } } + }, + "图片保存失败,请重试" : { + }, "在「+ 新建 → 指标记录 → %@」记录一次" : { "localizations" : { @@ -4879,6 +4935,7 @@ } }, "如:缬沙坦 80mg qd" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4955,6 +5012,9 @@ }, "字号放大 60%" : { + }, + "存入药品库" : { + }, "完成" : { "localizations" : { @@ -5142,9 +5202,6 @@ }, "导出历史" : { - }, - "将记入健康日记(记录页可查),并同步到「当前用药」供 AI 解读参考。不提供任何用药建议。" : { - }, "将追加:" : { "localizations" : { @@ -5441,6 +5498,16 @@ } } }, + "已拍 %lld/%lld 张 · 可拍正面、背面、说明书" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "已拍 %1$lld/%2$lld 张 · 可拍正面、背面、说明书" + } + } + } + }, "已拍 1 页" : { "extractionState" : "stale", "localizations" : { @@ -5979,6 +6046,9 @@ } } } + }, + "开始识别" : { + }, "开始说话…" : { @@ -6078,6 +6148,7 @@ }, "当前用药" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -6513,6 +6584,9 @@ }, "或手动填写" : { + }, + "或手动输入药名" : { + }, "或者自己写" : { "localizations" : { @@ -6606,6 +6680,9 @@ }, "手动填写,或拍照自动识别" : { + }, + "手动添加" : { + }, "手动记录" : { @@ -6877,10 +6954,10 @@ "拍药盒" : { }, - "拍药盒或说明书,本地识别药名与规格" : { + "拍药盒或手动添加常用药" : { }, - "拍药盒自动识别" : { + "拍药盒添加" : { }, "拖动方框对准要识别的指标,可拖右下角缩放" : { @@ -7419,6 +7496,18 @@ } } } + }, + "搜索指标" : { + + }, + "搜索指标 / 报告 / 症状名" : { + + }, + "搜索指标名" : { + + }, + "搜索记录" : { + }, "摘要" : { @@ -8124,6 +8213,9 @@ }, "月份" : { + }, + "服药提醒" : { + }, "未下载" : { "localizations" : { @@ -8212,6 +8304,12 @@ } } } + }, + "未能自动识别报告信息,可手动填写" : { + + }, + "未能自动识别报告信息,已保存原图,可手动填写日期 / 机构" : { + }, "未设置" : { "localizations" : { @@ -8940,6 +9038,9 @@ } } } + }, + "核对报告信息" : { + }, "核对指标" : { @@ -8948,6 +9049,7 @@ }, "核对识别结果" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -9282,6 +9384,9 @@ } } } + }, + "每周 · 选星期几" : { + }, "每天" : { "localizations" : { @@ -9307,6 +9412,9 @@ }, "每年" : { + }, + "每年 · 选月/日" : { + }, "每年%lld月%lld日" : { "localizations" : { @@ -9324,7 +9432,7 @@ "每月" : { }, - "每月%lld日" : { + "每月 · 选日期(可多选)" : { }, "比如:记一下血压 / 我头疼 / 拍个药盒" : { @@ -9404,6 +9512,15 @@ }, "没听清,再试一次" : { + }, + "没有匹配「%@」的指标" : { + + }, + "没有匹配「%@」的记录" : { + + }, + "没有匹配的长期监测指标" : { + }, "没有指标 — 点上方「加一项」补一行,或直接保存只存图片" : { "localizations" : { @@ -9513,6 +9630,15 @@ }, "添加快捷问答" : { + }, + "添加药品" : { + + }, + "点图放大" : { + + }, + "点图片可放大查看。原图均存在本机加密目录,不上传。" : { + }, "点底部 + 号可以补一条" : { "localizations" : { @@ -9535,6 +9661,9 @@ } } } + }, + "点照片选「识别此张」· 一次记一种药" : { + }, "点这里再开一次" : { "localizations" : { @@ -9873,6 +10002,9 @@ }, "用药记录" : { + }, + "用药详情" : { + }, "甲状腺疾病" : { "localizations" : { @@ -10175,6 +10307,9 @@ } } } + }, + "管理常用药清单 · 拍药盒或手动添加" : { + }, "管理用药、复查、监测的周期提醒" : { "localizations" : { @@ -10507,6 +10642,9 @@ } } } + }, + "继续拍" : { + }, "继续拍下一项" : { "extractionState" : "stale", @@ -10646,6 +10784,9 @@ } } } + }, + "编辑药品" : { + }, "腹痛" : { "localizations" : { @@ -10854,9 +10995,27 @@ } } } + }, + "药名,如:缬沙坦胶囊" : { + }, "药品名,如:缬沙坦胶囊" : { + }, + "药品库" : { + + }, + "药品库 · %lld 种常用药" : { + + }, + "药品库是你的常用药清单。记录某次服用请到「写日记 · 用药」,可填剂量和时间。" : { + + }, + "药品库还是空的" : { + + }, + "药品库还没有药,可在「记录 · 药品库」拍药盒或手动添加。这里直接手输也行。" : { + }, "血压" : { "localizations" : { @@ -10976,6 +11135,9 @@ } } } + }, + "记剂量与时间" : { + }, "记录" : { "localizations" : { @@ -11093,6 +11255,9 @@ }, "记录时间" : { + }, + "记录用药" : { + }, "记录症状" : { "localizations" : { @@ -11207,6 +11372,12 @@ } } } + }, + "设置提醒" : { + + }, + "识别入药品库" : { + }, "识别全程在本地,图片不会上传" : { "localizations" : { @@ -11260,8 +11431,12 @@ }, "识别框内指标" : { + }, + "识别此张" : { + }, "识别没有读出指标,请手动补充" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -11306,13 +11481,17 @@ } } }, - "识别用药" : { + "识别超时,已保存原图,请手动填写信息" : { + + }, + "识别超时,已保留原图" : { }, "识别超时,挪一下框再试或手动补充" : { }, "识别超时(>%llds)" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -11335,6 +11514,7 @@ } }, "识别超时(>%llds),保留旧编辑" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -11357,6 +11537,7 @@ } }, "识别超时(>%llds),先手动录入" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -12387,6 +12568,7 @@ } }, "重新识别没有读出新指标" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/康康/Models/Models.swift b/康康/Models/Models.swift index 5706877..f2fd7de 100644 --- a/康康/Models/Models.swift +++ b/康康/Models/Models.swift @@ -171,6 +171,12 @@ final class DiaryEntry { var createdAt: Date var tags: [String] + /// 拍药盒入档时关联的原图(最多 5 张:正面/背面/说明书…)。 + /// 默认空数组 → 旧数据轻量迁移安全(见 swiftdata-rebuild-data-loss)。 + /// cascade:删日记同删 Asset 记录;Vault 里的 JPEG 仍需在删除入口手动 unlink。 + @Relationship(deleteRule: .cascade) + var assets: [Asset] = [] + init(content: String, createdAt: Date = .now, tags: [String] = []) { self.content = content self.createdAt = createdAt @@ -204,6 +210,45 @@ final class Asset { } } +/// 药品库:用户「我有哪些药」的 master 档案(拍药盒识别或手输入库)。 +/// 与「用药使用记录」(带 `DiaryEntry.medicationTag` 的日记,记某次服用的剂量 + 时间)分层: +/// 这里只放清单 / 规格 / 用法 / 原图,不带服用时间。 +/// 新增 @Model 表 → SwiftData 轻量迁移安全(见 KangkangApp 兜底注释)。 +@Model +final class Medication { + var name: String // 药名(通用名,可含商品名),与 ParsedMedication.name 同义 + var strength: String // 规格,如 "80mg×7粒";无则 "" + var usage: String // 用法,如 "一日一次,一次一粒";无则 "" + var note: String? // 备注(可选) + var createdAt: Date + var updatedAt: Date + + /// 入库时关联的原图(药盒正面 / 背面 / 说明书,最多 5 张)。默认空数组 → 旧数据轻量迁移安全。 + /// cascade:删药品同删 Asset 记录;Vault 里的 JPEG 仍需在删除入口手动 unlink(同 DiaryEntry.assets 约定)。 + @Relationship(deleteRule: .cascade) + var assets: [Asset] = [] + + init(name: String, + strength: String = "", + usage: String = "", + note: String? = nil, + createdAt: Date = .now) { + self.name = name + self.strength = strength + self.usage = usage + self.note = note + self.createdAt = createdAt + self.updatedAt = createdAt + } + + /// 列表副标题 / 写日记选药时的展示行:"80mg×7粒 · 一日一次"(缺项自动省略)。 + var detailLine: String { + [strength, usage] + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + .joined(separator: " · ") + } +} + @Model final class Symptom { var name: String @@ -353,9 +398,13 @@ final class CustomReminder { var hour: Int // 0...23 var minute: Int // 0...59 var weekdays: [Int] // iOS Calendar 约定:1=日, 2=一, ..., 7=六。全 7 个 = 每天 - var frequencyRaw: String = "daily" // CustomReminder.Frequency 原始值 - var dayOfMonth: Int = 1 // monthly / yearly 用,1...31 + var frequencyRaw: String = "daily" // 旧:单选频率代表值;多选见 frequenciesRaw + var dayOfMonth: Int = 1 // yearly 用 + 旧 monthly 单选兜底,1...31 var month: Int = 1 // yearly 用,1...12 + /// 多选频率原始值(["daily","weekly",...])。空 = 旧数据,回退到单选 frequency。 + var frequenciesRaw: [String] = [] + /// 每月多选日期(1...31)。空 = 旧数据,回退到单选 dayOfMonth。 + var monthDays: [Int] = [] var enabled: Bool var createdAt: Date var updatedAt: Date @@ -392,10 +441,41 @@ final class CustomReminder { set { frequencyRaw = newValue.rawValue } } - /// 列表行副标题:按频率展示「每天 / 每周 一三五 / 每月15日 / 每年3月15日」。 + /// 生效的频率集合(多选)。frequenciesRaw 为空时回退到单选 frequency(兼容旧数据 / 旧 init)。 + var frequencies: Set { + get { + let parsed = Set(frequenciesRaw.compactMap { Frequency(rawValue: $0) }) + return parsed.isEmpty ? [frequency] : parsed + } + set { + frequenciesRaw = newValue.map(\.rawValue).sorted() + // 同步单选代表值,旧读者读 frequency 仍合理。 + if let rep = newValue.map(\.rawValue).sorted().first { frequencyRaw = rep } + } + } + + /// 每月生效日期(多选,1...31)。monthDays 为空时回退到单选 dayOfMonth(兼容旧数据)。 + /// 注意:不回写 dayOfMonth —— 后者仍归 yearly 独占,避免「同时选每月+每年」时互相覆盖。 + var monthlyDays: [Int] { + get { monthDays.isEmpty ? [dayOfMonth] : monthDays.sorted() } + set { monthDays = Set(newValue.map { max(1, min(31, $0)) }).sorted() } + } + + /// 列表行副标题:多选频率用「 · 」拼接,如「每周一三五 · 每月1·15日」。 + /// 含「每日」时直接显示「每天」(已覆盖其余)。 var frequencyLabel: String { if !enabled { return String(appLoc: "已关闭") } - switch frequency { + let active = frequencies + if active.contains(.daily) { return String(appLoc: "每天") } + // weekly 选满 7 天等价每天。 + if active == [.weekly] && isEveryDay { return String(appLoc: "每天") } + let order: [Frequency] = [.weekly, .monthly, .yearly] + let parts = order.filter { active.contains($0) }.map { freqPartLabel($0) } + return parts.isEmpty ? String(appLoc: "未选日") : parts.joined(separator: " · ") + } + + private func freqPartLabel(_ f: Frequency) -> String { + switch f { case .daily: return String(appLoc: "每天") case .weekly: @@ -404,7 +484,9 @@ final class CustomReminder { let names = [String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六")] return String(appLoc: "每周 ") + weekdays.sorted().map { names[$0 - 1] }.joined() case .monthly: - return String(appLoc: "每月\(dayOfMonth)日") + let days = monthlyDays + if days.isEmpty { return String(appLoc: "未选日") } + return String(appLoc: "每月") + days.map { String($0) }.joined(separator: "·") + String(appLoc: "日") case .yearly: return String(appLoc: "每年\(month)月\(dayOfMonth)日") } @@ -420,12 +502,17 @@ final class CustomReminder { func occurs(on date: Date, calendar: Calendar = .current) -> Bool { guard enabled else { return false } let c = calendar.dateComponents([.weekday, .day, .month], from: date) - switch frequency { - case .daily: return true - case .weekly: return weekdays.contains(c.weekday ?? -1) - case .monthly: return dayOfMonth == (c.day ?? -1) - case .yearly: return month == (c.month ?? -1) && dayOfMonth == (c.day ?? -1) + let wd = c.weekday ?? -1, day = c.day ?? -1, mo = c.month ?? -1 + // 多选频率:任一命中即触发。 + for f in frequencies { + switch f { + case .daily: return true + case .weekly: if weekdays.contains(wd) { return true } + case .monthly: if monthlyDays.contains(day) { return true } + case .yearly: if month == mo && dayOfMonth == day { return true } + } } + return false } } diff --git a/康康/Persistence/FileVault.swift b/康康/Persistence/FileVault.swift index 4031228..361befe 100644 --- a/康康/Persistence/FileVault.swift +++ b/康康/Persistence/FileVault.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import ImageIO enum FileVaultError: Error { case readFailed @@ -10,7 +11,10 @@ enum FileVaultError: Error { /// `@unchecked Sendable`:rootURL 是 let,方法只 I/O 到沙盒目录(线程安全), /// 可被任意 actor / Task 跨边界访问。实例方法显式 `nonisolated`,见 ModelStore 同款注释。 -final class FileVault: @unchecked Sendable { +/// 类级 `nonisolated`:工程开了 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`,默认会把本类 +/// 连同 `thumbnailCache`(非 Sendable 的 NSCache)推成 MainActor,令 nonisolated I/O 方法 / 单例 +/// 初始化访问不了;本类是纯文件 I/O + 缓存工具,必须脱离 MainActor 供任意 actor 调用,故整类标 nonisolated。 +nonisolated final class FileVault: @unchecked Sendable { nonisolated static let shared: FileVault = { do { let appSupport = try FileManager.default.url( @@ -28,6 +32,17 @@ final class FileVault: @unchecked Sendable { let rootURL: URL + /// 已降采样图片的内存缓存。NSCache 线程安全、内存吃紧时系统自动回收; + /// key = "相对路径@目标像素",避免 TabView 翻页 / 列表滚动反复读盘解码同一张图。 + /// 只缓存降采样后的小图(几百 KB),不缓存全分辨率原图。 + /// `nonisolated(unsafe)`:本工程默认 MainActor 隔离,非 Sendable 的 NSCache 存储属性即便整类标 + /// nonisolated 仍被推成 MainActor,令各 nonisolated I/O 方法访问不到;NSCache 本身线程安全,故 unsafe 豁免。 + private nonisolated(unsafe) let thumbnailCache: NSCache = { + let cache = NSCache() + cache.countLimit = 40 + return cache + }() + init(rootURL: URL) throws { self.rootURL = rootURL try FileManager.default.createDirectory( @@ -81,6 +96,33 @@ final class FileVault: @unchecked Sendable { return image } + /// 按目标最大边降采样加载。用 ImageIO 直接解出缩略图,**绝不**把全分辨率位图载进内存: + /// 一张 4000×3000 体检照全量解码是 ~48MB RGBA,翻几页就 jetsam;降到 ≤2000px 后仅几 MB。 + /// 自动尊重 EXIF 方向。结果按尺寸缓存,翻页/滚动回看命中缓存不再读盘。 + /// 失败(锁屏读不到 / 损坏)抛 readFailed,与 loadImage 一致,UI 显示占位即可。 + nonisolated func loadDownsampledImage(relativePath: String, maxPixelSize: CGFloat) throws -> UIImage { + let cacheKey = "\(relativePath)@\(Int(maxPixelSize))" as NSString + if let cached = thumbnailCache.object(forKey: cacheKey) { return cached } + + let url = try resolveSafePath(relativePath) + let srcOptions: [CFString: Any] = [kCGImageSourceShouldCache: false] + guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOptions as CFDictionary) else { + throw FileVaultError.readFailed + } + let thumbOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, // 应用 EXIF 旋转,免得横竖颠倒 + kCGImageSourceShouldCacheImmediately: true, // 在后台线程就解码,不拖到主线程绘制时 + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize + ] + guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOptions as CFDictionary) else { + throw FileVaultError.decodeFailed + } + let image = UIImage(cgImage: cg) + thumbnailCache.setObject(image, forKey: cacheKey) + return image + } + nonisolated func remove(relativePath: String) throws { let url = try resolveSafePath(relativePath) do { @@ -88,6 +130,8 @@ final class FileVault: @unchecked Sendable { } catch { throw FileVaultError.removeFailed } + // 删文件后清掉降采样缓存,避免详情页仍显示已删原图(删除次数极少,整清无虞)。 + thumbnailCache.removeAllObjects() } /// 清空 Vault 全部文件。单个文件删除失败(被占用/权限)不中断,继续删其余; @@ -99,6 +143,7 @@ final class FileVault: @unchecked Sendable { try? fm.removeItem(at: url) } let remaining = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? [] + thumbnailCache.removeAllObjects() if !remaining.isEmpty { throw FileVaultError.removeFailed } diff --git a/康康/RootView.swift b/康康/RootView.swift index c6e2f37..3e6b9d2 100644 --- a/康康/RootView.swift +++ b/康康/RootView.swift @@ -53,6 +53,8 @@ struct RootView: View { @State private var showVoiceCommand = false /// 语音直达「拍药盒」:RootView 层直接弹 MedicationScanFlow,不绕日记 sheet。 @State private var showMedicationScan = false + /// 「记录 · 药品库」:sheet + NavigationStack 形态的药品清单管理页。 + @State private var showMedicationLibrary = false /// 语音意图 → 打开对应新建入口(与 RecordSheet onPick 的路由一一对应)。 private func route(_ intent: VoiceIntent) { @@ -112,6 +114,7 @@ struct RootView: View { case .indicator: showIndicator = true case .reminder: showReminders = true case .healthExport: showHealthExport = true + case .medicationLibrary: showMedicationLibrary = true } } } @@ -135,6 +138,9 @@ struct RootView: View { // 列表页依赖外层 NavigationStack 提供标题栏;sheet 形态补「完成」按钮。 NavigationStack { RemindersListView(presentedAsSheet: true) } } + .sheet(isPresented: $showMedicationLibrary) { + NavigationStack { MedicationLibraryView(presentedAsSheet: true) } + } .fullScreenCover(isPresented: $showHealthExport) { HealthExportSheet() } @@ -156,8 +162,8 @@ struct RootView: View { } .fullScreenCover(isPresented: $showMedicationScan) { MedicationScanFlow( - onSave: { entries in - MedicationArchiver.archive(entries: entries, in: ctx) + onSave: { meds, images in + MedicationArchiver.archive(medications: meds, images: images, in: ctx) }, onClose: { showMedicationScan = false } ) diff --git a/康康/Services/CaptureService.swift b/康康/Services/CaptureService.swift index b21b273..f4304d9 100644 --- a/康康/Services/CaptureService.swift +++ b/康康/Services/CaptureService.swift @@ -33,7 +33,9 @@ struct ParsedReport: Sendable { var isEmpty: Bool { indicators.isEmpty } /// 占位空结果,失败回退时给 UI。 - static func empty(date: Date = .now) -> ParsedReport { + /// nonisolated:本工程默认 MainActor 隔离,而 CaptureService(actor)里的 extractReportMeta + /// 需要在 actor 上下文构造空结果 —— 纯值工厂,标 nonisolated 才能跨隔离调用(Swift 6)。 + nonisolated static func empty(date: Date = .now) -> ParsedReport { ParsedReport( title: "", typeRaw: ReportType.other.rawValue, @@ -78,6 +80,40 @@ actor CaptureService { try await runVL(on: assets) } + /// 报告归档「轻量 meta 提取」:**只保存原图,不逐项识别指标**。 + /// 逐项多模态识别在 2B 上又慢又易 OOM(jetsam 杀进程 = 用户说的「死机」), + /// 故归档链路改为:Vision OCR(本地,<1s/页)→ 文本 LLM 只抽 {title,type,date,institution}(~50 token)。 + /// 全程容错:OCR 空 / 模型未就绪 / 解析失败都返回 (空 meta, recognized:false),绝不抛、绝不阻断保存原图(§3.2)。 + /// 返回的 indicators 恒为空 —— 归档不建指标。 + func extractReportMeta(assets: [FileVault.SavedAsset]) async -> (meta: ParsedReport, recognized: Bool) { + let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) } + let ocr = await Self.ocrReference(for: urls) + guard !ocr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return (.empty(), false) + } + do { + try await AIRuntime.shared.prepare() // 文本 LLM(轻);OOM 闸门已处理 VL 卸载 + } catch { + return (.empty(), false) + } + var collected = "" + do { + // meta 输出极小,256 token 足够,远小于逐项识别的 2048 —— 这是不卡死的关键。 + let stream = await AIRuntime.shared.generate(prompt: VLPrompts.reportMetaFromText(ocr), + maxTokens: 256) + for try await chunk in stream { collected += chunk.text } + } catch { + return (.empty(), false) + } + let cleaned = CaptureService.stripThink(collected) + guard var parsed = try? CaptureService.parseReportJSON(cleaned, pageCount: assets.count) else { + return (.empty(), false) + } + // 归档只存 meta + 原图,丢弃模型可能附带的任何指标。 + parsed.indicators = [] + return (parsed, true) + } + /// 「拍照识别」OCR 链路:把 Vision OCR 出的纯文本交给 LLM(Qwen3-1.7B)结构化抽指标。 /// 不建 Report、不留图;失败抛 `CaptureError`,UI 回退手动录入(§3.2)。 /// 调用方(MainActor)先做 OCR,再把文本传进来——OCR 不需进 actor,也避免 UIImage 跨 actor。 @@ -169,8 +205,17 @@ actor CaptureService { private static func ocrReference(for urls: [URL]) async -> String { var pages: [String] = [] for (idx, url) in urls.prefix(4).enumerated() { - guard let src = CGImageSourceCreateWithURL(url as CFURL, nil), - let cg = CGImageSourceCreateImageAtIndex(src, 0, nil) else { continue } + guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { continue } + // OCR 不需要全分辨率:一张 4000px 体检照全量解码 ≈48MB,正赶在 VL 推理前叠加, + // 易触发 jetsam。降到 ≤3000px 既省内存又加速 Vision,医检报告字号此分辨率仍清晰; + // 且原图仍完整交给 VL 自行读取,OCR 仅当数字「抄写员」辅助,降采样不影响最终可用信息。 + let thumbOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: 3000 + ] + guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOptions as CFDictionary) else { continue } guard let text = try? await OCRService.recognizeText(in: cg), !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } pages.append(urls.count > 1 ? "【第 \(idx + 1) 页】\n\(text)" : text) diff --git a/康康/Services/HealthExportService.swift b/康康/Services/HealthExportService.swift index a0c08b0..abaaec6 100644 --- a/康康/Services/HealthExportService.swift +++ b/康康/Services/HealthExportService.swift @@ -450,6 +450,8 @@ struct HealthExportService { var reports: [Report] var diaries: [DiaryEntry] var profile: UserProfile + /// 药品库(用户「我有哪些药」清单)→ AI 背景 current_meds。空 → 不写该字段。 + var medications: [Medication] = [] /// 相关指标的趋势行(确定性计算,不进 LLM)。空 → 不渲染「## 指标趋势」段。 var trends: [ExportTrend] = [] } @@ -530,6 +532,9 @@ struct HealthExportService { // —— Profile(单例) —— let profile = UserProfileStore.loadOrCreate(in: ctx) + // —— 药品库(全量,作为 AI 背景 current_meds) —— + let medications = (try? ctx.fetch(FetchDescriptor())) ?? [] + // —— 趋势(确定性,不进 LLM) —— // 用全量 in-window 还原完整序列;裁剪后的 indicators 决定哪些 series 相关。 let trends = ExportTrendBuilder.build( @@ -546,6 +551,7 @@ struct HealthExportService { reports: reports, diaries: diaries, profile: profile, + medications: medications, trends: trends ) } @@ -561,6 +567,7 @@ struct HealthExportService { let indicators = (try? ctx.fetch(indicatorDesc)) ?? [] let diaries = (try? ctx.fetch(diaryDesc)) ?? [] let profile = UserProfileStore.loadOrCreate(in: ctx) + let medications = (try? ctx.fetch(FetchDescriptor())) ?? [] let dates = indicators.map(\.capturedAt) + diaries.map(\.createdAt) let fromDate = dates.min() ?? Date() @@ -581,6 +588,7 @@ struct HealthExportService { reports: [], diaries: diaries, profile: profile, + medications: medications, trends: trends ) } @@ -611,7 +619,11 @@ struct HealthExportService { if !profile.allergies.isEmpty { profDict["allergies"] = profile.allergies } if !profile.chronicConditions.isEmpty { profDict["chronic"] = profile.chronicConditions } if !profile.familyHistory.isEmpty { profDict["family_history"] = profile.familyHistory } - if !profile.currentMedications.isEmpty { profDict["current_meds"] = profile.currentMedications } + // current_meds 改读药品库(Medication);旧 profile.currentMedications 已停用。 + let medNames = snapshot.medications.map { m in + m.detailLine.isEmpty ? m.name : "\(m.name) \(m.detailLine)" + } + if !medNames.isEmpty { profDict["current_meds"] = medNames } root["profile"] = profDict // symptoms @@ -681,7 +693,8 @@ struct HealthExportService { /// 检索结果是否「实质为空」:无症状/指标/报告/日记,且 profile 也没有任何可写字段。 /// 为真时跳过 LLM,改用确定性「无记录」摘要,避免小模型凭先验编造病例。 static func isEffectivelyEmpty(_ s: Snapshot) -> Bool { - guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty, s.diaries.isEmpty else { + guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty, + s.diaries.isEmpty, s.medications.isEmpty else { return false } let p = s.profile @@ -693,7 +706,6 @@ struct HealthExportService { && p.allergies.isEmpty && p.chronicConditions.isEmpty && p.familyHistory.isEmpty - && p.currentMedications.isEmpty } /// 无真实记录时的确定性摘要:6 段全「无记录」,主诉仅照搬本人原话,不做任何推断。 diff --git a/康康/Services/ReminderService.swift b/康康/Services/ReminderService.swift index 8bc0427..f9a5985 100644 --- a/康康/Services/ReminderService.swift +++ b/康康/Services/ReminderService.swift @@ -80,20 +80,24 @@ enum ReminderService { let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines) let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines) let h = reminder.hour, m = reminder.minute - let slots: [Slot] - switch reminder.frequency { - case .daily: - slots = [Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m))] - case .weekly: - slots = reminder.weekdays.map { wd in - Slot(suffix: "w\(wd)", dc: DateComponents(hour: h, minute: m, weekday: wd)) + // 多选频率:把每个选中频率展开成槽,合并调度(suffix 各不冲突,可单独取消)。 + var slots: [Slot] = [] + for f in reminder.frequencies { + switch f { + case .daily: + slots.append(Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m))) + case .weekly: + slots += reminder.weekdays.map { wd in + Slot(suffix: "w\(wd)", dc: DateComponents(hour: h, minute: m, weekday: wd)) + } + case .monthly: + slots += reminder.monthlyDays.map { d in + Slot(suffix: "m\(d)", dc: DateComponents(day: d, hour: h, minute: m)) + } + case .yearly: + slots.append(Slot(suffix: "yearly", + dc: DateComponents(month: reminder.month, day: reminder.dayOfMonth, hour: h, minute: m))) } - case .monthly: - slots = [Slot(suffix: "monthly", - dc: DateComponents(day: reminder.dayOfMonth, hour: h, minute: m))] - case .yearly: - slots = [Slot(suffix: "yearly", - dc: DateComponents(month: reminder.month, day: reminder.dayOfMonth, hour: h, minute: m))] } await schedule( idBase: "\(customIdPrefix)\(reminder.id.uuidString)", @@ -146,11 +150,13 @@ enum ReminderService { } } - /// 取消某个 idBase 下所有可能后缀的 pending 通知(daily/monthly/yearly + 7 个 weekday,不漏)。 + /// 取消某个 idBase 下所有可能后缀的 pending 通知,不漏: + /// daily / yearly / 旧版 monthly + 7 个 weekday(w1...w7)+ 31 个月内日(m1...m31)。 private static func cancelBase(_ idBase: String) { let center = UNUserNotificationCenter.current() var ids = ["\(idBase).daily", "\(idBase).monthly", "\(idBase).yearly"] ids += (1...7).map { "\(idBase).w\($0)" } + ids += (1...31).map { "\(idBase).m\($0)" } center.removePendingNotificationRequests(withIdentifiers: ids) } } diff --git a/康康/Services/SpeechDictationService.swift b/康康/Services/SpeechDictationService.swift index 0c3cedb..2593dd8 100644 --- a/康康/Services/SpeechDictationService.swift +++ b/康康/Services/SpeechDictationService.swift @@ -25,6 +25,16 @@ final class SpeechDictationService { } } + /// 把已有文字 `prefix` 与新听写片段 `partial` 合并成一段。纯函数,方便单测、与录音生命周期解耦。 + /// 规则:空片段保留原文;空前缀直接用片段;前缀已以空白(空格/换行)结尾则直接拼, + /// 否则中间补一个空格——避免「已有内容新听写」黏成一坨,也不会在换行后多塞空格。 + static func merge(prefix: String, partial: String) -> String { + if partial.isEmpty { return prefix } + if prefix.isEmpty { return partial } + if prefix.last?.isWhitespace == true { return prefix + partial } + return prefix + " " + partial + } + /// 优先系统语言;系统语言不支持端侧时兜底中文(demo 机即使系统是英文也能用)。 private static func makeRecognizer() -> SFSpeechRecognizer? { if let r = SFSpeechRecognizer(locale: .current), r.supportsOnDeviceRecognition { diff --git a/康康Tests/MedicationReminderParsingTests.swift b/康康Tests/MedicationReminderParsingTests.swift new file mode 100644 index 0000000..9ed51cd --- /dev/null +++ b/康康Tests/MedicationReminderParsingTests.swift @@ -0,0 +1,45 @@ +import Testing +import Foundation +@testable import 康康 + +/// 「用药记录」点药设吃药提醒:行拆分与提醒预填的纯函数测试。 +/// 入口逻辑见 `TimelineEntryDetailView.medicationBody`。 +struct MedicationReminderParsingTests { + + // MARK: - 行拆分 + + @Test func splitsMultipleLinesAndDropsBlanks() { + let content = "缬沙坦胶囊 80mg · 一日一次\n\n二甲双胍 0.5g · 一日三次\n " + let lines = TimelineEntryDetailView.medicationLines(content) + #expect(lines == ["缬沙坦胶囊 80mg · 一日一次", "二甲双胍 0.5g · 一日三次"]) + } + + @Test func singleLineNoNewline() { + #expect(TimelineEntryDetailView.medicationLines("阿司匹林肠溶片 100mg") == ["阿司匹林肠溶片 100mg"]) + } + + @Test func emptyContentYieldsNoLines() { + #expect(TimelineEntryDetailView.medicationLines("\n \n").isEmpty) + } + + // MARK: - 提醒预填 + + @Test func splitsNameAndUsageOnMiddot() { + let f = TimelineEntryDetailView.medicationReminderFields(forLine: "缬沙坦胶囊 80mg · 一日一次") + #expect(f.title.contains("缬沙坦胶囊 80mg")) // 标题带药名+规格(可能含「吃药:」前缀) + #expect(!f.title.contains("一日一次")) // 用法不进标题 + #expect(f.note == "一日一次") // 用法进备注 + } + + @Test func noUsageGivesEmptyNote() { + let f = TimelineEntryDetailView.medicationReminderFields(forLine: "阿司匹林 100mg") + #expect(f.title.contains("阿司匹林 100mg")) + #expect(f.note.isEmpty) + } + + @Test func multipleMiddotsKeepEverythingAfterFirstAsUsage() { + let f = TimelineEntryDetailView.medicationReminderFields(forLine: "甲药 · 餐后 · 一日两次") + #expect(f.title.contains("甲药")) + #expect(f.note == "餐后 · 一日两次") + } +} diff --git a/康康Tests/MedicationScanServiceTests.swift b/康康Tests/MedicationScanServiceTests.swift index 2866129..26c63b6 100644 --- a/康康Tests/MedicationScanServiceTests.swift +++ b/康康Tests/MedicationScanServiceTests.swift @@ -57,6 +57,23 @@ struct MedicationScanServiceTests { try MedicationScanService.parseMedicationsJSON("识别不出来,抱歉") } } + + /// 英文药名照原样保留(prompt 已要求英文不翻译);解析层只透传,不应被丢弃。 + @Test func parsesEnglishDrugName() throws { + let raw = #"{"medications":[{"name":"Amoxicillin","strength":"500mg","usage":"Take one capsule three times daily"}]}"# + let meds = try MedicationScanService.parseMedicationsJSON(raw) + #expect(meds.count == 1) + #expect(meds[0].name == "Amoxicillin") + #expect(meds[0].entryText == "Amoxicillin 500mg · Take one capsule three times daily") + } + + /// 通用名 + 商品名合并写法(prompt 约定 "通用名(商品名)")原样透传。 + @Test func parsesGenericWithBrandName() throws { + let raw = #"{"medications":[{"name":"缬沙坦胶囊(代文)","strength":"80mg×7粒","usage":""}]}"# + let meds = try MedicationScanService.parseMedicationsJSON(raw) + #expect(meds.count == 1) + #expect(meds[0].name == "缬沙坦胶囊(代文)") + } } /// 「用药」日记 → 时间线分类映射(拍药盒入档落库后在「记录」tab 的归类)。 @@ -64,7 +81,8 @@ struct MedicationScanServiceTests { struct MedicationTimelineTests { private func makeContext() throws -> ModelContext { - let schema = Schema([DiaryEntry.self]) + // DiaryEntry 现关联 Asset(拍药盒原图),schema 必须带上 Asset.self,否则建容器报错。 + let schema = Schema([DiaryEntry.self, Asset.self]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) return ModelContext(try ModelContainer(for: schema, configurations: [config])) } diff --git a/康康Tests/ModelsSchemaTests.swift b/康康Tests/ModelsSchemaTests.swift index 8158fae..9bf68ca 100644 --- a/康康Tests/ModelsSchemaTests.swift +++ b/康康Tests/ModelsSchemaTests.swift @@ -17,6 +17,7 @@ struct ModelsSchemaTests { UserProfile.self, MetricReminder.self, CustomMonitorMetric.self, + Medication.self, ]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) return try ModelContainer(for: schema, configurations: [config]) @@ -190,4 +191,42 @@ struct ModelsSchemaTests { #expect(fetched.bloodTypeRaw == "A") #expect(fetched.chronicConditions == ["高血压"]) } + + @Test func medicationRoundtripAndDetailLine() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + + let med = Medication(name: "缬沙坦胶囊", strength: "80mg×7粒", usage: "一日一次,一次一粒") + ctx.insert(med) + try ctx.save() + + let fetched = try #require(try ctx.fetch(FetchDescriptor()).first) + #expect(fetched.name == "缬沙坦胶囊") + #expect(fetched.detailLine == "80mg×7粒 · 一日一次,一次一粒") + #expect(fetched.updatedAt == fetched.createdAt) + } + + @Test func medicationDetailLineOmitsEmptyParts() { + #expect(Medication(name: "维生素C").detailLine == "") + #expect(Medication(name: "钙片", strength: "600mg").detailLine == "600mg") + } + + @Test func cascadeDeleteMedicationRemovesAssets() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + + let med = Medication(name: "二甲双胍缓释片", strength: "0.5g×30片") + let asset = Asset(relativePath: "med-1.jpg", bytes: 2048) + ctx.insert(asset) + med.assets.append(asset) + ctx.insert(med) + try ctx.save() + #expect(med.assets.count == 1) + + ctx.delete(med) + try ctx.save() + + #expect(try ctx.fetch(FetchDescriptor()).isEmpty) + #expect(try ctx.fetch(FetchDescriptor()).isEmpty) + } } diff --git a/康康Tests/TodayRemindersLogicTests.swift b/康康Tests/TodayRemindersLogicTests.swift index 724b624..fcc7794 100644 --- a/康康Tests/TodayRemindersLogicTests.swift +++ b/康康Tests/TodayRemindersLogicTests.swift @@ -61,6 +61,42 @@ struct TodayRemindersLogicTests { #expect(!CustomReminder(title: "x", frequency: .yearly, dayOfMonth: 29, month: 5).occurs(on: d, calendar: cal)) } + // MARK: - 多选频率(每日/每周/每月 可同时勾选) + + @Test func multiFrequencyOccursOnAnySelected() { + // 周一(周二日 = 2)+ 每月15日,两种节奏任一命中即触发。 + let monday = date(2026, 6, 1) // 2026-06-01 是周一 + let wdMon = cal.component(.weekday, from: monday) + let r = CustomReminder(title: "x") + r.frequencies = [.weekly, .monthly] + r.weekdays = [wdMon] + r.monthlyDays = [15] + // 周一但非15号 → 命中(weekly) + #expect(r.occurs(on: monday, calendar: cal)) + // 15号但非周一(2026-06-15 是周一,换 2026-07-15 是周三)→ 命中(monthly) + let mid = date(2026, 7, 15) + #expect(r.occurs(on: mid, calendar: cal)) + // 既非周一也非15号 → 不触发 + #expect(!r.occurs(on: date(2026, 7, 16), calendar: cal)) + } + + @Test func monthlyMultiDayOccursOnEach() { + let r = CustomReminder(title: "x") + r.frequencies = [.monthly] + r.monthlyDays = [1, 15] + #expect(r.occurs(on: date(2026, 6, 1), calendar: cal)) + #expect(r.occurs(on: date(2026, 6, 15), calendar: cal)) + #expect(!r.occurs(on: date(2026, 6, 10), calendar: cal)) + } + + @Test func legacySingleFrequencyStillReadsThroughFrequenciesFallback() { + // 旧数据:只设单选 frequency,frequenciesRaw 为空 → frequencies 回退到 [frequency]。 + let r = CustomReminder(title: "x", frequency: .weekly, dayOfMonth: 1) + r.weekdays = [2] + #expect(r.frequencies == [.weekly]) + #expect(r.monthlyDays == [1]) // monthDays 空 → 回退 dayOfMonth + } + // MARK: - MetricReminder @Test func metricReminderOccursOnSelectedWeekday() {