feat(AI): 集成MNN推理引擎替换MLX作为主AI运行时

- 引入MNN(alibaba) + Arm SME2 + CPU作为主AI运行时,支持A19/iPhone17的
  SME2和A17的NEON加速
- 添加MLX Swift作为兜底GPU推理方案,实现双后端切换机制
- 使用单一Qwen3.5-2B多模态模型(1.2GB),替代原有的LLM+VL分离架构
- 实现InferenceEngine.current引擎选择逻辑,真机默认MNN,模拟器回退MLX
- 更新AIAgent架构,通过MNNLLMBridge(ObjC++) → MNNBackend进行推理
- 修改队列机制防止并发推理导致OOM,使用信号量闸门控制显存占用
- 更新文档中的技术栈说明、模块边界和周次交付计划
```
This commit is contained in:
link2026
2026-06-15 09:24:59 +08:00
parent 6c6a950140
commit 9d856fcfc4
37 changed files with 2605 additions and 430 deletions

View File

@@ -22,9 +22,9 @@
| UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` | | UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` |
| 持久化 | SwiftData | 见 §5 数据模型 | | 持久化 | SwiftData | 见 §5 数据模型 |
| 图表 | Swift Charts | iOS 16+ 原生 | | 图表 | Swift Charts | iOS 16+ 原生 |
| **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama | | **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` |
| LLM | Qwen3-1.7B 4bit (HF: `mlx-community/Qwen3-1.7B-4bit`) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 | | **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama |
| VL | Qwen2.5-VL-3B-Instruct 4bit (HF: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`) | ~2.0GB,负责拍照→结构化指标 | | 模型 | **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` | 不要自己写透视校正 | | 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
| Face ID | LocalAuthentication | | | Face ID | LocalAuthentication | |
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 | | Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
@@ -38,13 +38,14 @@
### 3.1 模块边界(强制) ### 3.1 模块边界(强制)
``` ```
UI → CaptureService / AskService / TrendService → AIRuntime → MLX UI → CaptureService / AskService / TrendService → AIRuntime → MNN / MLX
Persistence Persistence
``` ```
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。 - **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,方便测试。 - **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
### 3.2 VL pipeline(拍一张 = 一条流程) ### 3.2 VL pipeline(拍一张 = 一条流程)
@@ -66,7 +67,7 @@ VL prompt 必须:
### 3.3 RAG(结构化检索,不做 embedding) ### 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,流式生成回答 2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。 **第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
@@ -84,7 +85,9 @@ VL prompt 必须:
## 4. 模型分发 ## 4. 模型分发
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条 - 模型放 `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 入口显示"模型未就绪,前往下载" - App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用) - `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 进行中) | | W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault | | W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) | | W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |

View File

@@ -24,8 +24,10 @@
| 图表 | Swift Charts | iOS 16+ 原生 | | 图表 | 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 运行时(主)** | **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 | | **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 | | **统一模型(文本+视觉)** | **Qwen3.5-2B 多模态,一个模型全包** | 同一个 Qwen3.5-2B 同时做文本生成 / 关键词抽取 / 趋势解读 **和** 拍照→结构化指标。两种格式两种引擎,按设备选(见下两行)。代码:`ModelKind` |
| VL | Qwen3-VL-4B-Instruct 4bit (MLX `mlx-community/Qwen3-VL-4B-Instruct-4bit`) | 拍照→结构化指标。MNN VL 需 OMNI 构建,暂走 MLX | | ├ 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` | 不要自己写透视校正 | | 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
| Face ID | LocalAuthentication | | | Face ID | LocalAuthentication | |
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 | | Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
@@ -39,13 +41,13 @@
### 3.1 模块边界(强制) ### 3.1 模块边界(强制)
``` ```
UI → CaptureService / AskService / TrendService → AIRuntime → MLX UI → CaptureService / AskService / TrendService → AIRuntime → MNN(主) / MLX(兜底)
Persistence Persistence
``` ```
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。 - **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,方便测试。 - **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
### 3.2 VL pipeline(拍一张 = 一条流程) ### 3.2 VL pipeline(拍一张 = 一条流程)
@@ -67,7 +69,7 @@ VL prompt 必须:
### 3.3 RAG(结构化检索,不做 embedding) ### 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,流式生成回答 2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。 **第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
@@ -85,7 +87,7 @@ VL prompt 必须:
## 4. 模型分发 ## 4. 模型分发
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条 - 模型放 `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 入口显示"模型未就绪,前往下载" - App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用) - `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
@@ -250,7 +252,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
3. **UI 不直接调 AIRuntime**——必须经过 Service 3. **UI 不直接调 AIRuntime**——必须经过 Service
4. **AIRuntime 必须 actor 化**——禁止 class + lock 4. **AIRuntime 必须 actor 化**——禁止 class + lock
5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏 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 骨架**——增量加东西,不要推倒重来 7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架 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 进行中) | | W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault | | W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) | | W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |

View File

@@ -0,0 +1,137 @@
# 康康 · 小红书发布文案(比赛评审用)
> 使用说明:
> - `◻︎` 处填真机实测数字(打开 我的 → 模型管理 → 性能自检,截图同时把数字抄进来)
> - `#比赛官方话题#` 和 `@官方账号` 替换成组委会指定的话题和账号(评审通常按官方话题检索作品,**漏带话题可能查不到你的帖子**)
> - 主推版做主帖;技术版可隔 2~3 天发第二篇,小红书对"同一项目多角度连发"权重友好
> - 发布时间建议:工作日 12:0013:30 或 20:0022: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,模型疯狂输出 <think> 吃光 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. 主帖发出后把链接填进比赛报名系统/问卷(如果章程要求回填链接)

View File

@@ -7,42 +7,55 @@ import Foundation
nonisolated enum MedicationPrompts { nonisolated enum MedicationPrompts {
static func medicationsFromText(_ ocrText: String) -> String { static func medicationsFromText(_ ocrText: String) -> String {
// 5 OCR, 2400( 1200 ,)
medicationsFromTextTemplate 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 = #""" private static let medicationsFromTextTemplate: String = #"""
你是药品包装识别助手。下面是对一张药盒、药品说明书处方单做 OCR 得到的纯文本,可能有错字、换行混乱或无关噪声。 你是药品包装识别助手。下面是对一种药品的多张照片(药盒正面/背面/说明书/处方单)做 OCR 得到的纯文本,各张之间用「---」分隔,可能有错字、换行混乱或无关噪声。
请从中提取药品信息,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。 请从中提取药品信息,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
JSON schema(严格): JSON schema(严格):
{ {
"medications": [ "medications": [
{ {
"name": string, // 药品通用名或商品名,如 "" "name": string, // 药品名,见下方「name 怎么填」
"strength": string, // 规格,如 "80mg""0.5g×24";识别不出填 "" "strength": string, // 规格,如 "80mg""0.5g×24";识别不出填 ""
"usage": string // 用法用量,如 ",";包装上没有就填 "" "usage": string // 用法用量,如 ",";包装上没有就填 ""
} }
] ]
} }
规则: name 怎么填(关键,别搞混):
- 只提取药品本身;""批准文号、生产厂家、批号、有效期、条形码一律忽略 - 药品名 = 通用名(化学/药典名),这是要填进 name 的主体。中文药名照中文写,英文药名(如 "Metformin""Amoxicillin")就照英文原样抄,不要翻译、不要丢
- 一张药盒通常只有 1 种药;处方单可能有多种,都要提取 - 若包装上同时印有商品名/商标名(厂商起的牌子名,如 """""Tylenol"),把它放在通用名后的括号里,例如 "()"。只读到商品名、读不到通用名时,就直接用商品名当 name
- 生产厂家/公司名/品牌 LOGO 文字(如 "XX药业有限公司""""")不是药名,一律不要当 name,也不要塞进括号。
通用规则:
- 只提取药品本身;""批准文号、生产厂家、批号、有效期、条形码、贮藏、二维码一律忽略。
- 多张照片通常是同一种药的不同面,合并成一条,不要因为来自不同照片就重复输出;处方单可能有多种药,才分多条。
- 不要发明药品。名称读不清的整条跳过;strength / usage 读不清就填 "",不要编造。 - 不要发明药品。名称读不清的整条跳过;strength / usage 读不清就填 "",不要编造。
- 不要输出任何服药建议或剂量调整建议,只抄录包装上已有的文字。 - 不要输出任何服药建议或剂量调整建议,只抄录包装上已有的文字。
- 同一药品只输出一次。 - 同一药品只输出一次。
示例 1(药盒): 示例 1(药盒,含商品名 + 厂商):
输入 OCR 文本: 缬沙坦胶囊 80mg×7粒 国药准字H20103521 XX药业有限公司 输入 OCR 文本: 代文 缬沙坦胶囊 80mg×7粒 国药准字H20103521 北京诺华制药有限公司
输出: 输出:
{"medications":[{"name":"","strength":"80mg×7","usage":""}]} {"medications":[{"name":"()","strength":"80mg×7","usage":""}]}
示例 2(说明书含用法): 示例 2(说明书含用法):
输入 OCR 文本: 二甲双胍缓释片 0.5g×30片 用法用量:口服,一次1片,一日2次,随餐服用 输入 OCR 文本: 二甲双胍缓释片 0.5g×30片 用法用量:口服,一次1片,一日2次,随餐服用
输出: 输出:
{"medications":[{"name":"","strength":"0.5g×30","usage":",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 文本,只输出 JSON。/no_think
OCR 文本: OCR 文本:

View File

@@ -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]}]} {"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}} {{OCR_SECTION}}
现在请识别图片并输出 JSON: 现在请识别图片并输出 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: - () // MARK: - ()

View File

@@ -24,6 +24,7 @@ struct KangkangApp: App {
CustomMonitorMetric.self, CustomMonitorMetric.self,
HealthExport.self, HealthExport.self,
CustomReminder.self, CustomReminder.self,
Medication.self,
]) ])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
// store .completeUnlessOpen (§6), // store .completeUnlessOpen (§6),

View File

@@ -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<Content: View, Placeholder: View>: 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
}
}
}

View File

