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