@@ -23,9 +23,12 @@ struct ArchiveListView: View {
@Query(sort: \MetricReminder.updatedAt, order: .reverse) @Query(sort: \MetricReminder.updatedAt, order: .reverse)
private var metricReminders: [MetricReminder] private var metricReminders: [MetricReminder]
@Query(sort: \Medication.updatedAt, order: .reverse)
private var medications: [Medication]
/// push `navigationDestination(item:)` /// push `navigationDestination(item:)`
/// `navigationDestination(isPresented:)` SwiftUI () /// `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 filter: TimelineKind? = nil
@State private var endingSymptom: Symptom? @State private var endingSymptom: Symptom?
@@ -33,57 +36,73 @@ struct ArchiveListView: View {
@State private var selectedGroup: IndicatorGroup? @State private var selectedGroup: IndicatorGroup?
@State private var route: Route? @State private var route: Route?
/// :,(///), chip
@State private var searching = false
@State private var query = ""
@MainActor @MainActor
private var allEntries: [TimelineEntry] { private var allEntries: [TimelineEntry] {
let mapped = let mapped =
TimelineEntry.from(indicators: indicators) + TimelineEntry.aggregatedIndicators(indicators) +
reports.map(TimelineEntry.from(report:)) + reports.map(TimelineEntry.from(report:)) +
diaries.map(TimelineEntry.from(diary:)) + diaries.map(TimelineEntry.from(diary:)) +
symptoms.map(TimelineEntry.from(symptom:)) symptoms.map(TimelineEntry.from(symptom:))
let filtered = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped let byKind = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
return filtered.sorted { $0.date > $1.date } 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 { var body: some View {
NavigationStack { NavigationStack {
content content
.navigationDestination(item: $route) { route in .navigationDestination(item: $route) { route in
switch route { switch route {
case .exports: HealthExportListView() case .exports: HealthExportListView()
case .reminders: RemindersListView() case .reminders: RemindersListView()
case .medicationLibrary: MedicationLibraryView()
} }
} }
} }
} }
private var content: some View { private var content: some View {
VStack(alignment: .leading, spacing: 0) { // ( O(m²))+ / body .isEmpty
header // allEntries,;,
let entries = allEntries
let groups = TimelineGrouping.group(entries)
return VStack(alignment: .leading, spacing: 0) {
header(total: entries.count)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 8) .padding(.top, 8)
.padding(.bottom, 14) .padding(.bottom, 14)
if reminderTotal > 0 { if reminderTotal > 0 {
reminderBoard 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(.horizontal, 20)
.padding(.bottom, 14) .padding(.bottom, 14)
} }
filterChips if entries.isEmpty {
.padding(.bottom, 14)
if allEntries.isEmpty {
emptyState emptyState
} else { } else {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) { LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) {
ForEach(grouped, id: \.section) { group in ForEach(groups, id: \.section) { group in
Section { Section {
VStack(spacing: 10) { VStack(spacing: 10) {
ForEach(group.items) { entry in ForEach(group.items) { entry in
@@ -149,12 +168,12 @@ struct ArchiveListView: View {
diaries: diaries, symptoms: symptoms) diaries: diaries, symptoms: symptoms)
} }
private var header: some View { private func header(total: Int) -> some View {
HStack(alignment: .lastTextBaseline) { HStack(alignment: .lastTextBaseline) {
Text("记录") Text("记录")
.font(.tjTitle(26)) .font(.tjTitle(26))
.foregroundStyle(Tj.Palette.text) .foregroundStyle(Tj.Palette.text)
Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount)")) Text(total == 0 ? "" : String(appLoc: "\(total)"))
.font(.tjScaled( 12)) .font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
Spacer() Spacer()
@@ -173,9 +192,57 @@ struct ArchiveListView: View {
} }
.buttonStyle(.plain) .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: - // MARK: -
/// ( + ), /// ( + ),
@@ -241,6 +308,58 @@ struct ArchiveListView: View {
.buttonStyle(.plain) .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 { private var filterChips: some View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -291,13 +410,19 @@ struct ArchiveListView: View {
} }
private var emptyState: some View { private var emptyState: some View {
VStack(spacing: 14) { let q = query.trimmingCharacters(in: .whitespaces)
let isSearchMiss = !q.isEmpty
return VStack(spacing: 14) {
Spacer() Spacer()
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始")) TjPlaceholder(label: isSearchMiss
? String(appLoc: "没有匹配「\(q)」的记录")
: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
.frame(width: 240, height: 140) .frame(width: 240, height: 140)
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录")) if !isSearchMiss {
.font(.tjScaled( 13)) Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
.foregroundStyle(Tj.Palette.text3) .font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
}
Spacer() Spacer()
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)

View File

@@ -8,9 +8,12 @@ struct CaptureReviewForm: View {
@State var parsed: ParsedReport @State var parsed: ParsedReport
let assets: [FileVault.SavedAsset] let assets: [FileVault.SavedAsset]
let warning: String? let warning: String?
/// : + (///),
/// ( 2B OOM ), CaptureService.extractReportMeta
var metaOnly: Bool = false
let onSave: (ParsedReport) -> Void let onSave: (ParsedReport) -> Void
let onCancel: () -> Void let onCancel: () -> Void
/// assets () nil,banner /// assets () nil,banner
var onReanalyze: (() -> Void)? = nil var onReanalyze: (() -> Void)? = nil
var body: some View { var body: some View {
@@ -23,7 +26,9 @@ struct CaptureReviewForm: View {
pageThumbnails pageThumbnails
} }
metaSection metaSection
indicatorSection if !metaOnly {
indicatorSection
}
Spacer(minLength: 8) Spacer(minLength: 8)
actions actions
} }
@@ -68,20 +73,26 @@ struct CaptureReviewForm: View {
private var pageThumbnails: some View { private var pageThumbnails: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)")) sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
if metaOnly {
Text("原图已加密保存,详情页随时可翻看放大。系统只识别报告日期与机构作为标签,不逐项录入数值。")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
.fixedSize(horizontal: false, vertical: true)
}
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) { HStack(spacing: 10) {
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) { VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in
Image(uiImage: img) Image(uiImage: img).resizable().scaledToFill()
.resizable() } placeholder: { _ in
.scaledToFill() 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)
)
} }
.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: "机构(可选)")) { labeledField(String(appLoc: "机构(可选)")) {
TextField("如:协和医院", text: $parsed.institution) TextField("如:协和医院", text: $parsed.institution)
} }
labeledField(String(appLoc: "摘要(可选)")) { if !metaOnly {
TextField("一句话总结", text: $parsed.summary, axis: .vertical) labeledField(String(appLoc: "摘要(可选)")) {
.lineLimit(1...3) TextField("一句话总结", text: $parsed.summary, axis: .vertical)
.lineLimit(1...3)
}
} }
} }
.padding(12) .padding(12)

View File

@@ -62,7 +62,7 @@ struct UnifiedCaptureFlow: View {
switch phase { switch phase {
case .idle: return String(appLoc: "拍摄报告") case .idle: return String(appLoc: "拍摄报告")
case .analyzing: 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, parsed: parsed,
assets: assets, assets: assets,
warning: warning, warning: warning,
metaOnly: true, // + meta,(§ CaptureService.extractReportMeta)
onSave: { final in saveAll(parsed: final, assets: assets) }, onSave: { final in saveAll(parsed: final, assets: assets) },
onCancel: cancelAll, onCancel: cancelAll,
onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) } onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) }
@@ -152,9 +153,7 @@ struct UnifiedCaptureFlow: View {
phase = .analyzing(images: images, assets: nil) phase = .analyzing(images: images, assets: nil)
let timeout = analyzeTimeoutSeconds let timeout = analyzeTimeoutSeconds
analyzeTask = Task { analyzeTask = Task {
// Step 1: Vault // Step 1: Vault(,)
// UI , CaptureService.analyze /退,
// assets phase ,cancelAll ,editingFallback
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) } let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
// :,View dismisscancelAll // :,View dismisscancelAll
// phase .analyzing(_, nil), // phase .analyzing(_, nil),
@@ -167,7 +166,7 @@ struct UnifiedCaptureFlow: View {
phase = .editing( phase = .editing(
parsed: .empty(), parsed: .empty(),
assets: [], assets: [],
warning: String(appLoc: "图片保存失败,手动录入并保留文本") warning: String(appLoc: "图片保存失败,请重试")
) )
} }
return 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 { let watchdog = Task {
try? await Task.sleep(for: .seconds(timeout)) try? await Task.sleep(for: .seconds(timeout))
analyzeTask?.cancel() analyzeTask?.cancel()
} }
defer { watchdog.cancel() } defer { watchdog.cancel() }
do { let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
let parsed = try await CaptureService.shared.reanalyze(assets: assets) if Task.isCancelled {
if Task.isCancelled {
await editingFallback(assets: assets,
msg: String(appLoc: "识别超时(>\(timeout)s),先手动录入"))
return
}
await MainActor.run { await MainActor.run {
phase = .editing( phase = .editing(parsed: .empty(), assets: assets,
parsed: parsed, warning: String(appLoc: "识别超时,已保存原图,请手动填写信息"))
assets: assets,
warning: parsed.isEmpty ? String(appLoc: "识别没有读出指标,请手动补充") : nil
)
} }
} catch let CaptureError.parseFailed(msg) { return
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)")) }
} catch let CaptureError.inferenceFailed(msg) { await MainActor.run {
await editingFallback(assets: assets, phase = .editing(
msg: Task.isCancelled parsed: meta,
? String(appLoc: "识别超时(>\(timeout)s),先手动录入") assets: assets,
: String(appLoc: "推理失败:\(msg)")) warning: recognized ? nil
} catch CaptureError.modelNotReady { : String(appLoc: "未能自动识别报告信息,已保存原图,可手动填写日期 / 机构")
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪,先手动录入")) )
} catch {
await editingFallback(assets: assets,
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
} }
} }
} }
/// : assets,, VL /// : assets,, meta
private func reanalyze(assets: [FileVault.SavedAsset]) { private func reanalyze(assets: [FileVault.SavedAsset]) {
analyzeTask?.cancel() analyzeTask?.cancel()
// UIImage,AnalyzingView // UIImage,AnalyzingView , 600px ,
// ( MB)
let thumbnails: [UIImage] = assets.compactMap { 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) phase = .analyzing(images: thumbnails, assets: assets)
let timeout = analyzeTimeoutSeconds let timeout = analyzeTimeoutSeconds
@@ -232,40 +222,19 @@ struct UnifiedCaptureFlow: View {
} }
defer { watchdog.cancel() } defer { watchdog.cancel() }
do { let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
let parsed = try await CaptureService.shared.reanalyze(assets: assets) if Task.isCancelled {
if Task.isCancelled {
await editingFallback(assets: assets,
msg: String(appLoc: "识别超时(>\(timeout)s),保留旧编辑"))
return
}
await MainActor.run { await MainActor.run {
phase = .editing( phase = .editing(parsed: .empty(), assets: assets,
parsed: parsed, warning: String(appLoc: "识别超时,已保留原图"))
assets: assets,
warning: parsed.isEmpty ? String(appLoc: "重新识别没有读出新指标") : nil
)
} }
} catch CaptureError.modelNotReady { return
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪")) }
} catch let CaptureError.parseFailed(msg) { await MainActor.run {
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)")) phase = .editing(parsed: meta, assets: assets,
} catch let CaptureError.inferenceFailed(msg) { warning: recognized ? nil
await editingFallback(assets: assets, : String(appLoc: "未能自动识别报告信息,可手动填写"))
msg: Task.isCancelled
? String(appLoc: "识别超时(>\(timeout)s)")
: String(appLoc: "推理失败:\(msg)"))
} catch {
await editingFallback(assets: assets,
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
} }
}
}
/// reanalyze editing, assets parsed
private func editingFallback(assets: [FileVault.SavedAsset], msg: String) async {
await MainActor.run {
phase = .editing(parsed: .empty(), assets: assets, warning: msg)
} }
} }

View File

@@ -11,8 +11,10 @@ struct DiaryQuickSheet: View {
@State private var content: String = "" @State private var content: String = ""
@State private var createdAt: Date = .now @State private var createdAt: Date = .now
/// :,tag /// :,
@State private var showMedicationScan = false @State private var showMedicationScan = false
/// :( + + ),tag
@State private var showMedicationLog = false
/// : SymptomStartSheet(/,) /// : SymptomStartSheet(/,)
@State private var showSymptomStart = false @State private var showSymptomStart = false
@@ -98,14 +100,20 @@ struct DiaryQuickSheet: View {
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 10) .padding(.bottom, 10)
// :()/ ()/ (SymptomStartSheet) // (2×2):()/ (MedicationLogSheet,+)/
HStack(spacing: 10) { // ()/ (SymptomStartSheet)
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10),
GridItem(.flexible(), spacing: 10)], spacing: 10) {
modeCard(icon: "pencil", title: String(appLoc: "写日记"), modeCard(icon: "pencil", title: String(appLoc: "写日记"),
subtitle: String(appLoc: "文字或语音"), active: true) { subtitle: String(appLoc: "文字或语音"), active: true) {
contentFocused = true contentFocused = true
} }
modeCard(icon: "pills.fill", title: String(appLoc: "拍药盒"), modeCard(icon: "pills.fill", title: String(appLoc: "用药"),
subtitle: String(appLoc: "识别用药"), active: false) { subtitle: String(appLoc: "记剂量与时间"), active: false) {
showMedicationLog = true
}
modeCard(icon: "camera.viewfinder", title: String(appLoc: "拍药盒"),
subtitle: String(appLoc: "识别入药品库"), active: false) {
showMedicationScan = true showMedicationScan = true
} }
modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"), modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"),
@@ -252,9 +260,9 @@ struct DiaryQuickSheet: View {
.presentationCornerRadius(Tj.Radius.xl) .presentationCornerRadius(Tj.Radius.xl)
.fullScreenCover(isPresented: $showMedicationScan) { .fullScreenCover(isPresented: $showMedicationScan) {
MedicationScanFlow( MedicationScanFlow(
onSave: { entries in onSave: { meds, images in
// :(线)+ · // (), ·
MedicationArchiver.archive(entries: entries, in: ctx) MedicationArchiver.archive(medications: meds, images: images, in: ctx)
dismiss() dismiss()
}, },
onClose: { showMedicationScan = false } onClose: { showMedicationScan = false }
@@ -264,6 +272,10 @@ struct DiaryQuickSheet: View {
// sheet:/;, // sheet:/;,
SymptomStartSheet() SymptomStartSheet()
} }
.sheet(isPresented: $showMedicationLog) {
// sheet:/;()
MedicationLogSheet()
}
.onDisappear { .onDisappear {
suggestTask?.cancel() suggestTask?.cancel()
voiceFlowTask?.cancel() voiceFlowTask?.cancel()

View File

@@ -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)
}

View File

@@ -18,21 +18,19 @@ struct HomeView: View {
/// sheet( C1 ) /// sheet( C1 )
@State private var selectedEntry: TimelineEntry? @State private var selectedEntry: TimelineEntry?
/// ( + , C1 )
@State private var selectedGroup: IndicatorGroup?
@MainActor @MainActor
private var recentEntries: [TimelineEntry] { private var recentEntries: [TimelineEntry] {
let all = let all =
TimelineEntry.from(indicators: indicators) + TimelineEntry.aggregatedIndicators(indicators) +
reports.map(TimelineEntry.from(report:)) + reports.map(TimelineEntry.from(report:)) +
diaries.map(TimelineEntry.from(diary:)) + diaries.map(TimelineEntry.from(diary:)) +
symptoms.map(TimelineEntry.from(symptom:)) symptoms.map(TimelineEntry.from(symptom:))
return all.sorted { $0.date > $1.date }.prefix(6).map { $0 } 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 { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -65,6 +63,9 @@ struct HomeView: View {
TimelineEntryDetailView(detail: d) TimelineEntryDetailView(detail: d)
} }
} }
.sheet(item: $selectedGroup) { group in
IndicatorSeriesDetailView(group: group)
}
} }
private var greeting: some View { private var greeting: some View {
@@ -100,7 +101,10 @@ struct HomeView: View {
} }
private var recentSection: some 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) { HStack(alignment: .lastTextBaseline) {
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text) Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer() Spacer()
@@ -112,11 +116,11 @@ struct HomeView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
if recentEntries.isEmpty { if entries.isEmpty {
emptyRecent emptyRecent
} else { } else {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
ForEach(recentGrouped, id: \.section) { group in ForEach(groups, id: \.section) { group in
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(group.section.label) Text(group.section.label)
.font(.tjScaled( 11, weight: .semibold)) .font(.tjScaled( 11, weight: .semibold))
@@ -125,12 +129,16 @@ struct HomeView: View {
VStack(spacing: 10) { VStack(spacing: 10) {
ForEach(group.items) { entry in ForEach(group.items) { entry in
Button { Button {
if TimelineDetail.resolve( // ( + ); C1
guard let d = TimelineDetail.resolve(
for: entry, for: entry,
indicators: indicators, reports: reports, indicators: indicators, reports: reports,
diaries: diaries, symptoms: symptoms diaries: diaries, symptoms: symptoms
) != nil { ) else { return }
selectedEntry = entry switch d {
case .indicator(let i): selectedGroup = IndicatorGroup.of(i)
case .bloodPressure(let sys, _): selectedGroup = IndicatorGroup.of(sys)
default: selectedEntry = entry
} }
} label: { } label: {
TimelineRow(entry: entry) TimelineRow(entry: entry)

View File

@@ -31,6 +31,18 @@ struct IndicatorQuickSheet: View {
/// nil ( Preview) /// nil ( Preview)
var onRequestCamera: (() -> Void)? = nil 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(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Query private var profiles: [UserProfile] @Query private var profiles: [UserProfile]
@@ -69,6 +81,32 @@ struct IndicatorQuickSheet: View {
// sheet // sheet
@State private var showHiddenSheet: Bool = false @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 { private static var defaultReminderTime: Date {
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
} }
@@ -137,6 +175,7 @@ struct IndicatorQuickSheet: View {
footer footer
} }
.onAppear { applyPrefillIfNeeded() }
.task(id: longTermKey) { hydrateReminder() } .task(id: longTermKey) { hydrateReminder() }
.background( .background(
Tj.Palette.sand Tj.Palette.sand
@@ -161,19 +200,64 @@ struct IndicatorQuickSheet: View {
} }
private var header: some View { private var header: some View {
HStack { VStack(spacing: 12) {
Text("记录指标") HStack(spacing: 10) {
.font(.tjH2()) Text("记录指标")
.foregroundStyle(Tj.Palette.text) .font(.tjH2())
Spacer() .foregroundStyle(Tj.Palette.text)
Text("本地处理 · 永不上传") Spacer()
.font(.tjScaled( 12)) Text("本地处理 · 永不上传")
.foregroundStyle(Tj.Palette.text3) .font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
searchToggle
}
if searchingMetrics { searchField }
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 16) .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 /// : RootView VL
@ViewBuilder @ViewBuilder
private var cameraEntrySection: some View { private var cameraEntrySection: some View {
@@ -241,13 +325,19 @@ struct IndicatorQuickSheet: View {
} }
let columns = [GridItem(.flexible()), GridItem(.flexible())] let columns = [GridItem(.flexible()), GridItem(.flexible())]
LazyVGrid(columns: columns, spacing: 8) { LazyVGrid(columns: columns, spacing: 8) {
ForEach(visibleMonitorMetrics) { m in ForEach(filteredMonitorMetrics) { m in
monitorTile(m) monitorTile(m)
} }
ForEach(customMetrics) { cm in ForEach(filteredCustomMetrics) { cm in
customTile(cm) customTile(cm)
} }
addCustomTile // (),
if !isSearchingMetrics { addCustomTile }
}
if isSearchingMetrics, filteredMonitorMetrics.isEmpty, filteredCustomMetrics.isEmpty {
Text("没有匹配的长期监测指标")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
} }
} }
.sheet(isPresented: $showHiddenSheet) { .sheet(isPresented: $showHiddenSheet) {
@@ -386,14 +476,18 @@ struct IndicatorQuickSheet: View {
} }
} }
@ViewBuilder
private var labPresetSection: some View { private var labPresetSection: some View {
VStack(alignment: .leading, spacing: 8) { // :()
sectionLabel(String(appLoc: "化验项快捷(不进趋势)")) if !(isSearchingMetrics && filteredLabPresets.isEmpty) {
ScrollView(.horizontal, showsIndicators: false) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) { sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
ForEach(labPresets) { p in ScrollView(.horizontal, showsIndicators: false) {
chip(p.name, selected: selectedLabPreset == p) { HStack(spacing: 8) {
applyLab(p) ForEach(filteredLabPresets) { p in
chip(p.name, selected: selectedLabPreset == p) {
applyLab(p)
}
} }
} }
} }
@@ -941,6 +1035,29 @@ struct IndicatorQuickSheet: View {
// MARK: - apply preset // 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) { private func applyMonitor(_ m: MonitorMetric) {
if selectedMonitor == m { if selectedMonitor == m {
// //

View File

@@ -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)
}
}
}

View File

@@ -10,13 +10,20 @@ struct CustomReminderEditSheet: View {
/// nil = /// nil =
let reminder: CustomReminder? let reminder: CustomReminder?
/// (,:+ )
/// (reminder != nil)
private let prefillTitle: String
private let prefillNote: String
@State private var title = "" @State private var title = ""
@State private var note = "" @State private var note = ""
@State private var pickedTime: Date = .now @State private var pickedTime: Date = .now
@State private var frequency: CustomReminder.Frequency = .daily /// (/// )
@State private var frequencies: Set<CustomReminder.Frequency> = [.daily]
@State private var weekdays: Set<Int> = Set(1...7) @State private var weekdays: Set<Int> = Set(1...7)
@State private var dayOfMonth = 1 /// (1...31)
@State private var monthDays: Set<Int> = [1]
@State private var dayOfMonth = 1 //
@State private var month = 1 @State private var month = 1
@State private var hydrated = false @State private var hydrated = false
@State private var showAuthDeniedAlert = 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)] 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.reminder = reminder
self.prefillTitle = prefillTitle
self.prefillNote = prefillNote
} }
private var isEditing: Bool { reminder != nil } private var isEditing: Bool { reminder != nil }
@@ -33,8 +42,9 @@ struct CustomReminderEditSheet: View {
title.trimmingCharacters(in: .whitespacesAndNewlines) title.trimmingCharacters(in: .whitespacesAndNewlines)
} }
private var canSave: Bool { private var canSave: Bool {
guard !trimmedTitle.isEmpty else { return false } guard !trimmedTitle.isEmpty, !frequencies.isEmpty else { return false }
if frequency == .weekly { return !weekdays.isEmpty } if frequencies.contains(.weekly) && weekdays.isEmpty { return false }
if frequencies.contains(.monthly) && monthDays.isEmpty { return false }
return true return true
} }
@@ -51,18 +61,12 @@ struct CustomReminderEditSheet: View {
} }
Section { Section {
Picker(String(appLoc: "重复"), selection: $frequency) { frequencyChips
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)
frequencyDetail frequencyDetail
} header: { } header: {
Text("重复") Text("重复")
} footer: {
Text("可多选:如同时勾选「每周一三五」+「每月1日」,两种节奏都会提醒。")
} }
Section { 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 @ViewBuilder
private var frequencyDetail: some View { private var frequencyDetail: some View {
switch frequency { if frequencies.contains(.weekly) {
case .daily: subCaption(String(appLoc: "每周 · 选星期几"))
EmptyView()
case .weekly:
weekdayRow weekdayRow
case .monthly: }
Picker(String(appLoc: "日期"), selection: $dayOfMonth) { if frequencies.contains(.monthly) {
ForEach(1...31, id: \.self) { d in subCaption(String(appLoc: "每月 · 选日期(可多选)"))
Text(String(appLoc: "\(d)")).tag(d) monthDayGrid
} if monthDays.contains(where: { $0 >= 29 }) { skipHint }
} }
if dayOfMonth >= 29 { skipHint } if frequencies.contains(.yearly) {
case .yearly: subCaption(String(appLoc: "每年 · 选月/日"))
Picker(String(appLoc: "月份"), selection: $month) { Picker(String(appLoc: "月份"), selection: $month) {
ForEach(1...12, id: \.self) { mo in ForEach(1...12, id: \.self) { mo in
Text(String(appLoc: "\(mo)")).tag(mo) 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 { private var skipHint: some View {
Text(String(appLoc: "部分月份无此日,该月将跳过")) Text(String(appLoc: "部分月份无此日,该月将跳过"))
.font(.tjScaled( 11)) .font(.tjScaled( 11))
@@ -229,13 +305,18 @@ struct CustomReminderEditSheet: View {
if let r = reminder { if let r = reminder {
title = r.title title = r.title
note = r.note note = r.note
frequency = r.frequency frequencies = r.frequencies
weekdays = Set(r.weekdays) weekdays = Set(r.weekdays)
monthDays = Set(r.monthlyDays)
dayOfMonth = r.dayOfMonth dayOfMonth = r.dayOfMonth
month = r.month month = r.month
pickedTime = Calendar.current.date( pickedTime = Calendar.current.date(
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
) ?? .now ) ?? .now
} else {
// :( / )
title = prefillTitle
note = prefillNote
} }
} }
@@ -245,6 +326,7 @@ struct CustomReminderEditSheet: View {
let hour = cal.component(.hour, from: pickedTime) let hour = cal.component(.hour, from: pickedTime)
let minute = cal.component(.minute, from: pickedTime) let minute = cal.component(.minute, from: pickedTime)
let sortedDays = weekdays.sorted() let sortedDays = weekdays.sorted()
let sortedMonthDays = monthDays.sorted()
let target: CustomReminder let target: CustomReminder
if let r = reminder { if let r = reminder {
@@ -253,8 +335,9 @@ struct CustomReminderEditSheet: View {
r.hour = hour r.hour = hour
r.minute = minute r.minute = minute
r.weekdays = sortedDays r.weekdays = sortedDays
r.frequency = frequency r.frequencies = frequencies // frequenciesRaw(+ frequencyRaw)
r.dayOfMonth = dayOfMonth r.monthlyDays = sortedMonthDays // monthDays
r.dayOfMonth = dayOfMonth //
r.month = month r.month = month
r.updatedAt = .now r.updatedAt = .now
target = r target = r
@@ -265,10 +348,11 @@ struct CustomReminderEditSheet: View {
hour: hour, hour: hour,
minute: minute, minute: minute,
weekdays: sortedDays, weekdays: sortedDays,
frequency: frequency,
dayOfMonth: dayOfMonth, dayOfMonth: dayOfMonth,
month: month month: month
) )
new.frequencies = frequencies
new.monthlyDays = sortedMonthDays
ctx.insert(new) ctx.insert(new)
target = new target = new
} }

View File

@@ -282,6 +282,6 @@ struct MeView: View {
.modelContainer(for: [ .modelContainer(for: [
UserProfile.self, Indicator.self, Report.self, DiaryEntry.self, UserProfile.self, Indicator.self, Report.self, DiaryEntry.self,
Asset.self, ChatTurn.self, Symptom.self, MetricReminder.self, Asset.self, ChatTurn.self, Symptom.self, MetricReminder.self,
CustomMonitorMetric.self, CustomMonitorMetric.self, Medication.self,
], inMemory: true) ], inMemory: true)
} }

View File

@@ -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)
}

View File

@@ -2,28 +2,41 @@ import SwiftUI
import SwiftData import SwiftData
import UIKit import UIKit
/// :/ Vision OCR LLM /// :/( 5 ,) Vision OCR LLM ()
/// :+ · · · · /// : · · ·
/// `MedicationArchiver`:(线)+ /// `MedicationArchiver`: `Medication`(),
/// ,/(§1) /// · `medicationTag` DiaryEntry,/(§1)
/// ///
/// ( QuickRegionCaptureFlow ): /// :
/// ``` /// ```
/// idle(/) recognizing(OCR + LLM) confirm() onSave /// idle(/) 1 collecting(:/5//)
/// / confirm( + ,) ///
///
/// recognizing( OCR + LLM) confirm() onSave
/// / confirm( + )
/// ``` /// ```
struct MedicationScanFlow: View { struct MedicationScanFlow: View {
/// (, " 80mg · ") /// (, )( MedicationArchiver.archive(medications:))
let onSave: ([String]) -> Void let onSave: ([ParsedMedication], [UIImage]) -> Void
let onClose: () -> Void let onClose: () -> Void
/// 5 (//)
static let maxImages = 5
@State private var phase: Phase = .idle @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<Void, Never>? @State private var recognitionTask: Task<Void, Never>?
enum Phase { enum Phase {
case idle case idle
case recognizing(image: UIImage) case collecting
case recognizing
case confirm(items: [EditableMedication], warning: String?) case confirm(items: [EditableMedication], warning: String?)
} }
@@ -35,6 +48,8 @@ struct MedicationScanFlow: View {
var include: Bool = true var include: Bool = true
} }
private var remainingSlots: Int { max(0, Self.maxImages - images.count) }
var body: some View { var body: some View {
content content
.background(Tj.Palette.sand.ignoresSafeArea()) .background(Tj.Palette.sand.ignoresSafeArea())
@@ -45,10 +60,14 @@ struct MedicationScanFlow: View {
switch phase { switch phase {
case .idle: case .idle:
// ignoresSafeArea:, // ignoresSafeArea:,
captureEntry initialCaptureEntry
case .recognizing(let image): case .collecting:
recognizingView(image: image) collectingView
.fullScreenCover(isPresented: $showMoreCapture) { moreCaptureSheet }
case .recognizing:
recognizingView
case .confirm(let items, let warning): case .confirm(let items, let warning):
NavigationStack { NavigationStack {
@@ -56,7 +75,7 @@ struct MedicationScanFlow: View {
items: items, items: items,
warning: warning, warning: warning,
onSave: { saveItems($0) }, onSave: { saveItems($0) },
onRetake: { phase = .idle } onRetake: { images = []; phase = .idle }
) )
.navigationTitle("核对药品") .navigationTitle("核对药品")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -72,31 +91,160 @@ struct MedicationScanFlow: View {
// MARK: - :()/ () // MARK: - :()/ ()
/// :/ collecting
@ViewBuilder @ViewBuilder
private var captureEntry: some View { private var initialCaptureEntry: some View {
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
PhotoPickerSheet( PhotoPickerSheet(
onFinish: { images in onFinish: { picked in
if let first = images.first { startRecognition(first) } else { onClose() } appendImages(picked)
if images.isEmpty { onClose() } else { phase = .collecting }
}, },
onCancel: onClose onCancel: onClose
) )
#else #else
SingleShotCameraView( SingleShotCameraView(
onCapture: { startRecognition($0) }, onCapture: { appendImages([$0]); phase = .collecting },
onCancel: onClose onCancel: onClose
) )
#endif #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) { VStack(spacing: 18) {
Image(uiImage: image) if images.indices.contains(recognizeIndex) {
.resizable() Image(uiImage: images[recognizeIndex])
.scaledToFit() .resizable()
.frame(maxHeight: 320) .scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)) .frame(maxHeight: 320)
.padding(.horizontal, 24) .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
.padding(.horizontal, 24)
}
ProgressView().tint(Tj.Palette.ink) ProgressView().tint(Tj.Palette.ink)
Text("正在本地识别药品…") Text("正在本地识别药品…")
.font(.tjScaled(14)) .font(.tjScaled(14))
@@ -108,31 +256,37 @@ struct MedicationScanFlow: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
// 退,(§3.2 ) // 退,(§3.2 )
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
Button { flowCancelButton {
recognitionTask?.cancel() recognitionTask?.cancel()
onClose() 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) { // MARK: - ( OCR LLM )
phase = .recognizing(image: image)
private func startRecognition() {
guard images.indices.contains(recognizeIndex) else { return }
phase = .recognizing
let target = images[recognizeIndex]
recognitionTask = Task { recognitionTask = Task {
let (items, warning) = await recognize(image) let (items, warning) = await recognize(target)
guard !Task.isCancelled else { return } // : phase guard !Task.isCancelled else { return } // : phase
await MainActor.run { await MainActor.run {
// :(§3.2 退线) // :(§3.2 退线)
@@ -148,13 +302,15 @@ struct MedicationScanFlow: View {
private func recognize(_ image: UIImage) async -> (items: [EditableMedication], warning: String?) { private func recognize(_ image: UIImage) async -> (items: [EditableMedication], warning: String?) {
do { do {
let text = try await OCRService.recognizeText(in: image) //
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let text = (try? await OCRService.recognizeText(in: image))?
if trimmed.isEmpty { .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if text.isEmpty {
return ([], String(appLoc: "没识别到文字,拍清楚一点再试")) return ([], String(appLoc: "没识别到文字,拍清楚一点再试"))
} }
let parsed = try await MedicationScanService.shared.recognizeMedications(fromOCRText: trimmed) let parsed = try await MedicationScanService.shared.recognizeMedications(fromOCRText: text)
let items = parsed.map { // :使,
let items = parsed.prefix(1).map {
EditableMedication(name: $0.name, strength: $0.strength, usage: $0.usage) EditableMedication(name: $0.name, strength: $0.strength, usage: $0.usage)
} }
return (items, items.isEmpty ? String(appLoc: "没读出药品,可以手动填写") : nil) return (items, items.isEmpty ? String(appLoc: "没读出药品,可以手动填写") : nil)
@@ -172,34 +328,56 @@ struct MedicationScanFlow: View {
// MARK: - // MARK: -
private func saveItems(_ items: [EditableMedication]) { private func saveItems(_ items: [EditableMedication]) {
let entries = items let meds = items
.filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty } .filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty }
.map { .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() onClose()
} }
} }
// MARK: - (MainActor,SwiftData View ctx ,§3.1) // MARK: - (MainActor,SwiftData View ctx ,§3.1)
/// ,: /// ,( · ):
/// 1. tag DiaryEntry 线 /// `Medication`(), name+strength ;** currentMedications**
/// 2. UserProfile.currentMedications() AI / prompt /// · `DiaryEntry.medicationTag`
@MainActor @MainActor
enum MedicationArchiver { enum MedicationArchiver {
static func archive(entries: [String], in ctx: ModelContext) { static func archive(medications: [ParsedMedication], images: [UIImage] = [], in ctx: ModelContext) {
guard !entries.isEmpty else { return } guard !medications.isEmpty else { return }
let diary = DiaryEntry(content: entries.joined(separator: "\n"),
tags: [DiaryEntry.medicationTag])
ctx.insert(diary)
let profile = UserProfileStore.loadOrCreate(in: ctx) // Vault(§5/§6: Application Support/Vault,)
for entry in entries where !profile.currentMedications.contains(entry) { // , JPEG Asset
profile.currentMedications.append(entry) // cascade
let savedAssets = images
.prefix(MedicationScanFlow.maxImages)
.compactMap { try? FileVault.shared.writeJPEG($0) }
let existing = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
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() try? ctx.save()
} }
} }
@@ -231,13 +409,8 @@ private struct MedicationConfirmView: View {
ForEach($items) { $item in ForEach($items) { $item in
Section { Section {
HStack { TextField(String(appLoc: "药品名,如:缬沙坦胶囊"), text: $item.name)
TextField(String(appLoc: "药品名,如:缬沙坦胶囊"), text: $item.name) .foregroundStyle(Tj.Palette.text)
.foregroundStyle(Tj.Palette.text)
Toggle("", isOn: $item.include)
.labelsHidden()
.tint(Tj.Palette.ink)
}
TextField(String(appLoc: "规格,如:80mg×7粒"), text: $item.strength) TextField(String(appLoc: "规格,如:80mg×7粒"), text: $item.strength)
.foregroundStyle(Tj.Palette.text2) .foregroundStyle(Tj.Palette.text2)
TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $item.usage) TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $item.usage)
@@ -246,12 +419,6 @@ private struct MedicationConfirmView: View {
} }
Section { Section {
Button {
items.append(.init(name: "", strength: "", usage: ""))
} label: {
Label("再加一种", systemImage: "plus.circle")
.foregroundStyle(Tj.Palette.ink)
}
Button { Button {
onRetake() onRetake()
} label: { } label: {
@@ -259,7 +426,7 @@ private struct MedicationConfirmView: View {
.foregroundStyle(Tj.Palette.ink) .foregroundStyle(Tj.Palette.ink)
} }
} footer: { } footer: {
Text("将记入健康日记(记录页可查),并同步到「当前用药」供 AI 解读参考。不提供任何用药建议。") Text("一次记一种药,多张照片都会作为这种药的原图存入药品库,供查看与 AI 解读参考。不提供任何用药建议。")
} }
} }
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
@@ -267,7 +434,7 @@ private struct MedicationConfirmView: View {
Button { Button {
onSave(items) onSave(items)
} label: { } label: {
Text("保存用药记录") Text("存入药品库")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
.buttonStyle(TjPrimaryButton()) .buttonStyle(TjPrimaryButton())
@@ -281,5 +448,5 @@ private struct MedicationConfirmView: View {
} }
#Preview { #Preview {
MedicationScanFlow(onSave: { print($0) }, onClose: {}) MedicationScanFlow(onSave: { _, _ in }, onClose: {})
} }

View File

@@ -38,7 +38,6 @@ private struct ProfileEditForm: View {
@State private var healthImportDraft: HealthProfileImportDraft? @State private var healthImportDraft: HealthProfileImportDraft?
@State private var healthImportError: String? @State private var healthImportError: String?
@State private var isImportingHealthProfile = false @State private var isImportingHealthProfile = false
@State private var showMedicationScan = false
var body: some View { var body: some View {
Form { Form {
@@ -88,9 +87,6 @@ private struct ProfileEditForm: View {
items: $profile.allergies) items: $profile.allergies)
StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"), StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"),
items: $profile.familyHistory) items: $profile.familyHistory)
StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"),
items: $profile.currentMedications,
onScan: { showMedicationScan = true })
} }
.navigationTitle("个人资料") .navigationTitle("个人资料")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -100,16 +96,6 @@ private struct ProfileEditForm: View {
profile.updatedAt = .now profile.updatedAt = .now
try? ctx.save() 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 .sheet(item: $healthImportDraft) { draft in
HealthProfileImportPreviewSheet( HealthProfileImportPreviewSheet(
draft: draft, draft: draft,
@@ -468,27 +454,10 @@ private struct StringListSection: View {
let title: String let title: String
let placeholder: String let placeholder: String
@Binding var items: [String] @Binding var items: [String]
/// nil ()
var onScan: (() -> Void)? = nil
@State private var newInput = "" @State private var newInput = ""
var body: some View { var body: some View {
Section(title) { 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 ForEach(items, id: \.self) { item in
HStack { HStack {
Text(item) Text(item)

View File

@@ -1,13 +1,15 @@
import SwiftUI import SwiftUI
enum RecordKind: String, Identifiable, CaseIterable { 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 } var id: String { rawValue }
/// RecordSheet () enum , /// RecordSheet () enum ,
/// :`.quick`() `.indicator`(); /// :`.quick`() `.indicator`();
/// `.symptom`() `.diary`(), /// `.symptom`() `.diary`(),;
static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive] /// `.medicationLibrary`()/,Tab ,
/// (,)
static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive, .medicationLibrary]
/// pill( subtitle,"/") /// pill( subtitle,"/")
/// :,( ProfileEditView presets ) /// :,( ProfileEditView presets )
@@ -24,6 +26,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .diary: return String(appLoc: "健康日记") case .diary: return String(appLoc: "健康日记")
case .symptom: return String(appLoc: "记录症状") case .symptom: return String(appLoc: "记录症状")
case .reminder: return String(appLoc: "开启一个提醒") case .reminder: return String(appLoc: "开启一个提醒")
case .medicationLibrary: return String(appLoc: "药品库")
} }
} }
var subtitle: String { var subtitle: String {
@@ -35,6 +38,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .diary: return String(appLoc: "写日记或拍药盒记录用药 · 可让 AI 辅助") case .diary: return String(appLoc: "写日记或拍药盒记录用药 · 可让 AI 辅助")
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束") case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒") case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
case .medicationLibrary: return String(appLoc: "管理常用药清单 · 拍药盒或手动添加")
} }
} }
var icon: String { var icon: String {
@@ -46,6 +50,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .diary: return "heart.text.square" case .diary: return "heart.text.square"
case .symptom: return "waveform.path.ecg" case .symptom: return "waveform.path.ecg"
case .reminder: return "bell.badge" case .reminder: return "bell.badge"
case .medicationLibrary: return "pills.fill"
} }
} }
var accent: Color { var accent: Color {
@@ -57,6 +62,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .diary: return Tj.Palette.leaf case .diary: return Tj.Palette.leaf
case .symptom: return Tj.Palette.amber case .symptom: return Tj.Palette.amber
case .reminder: return Tj.Palette.leaf case .reminder: return Tj.Palette.leaf
case .medicationLibrary: return Tj.Palette.ink
} }
} }
} }
@@ -83,7 +89,7 @@ struct RecordSheet: View {
} }
.padding(.bottom, 14) .padding(.bottom, 14)
// ScrollView :6 detent , // ScrollView : detent ,
ScrollView { ScrollView {
VStack(spacing: 10) { VStack(spacing: 10) {
ForEach(RecordKind.displayOrder) { kind in ForEach(RecordKind.displayOrder) { kind in

View File

@@ -140,6 +140,7 @@ struct IndicatorSeriesDetailView: View {
} else { } else {
pages pages
pager pager
recordAnotherRow
if bucket != nil { trendButton } if bucket != nil { trendButton }
} }
} }
@@ -311,6 +312,30 @@ struct IndicatorSeriesDetailView: View {
.disabled(!enabled) .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: - / // MARK: - /
private var trendButton: some View { private var trendButton: some View {

View File

@@ -42,10 +42,12 @@ struct TimelineEntry: Identifiable, Hashable {
let kind: TimelineKind let kind: TimelineKind
let date: Date let date: Date
let title: String let title: String
let subtitle: String var subtitle: String
let trailing: String? let trailing: String?
let trailingIsAlert: Bool let trailingIsAlert: Bool
let isOngoing: Bool let isOngoing: Bool
/// (>1 N ) 1
var aggregateCount: Int = 1
static func from(indicator i: Indicator) -> TimelineEntry { static func from(indicator i: Indicator) -> TimelineEntry {
TimelineEntry( TimelineEntry(
@@ -87,6 +89,34 @@ struct TimelineEntry: Identifiable, Hashable {
return entries 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 { private static func mergedBP(systolic sys: Indicator, diastolic dia: Indicator) -> TimelineEntry {
let abnormal = sys.status != .normal || dia.status != .normal let abnormal = sys.status != .normal || dia.status != .normal
// status : /; // status : /;

View File

@@ -54,6 +54,27 @@ struct TimelineEntryDetailView: View {
@State private var showDeleteConfirm = false @State private var showDeleteConfirm = false
@State private var evidenceTarget: Indicator? @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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -84,6 +105,15 @@ struct TimelineEntryDetailView: View {
EvidenceImagePreview(report: report, indicator: indicator) 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) // MARK: - (:SwiftData + Vault unlink, CLAUDE.md §6)
@@ -120,6 +150,10 @@ struct TimelineEntryDetailView: View {
for p in paths { try? FileVault.shared.remove(relativePath: p) } for p in paths { try? FileVault.shared.remove(relativePath: p) }
ctx.delete(r) ctx.delete(r)
case .diary(let d): 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) ctx.delete(d)
case .symptom(let s): case .symptom(let s):
ctx.delete(s) ctx.delete(s)
@@ -167,7 +201,7 @@ struct TimelineEntryDetailView: View {
case .indicator: return String(appLoc: "指标详情") case .indicator: return String(appLoc: "指标详情")
case .bloodPressure: return String(appLoc: "血压详情") case .bloodPressure: return String(appLoc: "血压详情")
case .report: 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: "症状详情") case .symptom: return String(appLoc: "症状详情")
} }
} }
@@ -186,28 +220,31 @@ struct TimelineEntryDetailView: View {
// MARK: - // MARK: -
private func indicatorBody(_ i: Indicator) -> some View { private func indicatorBody(_ i: Indicator) -> some View {
card { VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .firstTextBaseline) { card {
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text) HStack(alignment: .firstTextBaseline) {
Spacer() Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
statusChip(i.status) 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)
} }
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 RecordAnotherButton(name: i.name, prefill: .init(indicator: i))
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) }
} }
} }
@@ -217,21 +254,28 @@ struct TimelineEntryDetailView: View {
let combined: IndicatorStatus = sys.status != .normal let combined: IndicatorStatus = sys.status != .normal
? sys.status ? sys.status
: (dia?.status ?? .normal) : (dia?.status ?? .normal)
return card { return VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .firstTextBaseline) { card {
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text) HStack(alignment: .firstTextBaseline) {
Spacer() Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
statusChip(combined) 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) { // :seriesKey bp.systolic MonitorMetric.bloodPressure
Text("\(sys.value)/\(dia?.value ?? "")") RecordAnotherButton(name: String(appLoc: "血压"),
.font(.tjScaled( 30, weight: .bold, design: .rounded)) prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic",
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick) name: String(appLoc: "血压"),
Text("mmHg").font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3) unit: "mmHg", range: sys.range))
}
divider
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
} }
} }
@@ -248,16 +292,16 @@ struct TimelineEntryDetailView: View {
TjBadge(text: r.type.label, style: .neutral) TjBadge(text: r.type.label, style: .neutral)
Text(Self.dateText(r.reportDate)) Text(Self.dateText(r.reportDate))
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) .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 { if let inst = r.institution, !inst.isEmpty {
field(String(appLoc: "机构"), inst) field(String(appLoc: "机构"), inst)
} }
} }
if !r.assets.isEmpty {
reportPhotosCard(r.assets)
}
ReportSummaryCard(report: r) ReportSummaryCard(report: r)
if !r.indicators.isEmpty { if !r.indicators.isEmpty {
@@ -286,26 +330,146 @@ struct TimelineEntryDetailView: View {
} }
} }
// MARK: - /// : ,,
private func reportPhotosCard(_ assets: [Asset]) -> some View {
private func diaryBody(_ d: DiaryEntry) -> some View { card {
VStack(alignment: .leading, spacing: 16) { HStack {
card { Text(String(appLoc: "原图\(assets.count)"))
Text(Self.dateTimeText(d.createdAt)) .font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) Spacer()
Text(d.content) Text(String(appLoc: "点图放大")).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
.font(.tjScaled( 15)) }
.foregroundStyle(Tj.Palette.text) ScrollView(.horizontal, showsIndicators: false) {
.textSelection(.enabled) HStack(spacing: 10) {
.frame(maxWidth: .infinity, alignment: .leading) ForEach(Array(assets.enumerated()), id: \.offset) { idx, asset in
.fixedSize(horizontal: false, vertical: true) Button {
if !d.tags.isEmpty { reportPhotoStart = ReportPhotoPage(index: idx)
field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " ")) } 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: - // MARK: -
private func symptomBody(_ s: Symptom) -> some View { private func symptomBody(_ s: Symptom) -> some View {
@@ -412,6 +576,76 @@ struct TimelineEntryDetailView: View {
private nonisolated static func dateText(_ d: Date) -> String { private nonisolated static func dateText(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day()) 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 asset: Asset
let highlight: CGRect? let highlight: CGRect?
private var image: UIImage? {
try? FileVault.shared.loadImage(relativePath: asset.relativePath)
}
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
if let image { VaultImage(relativePath: asset.relativePath, maxPixel: 2000) { image in
ZStack { ZStack {
Image(uiImage: image) Image(uiImage: image)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: geo.size.width, height: geo.size.height) .frame(width: geo.size.width, height: geo.size.height)
if let highlight { if let highlight {
// ,imageSize letterbox ,
EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight) EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight)
} }
} }
@@ -502,9 +733,14 @@ private struct EvidenceImagePage: View {
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
) )
} else { } placeholder: { isLoading in
TjPlaceholder(label: String(appLoc: "原图无法读取")) if isLoading {
.frame(width: geo.size.width, height: geo.size.height) ProgressView()
.frame(width: geo.size.width, height: geo.size.height)
} else {
TjPlaceholder(label: String(appLoc: "原图无法读取"))
.frame(width: geo.size.width, height: geo.size.height)
}
} }
} }
} }

View File

@@ -12,6 +12,10 @@ struct TrendsView: View {
private var profile: UserProfile? { profiles.first } private var profile: UserProfile? { profiles.first }
/// :,(bucket.title)
@State private var searching = false
@State private var query = ""
private var seriesBuckets: [SeriesBucket] { private var seriesBuckets: [SeriesBucket] {
SeriesBucket.build(from: indicators, SeriesBucket.build(from: indicators,
profile: profile, profile: profile,
@@ -25,6 +29,14 @@ struct TrendsView: View {
seriesBuckets.filter { $0.kind == .lab } 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 { var body: some View {
NavigationStack { NavigationStack {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
@@ -32,12 +44,14 @@ struct TrendsView: View {
header.padding(.top, 4) header.padding(.top, 4)
if seriesBuckets.isEmpty { if seriesBuckets.isEmpty {
emptyState emptyState
} else if filteredMonitor.isEmpty && filteredLab.isEmpty {
noMatchState
} else { } else {
if !monitorBuckets.isEmpty { if !filteredMonitor.isEmpty {
section(title: String(appLoc: "长期监测"), buckets: monitorBuckets) section(title: String(appLoc: "长期监测"), buckets: filteredMonitor)
} }
if !labBuckets.isEmpty { if !filteredLab.isEmpty {
section(title: String(appLoc: "化验指标趋势"), buckets: labBuckets) section(title: String(appLoc: "化验指标趋势"), buckets: filteredLab)
} }
} }
} }
@@ -51,9 +65,73 @@ struct TrendsView: View {
} }
private var header: some View { private var header: some View {
Text("趋势") VStack(alignment: .leading, spacing: 12) {
.font(.tjTitle(26)) HStack(alignment: .lastTextBaseline) {
.foregroundStyle(Tj.Palette.text) 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 { private func section(title: String, buckets: [SeriesBucket]) -> some View {

View File

@@ -189,6 +189,9 @@
} }
} }
} }
},
"「设置提醒」只到点提示,不提供任何用药或剂量建议。" : {
}, },
"/" : { "/" : {
@@ -909,6 +912,9 @@
} }
} }
} }
},
"📷 %lld" : {
}, },
"1 项偏低" : { "1 项偏低" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -1497,6 +1503,7 @@
} }
}, },
"VL 模型未就绪" : { "VL 模型未就绪" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -1519,6 +1526,7 @@
} }
}, },
"VL 模型未就绪,先手动录入" : { "VL 模型未就绪,先手动录入" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -1541,6 +1549,7 @@
} }
}, },
"VL 输出无法解析:%@" : { "VL 输出无法解析:%@" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -1628,6 +1637,9 @@
} }
} }
} }
},
"一次记一种药,多张照片都会作为这种药的原图存入药品库,供查看与 AI 解读参考。不提供任何用药建议。" : {
}, },
"三" : { "三" : {
"localizations" : { "localizations" : {
@@ -2031,6 +2043,9 @@
} }
} }
} }
},
"仅作清单记录,不提供任何用药或剂量建议。" : {
}, },
"仅供参考,不构成医疗建议" : { "仅供参考,不构成医疗建议" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -2214,6 +2229,9 @@
} }
} }
} }
},
"从药品库删除" : {
}, },
"任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。" : { "任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。" : {
"localizations" : { "localizations" : {
@@ -2841,9 +2859,6 @@
} }
} }
} }
},
"保存用药记录" : {
}, },
"偏低" : { "偏低" : {
"localizations" : { "localizations" : {
@@ -3195,6 +3210,9 @@
} }
} }
} }
},
"共 %lld 次" : {
}, },
"共 %lld 页" : { "共 %lld 页" : {
"localizations" : { "localizations" : {
@@ -3306,6 +3324,9 @@
} }
} }
} }
},
"关闭搜索" : {
}, },
"其他" : { "其他" : {
"localizations" : { "localizations" : {
@@ -3350,9 +3371,6 @@
} }
} }
} }
},
"再加一种" : {
}, },
"再拍一项" : { "再拍一项" : {
"extractionState" : "stale", "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张" : { "原图%lld张" : {
}, },
@@ -3937,6 +3974,9 @@
} }
} }
} }
},
"原图已加密保存,详情页随时可翻看放大。系统只识别报告日期与机构作为标签,不逐项录入数值。" : {
}, },
"原图无法读取" : { "原图无法读取" : {
@@ -4125,6 +4165,9 @@
}, },
"只读取生日、性别、身高、血型" : { "只读取生日、性别、身高、血型" : {
},
"可多选:如同时勾选「每周一三五」+「每月1日」,两种节奏都会提醒。" : {
}, },
"可选开启 Face ID 启动锁,进一步保护隐私。" : { "可选开启 Face ID 启动锁,进一步保护隐私。" : {
"localizations" : { "localizations" : {
@@ -4169,6 +4212,15 @@
} }
} }
} }
},
"右上角拍药盒或 + 手动添加" : {
},
"吃了哪个药" : {
},
"吃药:" : {
}, },
"各引擎实测对比" : { "各引擎实测对比" : {
@@ -4378,6 +4430,7 @@
} }
}, },
"图片保存失败,手动录入并保留文本" : { "图片保存失败,手动录入并保留文本" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -4398,6 +4451,9 @@
} }
} }
} }
},
"图片保存失败,请重试" : {
}, },
"在「+ 新建 → 指标记录 → %@」记录一次" : { "在「+ 新建 → 指标记录 → %@」记录一次" : {
"localizations" : { "localizations" : {
@@ -4879,6 +4935,7 @@
} }
}, },
"如:缬沙坦 80mg qd" : { "如:缬沙坦 80mg qd" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -4955,6 +5012,9 @@
}, },
"字号放大 60%" : { "字号放大 60%" : {
},
"存入药品库" : {
}, },
"完成" : { "完成" : {
"localizations" : { "localizations" : {
@@ -5142,9 +5202,6 @@
}, },
"导出历史" : { "导出历史" : {
},
"将记入健康日记(记录页可查),并同步到「当前用药」供 AI 解读参考。不提供任何用药建议。" : {
}, },
"将追加:" : { "将追加:" : {
"localizations" : { "localizations" : {
@@ -5441,6 +5498,16 @@
} }
} }
}, },
"已拍 %lld/%lld 张 · 可拍正面、背面、说明书" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "new",
"value" : "已拍 %1$lld/%2$lld 张 · 可拍正面、背面、说明书"
}
}
}
},
"已拍 1 页" : { "已拍 1 页" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -5979,6 +6046,9 @@
} }
} }
} }
},
"开始识别" : {
}, },
"开始说话…" : { "开始说话…" : {
@@ -6078,6 +6148,7 @@
}, },
"当前用药" : { "当前用药" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -6513,6 +6584,9 @@
}, },
"或手动填写" : { "或手动填写" : {
},
"或手动输入药名" : {
}, },
"或者自己写" : { "或者自己写" : {
"localizations" : { "localizations" : {
@@ -6606,6 +6680,9 @@
}, },
"手动填写,或拍照自动识别" : { "手动填写,或拍照自动识别" : {
},
"手动添加" : {
}, },
"手动记录" : { "手动记录" : {
@@ -6877,10 +6954,10 @@
"拍药盒" : { "拍药盒" : {
}, },
"拍药盒或说明书,本地识别药名与规格" : { "拍药盒或手动添加常用药" : {
}, },
"拍药盒自动识别" : { "拍药盒添加" : {
}, },
"拖动方框对准要识别的指标,可拖右下角缩放" : { "拖动方框对准要识别的指标,可拖右下角缩放" : {
@@ -7419,6 +7496,18 @@
} }
} }
} }
},
"搜索指标" : {
},
"搜索指标 / 报告 / 症状名" : {
},
"搜索指标名" : {
},
"搜索记录" : {
}, },
"摘要" : { "摘要" : {
@@ -8124,6 +8213,9 @@
}, },
"月份" : { "月份" : {
},
"服药提醒" : {
}, },
"未下载" : { "未下载" : {
"localizations" : { "localizations" : {
@@ -8212,6 +8304,12 @@
} }
} }
} }
},
"未能自动识别报告信息,可手动填写" : {
},
"未能自动识别报告信息,已保存原图,可手动填写日期 / 机构" : {
}, },
"未设置" : { "未设置" : {
"localizations" : { "localizations" : {
@@ -8940,6 +9038,9 @@
} }
} }
} }
},
"核对报告信息" : {
}, },
"核对指标" : { "核对指标" : {
@@ -8948,6 +9049,7 @@
}, },
"核对识别结果" : { "核对识别结果" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -9282,6 +9384,9 @@
} }
} }
} }
},
"每周 · 选星期几" : {
}, },
"每天" : { "每天" : {
"localizations" : { "localizations" : {
@@ -9307,6 +9412,9 @@
}, },
"每年" : { "每年" : {
},
"每年 · 选月/日" : {
}, },
"每年%lld月%lld日" : { "每年%lld月%lld日" : {
"localizations" : { "localizations" : {
@@ -9324,7 +9432,7 @@
"每月" : { "每月" : {
}, },
"每月%lld日" : { "每月 · 选日期(可多选)" : {
}, },
"比如:记一下血压 / 我头疼 / 拍个药盒" : { "比如:记一下血压 / 我头疼 / 拍个药盒" : {
@@ -9404,6 +9512,15 @@
}, },
"没听清,再试一次" : { "没听清,再试一次" : {
},
"没有匹配「%@」的指标" : {
},
"没有匹配「%@」的记录" : {
},
"没有匹配的长期监测指标" : {
}, },
"没有指标 — 点上方「加一项」补一行,或直接保存只存图片" : { "没有指标 — 点上方「加一项」补一行,或直接保存只存图片" : {
"localizations" : { "localizations" : {
@@ -9513,6 +9630,15 @@
}, },
"添加快捷问答" : { "添加快捷问答" : {
},
"添加药品" : {
},
"点图放大" : {
},
"点图片可放大查看。原图均存在本机加密目录,不上传。" : {
}, },
"点底部 + 号可以补一条" : { "点底部 + 号可以补一条" : {
"localizations" : { "localizations" : {
@@ -9535,6 +9661,9 @@
} }
} }
} }
},
"点照片选「识别此张」· 一次记一种药" : {
}, },
"点这里再开一次" : { "点这里再开一次" : {
"localizations" : { "localizations" : {
@@ -9873,6 +10002,9 @@
}, },
"用药记录" : { "用药记录" : {
},
"用药详情" : {
}, },
"甲状腺疾病" : { "甲状腺疾病" : {
"localizations" : { "localizations" : {
@@ -10175,6 +10307,9 @@
} }
} }
} }
},
"管理常用药清单 · 拍药盒或手动添加" : {
}, },
"管理用药、复查、监测的周期提醒" : { "管理用药、复查、监测的周期提醒" : {
"localizations" : { "localizations" : {
@@ -10507,6 +10642,9 @@
} }
} }
} }
},
"继续拍" : {
}, },
"继续拍下一项" : { "继续拍下一项" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -10646,6 +10784,9 @@
} }
} }
} }
},
"编辑药品" : {
}, },
"腹痛" : { "腹痛" : {
"localizations" : { "localizations" : {
@@ -10854,9 +10995,27 @@
} }
} }
} }
},
"药名,如:缬沙坦胶囊" : {
}, },
"药品名,如:缬沙坦胶囊" : { "药品名,如:缬沙坦胶囊" : {
},
"药品库" : {
},
"药品库 · %lld 种常用药" : {
},
"药品库是你的常用药清单。记录某次服用请到「写日记 · 用药」,可填剂量和时间。" : {
},
"药品库还是空的" : {
},
"药品库还没有药,可在「记录 · 药品库」拍药盒或手动添加。这里直接手输也行。" : {
}, },
"血压" : { "血压" : {
"localizations" : { "localizations" : {
@@ -10976,6 +11135,9 @@
} }
} }
} }
},
"记剂量与时间" : {
}, },
"记录" : { "记录" : {
"localizations" : { "localizations" : {
@@ -11093,6 +11255,9 @@
}, },
"记录时间" : { "记录时间" : {
},
"记录用药" : {
}, },
"记录症状" : { "记录症状" : {
"localizations" : { "localizations" : {
@@ -11207,6 +11372,12 @@
} }
} }
} }
},
"设置提醒" : {
},
"识别入药品库" : {
}, },
"识别全程在本地,图片不会上传" : { "识别全程在本地,图片不会上传" : {
"localizations" : { "localizations" : {
@@ -11260,8 +11431,12 @@
}, },
"识别框内指标" : { "识别框内指标" : {
},
"识别此张" : {
}, },
"识别没有读出指标,请手动补充" : { "识别没有读出指标,请手动补充" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -11306,13 +11481,17 @@
} }
} }
}, },
"识别用药" : { "识别超时,已保存原图,请手动填写信息" : {
},
"识别超时,已保留原图" : {
}, },
"识别超时,挪一下框再试或手动补充" : { "识别超时,挪一下框再试或手动补充" : {
}, },
"识别超时(>%llds)" : { "识别超时(>%llds)" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -11335,6 +11514,7 @@
} }
}, },
"识别超时(>%llds),保留旧编辑" : { "识别超时(>%llds),保留旧编辑" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -11357,6 +11537,7 @@
} }
}, },
"识别超时(>%llds),先手动录入" : { "识别超时(>%llds),先手动录入" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -12387,6 +12568,7 @@
} }
}, },
"重新识别没有读出新指标" : { "重新识别没有读出新指标" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {

View File

@@ -171,6 +171,12 @@ final class DiaryEntry {
var createdAt: Date var createdAt: Date
var tags: [String] 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] = []) { init(content: String, createdAt: Date = .now, tags: [String] = []) {
self.content = content self.content = content
self.createdAt = createdAt 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 @Model
final class Symptom { final class Symptom {
var name: String var name: String
@@ -353,9 +398,13 @@ final class CustomReminder {
var hour: Int // 0...23 var hour: Int // 0...23
var minute: Int // 0...59 var minute: Int // 0...59
var weekdays: [Int] // iOS Calendar :1=, 2=, ..., 7= 7 = var weekdays: [Int] // iOS Calendar :1=, 2=, ..., 7= 7 =
var frequencyRaw: String = "daily" // CustomReminder.Frequency var frequencyRaw: String = "daily" // :; frequenciesRaw
var dayOfMonth: Int = 1 // monthly / yearly ,1...31 var dayOfMonth: Int = 1 // yearly + monthly ,1...31
var month: Int = 1 // yearly ,1...12 var month: Int = 1 // yearly ,1...12
/// (["daily","weekly",...]) = ,退 frequency
var frequenciesRaw: [String] = []
/// (1...31) = ,退 dayOfMonth
var monthDays: [Int] = []
var enabled: Bool var enabled: Bool
var createdAt: Date var createdAt: Date
var updatedAt: Date var updatedAt: Date
@@ -392,10 +441,41 @@ final class CustomReminder {
set { frequencyRaw = newValue.rawValue } set { frequencyRaw = newValue.rawValue }
} }
/// : / / 15 / 315 /// ()frequenciesRaw 退 frequency( / init)
var frequencies: Set<Frequency> {
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 { var frequencyLabel: String {
if !enabled { return String(appLoc: "已关闭") } 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: case .daily:
return String(appLoc: "每天") return String(appLoc: "每天")
case .weekly: 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: "")] 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() return String(appLoc: "每周 ") + weekdays.sorted().map { names[$0 - 1] }.joined()
case .monthly: 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: case .yearly:
return String(appLoc: "每年\(month)\(dayOfMonth)") return String(appLoc: "每年\(month)\(dayOfMonth)")
} }
@@ -420,12 +502,17 @@ final class CustomReminder {
func occurs(on date: Date, calendar: Calendar = .current) -> Bool { func occurs(on date: Date, calendar: Calendar = .current) -> Bool {
guard enabled else { return false } guard enabled else { return false }
let c = calendar.dateComponents([.weekday, .day, .month], from: date) let c = calendar.dateComponents([.weekday, .day, .month], from: date)
switch frequency { let wd = c.weekday ?? -1, day = c.day ?? -1, mo = c.month ?? -1
case .daily: return true // :
case .weekly: return weekdays.contains(c.weekday ?? -1) for f in frequencies {
case .monthly: return dayOfMonth == (c.day ?? -1) switch f {
case .yearly: return month == (c.month ?? -1) && dayOfMonth == (c.day ?? -1) 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
} }
} }

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
import UIKit import UIKit
import ImageIO
enum FileVaultError: Error { enum FileVaultError: Error {
case readFailed case readFailed
@@ -10,7 +11,10 @@ enum FileVaultError: Error {
/// `@unchecked Sendable`:rootURL let, I/O (线), /// `@unchecked Sendable`:rootURL let, I/O (线),
/// actor / Task 访 `nonisolated`, ModelStore /// 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 = { nonisolated static let shared: FileVault = {
do { do {
let appSupport = try FileManager.default.url( let appSupport = try FileManager.default.url(
@@ -28,6 +32,17 @@ final class FileVault: @unchecked Sendable {
let rootURL: URL 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<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 40
return cache
}()
init(rootURL: URL) throws { init(rootURL: URL) throws {
self.rootURL = rootURL self.rootURL = rootURL
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
@@ -81,6 +96,33 @@ final class FileVault: @unchecked Sendable {
return image 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 { nonisolated func remove(relativePath: String) throws {
let url = try resolveSafePath(relativePath) let url = try resolveSafePath(relativePath)
do { do {
@@ -88,6 +130,8 @@ final class FileVault: @unchecked Sendable {
} catch { } catch {
throw FileVaultError.removeFailed throw FileVaultError.removeFailed
} }
// ,(,)
thumbnailCache.removeAllObjects()
} }
/// Vault (/),; /// Vault (/),;
@@ -99,6 +143,7 @@ final class FileVault: @unchecked Sendable {
try? fm.removeItem(at: url) try? fm.removeItem(at: url)
} }
let remaining = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? [] let remaining = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? []
thumbnailCache.removeAllObjects()
if !remaining.isEmpty { if !remaining.isEmpty {
throw FileVaultError.removeFailed throw FileVaultError.removeFailed
} }

View File

@@ -53,6 +53,8 @@ struct RootView: View {
@State private var showVoiceCommand = false @State private var showVoiceCommand = false
/// :RootView MedicationScanFlow, sheet /// :RootView MedicationScanFlow, sheet
@State private var showMedicationScan = false @State private var showMedicationScan = false
/// · :sheet + NavigationStack
@State private var showMedicationLibrary = false
/// ( RecordSheet onPick ) /// ( RecordSheet onPick )
private func route(_ intent: VoiceIntent) { private func route(_ intent: VoiceIntent) {
@@ -112,6 +114,7 @@ struct RootView: View {
case .indicator: showIndicator = true case .indicator: showIndicator = true
case .reminder: showReminders = true case .reminder: showReminders = true
case .healthExport: showHealthExport = true case .healthExport: showHealthExport = true
case .medicationLibrary: showMedicationLibrary = true
} }
} }
} }
@@ -135,6 +138,9 @@ struct RootView: View {
// NavigationStack ;sheet // NavigationStack ;sheet
NavigationStack { RemindersListView(presentedAsSheet: true) } NavigationStack { RemindersListView(presentedAsSheet: true) }
} }
.sheet(isPresented: $showMedicationLibrary) {
NavigationStack { MedicationLibraryView(presentedAsSheet: true) }
}
.fullScreenCover(isPresented: $showHealthExport) { .fullScreenCover(isPresented: $showHealthExport) {
HealthExportSheet() HealthExportSheet()
} }
@@ -156,8 +162,8 @@ struct RootView: View {
} }
.fullScreenCover(isPresented: $showMedicationScan) { .fullScreenCover(isPresented: $showMedicationScan) {
MedicationScanFlow( MedicationScanFlow(
onSave: { entries in onSave: { meds, images in
MedicationArchiver.archive(entries: entries, in: ctx) MedicationArchiver.archive(medications: meds, images: images, in: ctx)
}, },
onClose: { showMedicationScan = false } onClose: { showMedicationScan = false }
) )

View File

@@ -33,7 +33,9 @@ struct ParsedReport: Sendable {
var isEmpty: Bool { indicators.isEmpty } var isEmpty: Bool { indicators.isEmpty }
/// ,退 UI /// ,退 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( ParsedReport(
title: "", title: "",
typeRaw: ReportType.other.rawValue, typeRaw: ReportType.other.rawValue,
@@ -78,6 +80,40 @@ actor CaptureService {
try await runVL(on: assets) 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) /// OCR : Vision OCR LLM(Qwen3-1.7B)
/// Report; `CaptureError`,UI 退(§3.2) /// Report; `CaptureError`,UI 退(§3.2)
/// (MainActor) OCR,OCR actor, UIImage actor /// (MainActor) OCR,OCR actor, UIImage actor
@@ -169,8 +205,17 @@ actor CaptureService {
private static func ocrReference(for urls: [URL]) async -> String { private static func ocrReference(for urls: [URL]) async -> String {
var pages: [String] = [] var pages: [String] = []
for (idx, url) in urls.prefix(4).enumerated() { for (idx, url) in urls.prefix(4).enumerated() {
guard let src = CGImageSourceCreateWithURL(url as CFURL, nil), guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { continue }
let cg = CGImageSourceCreateImageAtIndex(src, 0, 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), guard let text = try? await OCRService.recognizeText(in: cg),
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue }
pages.append(urls.count > 1 ? "【第 \(idx + 1) 页】\n\(text)" : text) pages.append(urls.count > 1 ? "【第 \(idx + 1) 页】\n\(text)" : text)

View File

@@ -450,6 +450,8 @@ struct HealthExportService {
var reports: [Report] var reports: [Report]
var diaries: [DiaryEntry] var diaries: [DiaryEntry]
var profile: UserProfile var profile: UserProfile
/// () AI current_meds
var medications: [Medication] = []
/// (, LLM) ## /// (, LLM) ##
var trends: [ExportTrend] = [] var trends: [ExportTrend] = []
} }
@@ -530,6 +532,9 @@ struct HealthExportService {
// Profile() // Profile()
let profile = UserProfileStore.loadOrCreate(in: ctx) let profile = UserProfileStore.loadOrCreate(in: ctx)
// (, AI current_meds)
let medications = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
// (, LLM) // (, LLM)
// in-window ; indicators series // in-window ; indicators series
let trends = ExportTrendBuilder.build( let trends = ExportTrendBuilder.build(
@@ -546,6 +551,7 @@ struct HealthExportService {
reports: reports, reports: reports,
diaries: diaries, diaries: diaries,
profile: profile, profile: profile,
medications: medications,
trends: trends trends: trends
) )
} }
@@ -561,6 +567,7 @@ struct HealthExportService {
let indicators = (try? ctx.fetch(indicatorDesc)) ?? [] let indicators = (try? ctx.fetch(indicatorDesc)) ?? []
let diaries = (try? ctx.fetch(diaryDesc)) ?? [] let diaries = (try? ctx.fetch(diaryDesc)) ?? []
let profile = UserProfileStore.loadOrCreate(in: ctx) let profile = UserProfileStore.loadOrCreate(in: ctx)
let medications = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
let dates = indicators.map(\.capturedAt) + diaries.map(\.createdAt) let dates = indicators.map(\.capturedAt) + diaries.map(\.createdAt)
let fromDate = dates.min() ?? Date() let fromDate = dates.min() ?? Date()
@@ -581,6 +588,7 @@ struct HealthExportService {
reports: [], reports: [],
diaries: diaries, diaries: diaries,
profile: profile, profile: profile,
medications: medications,
trends: trends trends: trends
) )
} }
@@ -611,7 +619,11 @@ struct HealthExportService {
if !profile.allergies.isEmpty { profDict["allergies"] = profile.allergies } if !profile.allergies.isEmpty { profDict["allergies"] = profile.allergies }
if !profile.chronicConditions.isEmpty { profDict["chronic"] = profile.chronicConditions } if !profile.chronicConditions.isEmpty { profDict["chronic"] = profile.chronicConditions }
if !profile.familyHistory.isEmpty { profDict["family_history"] = profile.familyHistory } 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 root["profile"] = profDict
// symptoms // symptoms
@@ -681,7 +693,8 @@ struct HealthExportService {
/// :///, profile /// :///, profile
/// LLM,, /// LLM,,
static func isEffectivelyEmpty(_ s: Snapshot) -> Bool { 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 return false
} }
let p = s.profile let p = s.profile
@@ -693,7 +706,6 @@ struct HealthExportService {
&& p.allergies.isEmpty && p.allergies.isEmpty
&& p.chronicConditions.isEmpty && p.chronicConditions.isEmpty
&& p.familyHistory.isEmpty && p.familyHistory.isEmpty
&& p.currentMedications.isEmpty
} }
/// :6 ,, /// :6 ,,

View File

@@ -80,20 +80,24 @@ enum ReminderService {
let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines) let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines)
let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines) let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines)
let h = reminder.hour, m = reminder.minute let h = reminder.hour, m = reminder.minute
let slots: [Slot] // :,(suffix ,)
switch reminder.frequency { var slots: [Slot] = []
case .daily: for f in reminder.frequencies {
slots = [Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m))] switch f {
case .weekly: case .daily:
slots = reminder.weekdays.map { wd in slots.append(Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m)))
Slot(suffix: "w\(wd)", dc: DateComponents(hour: h, minute: m, weekday: wd)) 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( await schedule(
idBase: "\(customIdPrefix)\(reminder.id.uuidString)", 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) { private static func cancelBase(_ idBase: String) {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
var ids = ["\(idBase).daily", "\(idBase).monthly", "\(idBase).yearly"] var ids = ["\(idBase).daily", "\(idBase).monthly", "\(idBase).yearly"]
ids += (1...7).map { "\(idBase).w\($0)" } ids += (1...7).map { "\(idBase).w\($0)" }
ids += (1...31).map { "\(idBase).m\($0)" }
center.removePendingNotificationRequests(withIdentifiers: ids) center.removePendingNotificationRequests(withIdentifiers: ids)
} }
} }

View File

@@ -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 使) /// ;(demo 使)
private static func makeRecognizer() -> SFSpeechRecognizer? { private static func makeRecognizer() -> SFSpeechRecognizer? {
if let r = SFSpeechRecognizer(locale: .current), r.supportsOnDeviceRecognition { if let r = SFSpeechRecognizer(locale: .current), r.supportsOnDeviceRecognition {

View File

@@ -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 == "餐后 · 一日两次")
}
}

View File

@@ -57,6 +57,23 @@ struct MedicationScanServiceTests {
try MedicationScanService.parseMedicationsJSON("识别不出来,抱歉") 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 ) /// 线(tab )
@@ -64,7 +81,8 @@ struct MedicationScanServiceTests {
struct MedicationTimelineTests { struct MedicationTimelineTests {
private func makeContext() throws -> ModelContext { 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) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
return ModelContext(try ModelContainer(for: schema, configurations: [config])) return ModelContext(try ModelContainer(for: schema, configurations: [config]))
} }

View File

@@ -17,6 +17,7 @@ struct ModelsSchemaTests {
UserProfile.self, UserProfile.self,
MetricReminder.self, MetricReminder.self,
CustomMonitorMetric.self, CustomMonitorMetric.self,
Medication.self,
]) ])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
return try ModelContainer(for: schema, configurations: [config]) return try ModelContainer(for: schema, configurations: [config])
@@ -190,4 +191,42 @@ struct ModelsSchemaTests {
#expect(fetched.bloodTypeRaw == "A") #expect(fetched.bloodTypeRaw == "A")
#expect(fetched.chronicConditions == ["高血压"]) #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<Medication>()).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<Medication>()).isEmpty)
#expect(try ctx.fetch(FetchDescriptor<Asset>()).isEmpty)
}
} }

View File

@@ -61,6 +61,42 @@ struct TodayRemindersLogicTests {
#expect(!CustomReminder(title: "x", frequency: .yearly, dayOfMonth: 29, month: 5).occurs(on: d, calendar: cal)) #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 // MARK: - MetricReminder
@Test func metricReminderOccursOnSelectedWeekday() { @Test func metricReminderOccursOnSelectedWeekday() {