Compare commits
56 Commits
910ca99f21
...
feat/mnn-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d856fcfc4 | ||
|
|
6c6a950140 | ||
|
|
f58d6064ba | ||
|
|
c3f8ec400c | ||
|
|
69de5faf4b | ||
|
|
477a64ecb4 | ||
|
|
6405733358 | ||
|
|
2e27677f80 | ||
|
|
2e90139df7 | ||
|
|
77139f5e32 | ||
|
|
0dd60d6021 | ||
|
|
43cdde9bab | ||
|
|
0a824610cf | ||
|
|
7e8e692695 | ||
|
|
3f9a2af279 | ||
|
|
a65c63947b | ||
|
|
8494e51823 | ||
|
|
070e016f81 | ||
|
|
8c8599e77d | ||
|
|
b7e8ab33ec | ||
|
|
db327afd79 | ||
|
|
5eb724ab86 | ||
|
|
cfeb25247a | ||
|
|
26a7d53b1b | ||
|
|
e603738330 | ||
|
|
7f0a76098a | ||
|
|
b79ae54b7b | ||
|
|
ca5a3fa38b | ||
|
|
836f3d4234 | ||
|
|
b919404412 | ||
|
|
ddfd474bb3 | ||
|
|
cbacd9461a | ||
|
|
39b1521f00 | ||
|
|
9da3fbc87e | ||
|
|
f6c0ba7077 | ||
|
|
afc6a79dd7 | ||
|
|
06484d09ff | ||
|
|
ac11aa0f99 | ||
|
|
77a4ee1c37 | ||
|
|
074d99715d | ||
|
|
60b6ad6d65 | ||
|
|
675c33bea1 | ||
|
|
77697e1600 | ||
|
|
30f97b3535 | ||
|
|
3798efa48d | ||
|
|
770dd6bedf | ||
|
|
bff7cfd4b6 | ||
|
|
32e7c25ed7 | ||
|
|
d72a1fec17 | ||
|
|
db7cc1bba7 | ||
|
|
adb589af16 | ||
|
|
da6223e051 | ||
|
|
40155de709 | ||
|
|
7ad41c5f09 | ||
|
|
dad9d43486 | ||
|
|
d2c77d5c51 |
5
.gitignore
vendored
@@ -1,2 +1,7 @@
|
|||||||
# 大模型素材:本地下载用于上传到 OpenList,不入库(~3GB)
|
# 大模型素材:本地下载用于上传到 OpenList,不入库(~3GB)
|
||||||
/Models/
|
/Models/
|
||||||
|
/build/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# MNN 预编译二进制:由 scripts/build-mnn-xcframework.sh 本地生成,不入库防历史膨胀
|
||||||
|
/Frameworks/MNN.xcframework/
|
||||||
|
|||||||
293
AGENTS.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# 康康 —— 工程前提
|
||||||
|
|
||||||
|
> 这是一个 6 周决赛 demo 项目。今天是 2026-05-25,处于 W1末/W2初。
|
||||||
|
> 任何 IDE/Codex 会话开始干活前,先读这份文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 产品定位
|
||||||
|
|
||||||
|
- **名字**:康康(对内代号 Kangkang)
|
||||||
|
- **形态**:iOS 原生 App,SwiftUI + SwiftData
|
||||||
|
- **核心卖点**:**100% 本地推理**的个人健康影像档案 + 大白话解读 + 本地 RAG 问答
|
||||||
|
- **目标用户**:不愿把体检/化验报告交给云端的普通人
|
||||||
|
- **明确不做**:医疗诊断、剂量推荐、急诊判断、医生预约、社交、广告、内购、数据上云、账号系统
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 技术栈 / 选型(已锁定,不要再讨论)
|
||||||
|
|
||||||
|
| 项 | 选型 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` |
|
||||||
|
| 持久化 | SwiftData | 见 §5 数据模型 |
|
||||||
|
| 图表 | 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 |
|
||||||
|
| 模型 | **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 杀手锏,真机才能测 |
|
||||||
|
|
||||||
|
**不引入**:任何云服务 SDK、任何 embedding 模型(RAG 用结构化检索,不用语义)、任何账号系统、任何分析 SDK。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. AI 链路核心规则
|
||||||
|
|
||||||
|
### 3.1 模块边界(强制)
|
||||||
|
|
||||||
|
```
|
||||||
|
UI → CaptureService / AskService / TrendService → AIRuntime → MNN / MLX
|
||||||
|
↓
|
||||||
|
Persistence
|
||||||
|
```
|
||||||
|
|
||||||
|
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。
|
||||||
|
- **`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(拍一张 = 一条流程)
|
||||||
|
|
||||||
|
**重要**:快拍(1.x) 和 报告归档(2.x) 已经合并成统一 `CaptureService`,UI 不再有 A1-A3 和 B1-B4 两条独立路径。流程:
|
||||||
|
|
||||||
|
```
|
||||||
|
拍照 → 写 Vault(加密目录) → VL 推理(要求输出 JSON,含 kind=single|report)
|
||||||
|
→ 解析容错(失败回退到手动录入,不卡死)
|
||||||
|
→ 单项走 A2ConfirmView,整份走 B3MetaView
|
||||||
|
→ 保存到 Indicator/Report + 关联 Asset
|
||||||
|
```
|
||||||
|
|
||||||
|
VL prompt 必须:
|
||||||
|
- 明确要求"只输出 JSON,不要解释"
|
||||||
|
- 带 2 个 few-shot 示例(单项 + 多项)
|
||||||
|
- 异常状态由 VL 模型基于参考范围直接判断,不要再二次调用 LLM
|
||||||
|
|
||||||
|
### 3.3 RAG(结构化检索,不做 embedding)
|
||||||
|
|
||||||
|
**两段式调用**:
|
||||||
|
1. 用 Qwen3.5-2B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
|
||||||
|
2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
|
||||||
|
|
||||||
|
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
|
||||||
|
**引用回链**:回答中 `[1][2]` 后处理为可点击 Pill,点击跳源记录详情。
|
||||||
|
|
||||||
|
### 3.4 Live Activity
|
||||||
|
|
||||||
|
- VL 推理 / RAG 生成开始时启动 Activity
|
||||||
|
- 每 0.5s 通过 `AIRuntime.lastDecodeRate` 推送 tok/s
|
||||||
|
- 推理完成保留 2s 显示"已完成 · 0.8s"再 dismiss
|
||||||
|
- **只能真机测**,模拟器不显示。W5 末预留时间。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 模型分发
|
||||||
|
|
||||||
|
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
||||||
|
- **用户面只有一个模型**:Qwen3.5-2B-MNN(~1.2GB,`ModelKind.userFacing = [.mnnLLM]`)。多模态,文本+视觉全包,下载全部 / 就绪计数只算它
|
||||||
|
- MLX 兜底版 Qwen3.5-2B-4bit(~1.7GB)仅模拟器与兜底用,不展示、不计入「下载全部」,但旁路导入仍可单独导
|
||||||
|
- WiFi 提示必须有
|
||||||
|
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
||||||
|
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 数据模型(SwiftData)
|
||||||
|
|
||||||
|
**当前 schema(2026-05-26)**:7 个 @Model。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@Model class Indicator {
|
||||||
|
name, value, unit, range, statusRaw, note, capturedAt,
|
||||||
|
report: Report?, asset: Asset?,
|
||||||
|
pinned: Bool, // 长期监测自动 true,Trends 默认展示
|
||||||
|
seriesKey: String? // "bp.systolic" / "glucose.fasting" / ... 长期指标分组 key
|
||||||
|
}
|
||||||
|
@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt,
|
||||||
|
indicators: [Indicator] cascade,
|
||||||
|
assets: [Asset] cascade }
|
||||||
|
@Model class DiaryEntry { content, createdAt, tags: [String] }
|
||||||
|
@Model class Symptom { name, startedAt, endedAt?, note?, severity 1-5, tags, createdAt }
|
||||||
|
@Model class Asset { relativePath, mimeType, bytes, createdAt }
|
||||||
|
@Model class ChatTurn { question, answer, referencedIndicatorIDs, referencedReportIDs, createdAt, decodeRate }
|
||||||
|
|
||||||
|
@Model class UserProfile { // 全 App 单例(UserProfileStore.loadOrCreate)
|
||||||
|
birthYear?, biologicalSexRaw, heightCM?, bloodTypeRaw,
|
||||||
|
allergies, chronicConditions, familyHistory, currentMedications,
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**原图存储**: `Asset` 只存元数据 + 相对路径,真实 JPEG 落在 `Application Support/Vault/`,目录用 `.completeFileProtection`(iOS 硬件级加密,不要自己造 AES 轮子)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 安全 / 隐私(已收敛 — 不要扩展)
|
||||||
|
|
||||||
|
| 做 | 不做 |
|
||||||
|
|---|---|
|
||||||
|
| `Application Support/Vault/` 全目录 `.completeFileProtection` | 自实现 AES 加密 |
|
||||||
|
| SwiftData store 文件 `.completeFileProtection` | |
|
||||||
|
| Face ID 启动锁(可选开关,默认关) | |
|
||||||
|
| 永久删除(SwiftData 硬删 + Asset 文件 unlink) | |
|
||||||
|
| 离线运行(自然结果,不用单独做) | |
|
||||||
|
| | 截屏黑屏防护(iOS 没有官方 API,不做) |
|
||||||
|
| | 加密 ZIP 导出 |
|
||||||
|
|
||||||
|
唯一的"导出"是 **9.4 分享文字摘要**(只分享解读文本,不带原图)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 信息架构
|
||||||
|
|
||||||
|
```
|
||||||
|
TabBar: [主页] [记录] [+ 新建] [趋势] [我的]
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ │ └─ 个人资料 / 模型管理 / Face ID / 关于
|
||||||
|
│ │ │ └─ 折线图 + AI 一句话解读
|
||||||
|
│ │ └─ Sheet: 拍一张 / 指标记录 / 报告归档 / 写日记 / 症状
|
||||||
|
│ └─ ArchiveListView(时间线 + 分类 chip + 年/月分组)
|
||||||
|
└─ 问候 + 今日摘要 + 进行中症状 + 最近时间线
|
||||||
|
```
|
||||||
|
|
||||||
|
- TabBar **5 槽**:左 2 个内容 Tab + 中间 + 号 + 右 2 个 Tab
|
||||||
|
- "+ 新建" 是 sheet 不是 Tab
|
||||||
|
- AI 问答以 Modal Sheet 形式出现,**不占 Tab**
|
||||||
|
- 「指标记录」sheet 顶部 LazyVGrid 是 8 个 MonitorMetric 长期监测预设(进趋势),
|
||||||
|
下方 horizontal scroll 是化验项快捷预设(不进趋势),不选预设走自由输入
|
||||||
|
- 「我的 · 个人资料」是 NavigationLink push 的 Form 编辑页
|
||||||
|
|
||||||
|
### 7.1 档案库 C1 / C2 导航(看的一半)
|
||||||
|
|
||||||
|
录入流程(拍照→VL→编辑→存)只是"录的一半"。**"看的一半"由 C1 列表 + C2 详情承担**——这是 demo 的核心看点之一,不能砍。
|
||||||
|
|
||||||
|
```
|
||||||
|
首页 "我的报告档案" 卡 ──push──► C1 ArchiveListView
|
||||||
|
│
|
||||||
|
│ 分类 chip:全部/体检/化验/影像/处方
|
||||||
|
│ 按 reportDate 年份分组,卡片显示异常 chip
|
||||||
|
│
|
||||||
|
└──push──► C2 ReportDetailView
|
||||||
|
│
|
||||||
|
├─ Tab "原图":TabView(.page) 翻页 + 长按保存
|
||||||
|
├─ Tab "解读":数字摘要(总/高/低/正常)
|
||||||
|
│ + AI 整体摘要
|
||||||
|
│ + 对比上次(同类型上一份 Report diff)
|
||||||
|
└─ Tab "指标":Indicator 列表,异常优先
|
||||||
|
|
||||||
|
C2 底部两个动作:
|
||||||
|
├─ "关联到趋势" ──► 把本报告内未 pinned 的 Indicator 批量 pinned = true,Trends 默认展示
|
||||||
|
└─ "重新解读" ──► CaptureService.reanalyze(report:),重跑 VL 覆盖 summary/indicators
|
||||||
|
|
||||||
|
其他进入 C2 的入口:
|
||||||
|
• ChatTurn 引用 Pill 点击(referencedReportIDs)
|
||||||
|
• 趋势页数据点 tap → 跳到该点来源 Report 的 C2
|
||||||
|
• HomeView 时间线点报告类条目
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 对比上次("对比上次"=报告对比,已加回)
|
||||||
|
|
||||||
|
C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算,不再调 LLM**:
|
||||||
|
|
||||||
|
- 找出"同 `typeRaw` 的上一份 Report"(`reportDate < current AND ORDER BY DESC LIMIT 1`)
|
||||||
|
- 同名 `Indicator` 配对,数值 diff:`Δ` 绝对值 + 百分比 + 升/降箭头
|
||||||
|
- 标红:跨越参考范围边界(原本正常→偏高,或反过来)
|
||||||
|
- 文案模板拼装,不走 LLM,响应即时
|
||||||
|
- 若无上一份,该区块隐藏
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 现有代码状态(2026-05-25)
|
||||||
|
|
||||||
|
```
|
||||||
|
康康/
|
||||||
|
├── App/KangkangApp.swift ✅ SwiftData container 已建
|
||||||
|
├── RootView.swift ✅ 3 Tab + RecordSheet 已建
|
||||||
|
├── Models/Models.swift ✅ Indicator / Report / DiaryEntry,缺 Asset / ChatTurn
|
||||||
|
├── DesignSystem/ ✅ Tokens + Components,沿用
|
||||||
|
└── Features/
|
||||||
|
├── Home/ ✅ HomeView 静态 UI,数据未接
|
||||||
|
├── Quick/A1-A3 🔧 待合并进 UnifiedCaptureFlow
|
||||||
|
├── Archive/B1-B4 🔧 B1 砍,B2 改用 VisionKit DocumentCamera
|
||||||
|
├── Record/RecordSheet ✅ 入口选择 UI
|
||||||
|
├── Trends/ ❌ 只有 placeholder
|
||||||
|
└── Me/ ❌ 只有 placeholder
|
||||||
|
|
||||||
|
待建:
|
||||||
|
├── AI/ ⚠️ AIRuntime + LLMSession + ModelStore + TokenChunk ✅;VLSession + Prompts/ ❌
|
||||||
|
├── Debug/DebugAIRunner.swift ✅ DEBUG-only AI 自检入口
|
||||||
|
├── Services/ ❌ CaptureService, AskService, TrendService, ReportCompareService
|
||||||
|
├── Persistence/FileVault.swift ✅ 原图加密目录管理
|
||||||
|
├── Security/AppLock.swift ❌ Face ID 启动锁
|
||||||
|
├── Features/Ask/ ❌ AskSheet (RAG 问答 UI)
|
||||||
|
├── Features/Archive/
|
||||||
|
│ ├── ArchiveListView ❌ C1 档案列表(分类 chip + 年份分组)
|
||||||
|
│ └── ReportDetailView ❌ C2 报告详情(三 Tab:原图/解读/指标 + 对比上次)
|
||||||
|
├── Features/Capture/
|
||||||
|
│ └── UnifiedCaptureFlow ❌ 替代 QuickCaptureFlow,状态机驱动 A1→VL→A2/B3
|
||||||
|
├── Features/Onboarding/ ❌ 首启动隐私承诺 + 模型下载
|
||||||
|
└── LiveActivity/ ❌ WidgetExtension target
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 设计系统约束
|
||||||
|
|
||||||
|
- **不要新增颜色 token**。所有颜色走 `Tj.Palette.*` (sand / paper / ink / brick / leaf / line / text / text3)
|
||||||
|
- **不要新增字体大小**。走 `Font.tjTitle()` / `tjH2()` / `tjSerifBody()` / 系统 size
|
||||||
|
- **圆角走 `Tj.Radius.*`**,卡片走 `.tjCard()` modifier
|
||||||
|
- 按钮走 `TjPrimaryButton` / `TjGhostButton`
|
||||||
|
|
||||||
|
新加 View 时先看 `DesignSystem/Components.swift`,有现成的不要复刻。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 不能跨越的红线
|
||||||
|
|
||||||
|
写代码前必读:
|
||||||
|
|
||||||
|
1. **不引入云服务**——任何 SDK 都不行,包括崩溃上报、分析、灰度
|
||||||
|
2. **不自己实现密码学**——`.completeFileProtection` 已经够
|
||||||
|
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)
|
||||||
|
7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
|
||||||
|
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 6 周时间表
|
||||||
|
|
||||||
|
| 周次 | 必交付 |
|
||||||
|
|---|---|
|
||||||
|
| W1 末 / W2 当前 | 项目结构、跑通 Qwen3.5-2B(MLX/MNN)、首个 token 在设备吐出 |
|
||||||
|
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
|
||||||
|
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
|
||||||
|
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |
|
||||||
|
| W4-W5 | 趋势(Swift Charts + AI 解读)、**C2 ReportDetailView**(三 Tab + 重新解读) |
|
||||||
|
| W5 中 | **ReportCompareService** + C2 解读 Tab "对比上次" 区块 |
|
||||||
|
| W5 末 | Face ID、永久删除、首页时间线接入真数据、Live Activity(真机) |
|
||||||
|
| W6 | 模型管理页、首启动下载流程、UI polish、demo 视频、PPT |
|
||||||
|
|
||||||
|
**P0 新增项**(从 C 区视觉稿补回):C1 档案列表、C2 报告详情三 Tab、对比上次、关联到趋势、重新解读
|
||||||
|
|
||||||
|
**P1**(必须做完):Live Activity、分享文字摘要(9.4)、首启动隐私承诺页
|
||||||
|
|
||||||
|
**P2**(余力做):—— 任何 P2/P3 都暂时不做,清单里 11/12/13/14/15/17/18/19 全部 deferred(注意:16.1 报告对比已升 P0)
|
||||||
|
|
||||||
|
**砍 P1 决策顺序**(任何一周延期触发):Live Activity → Onboarding 简化 → 分享摘要 → 模型管理页 polish。**绝不动 C1/C2/对比上次**——视觉稿都做了,demo PPT 也要展示,这是核心卖点之一。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 评委 PPT 卖点排序(写代码时记住为什么这么做)
|
||||||
|
|
||||||
|
1. 影像档案系统(统一 VL 拍照 + 归档) — 核心创意
|
||||||
|
2. 100% 本地 + SME2 加速 — 技术亮点
|
||||||
|
3. 本地 RAG 长期记忆 — 端侧不可替代性
|
||||||
|
4. 隐私三件套(系统级加密 + Face ID + 永久删除) — 信任建立
|
||||||
|
5. AI 趋势解读 — 长期价值
|
||||||
|
6. Live Activity 实时 tok/s — 现场记忆点
|
||||||
|
|
||||||
|
每写一个功能,问自己:这条提升了上面哪一项?如果都没有,就别做。
|
||||||
23
CLAUDE.md
@@ -22,9 +22,12 @@
|
|||||||
| UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` |
|
| UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` |
|
||||||
| 持久化 | SwiftData | 见 §5 数据模型 |
|
| 持久化 | SwiftData | 见 §5 数据模型 |
|
||||||
| 图表 | Swift Charts | iOS 16+ 原生 |
|
| 图表 | Swift Charts | iOS 16+ 原生 |
|
||||||
| **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama |
|
| **AI 运行时(主)** | **MNN (alibaba) + Arm SME2 + CPU** | 挑战赛考核点:Qwen + MNN + SME2 端侧 CPU 推理。device-only(xcframework 见 `scripts/build-mnn-xcframework.sh`),A19/iPhone17 启用 SME2、A17 回退 NEON。经 `MNNLLMBridge`(ObjC++)→ `MNNBackend` |
|
||||||
| LLM | Qwen3-1.7B 4bit (HF: `mlx-community/Qwen3-1.7B-4bit`) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 |
|
| **AI 运行时(兜底)** | **MLX Swift (Apple 官方,Metal GPU)** | 双后端:`InferenceEngine` 切换,模拟器/兜底用 MLX。不要建议 Core ML / llama.cpp / Ollama |
|
||||||
| VL | Qwen2.5-VL-3B-Instruct 4bit (HF: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`) | ~2.0GB,负责拍照→结构化指标 |
|
| **统一模型(文本+视觉)** | **Qwen3.5-2B 多模态,一个模型全包** | 同一个 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` | 不要自己写透视校正 |
|
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
|
||||||
| Face ID | LocalAuthentication | |
|
| Face ID | LocalAuthentication | |
|
||||||
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
|
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
|
||||||
@@ -38,13 +41,13 @@
|
|||||||
### 3.1 模块边界(强制)
|
### 3.1 模块边界(强制)
|
||||||
|
|
||||||
```
|
```
|
||||||
UI → CaptureService / AskService / TrendService → AIRuntime → MLX
|
UI → CaptureService / AskService / TrendService → AIRuntime → MNN(主) / MLX(兜底)
|
||||||
↓
|
↓
|
||||||
Persistence
|
Persistence
|
||||||
```
|
```
|
||||||
|
|
||||||
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。
|
- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。
|
||||||
- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务,MLX 共享显存,并发会 OOM。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。
|
- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务(`InferenceEngine` 选 MNN/SME2 主或 MLX/GPU 兜底,共享内存/显存,并发会 OOM)。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。
|
||||||
- **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
|
- **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。
|
||||||
|
|
||||||
### 3.2 VL pipeline(拍一张 = 一条流程)
|
### 3.2 VL pipeline(拍一张 = 一条流程)
|
||||||
@@ -66,7 +69,7 @@ VL prompt 必须:
|
|||||||
### 3.3 RAG(结构化检索,不做 embedding)
|
### 3.3 RAG(结构化检索,不做 embedding)
|
||||||
|
|
||||||
**两段式调用**:
|
**两段式调用**:
|
||||||
1. 用 Qwen3-1.7B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
|
1. 用统一 Qwen3.5-2B(MNN 主 / MLX 兜底)抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s
|
||||||
2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
|
2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答
|
||||||
|
|
||||||
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
|
**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。
|
||||||
@@ -84,7 +87,7 @@ VL prompt 必须:
|
|||||||
## 4. 模型分发
|
## 4. 模型分发
|
||||||
|
|
||||||
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
||||||
- 总体积 ~3GB,WiFi 提示必须有
|
- **用户侧只下载统一模型 Qwen3.5-2B(MNN,~1.1GiB,含视觉)**——不再是 ~4GB 两模型。`ModelKind.userFacing = [.mnnLLM]`,「下载全部」/ 就绪计数只算它。MLX 兜底模型 `Qwen3.5-2B-4bit`(~1.7GB)仅模拟器 / 旁路导入用,不计入用户下载;`Qwen3-VL-4B` 已废弃,不再分发。WiFi 提示仍保留
|
||||||
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
||||||
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
||||||
|
|
||||||
@@ -249,7 +252,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
|||||||
3. **UI 不直接调 AIRuntime**——必须经过 Service
|
3. **UI 不直接调 AIRuntime**——必须经过 Service
|
||||||
4. **AIRuntime 必须 actor 化**——禁止 class + lock
|
4. **AIRuntime 必须 actor 化**——禁止 class + lock
|
||||||
5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏
|
5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏
|
||||||
6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2)
|
6. **新功能必须问"清单里有吗"**——清单外的功能(多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2)、**用药提醒**(记录 · 用药记录点药 → 复用自由提醒 `CustomReminder` / `CustomReminderEditSheet`,只到点提示,**仍不给剂量/频次建议**,守 §1 "不做剂量推荐")
|
||||||
7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
|
7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
|
||||||
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
|
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
|
||||||
|
|
||||||
@@ -259,7 +262,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
|||||||
|
|
||||||
| 周次 | 必交付 |
|
| 周次 | 必交付 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| W1 末 / W2 当前 | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 |
|
| W1 末 / W2 当前 | 项目结构、跑通 Qwen3.5-2B(MLX/MNN)、首个 token 在设备吐出 |
|
||||||
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
|
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
|
||||||
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
|
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
|
||||||
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |
|
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |
|
||||||
@@ -281,7 +284,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
|||||||
## 12. 评委 PPT 卖点排序(写代码时记住为什么这么做)
|
## 12. 评委 PPT 卖点排序(写代码时记住为什么这么做)
|
||||||
|
|
||||||
1. 影像档案系统(统一 VL 拍照 + 归档) — 核心创意
|
1. 影像档案系统(统一 VL 拍照 + 归档) — 核心创意
|
||||||
2. 100% 本地 + SME2 加速 — 技术亮点
|
2. 100% 本地 + **MNN + Arm SME2 端侧 CPU 加速**(挑战赛考核点,MLX/GPU 兜底) — 技术亮点
|
||||||
3. 本地 RAG 长期记忆 — 端侧不可替代性
|
3. 本地 RAG 长期记忆 — 端侧不可替代性
|
||||||
4. 隐私三件套(系统级加密 + Face ID + 永久删除) — 信任建立
|
4. 隐私三件套(系统级加密 + Face ID + 永久删除) — 信任建立
|
||||||
5. AI 趋势解读 — 长期价值
|
5. AI 趋势解读 — 长期价值
|
||||||
|
|||||||
11
KangkangWidget-src/KangkangWidgetBundle.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// KangkangWidget extension 入口。
|
||||||
|
/// W5 做 Live Activity 时,把 ActivityConfiguration 也注册进这个 Bundle。
|
||||||
|
@main
|
||||||
|
struct KangkangWidgetBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
PinnedIndicatorsWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
249
KangkangWidget-src/PinnedIndicatorsWidget.swift
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 快照模型(主 App 的独立拷贝)
|
||||||
|
//
|
||||||
|
// ⚠️ 同步契约:与主 App `康康/Persistence/WidgetSnapshot.swift` 字段必须一致。
|
||||||
|
// extension 不引主 App 代码(免去 target membership 配置),改字段时两边一起改。
|
||||||
|
|
||||||
|
private struct WidgetSnapshot: Codable, Equatable {
|
||||||
|
struct Item: Codable, Equatable {
|
||||||
|
var name: String
|
||||||
|
var value: String
|
||||||
|
var unit: String
|
||||||
|
var statusRaw: String // high|low|normal
|
||||||
|
var capturedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedAt: Date
|
||||||
|
var items: [Item]
|
||||||
|
|
||||||
|
static let appGroupID = "group.com.xuhuayong.kangkang"
|
||||||
|
static let storeKey = "kk.widget.snapshot.v1"
|
||||||
|
|
||||||
|
static func load() -> WidgetSnapshot? {
|
||||||
|
guard let defaults = UserDefaults(suiteName: appGroupID),
|
||||||
|
let data = defaults.data(forKey: storeKey) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 调色(镜像主 App Tj.Palette,extension 不引 DesignSystem)
|
||||||
|
|
||||||
|
private enum KkColor {
|
||||||
|
static let sand = Color(red: 0.976, green: 0.969, blue: 0.949)
|
||||||
|
static let ink = Color(red: 0.165, green: 0.153, blue: 0.137)
|
||||||
|
static let text = Color(red: 0.149, green: 0.137, blue: 0.118)
|
||||||
|
static let text2 = Color(red: 0.420, green: 0.408, blue: 0.384)
|
||||||
|
static let text3 = Color(red: 0.616, green: 0.604, blue: 0.580)
|
||||||
|
static let brick = Color(red: 0.886, green: 0.388, blue: 0.314) // high
|
||||||
|
static let amber = Color(red: 0.871, green: 0.627, blue: 0.314) // low
|
||||||
|
static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518) // normal
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusColor(_ raw: String) -> Color {
|
||||||
|
switch raw {
|
||||||
|
case "high": return KkColor.brick
|
||||||
|
case "low": return KkColor.amber
|
||||||
|
default: return KkColor.leaf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timeline
|
||||||
|
|
||||||
|
private struct PinnedEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let items: [WidgetSnapshot.Item]
|
||||||
|
let updatedAt: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PinnedProvider: TimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> PinnedEntry {
|
||||||
|
PinnedEntry(date: .now, items: Self.sampleItems, updatedAt: .now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSnapshot(in context: Context, completion: @escaping (PinnedEntry) -> Void) {
|
||||||
|
if context.isPreview {
|
||||||
|
completion(placeholder(in: context))
|
||||||
|
} else {
|
||||||
|
completion(currentEntry())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeline(in context: Context, completion: @escaping (Timeline<PinnedEntry>) -> Void) {
|
||||||
|
// 数据由主 App 写快照后 reloadAllTimelines 主动推;这里 30 分钟兜底刷一次
|
||||||
|
// (只为让"x 天前"的相对时间不至于太陈旧)。
|
||||||
|
let entry = currentEntry()
|
||||||
|
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now) ?? .now
|
||||||
|
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentEntry() -> PinnedEntry {
|
||||||
|
let snap = WidgetSnapshot.load()
|
||||||
|
return PinnedEntry(date: .now, items: snap?.items ?? [], updatedAt: snap?.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let sampleItems: [WidgetSnapshot.Item] = [
|
||||||
|
.init(name: "收缩压", value: "128", unit: "mmHg", statusRaw: "normal",
|
||||||
|
capturedAt: .now.addingTimeInterval(-3600 * 5)),
|
||||||
|
.init(name: "空腹血糖", value: "6.4", unit: "mmol/L", statusRaw: "high",
|
||||||
|
capturedAt: .now.addingTimeInterval(-3600 * 30)),
|
||||||
|
.init(name: "体重", value: "68.5", unit: "kg", statusRaw: "normal",
|
||||||
|
capturedAt: .now.addingTimeInterval(-3600 * 50)),
|
||||||
|
.init(name: "尿酸", value: "486", unit: "μmol/L", statusRaw: "high",
|
||||||
|
capturedAt: .now.addingTimeInterval(-3600 * 80)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Views
|
||||||
|
|
||||||
|
private struct PinnedIndicatorsView: View {
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
|
let entry: PinnedEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if entry.items.isEmpty {
|
||||||
|
emptyView
|
||||||
|
} else {
|
||||||
|
switch family {
|
||||||
|
case .systemMedium: mediumView
|
||||||
|
default: smallView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.containerBackground(for: .widget) { KkColor.sand }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyView: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundStyle(KkColor.text3)
|
||||||
|
Text("在康康里关注指标后\n这里会显示最新值")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(KkColor.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 小尺寸:首条放大 + 其余最多 2 条小行。
|
||||||
|
private var smallView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
header
|
||||||
|
if let first = entry.items.first {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(first.name)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(KkColor.text2)
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||||||
|
Text(first.value)
|
||||||
|
.font(.system(size: 24, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundStyle(statusColor(first.statusRaw))
|
||||||
|
Text(first.unit)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(KkColor.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(entry.items.dropFirst().prefix(2), id: \.name) { item in
|
||||||
|
compactRow(item)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 中尺寸:两列网格,最多 6 条。
|
||||||
|
private var mediumView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
header
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible())],
|
||||||
|
alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(entry.items.prefix(6), id: \.name) { item in
|
||||||
|
gridCell(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("康康 · 长期监测")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(KkColor.text3)
|
||||||
|
Spacer()
|
||||||
|
if let updatedAt = entry.updatedAt {
|
||||||
|
Text(updatedAt, style: .relative)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(KkColor.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func compactRow(_ item: WidgetSnapshot.Item) -> some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(statusColor(item.statusRaw))
|
||||||
|
.frame(width: 5, height: 5)
|
||||||
|
Text(item.name)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(KkColor.text2)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 2)
|
||||||
|
Text(item.value)
|
||||||
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundStyle(KkColor.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func gridCell(_ item: WidgetSnapshot.Item) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(statusColor(item.statusRaw))
|
||||||
|
.frame(width: 5, height: 5)
|
||||||
|
Text(item.name)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(KkColor.text2)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
||||||
|
Text(item.value)
|
||||||
|
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundStyle(KkColor.text)
|
||||||
|
Text(item.unit)
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.foregroundStyle(KkColor.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget
|
||||||
|
|
||||||
|
struct PinnedIndicatorsWidget: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: "PinnedIndicatorsWidget", provider: PinnedProvider()) { entry in
|
||||||
|
PinnedIndicatorsView(entry: entry)
|
||||||
|
}
|
||||||
|
.configurationDisplayName("长期监测")
|
||||||
|
.description("展示你关注的健康指标最新值。数据 100% 在本机。")
|
||||||
|
.supportedFamilies([.systemSmall, .systemMedium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("small", as: .systemSmall) {
|
||||||
|
PinnedIndicatorsWidget()
|
||||||
|
} timeline: {
|
||||||
|
PinnedEntry(date: .now, items: PinnedProvider.sampleItems, updatedAt: .now)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("medium", as: .systemMedium) {
|
||||||
|
PinnedIndicatorsWidget()
|
||||||
|
} timeline: {
|
||||||
|
PinnedEntry(date: .now, items: PinnedProvider.sampleItems, updatedAt: .now)
|
||||||
|
}
|
||||||
52
docs/Widget接入步骤.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 桌面 Widget 接入步骤(约 3 分钟,Xcode 操作)
|
||||||
|
|
||||||
|
代码已全部写好。主 App 侧(快照写入 + RootView hook)已自动编译生效;
|
||||||
|
Widget extension 需要你在 Xcode 里建一次 target,再放入两个源文件。
|
||||||
|
|
||||||
|
## 1. 创建 Widget Extension target
|
||||||
|
|
||||||
|
1. Xcode 打开 `康康.xcodeproj` → 菜单 **File → New → Target…**
|
||||||
|
2. 选 **iOS → Widget Extension**,点 Next
|
||||||
|
3. Product Name 填 **`KangkangWidget`**
|
||||||
|
- ❌ 不勾 "Include Live Activity"(W5 做 Live Activity 时再往这个 target 里加,Bundle 入口已留好注释)
|
||||||
|
- ❌ 不勾 "Include Configuration App Intent"(我们用 StaticConfiguration)
|
||||||
|
4. 点 Finish;弹出 "Activate scheme?" 选 **Activate**
|
||||||
|
|
||||||
|
## 2. 替换模板代码
|
||||||
|
|
||||||
|
Xcode 会在工程根目录生成 `KangkangWidget/` 文件夹(含模板 swift 文件)。
|
||||||
|
|
||||||
|
1. 删除模板生成的所有 `.swift` 文件(`KangkangWidget.swift`、`KangkangWidgetBundle.swift`、`AppIntent.swift` 等,**保留 `Info.plist` 和 Assets**),选 "Move to Trash"
|
||||||
|
2. 把 `KangkangWidget-src/` 里的两个文件拖进 Xcode 的 `KangkangWidget` 文件夹(勾选 target:KangkangWidget):
|
||||||
|
- `KangkangWidgetBundle.swift`
|
||||||
|
- `PinnedIndicatorsWidget.swift`
|
||||||
|
3. 拖完后可删掉暂存目录 `KangkangWidget-src/`
|
||||||
|
|
||||||
|
## 3. 配置 App Group(两个 target 都要)
|
||||||
|
|
||||||
|
数据通过 App Group UserDefaults 传递,ID 固定为 **`group.com.xuhuayong.kangkang`**。
|
||||||
|
|
||||||
|
1. 选中工程 → target **康康** → Signing & Capabilities → **+ Capability → App Groups** → + 添加 `group.com.xuhuayong.kangkang`
|
||||||
|
2. target **KangkangWidget** → 同样添加 App Groups → 勾选同一个 `group.com.xuhuayong.kangkang`
|
||||||
|
3. KangkangWidget 的 **iOS Deployment Target 改成 17.0**(模板默认可能更高)
|
||||||
|
|
||||||
|
> 个人开发者账号下 App Group 会自动注册;如签名报错,在两个 target 的 Signing 里确认 Team 一致。
|
||||||
|
|
||||||
|
## 4. 验证
|
||||||
|
|
||||||
|
1. scheme 切回 **康康**,跑真机/模拟器
|
||||||
|
2. 进 App(首页出现即写入快照),回到桌面 → 长按 → 添加小组件 → 找 **康康 · 长期监测**
|
||||||
|
3. 小/中两个尺寸都支持。没有任何 pinned 指标时显示引导文案;
|
||||||
|
在趋势页关注指标(或 C2「关联到趋势」)后,回桌面即可看到最新值
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
- **小组件空白/不出现**:先确认两个 target 的 App Group 勾的是同一个 ID;再确认主 App 至少前台打开过一次(快照由主 App 写)
|
||||||
|
- **数据不更新**:快照在 App 进后台时刷新;强杀 App 不触发 `scenePhase == .background`,正常 Home 手势退出即可
|
||||||
|
- **编译报 `containerBackground` 不存在**:KangkangWidget 的 Deployment Target 没改成 17.0
|
||||||
|
|
||||||
|
## 架构备忘(给后续会话)
|
||||||
|
|
||||||
|
- 主 App 写快照:`康康/Persistence/WidgetSnapshot.swift`(数据契约)+ `WidgetSnapshotRefresher.swift`(pinned 指标 → App Group,RootView 在启动和进后台时调用)
|
||||||
|
- Widget 读快照:`KangkangWidget/PinnedIndicatorsWidget.swift` 内有 `WidgetSnapshot` 的**独立拷贝**(extension 不引主 App 代码)。⚠️ 改字段两边同步
|
||||||
|
- Widget 不读 SwiftData:store 有文件保护且在主 App 沙盒,extension 锁屏时读不到;快照 = 最后一次看到的值,锁屏也能显示
|
||||||
65
docs/legal/privacy-policy.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# 康康KK 隐私政策
|
||||||
|
|
||||||
|
生效日期:2026-05-31
|
||||||
|
|
||||||
|
康康KK 是一款本地优先的个人健康记录工具。本政策说明我们如何处理你的信息。
|
||||||
|
|
||||||
|
## 我们收集的信息
|
||||||
|
|
||||||
|
康康KK 不要求注册账号,不内置广告 SDK,不使用第三方分析 SDK,也不会主动将你的健康记录上传到我们的服务器。
|
||||||
|
|
||||||
|
你可以在 App 内自行记录或导入以下信息:
|
||||||
|
|
||||||
|
- 健康指标,例如血压、血糖、血脂、体重等。
|
||||||
|
- 体检、化验报告或其他健康资料照片。
|
||||||
|
- 症状记录、健康日记和个人资料。
|
||||||
|
- 本地提醒设置。
|
||||||
|
|
||||||
|
这些信息默认保存在你的设备本地。
|
||||||
|
|
||||||
|
## 权限用途
|
||||||
|
|
||||||
|
康康KK 可能请求以下系统权限:
|
||||||
|
|
||||||
|
- 相机:用于拍摄体检、化验报告或其他健康资料。
|
||||||
|
- 相册:用于读取你主动选择导入的报告或照片。
|
||||||
|
- Face ID:用于可选的本地 App 启动锁。
|
||||||
|
- 通知:用于你主动设置的本地提醒。
|
||||||
|
|
||||||
|
我们不会因为这些权限而访问与你选择无关的内容。
|
||||||
|
|
||||||
|
## AI 模型下载
|
||||||
|
|
||||||
|
康康KK 的本地 AI 功能需要下载模型文件。下载模型时,App 会连接模型文件服务器获取模型资源。模型下载请求可能包含常规网络信息,例如 IP 地址、请求时间和设备网络环境产生的技术日志。
|
||||||
|
|
||||||
|
健康记录、报告照片、症状和日记不会因为下载模型而上传。
|
||||||
|
|
||||||
|
## 数据存储
|
||||||
|
|
||||||
|
健康记录和导入的资料默认保存在设备本地。App 使用 iOS 系统提供的文件保护能力保护本地文件。你可以在 App 内删除记录;删除后,相关本地数据会从 App 数据库或文件目录中移除。
|
||||||
|
|
||||||
|
如果你通过系统备份、迁移或其他第三方工具处理设备数据,相关行为受对应服务或工具的政策约束。
|
||||||
|
|
||||||
|
## 数据共享
|
||||||
|
|
||||||
|
康康KK 不出售个人数据,不将健康记录用于广告追踪,也不会与第三方广告或分析服务共享你的健康数据。
|
||||||
|
|
||||||
|
只有在你主动使用系统分享功能时,相关内容才会由你选择的系统分享目标处理。
|
||||||
|
|
||||||
|
## 医疗说明
|
||||||
|
|
||||||
|
康康KK 是健康信息记录与整理工具,并非医疗器械。App 内的 AI 解读、趋势分析或问答内容仅供日常记录参考,不构成医疗诊断、治疗建议、用药或剂量建议,也不能替代医生、药师或其他专业人员的意见。任何健康决策请咨询专业医疗人员。
|
||||||
|
|
||||||
|
## 儿童隐私
|
||||||
|
|
||||||
|
康康KK 不面向儿童提供专门服务。未成年人使用本 App 时应取得监护人同意。
|
||||||
|
|
||||||
|
## 联系我们
|
||||||
|
|
||||||
|
如果你对本隐私政策有疑问,可以通过以下邮箱联系我们:
|
||||||
|
|
||||||
|
xuhuayong@gmail.com
|
||||||
|
|
||||||
|
## 政策更新
|
||||||
|
|
||||||
|
我们可能会根据功能变化或法律要求更新本政策。更新后的政策会在 App 或公开页面中展示。
|
||||||
81
docs/release/app-store-metadata.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# App Store Metadata
|
||||||
|
|
||||||
|
## App Name
|
||||||
|
|
||||||
|
康康KK
|
||||||
|
|
||||||
|
## Subtitle
|
||||||
|
|
||||||
|
本地优先的个人健康档案
|
||||||
|
|
||||||
|
## Promotional Text
|
||||||
|
|
||||||
|
把体检报告、化验指标、症状和日记整理在本机。无需账号,健康数据默认不上传。
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
康康KK 是一款本地优先的个人健康记录工具,帮助你把体检报告、化验指标、症状、日记和趋势整理在同一个地方。
|
||||||
|
|
||||||
|
你可以手动记录常见健康指标,拍照归档体检或化验报告,在时间线里回顾每次记录,也可以把重点指标加入趋势页,查看长期变化。
|
||||||
|
|
||||||
|
主要功能:
|
||||||
|
|
||||||
|
- 健康指标记录:记录血压、血糖、血脂、体重等常见指标,也支持自定义指标。
|
||||||
|
- 报告与照片归档:通过相机或相册导入体检、化验报告照片,保存到本机档案。
|
||||||
|
- 症状与日记:记录身体感受、症状变化和就医前想补充的信息。
|
||||||
|
- 趋势回顾:把长期关注的指标加入趋势页,查看变化曲线。
|
||||||
|
- 本地优先:无需注册账号,健康记录默认保存在设备本地。
|
||||||
|
- 可选本地 AI:下载模型后,可在设备本地辅助整理和通俗解释健康记录。
|
||||||
|
|
||||||
|
隐私与安全:
|
||||||
|
|
||||||
|
康康KK 不提供账号系统,不内置广告或第三方分析 SDK。健康数据默认保存在你的设备上。相机和相册权限仅用于导入你选择的报告或照片;Face ID 可用于本地 App 启动锁。
|
||||||
|
|
||||||
|
重要说明:
|
||||||
|
|
||||||
|
康康KK 是健康信息记录与整理工具,并非医疗器械。App 内的任何 AI 解读、趋势分析或问答内容仅供日常记录参考,不构成医疗诊断、治疗建议、用药或剂量建议,也不能替代医生、药师或其他专业人员的意见。任何健康决策请咨询专业医疗人员,并以原始报告和专业意见为准。
|
||||||
|
|
||||||
|
## Keywords
|
||||||
|
|
||||||
|
健康记录,体检报告,化验单,血压,血糖,健康档案,症状记录,健康日记,本地AI,隐私
|
||||||
|
|
||||||
|
## What's New
|
||||||
|
|
||||||
|
首次发布:支持健康指标、症状、日记和体检/化验报告的本地记录与趋势查看。
|
||||||
|
|
||||||
|
## Support URL
|
||||||
|
|
||||||
|
TODO: Add a public support URL before App Store submission.
|
||||||
|
|
||||||
|
## Privacy Policy URL
|
||||||
|
|
||||||
|
TODO: Add a public privacy policy URL before App Store submission.
|
||||||
|
|
||||||
|
## Category
|
||||||
|
|
||||||
|
Primary: Medical
|
||||||
|
|
||||||
|
Secondary: Health & Fitness
|
||||||
|
|
||||||
|
## Age Rating Notes
|
||||||
|
|
||||||
|
No gambling, no unrestricted web access, no user-generated public content, no commerce, no alcohol/tobacco/drug promotion, no medical treatment instructions. The app stores personal health records and includes medical disclaimers.
|
||||||
|
|
||||||
|
## App Review Notes
|
||||||
|
|
||||||
|
No login is required.
|
||||||
|
|
||||||
|
KangkangKK is a local-first personal health record app. It is not a medical device and does not provide diagnosis, treatment, medication, dosage, emergency triage, or doctor appointment services.
|
||||||
|
|
||||||
|
Suggested review steps:
|
||||||
|
|
||||||
|
1. Launch the app.
|
||||||
|
2. Tap the center + button.
|
||||||
|
3. Add a manual health metric, symptom, or diary entry.
|
||||||
|
4. View saved entries in the Records tab.
|
||||||
|
5. View charts in the Trends tab.
|
||||||
|
6. Open Me > About to review privacy and medical disclaimer information.
|
||||||
|
|
||||||
|
Camera and photo library permissions are used only when the reviewer chooses to import photos of lab reports or health documents. The app stores user records locally on device.
|
||||||
|
|
||||||
|
AI features are optional. They require downloading local models from the Model Management page and may require a higher-memory device. If the models are not downloaded, the app will show a model-not-ready state; this is expected and does not block the core record-management flows.
|
||||||
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. 主帖发出后把链接填进比赛报名系统/问卷(如果章程要求回填链接)
|
||||||
41
docs/research/mnn-kv-cache-prefix.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# MNN 前缀 KV Cache 调研(2026-06-10)
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
当前打包的 MNN.xcframework 已暴露 prefix cache 能力,技术上可以把每个场景**固定的
|
||||||
|
system prompt + few-shot 模板**的 prefill 结果缓存到磁盘,二次调用跳过这部分 prefill。
|
||||||
|
**建议 W6 polish 阶段、用性能自检卡量化 prefill 占比之后再决定是否接入**;当前瓶颈在
|
||||||
|
decode 而非二次 prefill,优先级低于 C1/C2/Live Activity。
|
||||||
|
|
||||||
|
## 依据(`Frameworks/MNN.xcframework/ios-arm64/MNN.framework/Headers/llm/llm.hpp`)
|
||||||
|
|
||||||
|
| API | 行号 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| `bool setPrefixCacheFile(const std::string& filename, int flag = 0)` | :161 | 指定前缀缓存文件;配套私有成员 `mPrefixCacheMode` / `mPrefixLength` / `mIsPrefixFileExist` / `completePrefixWrite()`(:250-255)印证:命中时 prefill 只算增量部分 |
|
||||||
|
| `bool reuse_kv()` | :171 | 读 config 开关 `reuse_kv`,多轮对话内复用 KV(同一会话增量 prefill) |
|
||||||
|
| `void syncPromptCache(const ChatMessages&)` | :176 | decode 结束后同步缓存文本——注释明确说明 cache 在 generate() 后自更新,此接口供做过后处理(如 deleteThinkPart)的调用方提供更准确版本 |
|
||||||
|
| `void setKVCacheInfo(size_t add, size_t remove, ...)` / `eraseHistory(begin, end)` | :158-160 | 更底层的 KV 区间管理,可做部分历史擦除 |
|
||||||
|
|
||||||
|
## 对本项目的适用性
|
||||||
|
|
||||||
|
- 我们所有调用都是「固定模板前缀 + 可变数据后缀」的单轮 `response()`,与 prefix cache
|
||||||
|
的模型吻合。
|
||||||
|
- 模板体量(估):报告识别 ~900 tok、导出报告 ~700 tok、意图抽取 ~300 tok。
|
||||||
|
按性能自检卡实测的 prefill 速率推算,每次调用预计省 **1~3s**。
|
||||||
|
- 多场景共用一个 cache 文件是否支持多前缀未知;最坏情况只对单一场景(建议选「报告识别」,
|
||||||
|
模板最长、调用最频繁)生效。
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
|
||||||
|
1. `flag` 参数语义在头文件无注释,需读 MNN 源码或实验确认。
|
||||||
|
2. OMNI(多模态)分支下行为未验证——我们的 MNN 模型是 Omni 构建。
|
||||||
|
3. cache 文件与模型权重版本绑定:模型更新/重下载后必须失效,否则可能输出乱码。
|
||||||
|
4. `<img>` 标签在 prompt 前部(`analyzeImages` 把图片标签拼在最前),意味着报告识别场景的
|
||||||
|
"固定前缀" 实际不固定 —— **文本场景(导出/意图抽取)才是干净的 prefix cache 候选**。
|
||||||
|
|
||||||
|
## 建议的接入步骤(W6,如性能自检显示 prefill 占比 >30%)
|
||||||
|
|
||||||
|
1. `MNNLLMBridge` init 后调 `setPrefixCacheFile(<AppSupport>/mnn-prefix.cache)`(仅文本场景)。
|
||||||
|
2. 真机 A/B:同一导出报告各跑 3 次,对比 `LlmContext.prefill_us`。
|
||||||
|
3. 异常处理:加载失败或输出劣化时删除 cache 文件并禁用,回退现状。
|
||||||
|
4. `ModelDownloadService.importModel` / 重下载路径上顺手删除旧 cache 文件。
|
||||||
1626
docs/superpowers/plans/2026-06-10-competition-optimizations.md
Normal file
930
docs/superpowers/plans/2026-06-10-voice-diary.md
Normal file
@@ -0,0 +1,930 @@
|
|||||||
|
# 语音健康日记 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 在「健康记录」(`DiaryQuickSheet`)加语音输入:iOS 端侧流式语音识别实时转写,停止后由本地 LLM(Qwen3.5-2B,经 AIRuntime)整理成健康日记草稿,追加进输入框,可一键回退原话。
|
||||||
|
|
||||||
|
**Architecture:** `DiaryQuickSheet`(mic 按钮 + 状态机)→ `SpeechDictationService`(新,AVAudioEngine + SFSpeechRecognizer 端侧流式转写,不落盘音频)→ `DiaryAssistService.organize(transcript:)`(新方法,经 AIRuntime actor 队列)。Spec:`docs/superpowers/specs/2026-06-10-voice-diary-design.md`。
|
||||||
|
|
||||||
|
**Tech Stack:** SwiftUI、Speech framework(`requiresOnDeviceRecognition = true`)、AVFoundation、Swift Testing(`康康Tests`)。
|
||||||
|
|
||||||
|
**工程约定(执行前必读):**
|
||||||
|
- 工程是 Xcode 16 同步组(`PBXFileSystemSynchronizedRootGroup`):`康康/`、`康康Tests/` 下新建文件**自动入 target,不要改 pbxproj 的文件列表**(权限键除外,见 Task 1)。
|
||||||
|
- CLI 编译/测试必须:`export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`,且加 `-derivedDataPath ./build/cli-dd`(避免和 Xcode 抢 build.db 锁)。
|
||||||
|
- 工程 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`:类型默认 MainActor;系统回调闭包(audio tap、recognitionTask handler)是 nonisolated,**闭包内只碰局部捕获变量,回主线程用 `Task { @MainActor in }`**。
|
||||||
|
- 用户可见文案用 `String(appLoc: "...")`;字号用 `Font.tjScaled(...)`,禁止裸 `.system(size:)`;颜色只用 `Tj.Palette.*`。**不要手改 `Localizable.xcstrings`**(键缺失时回退键名本身,中文键名即兜底文案)。
|
||||||
|
- `git status` 里已有 `康康/Localizable.xcstrings` 的无关改动——**任何 commit 都不要带上它**(逐文件 `git add`)。
|
||||||
|
- spec 偏差说明(已确认的两处小调整):① CLAUDE.md 提到的 `DebugAIRunner` 已不在工程中,prompt 自检改为 `康康Tests` 单元测试 + 真机手测清单;② mic 按钮放「内容」section 标签行右侧(而非输入框内右下角 overlay),避免与文字重叠,仍属"输入框旁"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 0: 建独立分支
|
||||||
|
|
||||||
|
**Files:** 无(纯 git)
|
||||||
|
|
||||||
|
- [x] **Step 1: 从当前分支建 `feat/voice-diary`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/xuhuayong/apps/康康
|
||||||
|
git checkout -b feat/voice-diary
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Switched to a new branch 'feat/voice-diary'`(`Localizable.xcstrings` 的本地改动会跟着工作区走,不影响)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 新增麦克风 + 语音识别权限描述(pbxproj)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `康康.xcodeproj/project.pbxproj:430`、`康康.xcodeproj/project.pbxproj:486`(Debug + Release 两个构建配置)
|
||||||
|
|
||||||
|
pbxproj 的 `INFOPLIST_KEY_*` 按字母序排列:Microphone 插在 `NSHealthUpdateUsageDescription` 之后,SpeechRecognition 插在 `NSPhotoLibraryUsageDescription` 之后。每个锚点行在文件中出现 **2 次**(Debug/Release),用 replace_all 一次改两处。
|
||||||
|
|
||||||
|
- [x] **Step 1: 插入 NSMicrophoneUsageDescription(replace_all)**
|
||||||
|
|
||||||
|
用 Edit 工具,`replace_all: true`:
|
||||||
|
|
||||||
|
old_string(注意行首是 4 个 tab):
|
||||||
|
```
|
||||||
|
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
|
||||||
|
```
|
||||||
|
|
||||||
|
new_string:
|
||||||
|
```
|
||||||
|
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 插入 NSSpeechRecognitionUsageDescription(replace_all)**
|
||||||
|
|
||||||
|
old_string:
|
||||||
|
```
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
||||||
|
```
|
||||||
|
|
||||||
|
new_string:
|
||||||
|
```
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
||||||
|
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: 验证两个键各出现 2 次**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -c "NSMicrophoneUsageDescription\|NSSpeechRecognitionUsageDescription" 康康.xcodeproj/project.pbxproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `4`
|
||||||
|
|
||||||
|
- [x] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add 康康.xcodeproj/project.pbxproj
|
||||||
|
git commit -m "feat(语音日记): 新增麦克风与语音识别权限描述(端侧识别文案)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: organize prompt(TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `康康Tests/DiaryOrganizePromptTests.swift`(新建)
|
||||||
|
- Modify: `康康/AI/Prompts/DiaryAssistPrompts.swift`(文件末尾 `}` 前加方法)
|
||||||
|
|
||||||
|
- [x] **Step 1: 写失败测试**
|
||||||
|
|
||||||
|
新建 `康康Tests/DiaryOrganizePromptTests.swift`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import Testing
|
||||||
|
@testable import 康康
|
||||||
|
|
||||||
|
struct DiaryOrganizePromptTests {
|
||||||
|
@Test func organizePromptContainsTranscriptAndHardRules() {
|
||||||
|
let prompt = DiaryAssistPrompts.organize(transcript: "今天早上头晕量了血压140 90")
|
||||||
|
|
||||||
|
#expect(prompt.contains("今天早上头晕量了血压140 90"))
|
||||||
|
// 健康数据红线:数值/单位/药名/时间不许改,必须写进 prompt
|
||||||
|
#expect(prompt.contains("数值"))
|
||||||
|
#expect(prompt.contains("药名"))
|
||||||
|
// 自适应样式两条规则都在
|
||||||
|
#expect(prompt.contains("一段通顺的话"))
|
||||||
|
#expect(prompt.contains("分行"))
|
||||||
|
// 项目 prompt 规范:禁思考标签
|
||||||
|
#expect(prompt.contains("/no_think"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func organizePromptTruncatesLongTranscript() {
|
||||||
|
let long = String(repeating: "头晕", count: 2000) // 4000 字符,超过上限
|
||||||
|
let prompt = DiaryAssistPrompts.organize(transcript: long)
|
||||||
|
|
||||||
|
// 整条 prompt 里口述部分被截断到 organizeTranscriptLimit
|
||||||
|
let expectedTail = String(long.prefix(DiaryAssistPrompts.organizeTranscriptLimit))
|
||||||
|
#expect(prompt.contains(expectedTail))
|
||||||
|
#expect(!prompt.contains(String(long.prefix(DiaryAssistPrompts.organizeTranscriptLimit + 2))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 跑测试确认编译失败(方法还不存在)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/xuhuayong/apps/康康
|
||||||
|
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||||
|
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||||
|
-only-testing:'康康Tests/DiaryOrganizePromptTests' \
|
||||||
|
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 编译错误 `type 'DiaryAssistPrompts' has no member 'organize'`(TEST FAILED)。
|
||||||
|
|
||||||
|
- [x] **Step 3: 实现 organize prompt**
|
||||||
|
|
||||||
|
在 `康康/AI/Prompts/DiaryAssistPrompts.swift` 的 enum 末尾(`suggest` 方法后、收尾 `}` 前)加:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// MARK: - 语音口述 → 日记整理
|
||||||
|
|
||||||
|
/// 口述转写稿截断上限(字符)。2B 模型 context 保护:超长口述只取前面部分。
|
||||||
|
static let organizeTranscriptLimit = 1200
|
||||||
|
|
||||||
|
/// 把语音转写稿整理成健康日记草稿。自适应样式:内容少 → 一段通顺的话;
|
||||||
|
/// 多方面 → 按「方面:内容」分行。
|
||||||
|
/// 红线(spec §2):只重组语言,严禁增删改任何数值、单位、药名、时间——
|
||||||
|
/// 2B 模型把 140/90 改成 130/90 即健康数据事故,所以规则放第一条并配 few-shot 强化。
|
||||||
|
static func organize(transcript: String) -> String {
|
||||||
|
let trimmed = String(transcript.prefix(organizeTranscriptLimit))
|
||||||
|
return """
|
||||||
|
你是健康记录助手。下面是用户口述身体状态的语音转写原话,可能口语化、有重复、缺标点。
|
||||||
|
请把它整理成一条清晰的健康日记。
|
||||||
|
|
||||||
|
硬性规则:
|
||||||
|
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
|
||||||
|
- 只重组语言:去掉口头语和重复;用第一人称;不加入原话没有的事实。
|
||||||
|
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
|
||||||
|
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。
|
||||||
|
- 不诊断、不给用药建议、不写「建议就医」。
|
||||||
|
- 只输出整理后的日记正文,不要解释、不要 markdown 围栏、不要 <think> 标签。
|
||||||
|
|
||||||
|
示例 1(口述:那个今天早上起来有点头晕然后我量了下血压140 90比平时高一点没吃早饭就出门了):
|
||||||
|
今天早上起来有点头晕,量了血压 140/90,比平时高一点。没吃早饭就出门了。
|
||||||
|
|
||||||
|
示例 2(口述:今天头晕了一上午下午好点了血压早上量的140 90嗯缬沙坦吃了降脂药忘了吃早饭没吃中午吃的清淡晚上散步了半小时):
|
||||||
|
症状:头晕了一上午,下午好转。
|
||||||
|
血压:早上 140/90。
|
||||||
|
用药:缬沙坦已服,降脂药忘服。
|
||||||
|
饮食:早饭未吃,午餐清淡。
|
||||||
|
运动:晚上散步半小时。
|
||||||
|
|
||||||
|
【口述原话】:
|
||||||
|
\(trimmed)
|
||||||
|
|
||||||
|
Output: /no_think
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: 跑测试确认通过**
|
||||||
|
|
||||||
|
同 Step 2 命令。Expected: `** TEST SUCCEEDED **`,2 个用例通过。
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add 康康Tests/DiaryOrganizePromptTests.swift 康康/AI/Prompts/DiaryAssistPrompts.swift
|
||||||
|
git commit -m "feat(语音日记): organize prompt(自适应样式 + 数值不可改红线)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: DiaryAssistService.organize
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `康康/Services/DiaryAssistService.swift:99` 之后(`suggest` 方法后、struct 收尾 `}` 前)
|
||||||
|
|
||||||
|
无新单测(纯转发 AIRuntime,LLM 行为靠真机手测;解析逻辑只有 strip + trim,复用已测过的 `stripThinkBlocks`)。
|
||||||
|
|
||||||
|
- [x] **Step 1: 加 organize 方法**
|
||||||
|
|
||||||
|
在 `suggest` 方法的收尾 `}` 之后、struct 收尾 `}` 之前加:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
/// 把语音转写稿整理成健康日记草稿(spec 2026-06-10-voice-diary)。
|
||||||
|
/// 失败(模型未就绪 / 输出为空)抛错,调用方回退为直接使用原话,不卡死。
|
||||||
|
/// 与 suggest 同样走 AIRuntime actor 队列,自然与追问/拍照串行。
|
||||||
|
func organize(transcript: String) async throws -> (text: String, decodeRate: Double) {
|
||||||
|
do {
|
||||||
|
try await AIRuntime.shared.prepare()
|
||||||
|
} catch {
|
||||||
|
throw AssistError.modelNotReady
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = DiaryAssistPrompts.organize(transcript: transcript)
|
||||||
|
var collected = ""
|
||||||
|
var lastRate: Double = 0
|
||||||
|
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400)
|
||||||
|
for try await chunk in stream {
|
||||||
|
collected += chunk.text
|
||||||
|
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = HealthExportService.stripThinkBlocks(collected)
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { throw AssistError.empty }
|
||||||
|
return (text, lastRate)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 编译验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/xuhuayong/apps/康康
|
||||||
|
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||||
|
xcodebuild -project 康康.xcodeproj -scheme 康康 \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||||
|
-configuration Debug build -derivedDataPath ./build/cli-dd \
|
||||||
|
CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "\.swift:[0-9]+:[0-9]+: (error|warning):|BUILD (SUCCEEDED|FAILED)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `BUILD SUCCEEDED`,无新增 warning。
|
||||||
|
|
||||||
|
- [x] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add 康康/Services/DiaryAssistService.swift
|
||||||
|
git commit -m "feat(语音日记): DiaryAssistService.organize 转写稿整理"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: SpeechDictationService(端侧流式转写)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `康康/Services/SpeechDictationService.swift`
|
||||||
|
|
||||||
|
硬件绑定,无单测;模拟器路径(`isAvailable == false`)与真机路径在 Task 7 手测。
|
||||||
|
|
||||||
|
- [x] **Step 1: 新建 SpeechDictationService.swift**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import Foundation
|
||||||
|
import Speech
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
/// 端侧流式语音转写(spec 2026-06-10-voice-diary)。
|
||||||
|
/// AVAudioEngine 麦克风 buffer → SFSpeechAudioBufferRecognitionRequest,
|
||||||
|
/// `requiresOnDeviceRecognition = true` 硬性端侧,识别内容不出设备;**不落盘任何音频**。
|
||||||
|
///
|
||||||
|
/// 生命周期:start(onPartial:) 开始录音并实时回调 partial;stop() 结束并返回最终稿。
|
||||||
|
/// 调用方:DiaryQuickSheet。工程默认 MainActor 隔离,本类型即 MainActor;
|
||||||
|
/// audio tap 与识别回调在系统线程,闭包内只碰局部捕获对象,回主线程统一走 Task { @MainActor }。
|
||||||
|
final class SpeechDictationService {
|
||||||
|
|
||||||
|
enum DictationError: Error, LocalizedError {
|
||||||
|
case unavailable
|
||||||
|
case audioEngineStartFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unavailable:
|
||||||
|
return String(appLoc: "本机不支持端侧语音识别")
|
||||||
|
case .audioEngineStartFailed(let m):
|
||||||
|
return String(appLoc: "录音启动失败:\(m)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 优先系统语言;系统语言不支持端侧时兜底中文(demo 机即使系统是英文也能用)。
|
||||||
|
private static func makeRecognizer() -> SFSpeechRecognizer? {
|
||||||
|
if let r = SFSpeechRecognizer(locale: .current), r.supportsOnDeviceRecognition {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if let r = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN")),
|
||||||
|
r.supportsOnDeviceRecognition {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本机是否支持端侧识别。false(模拟器/老机型)时 UI 隐藏 mic 入口,静默降级。
|
||||||
|
static var isAvailable: Bool { makeRecognizer() != nil }
|
||||||
|
|
||||||
|
private let audioEngine = AVAudioEngine()
|
||||||
|
private var request: SFSpeechAudioBufferRecognitionRequest?
|
||||||
|
private var task: SFSpeechRecognitionTask?
|
||||||
|
/// 识别回调持续刷新;isFinal 或出错时置 didFinish。stop() 用「final 优先、partial 兜底」。
|
||||||
|
private var latestText = ""
|
||||||
|
private var didFinish = false
|
||||||
|
|
||||||
|
private(set) var isRecording = false
|
||||||
|
|
||||||
|
/// 麦克风 + 语音识别两个权限一起申请。任一被拒返回 false。
|
||||||
|
func requestAuthorization() async -> Bool {
|
||||||
|
let speech = await withCheckedContinuation { (c: CheckedContinuation<SFSpeechRecognizerAuthorizationStatus, Never>) in
|
||||||
|
SFSpeechRecognizer.requestAuthorization { c.resume(returning: $0) }
|
||||||
|
}
|
||||||
|
guard speech == .authorized else { return false }
|
||||||
|
return await AVAudioApplication.requestRecordPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开始录音 + 流式识别。partial 结果在主线程回调(录音面板实时字幕)。
|
||||||
|
func start(onPartial: @escaping (String) -> Void) throws {
|
||||||
|
guard !isRecording else { return }
|
||||||
|
guard let recognizer = Self.makeRecognizer(), recognizer.isAvailable else {
|
||||||
|
throw DictationError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
do {
|
||||||
|
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
|
||||||
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||||
|
} catch {
|
||||||
|
throw DictationError.audioEngineStartFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||||
|
request.requiresOnDeviceRecognition = true // 红线:识别不出设备
|
||||||
|
request.shouldReportPartialResults = true
|
||||||
|
request.addsPunctuation = true
|
||||||
|
self.request = request
|
||||||
|
latestText = ""
|
||||||
|
didFinish = false
|
||||||
|
|
||||||
|
let input = audioEngine.inputNode
|
||||||
|
let format = input.outputFormat(forBus: 0)
|
||||||
|
// tap 在音频线程跑:只碰局部捕获的 request,不碰 self
|
||||||
|
input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
|
||||||
|
request.append(buffer)
|
||||||
|
}
|
||||||
|
audioEngine.prepare()
|
||||||
|
do {
|
||||||
|
try audioEngine.start()
|
||||||
|
} catch {
|
||||||
|
input.removeTap(onBus: 0)
|
||||||
|
deactivateSession()
|
||||||
|
throw DictationError.audioEngineStartFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||||
|
// 系统线程 → 主线程
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
if let result {
|
||||||
|
self.latestText = result.bestTranscription.formattedString
|
||||||
|
onPartial(self.latestText)
|
||||||
|
if result.isFinal { self.didFinish = true }
|
||||||
|
}
|
||||||
|
if error != nil { self.didFinish = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isRecording = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止录音,等待最终识别结果(最多 1.5s,超时用最新 partial),返回最终稿。
|
||||||
|
/// 中途识别出错时已拿到的 partial 一样返回(spec 错误表:照常进整理流程)。
|
||||||
|
func stop() async -> String {
|
||||||
|
guard isRecording else { return "" }
|
||||||
|
isRecording = false
|
||||||
|
|
||||||
|
audioEngine.stop()
|
||||||
|
audioEngine.inputNode.removeTap(onBus: 0)
|
||||||
|
request?.endAudio()
|
||||||
|
|
||||||
|
let deadline = Date().addingTimeInterval(1.5)
|
||||||
|
while !didFinish && Date() < deadline {
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
}
|
||||||
|
task?.cancel()
|
||||||
|
task = nil
|
||||||
|
request = nil
|
||||||
|
deactivateSession()
|
||||||
|
return latestText
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户直接关 sheet 时的清理:不关心结果,立即停。
|
||||||
|
func abort() {
|
||||||
|
guard isRecording else { return }
|
||||||
|
isRecording = false
|
||||||
|
audioEngine.stop()
|
||||||
|
audioEngine.inputNode.removeTap(onBus: 0)
|
||||||
|
request?.endAudio()
|
||||||
|
task?.cancel()
|
||||||
|
task = nil
|
||||||
|
request = nil
|
||||||
|
deactivateSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deactivateSession() {
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 编译验证**
|
||||||
|
|
||||||
|
同 Task 3 Step 2 命令。Expected: `BUILD SUCCEEDED`。若出现 actor 隔离 warning(标注 error in Swift 6 language mode 的不阻塞),按提示把回调内对 self 的访问收进 `Task { @MainActor in }`,不许用 `nonisolated(unsafe)` 糊。
|
||||||
|
|
||||||
|
- [x] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add 康康/Services/SpeechDictationService.swift
|
||||||
|
git commit -m "feat(语音日记): SpeechDictationService 端侧流式转写(不落盘音频)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: DiaryVoicePanel(录音/整理面板视图)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `康康/Features/Diary/DiaryVoicePanel.swift`
|
||||||
|
|
||||||
|
纯展示组件,状态全部外部传入,DiaryQuickSheet(已 600+ 行)不再膨胀。
|
||||||
|
|
||||||
|
- [x] **Step 1: 新建 DiaryVoicePanel.swift**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 「健康记录」语音输入面板(spec 2026-06-10-voice-diary)。
|
||||||
|
/// 两种模式:recording(实时字幕 + 计时 + 停止)/ organizing(AI 整理中,可取消)。
|
||||||
|
/// 纯展示:状态由 DiaryQuickSheet 持有并传入。
|
||||||
|
struct DiaryVoicePanel: View {
|
||||||
|
enum Mode: Equatable {
|
||||||
|
case recording(elapsedSeconds: Int)
|
||||||
|
case organizing
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode: Mode
|
||||||
|
/// recording 时为实时字幕;organizing 时为已定稿的转写稿(置灰展示)。
|
||||||
|
let transcript: String
|
||||||
|
let onStop: () -> Void
|
||||||
|
let onCancelOrganize: () -> Void
|
||||||
|
|
||||||
|
/// 录音上限 3 分钟(超时由 DiaryQuickSheet 的看门狗触发 onStop)。
|
||||||
|
static let maxRecordingSeconds = 180
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
header
|
||||||
|
transcriptArea
|
||||||
|
if case .recording = mode {
|
||||||
|
stopButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if mode == .organizing {
|
||||||
|
AIFlowBar().padding(.horizontal, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var header: some View {
|
||||||
|
switch mode {
|
||||||
|
case .recording(let elapsed):
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "waveform")
|
||||||
|
.font(.tjScaled(12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||||
|
Text("正在听 · 识别在本机完成")
|
||||||
|
.font(.tjScaled(13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(Self.format(elapsed))
|
||||||
|
.font(.tjScaled(12, design: .monospaced))
|
||||||
|
.foregroundStyle(elapsed >= Self.maxRecordingSeconds - 30
|
||||||
|
? Tj.Palette.brick : Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
case .organizing:
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.tjScaled(12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.symbolEffect(.pulse, options: .repeating)
|
||||||
|
Text("AI 整理中 · 本地推理")
|
||||||
|
.font(.tjScaled(13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button("取消") { onCancelOrganize() }
|
||||||
|
.font(.tjScaled(12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var transcriptArea: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
Text(transcript.isEmpty ? String(appLoc: "开始说话…") : transcript)
|
||||||
|
.font(.tjScaled(14))
|
||||||
|
.foregroundStyle(transcriptColor)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Color.clear.frame(height: 1).id("tail")
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 120)
|
||||||
|
.onChange(of: transcript) { _, _ in
|
||||||
|
proxy.scrollTo("tail", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var transcriptColor: Color {
|
||||||
|
if transcript.isEmpty { return Tj.Palette.text3 }
|
||||||
|
return mode == .organizing ? Tj.Palette.text3 : Tj.Palette.text
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stopButton: some View {
|
||||||
|
Button(action: onStop) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "stop.circle.fill")
|
||||||
|
Text("说完了,整理成日记")
|
||||||
|
}
|
||||||
|
.font(.tjScaled(14, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.brick)
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func format(_ seconds: Int) -> String {
|
||||||
|
String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("录音中") {
|
||||||
|
DiaryVoicePanel(mode: .recording(elapsedSeconds: 23),
|
||||||
|
transcript: "今天早上起来有点头晕,量了血压一百四九十",
|
||||||
|
onStop: {}, onCancelOrganize: {})
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("整理中") {
|
||||||
|
DiaryVoicePanel(mode: .organizing,
|
||||||
|
transcript: "今天早上起来有点头晕,量了血压一百四九十",
|
||||||
|
onStop: {}, onCancelOrganize: {})
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 编译验证**
|
||||||
|
|
||||||
|
同 Task 3 Step 2 命令。Expected: `BUILD SUCCEEDED`。
|
||||||
|
|
||||||
|
- [x] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add 康康/Features/Diary/DiaryVoicePanel.swift
|
||||||
|
git commit -m "feat(语音日记): DiaryVoicePanel 录音/整理面板"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: DiaryQuickSheet 接入(mic 按钮 + 状态机 + 回退 pill)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `康康/Features/Diary/DiaryQuickSheet.swift`
|
||||||
|
|
||||||
|
改 5 处:① 状态 + 录音流程函数;② 「内容」标签行加 mic 按钮;③ 输入框下方挂面板 / 提示条 / 回退 pill;④ `canRequestSuggest` 把 organizing 排除;⑤ onDisappear 清理。
|
||||||
|
|
||||||
|
- [x] **Step 1: 加语音状态(`@FocusState` 行之后、`hasContent` 之前)**
|
||||||
|
|
||||||
|
在 `DiaryQuickSheet.swift:38`(`@FocusState private var contentFocused: Bool`)之后插入:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
|
||||||
|
// MARK: 语音输入状态(spec 2026-06-10-voice-diary)
|
||||||
|
|
||||||
|
enum VoicePhase: Equatable { case idle, recording, organizing }
|
||||||
|
@State private var voicePhase: VoicePhase = .idle
|
||||||
|
@State private var liveTranscript = ""
|
||||||
|
@State private var recordingSeconds = 0
|
||||||
|
/// 最近一次最终转写稿,「改用原话」回退用;再次录音时覆盖。
|
||||||
|
@State private var rawTranscript: String?
|
||||||
|
/// 刚追加进正文的整理稿,用于「改用原话」时在正文中定位替换。
|
||||||
|
/// 用户手动编辑掉该段(正文中找不到了)时 pill 自然消失。
|
||||||
|
@State private var organizedAppended: String?
|
||||||
|
/// 一次性提示条文案(整理失败已填原话 / 没听清等),开始新录音时清掉。
|
||||||
|
@State private var voiceNote: String?
|
||||||
|
@State private var voiceDeniedAlert = false
|
||||||
|
@State private var voiceFlowTask: Task<Void, Never>?
|
||||||
|
@State private var recordingWatchdog: Task<Void, Never>?
|
||||||
|
private let dictation = SpeechDictationService()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 「内容」标签行加 mic 按钮**
|
||||||
|
|
||||||
|
把(`DiaryQuickSheet.swift:79-80` 附近):
|
||||||
|
|
||||||
|
```swift
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel(String(appLoc: "内容"))
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
sectionLabel(String(appLoc: "内容"))
|
||||||
|
Spacer()
|
||||||
|
if SpeechDictationService.isAvailable, voicePhase == .idle {
|
||||||
|
Button(action: startVoice) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "mic.fill")
|
||||||
|
.font(.tjScaled(11, weight: .semibold))
|
||||||
|
Text("说一段")
|
||||||
|
.font(.tjScaled(12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(isLoading ? Tj.Palette.text3 : Tj.Palette.brick)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Capsule().strokeBorder(
|
||||||
|
isLoading ? Tj.Palette.line : Tj.Palette.brick.opacity(0.5),
|
||||||
|
lineWidth: 1))
|
||||||
|
.contentShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isLoading) // AI 追问生成中不抢 AIRuntime 队列
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`TextField` 那段不动,仍在该 VStack 内。)
|
||||||
|
|
||||||
|
- [x] **Step 3: 输入框下方挂面板 / 提示条 / 回退 pill**
|
||||||
|
|
||||||
|
在 TextField 的 `.overlay(...)` 闭合后、该 VStack 的收尾 `}` 之前(即原 `DiaryQuickSheet.swift:95` `)` 与 `:96` `}` 之间)插入:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
|
||||||
|
if voicePhase != .idle {
|
||||||
|
DiaryVoicePanel(
|
||||||
|
mode: voicePhase == .organizing
|
||||||
|
? .organizing
|
||||||
|
: .recording(elapsedSeconds: recordingSeconds),
|
||||||
|
transcript: liveTranscript,
|
||||||
|
onStop: stopVoiceAndOrganize,
|
||||||
|
onCancelOrganize: cancelOrganize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let note = voiceNote {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.font(.tjScaled(11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(note)
|
||||||
|
.font(.tjScaled(11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let organized = organizedAppended,
|
||||||
|
rawTranscript != nil,
|
||||||
|
content.range(of: organized) != nil {
|
||||||
|
Button(action: revertToRawTranscript) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "arrow.uturn.backward")
|
||||||
|
.font(.tjScaled(10, weight: .semibold))
|
||||||
|
Text("改用原话")
|
||||||
|
.font(.tjScaled(11, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||||
|
.contentShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: organizing 期间禁用「AI 追问」+ 关 sheet 清理 + 权限 alert**
|
||||||
|
|
||||||
|
把 `DiaryQuickSheet.swift:48`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private var canRequestSuggest: Bool { hasContent && !isLoading }
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
|
||||||
|
```
|
||||||
|
|
||||||
|
把 `DiaryQuickSheet.swift:146`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
.onDisappear { suggestTask?.cancel() }
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
.onDisappear {
|
||||||
|
suggestTask?.cancel()
|
||||||
|
voiceFlowTask?.cancel()
|
||||||
|
recordingWatchdog?.cancel()
|
||||||
|
dictation.abort()
|
||||||
|
}
|
||||||
|
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $voiceDeniedAlert) {
|
||||||
|
Button(String(appLoc: "前往设置")) {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(String(appLoc: "取消"), role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: 加流程函数(`// MARK: - Actions` 区,`requestSuggestions` 之前)**
|
||||||
|
|
||||||
|
在 `DiaryQuickSheet.swift` 的 `sectionLabel` 函数后插入:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
|
||||||
|
// MARK: 语音输入流程
|
||||||
|
|
||||||
|
private func startVoice() {
|
||||||
|
contentFocused = false
|
||||||
|
voiceNote = nil
|
||||||
|
voiceFlowTask = Task { @MainActor in
|
||||||
|
guard await dictation.requestAuthorization() else {
|
||||||
|
voiceDeniedAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
liveTranscript = ""
|
||||||
|
recordingSeconds = 0
|
||||||
|
try dictation.start { partial in liveTranscript = partial }
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .recording }
|
||||||
|
// 计时 + 3 分钟看门狗(到点自动停,行为与点「停止」一致)
|
||||||
|
recordingWatchdog = Task { @MainActor in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
guard !Task.isCancelled, voicePhase == .recording else { return }
|
||||||
|
recordingSeconds += 1
|
||||||
|
if recordingSeconds >= DiaryVoicePanel.maxRecordingSeconds {
|
||||||
|
stopVoiceAndOrganize()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
voiceNote = error.localizedDescription
|
||||||
|
voicePhase = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopVoiceAndOrganize() {
|
||||||
|
guard voicePhase == .recording else { return }
|
||||||
|
recordingWatchdog?.cancel()
|
||||||
|
voiceFlowTask = Task { @MainActor in
|
||||||
|
let transcript = (await dictation.stop())
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
liveTranscript = transcript
|
||||||
|
guard !transcript.isEmpty else {
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||||
|
voiceNote = String(appLoc: "没听清,再试一次")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rawTranscript = transcript
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .organizing }
|
||||||
|
do {
|
||||||
|
let result = try await DiaryAssistService.shared.organize(transcript: transcript)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
appendToContent(result.text)
|
||||||
|
organizedAppended = result.text
|
||||||
|
lastRate = result.decodeRate
|
||||||
|
} catch is CancellationError {
|
||||||
|
// cancelOrganize 已处理回退,这里只收尾
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
appendToContent(transcript) // 红线 #5:整理失败回退原话,不卡死
|
||||||
|
organizedAppended = nil
|
||||||
|
voiceNote = String(appLoc: "AI 整理失败,已填入原话")
|
||||||
|
}
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消整理:中断 LLM,直接填原话(与失败回退同路径)。
|
||||||
|
private func cancelOrganize() {
|
||||||
|
guard voicePhase == .organizing else { return }
|
||||||
|
voiceFlowTask?.cancel()
|
||||||
|
if let raw = rawTranscript {
|
||||||
|
appendToContent(raw)
|
||||||
|
organizedAppended = nil
|
||||||
|
voiceNote = String(appLoc: "已取消整理,填入原话")
|
||||||
|
}
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「改用原话」:把刚追加的整理稿替换为原始转写稿(spec §2:LLM 改数兜底)。
|
||||||
|
private func revertToRawTranscript() {
|
||||||
|
guard let raw = rawTranscript,
|
||||||
|
let organized = organizedAppended,
|
||||||
|
let range = content.range(of: organized, options: .backwards) else { return }
|
||||||
|
withAnimation(.snappy(duration: 0.18)) {
|
||||||
|
content = content.replacingCharacters(in: range, with: raw)
|
||||||
|
organizedAppended = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 6: 编译验证(touch 强制重编拿全量警告)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/xuhuayong/apps/康康
|
||||||
|
touch 康康/Features/Diary/DiaryQuickSheet.swift
|
||||||
|
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||||
|
xcodebuild -project 康康.xcodeproj -scheme 康康 \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||||
|
-configuration Debug build -derivedDataPath ./build/cli-dd \
|
||||||
|
CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "\.swift:[0-9]+:[0-9]+: (error|warning):|BUILD (SUCCEEDED|FAILED)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `BUILD SUCCEEDED`,无新增 warning。
|
||||||
|
|
||||||
|
- [x] **Step 7: 跑全量单测(确认没碰坏别的)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||||
|
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `** TEST SUCCEEDED **`。
|
||||||
|
|
||||||
|
- [x] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add 康康/Features/Diary/DiaryQuickSheet.swift
|
||||||
|
git commit -m "feat(语音日记): DiaryQuickSheet 接入语音输入(录音→整理→回退原话)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: 验证与手测清单
|
||||||
|
|
||||||
|
**Files:** 无新增代码
|
||||||
|
|
||||||
|
- [x] **Step 1: 模拟器降级路径验证**
|
||||||
|
|
||||||
|
模拟器跑 App(或 Xcode Preview `DiaryQuickSheet`),打开「+ 新建 → 写日记」:
|
||||||
|
- `SpeechDictationService.isAvailable` 在模拟器多半为 false → 「说一段」按钮应**整体不显示**,其余功能照旧。
|
||||||
|
- 若模拟器恰好支持端侧识别(部分 macOS/Xcode 组合会),按钮出现也算通过——继续验证录音面板出现、无崩溃即可。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 真机手测清单(连 iPhone 跑,逐项确认)**
|
||||||
|
|
||||||
|
1. 首次点「说一段」→ 依次弹语音识别 + 麦克风两个系统权限框,文案是 Task 1 写的端侧说明
|
||||||
|
2. 拒绝权限 → 再点按钮弹「前往设置」alert,能跳系统设置
|
||||||
|
3. 录音中:实时字幕逐字上屏、计时走动、说话时 waveform 动画
|
||||||
|
4. 点「说完了,整理成日记」→ 面板转「AI 整理中」(AIFlowBar 流动)→ 整理稿**追加**进输入框(已有手打内容不被覆盖)
|
||||||
|
5. 口述含数值(如"血压一百四九十")→ 整理稿数值未被改动(说 3 条不同口述各验一次)
|
||||||
|
6. 「改用原话」pill 出现;点击 → 整理稿被替换为原始转写稿;再手动编辑正文该段 → pill 消失
|
||||||
|
7. 飞行模式(模型已下载)→ 全流程照常,验证 100% 本地
|
||||||
|
8. 一个字不说就点停止 → 「没听清,再试一次」,回 idle 不卡死
|
||||||
|
9. 模型未下载(或长按删除模型后)→ 整理失败 → 原话直接入框 + 提示
|
||||||
|
10. 录音中直接下滑关 sheet → 无崩溃,再次打开正常
|
||||||
|
11. 「AI 整理中」点取消 → 原话入框 + 「已取消整理,填入原话」
|
||||||
|
|
||||||
|
- [ ] **Step 3: 把手测结果记进 commit(若有 fix,随 fix 一起提)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit --allow-empty -m "test(语音日记): 真机手测清单通过(见 plan Task 7)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review 记录
|
||||||
|
|
||||||
|
- **Spec 覆盖**:权限(T1)、organize prompt + 自适应 + 数值红线(T2)、Service(T3)、端侧转写不落盘 + 3 分钟上限 + zh 兜底(T4)、面板 + 实时字幕(T5)、mic 入口 + 状态机 + 追加不覆盖 + 改用原话 + 全部错误回退 + organizing 禁用追问(T6)、手测含飞行模式/空转写/取消(T7)。spec 各节均有对应任务。
|
||||||
|
- **占位符**:无 TBD/TODO;所有代码步骤给了完整代码。
|
||||||
|
- **类型一致性**:`SpeechDictationService.isAvailable/requestAuthorization/start(onPartial:)/stop()/abort()` 在 T4 定义、T6 使用一致;`DiaryVoicePanel.Mode`/`maxRecordingSeconds` T5 定义、T6 使用一致;`organize(transcript:) -> (text:, decodeRate:)` T3 定义、T6 解构一致;`AssistError` 复用现有定义。
|
||||||
296
docs/superpowers/plans/2026-06-10-voice-export-composer.md
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# 「身体档案」输入框语音听写 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 在「身体档案」(`HealthExportSheet`)底部聊天输入框加端侧语音听写:点 mic 开始、识别文字实时流进输入框、再点停止,不调 LLM、不自动发送。
|
||||||
|
|
||||||
|
**Architecture:** 复用 `SpeechDictationService`(@State 持有);新增 static 纯函数 `merge(prefix:partial:)` 处理"已有文字 + 听写文字"拼接(唯一可单测逻辑);`HealthExportSheet` 加 6 个 @State + mic 按钮 + 3 个流程函数。Spec:`docs/superpowers/specs/2026-06-10-voice-export-composer-design.md`。
|
||||||
|
|
||||||
|
**Tech Stack:** SwiftUI、Speech(经 SpeechDictationService)、Swift Testing。
|
||||||
|
|
||||||
|
**工程约定:** 同 `2026-06-10-voice-diary.md` 的「执行前必读」(同步组免改 pbxproj、CLI 用 `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer` + `-derivedDataPath ./build/cli-dd`、全量并行测试不可信要 `-only-testing` 定点跑、commit 逐文件 add 不带 `Localizable.xcstrings`)。**当前环境注意**:xcode-select 已指向完整 Xcode 且许可证未接受——`git` 用 `DEVELOPER_DIR=/Library/Developer/CommandLineTools` 前缀绕过;`xcodebuild` 必须先让用户跑 `sudo xcodebuild -license accept`。直接在 `feat/mnn-sme2-runtime` 分支上做(上一功能合并后该分支即集成分支,不再另开分支避免并发会话分支错位)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `merge(prefix:partial:)`(TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `康康Tests/SpeechDictationMergeTests.swift`(新建)
|
||||||
|
- Modify: `康康/Services/SpeechDictationService.swift`(`isAvailable` 之后加 static 方法)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**
|
||||||
|
|
||||||
|
新建 `康康Tests/SpeechDictationMergeTests.swift`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import Testing
|
||||||
|
@testable import 康康
|
||||||
|
|
||||||
|
struct SpeechDictationMergeTests {
|
||||||
|
@Test func emptyPrefixReturnsPartial() {
|
||||||
|
#expect(SpeechDictationService.merge(prefix: "", partial: "今天头晕") == "今天头晕")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func plainPrefixJoinsWithSpace() {
|
||||||
|
#expect(SpeechDictationService.merge(prefix: "已有内容", partial: "新听写")
|
||||||
|
== "已有内容 新听写")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func whitespaceTerminatedPrefixConcatsDirectly() {
|
||||||
|
#expect(SpeechDictationService.merge(prefix: "第一行\n", partial: "新听写")
|
||||||
|
== "第一行\n新听写")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyPartialKeepsPrefix() {
|
||||||
|
#expect(SpeechDictationService.merge(prefix: "已有内容", partial: "") == "已有内容")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 跑测试确认编译失败**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/xuhuayong/apps/康康
|
||||||
|
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||||
|
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||||
|
-only-testing:'康康Tests/SpeechDictationMergeTests' \
|
||||||
|
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "error:|TEST (SUCCEEDED|FAILED)" | head -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `error: type 'SpeechDictationService' has no member 'merge'`(TEST FAILED)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现 merge**
|
||||||
|
|
||||||
|
在 `康康/Services/SpeechDictationService.swift` 的 `static var isAvailable` 行之后加:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
|
||||||
|
/// 听写文本拼接:听写开始时输入框已有 prefix,partial 持续拼在其后。
|
||||||
|
/// prefix 空 → 直接用 partial;prefix 以空白/换行结尾 → 直接连接;否则补一个空格。
|
||||||
|
static func merge(prefix: String, partial: String) -> String {
|
||||||
|
guard !partial.isEmpty else { return prefix }
|
||||||
|
guard !prefix.isEmpty else { return partial }
|
||||||
|
if let last = prefix.unicodeScalars.last,
|
||||||
|
CharacterSet.whitespacesAndNewlines.contains(last) {
|
||||||
|
return prefix + partial
|
||||||
|
}
|
||||||
|
return prefix + " " + partial
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 跑测试确认通过**
|
||||||
|
|
||||||
|
同 Step 2 命令。Expected: `** TEST SUCCEEDED **`,4 个用例通过。
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/xuhuayong/apps/康康
|
||||||
|
DEVELOPER_DIR=/Library/Developer/CommandLineTools git add 康康Tests/SpeechDictationMergeTests.swift 康康/Services/SpeechDictationService.swift
|
||||||
|
DEVELOPER_DIR=/Library/Developer/CommandLineTools git commit -m "feat(语音听写): SpeechDictationService.merge 前缀拼接(TDD)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: HealthExportSheet 接入
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `康康/Features/Archive/HealthExportSheet.swift`(状态区 :27-30、canAsk :38、canGenerateReport :49、快捷问答 chip :133、onDisappear :103、alert :104、composer :410)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 加听写状态(「快捷问答」状态块之后、`init` 之前)**
|
||||||
|
|
||||||
|
在 `@State private var newPromptText = ""` 之后插入:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
|
||||||
|
// 语音听写(spec 2026-06-10-voice-export-composer)。
|
||||||
|
// dictation 必须 @State:struct View 重建时普通 let 会换新实例(日记踩过的坑)。
|
||||||
|
@State private var dictation = SpeechDictationService()
|
||||||
|
@State private var isDictating = false
|
||||||
|
/// 听写开始时输入框已有文字,partial 始终拼在它后面。
|
||||||
|
@State private var dictationPrefix = ""
|
||||||
|
@State private var dictationTask: Task<Void, Never>?
|
||||||
|
@State private var dictationWatchdog: Task<Void, Never>?
|
||||||
|
@State private var dictationDeniedAlert = false
|
||||||
|
/// 录音上限,与日记一致(防麦克风悬挂)。
|
||||||
|
private static let dictationMaxSeconds = 180
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 录音中禁发送/生成/chip**
|
||||||
|
|
||||||
|
`canAsk` 加条件:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private var canAsk: Bool {
|
||||||
|
!isAnswering &&
|
||||||
|
!isGeneratingReport &&
|
||||||
|
!isDictating &&
|
||||||
|
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`canGenerateReport` 的 `!isGeneratingReport &&` 后加 `!isDictating &&`。
|
||||||
|
|
||||||
|
快捷问答 chip 动作(`draftQuestion = p.prompt` 处)改为:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
guard !isDictating else { return }
|
||||||
|
draftQuestion = p.prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: composer 加 mic 按钮 + TextField 录音中禁用**
|
||||||
|
|
||||||
|
TextField 的 `.disabled(isAnswering || isGeneratingReport)` 改为 `.disabled(isAnswering || isGeneratingReport || isDictating)`。
|
||||||
|
|
||||||
|
TextField 与发送 Button 之间插入:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
|
||||||
|
if SpeechDictationService.isAvailable {
|
||||||
|
Button { toggleDictation() } label: {
|
||||||
|
Image(systemName: isDictating ? "stop.fill" : "mic.fill")
|
||||||
|
.font(.tjScaled(15, weight: .semibold))
|
||||||
|
.foregroundStyle(isDictating ? Tj.Palette.paper : Tj.Palette.brick)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(Circle().fill(isDictating ? Tj.Palette.brick : Tj.Palette.brickSoft))
|
||||||
|
.symbolEffect(.pulse, options: .repeating, isActive: isDictating)
|
||||||
|
}
|
||||||
|
.disabled(isAnswering || isGeneratingReport)
|
||||||
|
.accessibilityLabel(isDictating ? String(appLoc: "停止听写") : String(appLoc: "语音输入"))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 生命周期 + 权限 alert**
|
||||||
|
|
||||||
|
`.onDisappear { task?.cancel() }` 改为:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
.onDisappear {
|
||||||
|
task?.cancel()
|
||||||
|
dictationTask?.cancel()
|
||||||
|
dictationWatchdog?.cancel()
|
||||||
|
dictation.abort()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
现有「添加快捷问答」alert 的 `}` 闭合之后追加:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $dictationDeniedAlert) {
|
||||||
|
Button(String(appLoc: "前往设置")) {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(String(appLoc: "取消"), role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("语音输入全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 流程函数(`// MARK: - Actions` 之后、`sendQuestion` 之前)**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// MARK: 语音听写
|
||||||
|
|
||||||
|
private func toggleDictation() {
|
||||||
|
if isDictating { stopDictation() } else { startDictation() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startDictation() {
|
||||||
|
questionFocused = false
|
||||||
|
dictationTask = Task { @MainActor in
|
||||||
|
guard await dictation.requestAuthorization() else {
|
||||||
|
dictationDeniedAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
dictationPrefix = draftQuestion
|
||||||
|
try dictation.start { partial in
|
||||||
|
draftQuestion = SpeechDictationService.merge(prefix: dictationPrefix,
|
||||||
|
partial: partial)
|
||||||
|
}
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { isDictating = true }
|
||||||
|
dictationWatchdog = Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(Self.dictationMaxSeconds) * 1_000_000_000)
|
||||||
|
guard !Task.isCancelled, isDictating else { return }
|
||||||
|
stopDictation()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
isDictating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopDictation() {
|
||||||
|
guard isDictating else { return }
|
||||||
|
dictationWatchdog?.cancel()
|
||||||
|
dictationTask = Task { @MainActor in
|
||||||
|
let final = (await dictation.stop()).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !final.isEmpty {
|
||||||
|
draftQuestion = SpeechDictationService.merge(prefix: dictationPrefix,
|
||||||
|
partial: final)
|
||||||
|
}
|
||||||
|
// final 为空:partial 已实时在输入框,保持现状即天然兜底(spec:不提示「没听清」)
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { isDictating = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: touch 强制重编验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/xuhuayong/apps/康康
|
||||||
|
touch 康康/Features/Archive/HealthExportSheet.swift
|
||||||
|
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||||
|
xcodebuild -project 康康.xcodeproj -scheme 康康 \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||||
|
-configuration Debug build -derivedDataPath ./build/cli-dd \
|
||||||
|
CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "\.swift:[0-9]+:[0-9]+: (error|warning):|BUILD (SUCCEEDED|FAILED)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `BUILD SUCCEEDED`,无新增 warning。
|
||||||
|
|
||||||
|
- [ ] **Step 7: 定点回归(语音相关全部测试)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild test -project 康康.xcodeproj -scheme 康康 \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||||
|
-only-testing:'康康Tests/SpeechDictationMergeTests' \
|
||||||
|
-only-testing:'康康Tests/SpeechDictationAvailabilityTests' \
|
||||||
|
-only-testing:'康康Tests/DiaryOrganizePromptTests' \
|
||||||
|
-derivedDataPath ./build/cli-dd CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "Test case.*(passed|failed)|TEST (SUCCEEDED|FAILED)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `** TEST SUCCEEDED **`,7 用例通过。
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/xuhuayong/apps/康康
|
||||||
|
DEVELOPER_DIR=/Library/Developer/CommandLineTools git add 康康/Features/Archive/HealthExportSheet.swift
|
||||||
|
DEVELOPER_DIR=/Library/Developer/CommandLineTools git commit -m "feat(语音听写): 身体档案输入框听写实时上屏"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 真机手测清单
|
||||||
|
|
||||||
|
- [ ] **Step 1: 真机逐项确认**
|
||||||
|
|
||||||
|
1. 「身体档案」composer 出现 mic 按钮(模拟器不支持端侧识别时隐藏)
|
||||||
|
2. 点 mic → 说话 → 字实时出现在输入框;输入框已有文字时保留并以空格衔接
|
||||||
|
3. 录音中:输入框/发送/「生成整理报告」/快捷问答 chip 均不可用;mic 为红色停止态
|
||||||
|
4. 再点 mic → 停止,文字落定,点发送正常走问答
|
||||||
|
5. 权限拒绝 → alert 跳设置
|
||||||
|
6. 录音中直接关 sheet → 无崩溃、麦克风指示灯熄灭
|
||||||
|
7. 3 分钟自动停止
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review 记录
|
||||||
|
|
||||||
|
- **Spec 覆盖**:merge 纯函数+单测(T1)、@State 持有/实时上屏/停止落定/空结果保持现状(T2 S5)、mic 隐藏与禁用矩阵(T2 S2-S3)、权限 alert + onDisappear abort + 看门狗(T2 S4-S5)、真机清单(T3)。无缺口。
|
||||||
|
- **占位符**:无;所有代码步骤给全。
|
||||||
|
- **类型一致性**:`merge(prefix:partial:)` T1 定义、T2 S5 调用一致;`dictationMaxSeconds`/`isDictating`/`dictationPrefix` 命名前后一致;`SpeechDictationService.isAvailable/requestAuthorization/start/stop/abort` 与现有实现签名一致。
|
||||||
@@ -413,3 +413,18 @@ Output:
|
|||||||
| **合计** | **~14h ≈ 2 个工作日** |
|
| **合计** | **~14h ≈ 2 个工作日** |
|
||||||
|
|
||||||
也是 W3「AskService 基础 RAG」的前置铺路工作,工程上一举两得。
|
也是 W3「AskService 基础 RAG」的前置铺路工作,工程上一举两得。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 修订记录:防编造加固(2026-05-30)
|
||||||
|
|
||||||
|
**现象**:导出摘要出现整份虚构病例(疲劳/盗汗/血红蛋白98/阿司匹林…),不符任何真实记录。
|
||||||
|
|
||||||
|
**根因(双重)**:① §数据范围里「Diary 由关键词过滤后入 prompt」在泛化请求(无症状词,如「最近身体异常」)下把日记**全部清空** → 真实记录没进 prompt;② 数据稀疏时,1.7B 在固定 6 段模板上**凭训练先验脑补**完整病例(对「只用数据/缺失写无记录」这类约束遵循差)。
|
||||||
|
|
||||||
|
**修复(三层,客户端硬保证为主)**:
|
||||||
|
1. **检索**:`retrieve` 改为——有症状词→按词过滤(保留隐私);无症状词→纳入时间窗内最近 5 条日记,确保真实记录进 prompt。
|
||||||
|
2. **空数据硬兜底**:`isEffectivelyEmpty` 判定无任何记录且 profile 空时,**跳过 LLM**,用 `fallbackReport` 产出确定性「6 段全无记录、主诉仅照搬原话」的摘要,从根上杜绝空数据编造。
|
||||||
|
3. **prompt 重写**:从「撰写」改为「抽取/搬运」框架;反编造铁律首尾各一遍;加一条**稀疏 few-shot** 教模型「缺失写无记录、数值原样照搬」。
|
||||||
|
|
||||||
|
**残留限制**:部分数据(如仅 1 条日记)仍走 LLM,强约束 + few-shot 大幅降低但不能 100% 杜绝小模型臆造;后续可加生成后数值校验。
|
||||||
|
|||||||
146
docs/superpowers/specs/2026-05-30-custom-reminder-design.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# 自由周期提醒(CustomReminder)— 设计文档
|
||||||
|
|
||||||
|
**日期**:2026-05-30(W2)
|
||||||
|
**作者**:link2026 + Claude
|
||||||
|
**关联卖点**:#4 隐私三件套之外的实用粘性功能(本地通知,无云)
|
||||||
|
**优先级**:用户明确要求(注:§10.6「用药提醒」原列默认不做,本轮经讨论确认要做,按最小可用实现)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 一句话定位
|
||||||
|
|
||||||
|
让用户新建**自由文案的周期性本地提醒**(如「每天 20:00 跑步 5 公里」「每天 12:30 吃 2 片护肝片」),与现有「指标记录提醒」(去录某项指标)并存但相互独立。完全本地 `UserNotifications`,不引云。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 已确认的设计决策
|
||||||
|
|
||||||
|
| 决策点 | 选择 |
|
||||||
|
|---|---|
|
||||||
|
| 模型 | 新建独立 `CustomReminder` @Model,不动现有 `MetricReminder` |
|
||||||
|
| 周期粒度 | **每日 / 每周选几天 / 每月某日 / 每年某月某日**(2026-05-30 用户反转原「不做按月/按年」决策)。仍不做「每 N 天间隔」/一次性 |
|
||||||
|
| 时间选择 | 常用时间快捷预设(8:00/12:00/18:00/22:00 chip)+ 保留 `DatePicker` 精调 |
|
||||||
|
| 入口 | 新建 → 开启一个提醒 → `RemindersListView`(提醒中心),顶部「+ 新建提醒」打开编辑 sheet |
|
||||||
|
| 列表范围 | 自由提醒 + 指标提醒**合展**(上次删了「我的」入口,指标提醒也只能从这里管) |
|
||||||
|
| 量词(5公里/2片) | 写在自由文本 `title` 里,不单设字段 |
|
||||||
|
| 多语言 | 所有固定文案走 `String(appLoc:)`,新增中文 key 补 en/ja/ko 到 `Localizable.xcstrings` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 数据模型
|
||||||
|
|
||||||
|
`Models/Models.swift` 新增:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@Model final class CustomReminder {
|
||||||
|
enum Frequency: String { case daily, weekly, monthly, yearly } // 嵌套枚举
|
||||||
|
@Attribute(.unique) var id: UUID
|
||||||
|
var title: String // 用户文案:"跑步5公里"
|
||||||
|
var note: String // 可选备注 → 通知正文
|
||||||
|
var hour: Int // 0...23
|
||||||
|
var minute: Int // 0...59
|
||||||
|
var weekdays: [Int] // 1=日…7=六,仅 weekly 用(复用 MetricReminder 约定)
|
||||||
|
var frequencyRaw: String = "daily" // Frequency 原始值(内联默认 → 走轻量迁移)
|
||||||
|
var dayOfMonth: Int = 1 // monthly / yearly 用,1...31
|
||||||
|
var month: Int = 1 // yearly 用,1...12
|
||||||
|
var enabled: Bool
|
||||||
|
var createdAt: Date
|
||||||
|
var updatedAt: Date
|
||||||
|
// computed: frequency(get/set 包 frequencyRaw)/ isEveryDay / frequencyLabel(分档)/ timeLabel
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema 已含 `CustomReminder.self`。**本轮只给已存在的 `CustomReminder` 加 3 个带内联默认值的属性 → SwiftData 自动轻量迁移,不触发删库兜底(见 §10)。**
|
||||||
|
|
||||||
|
四档语义 → iOS `UNCalendarNotificationTrigger(repeats:true)`:
|
||||||
|
| 频率 | DateComponents | 通知数 | id 后缀 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| daily | hour,minute | 1 | `.daily` |
|
||||||
|
| weekly | hour,minute,weekday ×N | N | `.w<weekday>` |
|
||||||
|
| monthly | day,hour,minute | 1 | `.monthly` |
|
||||||
|
| yearly | month,day,hour,minute | 1 | `.yearly` |
|
||||||
|
|
||||||
|
边界:iOS 重复触发**不顺延**。monthly 选 29/30/31 → 无此日的月份跳过(UI 给浅色提示);yearly 的「日」选项按所选月份最大天数动态收口(避免「4月31日」永不触发),仅闰年 2/29 给提示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 通知调度(ReminderService 泛化)
|
||||||
|
|
||||||
|
抽出私有共享核心,两种提醒复用:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private static func schedule(idBase:title:body:hour:minute:weekdays:thread:) async
|
||||||
|
static func sync(_ custom: CustomReminder) async // 新增
|
||||||
|
static func cancel(customId: UUID) // 新增
|
||||||
|
static func sync(_ metric: MetricReminder) async // 现有,内部改走共享核心,行为不变
|
||||||
|
```
|
||||||
|
|
||||||
|
- custom 通知:`title` = 提醒标题,`body` = 备注(空则用默认文案「到点啦,记得完成」)。
|
||||||
|
- id 前缀 `kangkang.custom.<uuid>.w<weekday>`(与指标的 `kangkang.reminder.<metricId>.w<weekday>` 不冲突)。
|
||||||
|
- 保存时调 `requestAuthorization()`;被拒则提示去系统设置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UI
|
||||||
|
|
||||||
|
### 5.1 `CustomReminderEditSheet`(新增)
|
||||||
|
创建 / 编辑共用。字段:
|
||||||
|
- 标题 TextField(占位:「做点什么?例:跑步5公里 / 吃2片护肝片」),空标题禁用保存。
|
||||||
|
- 备注 TextField(可选)。
|
||||||
|
- 时间 DatePicker(.hourAndMinute)。
|
||||||
|
- 周几选择(复用 RemindersListView 的 chip 行)。
|
||||||
|
- 保存 / 取消;编辑态多一个「删除提醒」。
|
||||||
|
保存:写 SwiftData → 请求通知权限 → `ReminderService.sync(custom)`。
|
||||||
|
|
||||||
|
### 5.2 `RemindersListView`(改造为提醒中心)
|
||||||
|
- 顶部「+ 新建提醒」按钮 → 打开 `CustomReminderEditSheet`(create)。
|
||||||
|
- 「我的提醒」区:`@Query CustomReminder`,每行点开走编辑 sheet,行上 Toggle 控 enabled。
|
||||||
|
- 「指标记录提醒」区:`@Query MetricReminder`,保持现有内联编辑不变(仅非空时显示区头)。
|
||||||
|
- 表头副文案、空状态文案更新。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 多语言
|
||||||
|
|
||||||
|
新增中文 key + en/ja/ko 译文写入 `Localizable.xcstrings`(源语言 zh-Hans,key 即中文)。脚本只增不改,已存在的 key 跳过。复用已有 key:时间/保存/取消/删除提醒/每天/已关闭/周几名等。用户输入的标题/备注是数据,不翻译。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 文件清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `Models/Models.swift` | `CustomReminder` +`Frequency` 枚举 +`frequencyRaw/dayOfMonth/month`(均带内联默认)+ 分档 `frequencyLabel` |
|
||||||
|
| `App/KangkangApp.swift` | **持久化兜底改造**:迁移失败时由「删库」改为「挪到 `StoreBackups/<时间戳>/` 再重建」(见 §10) |
|
||||||
|
| `Services/ReminderService.swift` | 调度核心泛化为 `Slot(suffix,DateComponents)` 列表;custom sync 按 frequency 分档;`cancelBase` 覆盖 daily/monthly/yearly/w1-7 |
|
||||||
|
| `Features/Me/CustomReminderEditSheet.swift` | 频率分段 Picker + 各档子控件(周几 / 日 / 月+日)+ 时间快捷预设行 |
|
||||||
|
| `Features/Me/RemindersListView.swift` | 不变(`frequencyLabel` 来自模型) |
|
||||||
|
| `Localizable.xcstrings` | 新增 11 个 key × en/ja/ko |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 红线对齐
|
||||||
|
|
||||||
|
- 不引云、不碰密码学(纯本地通知)✅
|
||||||
|
- 不重构 Tab/RecordSheet 骨架 ✅
|
||||||
|
- §10.6「用药提醒默认不做」→ 已讨论确认,最小实现(无贪睡/铃声/间隔)✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 验收(真机)
|
||||||
|
|
||||||
|
① 新建「每天 20:00 跑步 5 公里」→ 列表出现 → 到点收到本地通知(标题=跑步5公里);② 改时间/周几即时重排;③ 关闭 Toggle 取消通知;④ 删除清除 pending;⑤ 切换语言后固定文案随之变化(用户输入文案不变);⑥ 指标提醒仍在同一列表可管;⑦ **每月/每年**:切频率后子控件随之变化,边界提示出现;改频率后旧档 pending 通知被清掉(不留孤儿);⑧ **时间预设**:点 8:00/12:00/18:00/22:00 即填,精调仍可用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 顺带修复:重打包数据丢失(根因 + 方案)
|
||||||
|
|
||||||
|
**问题**:Demo 期每次改 schema 重打包,SwiftData 数据被清空。
|
||||||
|
|
||||||
|
**根因(单点)**:`App/KangkangApp.swift` 的 `ModelContainer` 创建 catch 块**直接删 store 文件**。SwiftData 只对**纯增量**改动自动轻量迁移;一旦某次改动超纲(最常见:给已存在的 `@Model` 新增「非可选且无内联默认值」的属性),自动迁移抛错 → 落入 catch → 删库。W2 几乎每次都在改 schema,故体感「每次都丢」。
|
||||||
|
|
||||||
|
**方案(两层)**:
|
||||||
|
1. **治本**:新增 `@Model` 属性一律「可选」或「内联默认值」(本轮 3 个新字段都给了 `= "daily"` / `= 1`)→ 走轻量迁移、不进 catch、数据保留。
|
||||||
|
2. **兜底**:catch 不再删库,改为把旧 store(含 `-wal`/`-shm`)**挪到 `Application Support/StoreBackups/<时间戳>/`** 再重建——App 仍能启动,旧数据可手动恢复;挪不动才降级删除。
|
||||||
|
|
||||||
|
⚠️ 正式发布前仍应升级为 `VersionedSchema` + `SchemaMigrationPlan` 的正式迁移(注释已就地标注)。
|
||||||
130
docs/superpowers/specs/2026-05-30-faceid-app-lock-design.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Face ID 启动锁 — 设计文档
|
||||||
|
|
||||||
|
**日期**:2026-05-30(W2)
|
||||||
|
**作者**:link2026 + Claude
|
||||||
|
**关联卖点**:#4 隐私三件套(系统级加密 + Face ID + 永久删除)
|
||||||
|
**优先级**:P1(CLAUDE.md §6 / §8 / §11,原排期 W5 末,提前实现)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 一句话定位
|
||||||
|
|
||||||
|
可选的 Face ID/Touch ID 启动锁(默认关)。开启后,冷启动与「后台超过 1 分钟再回前台」都需要系统认证才能进入 App;失败可用设备密码兜底。完全基于系统 `LocalAuthentication`,不自造任何密码学(对齐红线 §10.2)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 设计决策(已与用户确认)
|
||||||
|
|
||||||
|
| 决策点 | 选择 |
|
||||||
|
|---|---|
|
||||||
|
| 锁屏时机 | 冷启动 + 后台超过宽限才重锁 |
|
||||||
|
| 后台宽限 | 60 秒 |
|
||||||
|
| 认证策略 | `.deviceOwnerAuthentication`(Face ID/Touch ID 优先,自动跳设备密码兜底,避免锁死) |
|
||||||
|
| 默认状态 | 关(§6) |
|
||||||
|
| 开关位置 | 「我的」Tab 现有的 Face ID 卡,改为可交互 Toggle |
|
||||||
|
| 任务切换器隐私遮罩 | 加,**仅锁开启时生效**(进 `.inactive`/`.background` 盖品牌遮罩,防多任务快照泄露;默认关用户无感) |
|
||||||
|
|
||||||
|
**关于 §6「截屏黑屏防护…不做」**:那条针对的是**截图防护**(iOS 无官方 API);本设计的任务切换器遮罩是 `.inactive` 盖视图,是官方支持的标准做法,性质不同。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
KangkangApp
|
||||||
|
└─ WindowGroup { AppLockContainer { RootView() } } ← 仅包一层,RootView 零改动(§10.7)
|
||||||
|
│
|
||||||
|
┌─────────────┴──────────────────────────────┐
|
||||||
|
│ AppLockContainer<Content> │
|
||||||
|
│ @Environment(\.scenePhase) │
|
||||||
|
│ 渲染 content │
|
||||||
|
│ .overlay { if isLocked → LockScreen}│
|
||||||
|
│ .overlay { else if showsCover → PrivacyCover}│
|
||||||
|
│ onAppear → handleAppear(); │
|
||||||
|
│ onChange(scenePhase) → handleScenePhase() │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
│ 读写
|
||||||
|
┌─────────────┴──────────────────────────────┐
|
||||||
|
│ AppLock.shared (@MainActor @Observable) │ ← Security/AppLock.swift
|
||||||
|
│ enabled ←→ UserDefaults("faceIDLockEnabled")│
|
||||||
|
│ isLocked / showsPrivacyCover │
|
||||||
|
│ biometryAvailable / biometryLabel │
|
||||||
|
│ gracePeriod = 60s,lastBackgroundedAt │
|
||||||
|
│ authenticate() / enableWithAuth() / disable()│
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
单例写法与项目既有 `ModelDownloadService.shared` 一致(`@MainActor @Observable final class` + `static let shared`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 触发逻辑(状态机)
|
||||||
|
|
||||||
|
| scenePhase / 事件 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 容器 `onAppear`(冷启动) | `enabled` 为真且尚未冷启动锁过 → `isLocked = true` + 触发认证 |
|
||||||
|
| `.background` | `lastBackgroundedAt = now`;`showsPrivacyCover = enabled` |
|
||||||
|
| `.inactive`(任务切换器) | `showsPrivacyCover = enabled && !isLocked` |
|
||||||
|
| `.active` | 隐藏遮罩;若 `enabled && !isLocked && 离开 > 60s` → `isLocked = true`;若 `isLocked` → 触发认证;清空 `lastBackgroundedAt` |
|
||||||
|
| 认证成功 | `isLocked = false` |
|
||||||
|
| 认证失败/取消 | 保持锁定,锁屏提供「解锁」按钮重试(`isAuthenticating` 防重入,不重复弹窗) |
|
||||||
|
|
||||||
|
冷启动时 scenePhase 初值为 `.active` 不触发 `onChange`,由 `handleAppear()` 负责冷启动锁;两路触发由 `isAuthenticating` 守卫去重。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 能力探测与兜底
|
||||||
|
|
||||||
|
- `refreshAvailability()`:`LAContext.canEvaluatePolicy(.deviceOwnerAuthentication)` → `biometryAvailable`;读 `biometryType` 决定文案(Face ID / Touch ID / 密码)。
|
||||||
|
- 设备未设密码/无生物识别 → `biometryAvailable = false`,「我的」开关置灰,副标题「本设备未设置 Face ID 或密码」。
|
||||||
|
- 认证全程系统弹窗;失败/取消不抛错给 UI,只是停留锁屏。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 文件清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `康康/Security/AppLock.swift` | **新增**:单例 + LAContext 封装 + 触发逻辑 |
|
||||||
|
| `康康/Security/AppLockContainer.swift` | **新增**:包裹层 + scenePhase 驱动 + 两个 overlay |
|
||||||
|
| `康康/Security/LockScreenView.swift` | **新增**:`LockScreenView` + `PrivacyCoverView` |
|
||||||
|
| `康康/App/KangkangApp.swift` | `RootView()` → `AppLockContainer { RootView() }` |
|
||||||
|
| `康康/Features/Me/MeView.swift` | 静态 Face ID 卡 → 可交互 Toggle 卡 |
|
||||||
|
| `康康.xcodeproj/project.pbxproj` | 加 `INFOPLIST_KEY_NSFaceIDUsageDescription`(Debug + Release) |
|
||||||
|
|
||||||
|
工程用文件系统同步组,新增 `Security/` 下的源文件自动纳入编译,无需手改 pbxproj 注册。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. UI
|
||||||
|
|
||||||
|
锁屏(`LockScreenView`,全遮罩,走 Tj tokens):
|
||||||
|
|
||||||
|
```
|
||||||
|
🔒 (lock glyph)
|
||||||
|
康康 已锁定
|
||||||
|
你的健康档案已加密保护
|
||||||
|
[ Face ID 解锁 ] ← onAppear 自动触发一次认证;按钮文案随设备能力变
|
||||||
|
```
|
||||||
|
|
||||||
|
隐私遮罩(`PrivacyCoverView`):品牌色底 + app 名,无交互,仅用于遮挡多任务快照。
|
||||||
|
|
||||||
|
「我的」Face ID 卡:Toggle 开启时先认证一次(成功才置 `enabled`),关闭直接关。副标题动态:「已开启 · Face ID」/「关闭」/「本设备未设置 Face ID 或密码」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 红线对齐(CLAUDE.md §10)
|
||||||
|
|
||||||
|
- 不自造密码学,只用系统 `LocalAuthentication` ✅
|
||||||
|
- 默认关,可选开关 ✅
|
||||||
|
- 不引云 ✅
|
||||||
|
- 不重构 Tab/RecordSheet 骨架,只加一层包裹 ✅
|
||||||
|
- 清单内功能(§6/§8/§11 明列 Face ID 启动锁)✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 测试与验收
|
||||||
|
|
||||||
|
- 单元测试价值低(核心是系统弹窗 + scenePhase),不强求;`AppLock` 的宽限判定逻辑可抽纯函数测(可选)。
|
||||||
|
- **真机验收**:① 开关开启走 Face ID;② 杀进程冷启动需认证;③ 后台 <60s 回来不锁、>60s 回来锁;④ 多任务切换器快照被遮罩;⑤ 关 Face ID 录入(模拟失败)能跳设备密码;⑥ 默认关时全程无感。
|
||||||
|
- 模拟器:Features → Face ID → Enrolled / Matching Face 可模拟。
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 异常项快拍(局部小框 + VL 识别)— 设计
|
||||||
|
|
||||||
|
> 日期:2026-05-31 · 分支:feat/w2-ai-foundation
|
||||||
|
> 需求:异常项快拍要拍摄局部,采用小框拍局部,用 Qwen-VL 识别被拍区域→检测项目结构化数据;
|
||||||
|
> 存储前用户确认;最后只存参数和异常值,可和「记录指标」统一保存。
|
||||||
|
|
||||||
|
## 1. 现状与缺口
|
||||||
|
|
||||||
|
- `RecordSheet.quick`(标题「异常项快拍」)已存在,但 `RootView.recordFlow(.quick)` 当前直接路由到
|
||||||
|
`UnifiedCaptureFlow` —— 与「体检报告归档」(`.archive`)完全一样,走的是整页文档扫描,**没有局部小框**,
|
||||||
|
也会把整份当 `Report` + 原图存档。这与需求(局部 / 只存数值 / 不留图 / 并入指标)不符。
|
||||||
|
- `Features/Quick/` 下 `A1ViewfinderView` / `A2ConfirmView` / `SmartFramer` / `QuickCaptureFlow` /
|
||||||
|
`A3BatchView` 均为早期 mockup,全树无外部引用(纯孤儿)。`A1ViewfinderView` 有小框引导和 AVFoundation
|
||||||
|
预览,但**快门未接线**(`capturePhoto()` 从不触发)、**不裁剪**。
|
||||||
|
|
||||||
|
## 2. 目标流程
|
||||||
|
|
||||||
|
```
|
||||||
|
RecordSheet(.quick)
|
||||||
|
→ QuickRegionCaptureFlow(状态机)
|
||||||
|
├ 真机: RegionCameraView(实时预览 + 居中小框 + 快门 → 裁剪到小框的 UIImage)
|
||||||
|
└ 模拟器: PhotoPickerSheet(无小框,整图送 VL)
|
||||||
|
→ CaptureService.recognizeRegion(imageData:) ──actor──► AIRuntime.analyzeReport ─► VLSession
|
||||||
|
↑ VLPrompts.regionExtraction()
|
||||||
|
→ QuickRegionConfirmView(逐项可编辑 + 勾选纳入 + 测量时间;异常项高亮置顶)
|
||||||
|
→ 保存:勾选项各插入一条独立 Indicator(无 Report、无 Asset);ctx.save()
|
||||||
|
```
|
||||||
|
|
||||||
|
红线遵守:UI 不直接调 `AIRuntime`,经 `CaptureService`(§3.1);`AIRuntime` actor 串行(复用既有 VL 路径,
|
||||||
|
不新增并发);无新增 `@Model`,不触发 SwiftData 迁移。
|
||||||
|
|
||||||
|
## 3. 组件
|
||||||
|
|
||||||
|
### 3.1 RegionCameraView.swift(新建,取代 A1ViewfinderView)
|
||||||
|
- AVFoundation 实时预览,`videoGravity = .resizeAspectFill`。
|
||||||
|
- 居中**局部小框**(屏宽 ~84% × 高 ~140pt,虚线框 + 半透明遮罩挖空),提示「把异常项放进框里 · 对准一两行」。
|
||||||
|
- 底部快门键、顶部取消键。
|
||||||
|
- 拍照后:`previewLayer.metadataOutputRectConverted(fromLayerRect: 小框rect)` → 归一化裁剪 rect;
|
||||||
|
先把照片方向 bake 成 `.up`,再按归一化 rect 裁 `CGImage`,回调裁剪后的 `UIImage`。
|
||||||
|
- 相机权限:被拒时显示「去设置开启相机」态。
|
||||||
|
- 纯函数 `RegionImageCropper.crop(_:normalizedRect:)` + `UIImage.normalizedUp()`,与 View 解耦便于推理/复用。
|
||||||
|
|
||||||
|
### 3.2 VLPrompts.regionExtraction()(加进 VLPrompts.swift)
|
||||||
|
- 说明「这是报告的局部照片,可能只有一两行指标」。
|
||||||
|
- 严格 JSON,只要 `{"indicators":[{name,value,unit,range,status}]}`,**不要**报告元信息。
|
||||||
|
- status 由 value 与 range 自判;range 保留原文;不发明指标,看不清整行跳过。
|
||||||
|
- 2 个 few-shot(单行 / 两行)。
|
||||||
|
|
||||||
|
### 3.3 CaptureService.recognizeRegion(imageData: Data)(加进 CaptureService.swift)
|
||||||
|
- 把 JPEG 写临时文件(`NSTemporaryDirectory`,`.completeFileProtection`),`defer` 删除。
|
||||||
|
- `prepareVL()` → `analyzeReport(imageURLs:[temp], prompt: regionExtraction())`。
|
||||||
|
- 新增 `parseIndicatorsJSON(_:)`:复用 `extractJSONObject` + `parseIndicator`,抽出 `indicators` 数组,
|
||||||
|
返回 `[ParsedReport.ParsedIndicator]`。失败抛 `CaptureError`(UI 回退手动录入)。
|
||||||
|
|
||||||
|
### 3.4 QuickRegionCaptureFlow.swift(新建,状态机)
|
||||||
|
- `Phase { idle, analyzing(UIImage), confirm(items, warning) }`。
|
||||||
|
- 裁剪图 → analyzing → Task:JPEG 编码 → `recognizeRegion` → confirm。
|
||||||
|
- 30s 超时哨兵 → confirm(空 + warning);各类错误 → confirm(空 + warning)。
|
||||||
|
- 无 Vault 资产需清理(临时文件已在 service 内删除);取消即关闭。
|
||||||
|
|
||||||
|
### 3.5 QuickRegionConfirmView.swift(新建,确认 UI)
|
||||||
|
- 头部「核对异常项 · 只存数值,不保留照片」+ 内存中的裁剪缩略图(仅核对用,**不持久化**)。
|
||||||
|
- 测量时间 DatePicker(默认 now)。
|
||||||
|
- 指标列表:逐项可编辑(name/value/unit/range/status)+ 勾选「纳入保存」。
|
||||||
|
异常(high/low)项红色高亮、置顶、默认勾选;正常项默认也勾选(用户可取消),体现「只存参数和异常值」由用户掌控。
|
||||||
|
- 「加一项」手动补充(VL 空结果回退)。
|
||||||
|
- 底栏:取消 / 保存到记录(N 项)。
|
||||||
|
|
||||||
|
### 3.6 RootView 路由
|
||||||
|
- `.quick → QuickRegionCaptureFlow(onClose:)`(原为 `UnifiedCaptureFlow`)。
|
||||||
|
|
||||||
|
### 3.7 清理
|
||||||
|
- 删除 5 个孤儿 mockup:A1ViewfinderView / A2ConfirmView / SmartFramer / QuickCaptureFlow / A3BatchView。
|
||||||
|
|
||||||
|
## 4. 数据落库
|
||||||
|
|
||||||
|
- 每个勾选项 → 一条 `Indicator(name,value,unit,range,status,capturedAt,note=nil,pinned=false,seriesKey=nil)`。
|
||||||
|
- 不建 `Report`,不存 `Asset`(原图丢弃)→ 符合「最后只存参数和异常值」。
|
||||||
|
- 与「记录指标」自由输入路径落库一致(同一 Indicator 表,进记录时间线;不带 seriesKey 不强制进趋势)。
|
||||||
|
|
||||||
|
## 5. 取舍
|
||||||
|
|
||||||
|
- **裁剪 vs 整图**:需求明确「小框拍局部 / 识别被拍区域」,故真机裁剪到小框(也提升小目标 VL 准确率、降 token)。
|
||||||
|
模拟器无实时小框 → 退化为整图(与既有 UnifiedCaptureFlow 模拟器退化一致)。
|
||||||
|
- **不留图**:遵循「只存参数和异常值」与隐私基线,临时文件推理后即删,不写 Vault、不建 Asset。
|
||||||
|
- **正常项是否保存**:默认全部勾选、异常项高亮,正常项可手动取消 —— 不静默丢弃用户可能想留的读数。
|
||||||
|
- **不动既有归档流程**:UnifiedCaptureFlow / B3 / C2 不变;本功能只重写 `.quick` 这一条路径。
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# 导出身体档案 — 指标趋势段 设计
|
||||||
|
|
||||||
|
> 2026-06-07 · 在「导出身体档案」(`HealthExportService`)的输出里,为本次就诊相关、且有历史记录的指标补一段确定性计算的趋势摘要。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前导出是「快照式」:`HealthExportService.retrieve()` 在时间窗内每个指标只取最近一条,`serializeData()` 序列化成单点数值(name/value/unit/range/status/date),交给 LLM 拼成「## 关键指标」一段。医生看不到指标随时间的变化方向。
|
||||||
|
|
||||||
|
需求:导出要带上**相关指标的趋势信息**(同一指标多次记录的变化)。
|
||||||
|
|
||||||
|
## 决策(已与用户确认)
|
||||||
|
|
||||||
|
| 维度 | 决定 |
|
||||||
|
|---|---|
|
||||||
|
| 覆盖范围 | 本次就诊相关(命中关键词或异常)且时间窗内有 **≥2 次**记录的指标 |
|
||||||
|
| 粒度 | 一行摘要(首值→末值 + 方向箭头 + 时间跨度 + 次数) |
|
||||||
|
| 生成方式 | **确定性计算**(模板拼装,不经 LLM),与 `ReportCompareService` 同思路,零编造风险 |
|
||||||
|
| 呈现位置 | LLM 输出 6 段之后,**追加**独立一段 `## 指标趋势`;无数据则整段省略 |
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
retrieve() ──► 全量 in-window 指标(裁剪前) ┐
|
||||||
|
└─► 相关指标集(裁剪后,决定哪些 series 出趋势) ┤
|
||||||
|
▼
|
||||||
|
ExportTrendBuilder.build(...) → [TrendSummary]
|
||||||
|
▼
|
||||||
|
Snapshot.trends ──► export() 在 completed 前追加 "## 指标趋势"
|
||||||
|
```
|
||||||
|
|
||||||
|
LLM 链路(prompt / `serializeData`)**完全不变**——趋势不进 JSON,LLM 不知情。
|
||||||
|
|
||||||
|
## 组件
|
||||||
|
|
||||||
|
### 1. `TrendSummary`(值类型)
|
||||||
|
|
||||||
|
一个 series 的趋势结果。字段:
|
||||||
|
|
||||||
|
- `title: String` — 显示名(如「收缩压」「血压」)
|
||||||
|
- `unit: String`
|
||||||
|
- `firstValue: Double`、`lastValue: Double`
|
||||||
|
- `firstDate: Date`、`lastDate: Date`
|
||||||
|
- `count: Int` — 时间窗内记录次数
|
||||||
|
- `direction: Direction`(`.up` / `.down` / `.flat`)
|
||||||
|
- `range: String` — 参考范围原文(可空)
|
||||||
|
- `flagged: Bool` — 末值仍异常 **或** 跨越参考范围边界,为真时行首加 `⚠️`
|
||||||
|
|
||||||
|
方法 `line() -> String`,一行中文,格式:
|
||||||
|
|
||||||
|
```
|
||||||
|
收缩压 152→138 mmHg ↓(参考 90-140),近 21 天 4 次
|
||||||
|
```
|
||||||
|
|
||||||
|
- 方向箭头:`.up` → `↑`、`.down` → `↓`、`.flat` → `→`
|
||||||
|
- `flagged` 为真前缀 `⚠️ `
|
||||||
|
- `range` 为空时省略「(参考 …)」括号
|
||||||
|
- 数值用与现有指标一致的格式化(去掉无意义小数;血压等整数不带小数点)
|
||||||
|
|
||||||
|
> 血压合并行:`title` = 「血压」,数值写成「收缩/舒张」对,如 `血压 152/96→138/88 mmHg ↓…`;方向以收缩压为准。
|
||||||
|
|
||||||
|
### 2. `ExportTrendBuilder`(纯函数,可单测)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum ExportTrendBuilder {
|
||||||
|
static func build(allInWindow: [Indicator],
|
||||||
|
relevant: [Indicator]) -> [TrendSummary]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
逻辑:
|
||||||
|
|
||||||
|
1. **确定相关 series**:从 `relevant` 收集 series 标识(优先 `seriesKey`,无则 `name|unit`)。
|
||||||
|
2. **分组全量点**:把 `allInWindow` 按同一 series 标识分组;血压 `bp.systolic` + `bp.diastolic` 归到合成 series「血压」。
|
||||||
|
3. **过滤**:只保留(a)属于相关 series、(b)点数 ≥2 的组。
|
||||||
|
4. 每组按 `capturedAt` 升序,取首/末点,算:
|
||||||
|
- `direction`:相对变化 `|last-first|/first`,<5% → `.flat`,否则按符号 `.up`/`.down`(first 为 0 时退化按绝对差判定)
|
||||||
|
- `flagged`:末点 `status != .normal`,或首点 normal 而末点非 normal(或反之,跨界)
|
||||||
|
- `count`、`firstDate`、`lastDate`、`range`(取末点的 range)
|
||||||
|
5. 排序:`flagged` 优先,其次按 `lastDate` 倒序。
|
||||||
|
6. 返回 `[TrendSummary]`。
|
||||||
|
|
||||||
|
数值解析复用现有方式(`Double(indicator.value)`);解析失败的点跳过,若有效点 <2 则该 series 不出趋势。
|
||||||
|
|
||||||
|
### 3. 接入 `HealthExportService`
|
||||||
|
|
||||||
|
- `Snapshot` 加 `trends: [TrendSummary]`。
|
||||||
|
- `retrieve()`:在现有第 268 行 fetch 全量 in-window 指标后,保留该全量列表;裁剪逻辑不变得到 `indicators`(相关集);调用 `ExportTrendBuilder.build(allInWindow: 全量, relevant: indicators)` 填入 `Snapshot.trends`。
|
||||||
|
- `serializeData()`:**不改**(趋势不进 LLM)。
|
||||||
|
- `export()`:在发出 `completed` 事件、把内容存进 `HealthExport.content` 之前,若 `snapshot.trends` 非空,把 `## 指标趋势` 段追加到 LLM markdown 末尾。空数据兜底路径(`isEffectivelyEmpty`)trends 自然为空,不追加。
|
||||||
|
|
||||||
|
`## 指标趋势` 段渲染:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 指标趋势
|
||||||
|
⚠️ 收缩压 152→138 mmHg ↓(参考 90-140),近 21 天 4 次
|
||||||
|
空腹血糖 6.8→6.2 mmol/L ↓(参考 3.9-6.1),近 28 天 3 次
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
`ExportTrendBuilder.build` 是纯函数,单测覆盖:
|
||||||
|
|
||||||
|
- 升 / 降 / 平稳(阈值边界)方向判定
|
||||||
|
- 血压双 series 合并成一行
|
||||||
|
- 点数 <2 的 series 被过滤
|
||||||
|
- 不相关 series(不在 relevant 集)被过滤
|
||||||
|
- 跨参考范围边界 → `flagged = true`
|
||||||
|
- 数值无法解析的点被跳过
|
||||||
|
|
||||||
|
## 不做
|
||||||
|
|
||||||
|
- 不改 LLM prompt / `serializeData`(零编造风险的前提)
|
||||||
|
- 不引入 embedding、不加新颜色/字体 token
|
||||||
|
- 不改导出 UI 布局(仅输出内容多一段;`HealthExportSheet` / `HealthExportDetailView` 的 `MarkdownView` 已能渲染新段落)
|
||||||
|
- 不做逐点列表 / 峰谷均值(本次只要一行摘要)
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# 趋势大改 + 健康日历移至主页 — 设计文档
|
||||||
|
|
||||||
|
> 日期:2026-06-07 · 状态:已定方案(用户授权直接实现,免确认)
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
当前「趋势」Tab(`TrendsView.swift`)把两件事混在一起:
|
||||||
|
|
||||||
|
1. **健康日历**(月/年视图 + 当日详情)—— 占据页面上半部分。
|
||||||
|
2. **长期监测折线图**(`seriesSection`)—— 页面下半部分。
|
||||||
|
|
||||||
|
两个问题:
|
||||||
|
|
||||||
|
- **日历放错了地方**。它是「总览记录情况」的入口,更适合放在主页(用户每天第一眼看的页面),而不是埋在趋势 Tab 里。
|
||||||
|
- **趋势能力太弱**。`SeriesBucket.build` **只按 `seriesKey` 分桶**,因此只有 8 个长期监测预设(血压/血糖/体温…)和自定义指标能成图。所有**没有 seriesKey 的指标**——报告里解析出来的化验项、VL 快拍、自由输入——即使在多份报告里反复出现(如「血红蛋白」体检了 3 次),也**完全看不到趋势**。
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
1. **健康日历移到主页**:主页新增一张紧凑的「健康日历」卡(当前周的横条 + 本月记录摘要),点击展开完整的月/年总览页(可切月视图/年视图、看当日详情)。
|
||||||
|
2. **趋势 Tab 重构**:对**任何出现 ≥2 次的指标**(不限于长期监测预设)做时间序列查看。趋势页变成一个「可成趋势的指标」总览列表(分长期监测 / 化验指标两段),点任一项进入详情页:大图表 + 参考范围带 + 统计摘要(最新/最高/最低/平均/对比上次)+ 时间范围筛选 + 数据点列表(点击跳当日详情)。
|
||||||
|
|
||||||
|
### 非目标(本次不做)
|
||||||
|
|
||||||
|
- **AI 趋势解读**:需要 AIRuntime + TrendService 跑通,风险大、与本次「时间序列查看」正交。本次预留 UI 位但不接 LLM,留作后续。
|
||||||
|
- 不改 SwiftData schema(无 @Model 字段变更,规避迁移丢数据风险)。
|
||||||
|
- 不改 `Localizable.xcstrings`(新文案用 `String(appLoc: "中文")`,无对应词条时优雅回退到中文 key,符合既有大量用法;避免 xcstrings 噪声 diff)。
|
||||||
|
- 不动 TabBar 5 槽骨架、不动录入流程。
|
||||||
|
|
||||||
|
## 2. 架构总览
|
||||||
|
|
||||||
|
```
|
||||||
|
主页 HomeView
|
||||||
|
└─ HomeCalendarCard(自包含 @Query) ← 新增
|
||||||
|
当前周横条 + "本月 N 天有记录" + chevron
|
||||||
|
tap → fullScreenCover(CalendarOverviewView) ← 新增(从 TrendsView 抽出)
|
||||||
|
|
||||||
|
趋势 TrendsView(重写)
|
||||||
|
└─ TrendSeriesList:两段 section
|
||||||
|
├─ 长期监测(kind=.monitor:seriesKey 分桶,含血压合并/自定义)
|
||||||
|
└─ 化验指标趋势(kind=.lab:按 name+unit 分桶,≥2 点)
|
||||||
|
每行 TrendRow:名称 + 最新值/状态 + mini sparkline + 条数·跨度
|
||||||
|
tap → TrendDetailView(bucket) ← 新增
|
||||||
|
大图表 + 参考范围带 + 时间范围 chips + 统计摘要 + 数据点列表
|
||||||
|
数据点 tap → DayDetailSheet(date)(复用)
|
||||||
|
```
|
||||||
|
|
||||||
|
数据层只扩展 `SeriesBucket.build`,UI 层新增 4 个文件、改 2 个文件、删 1 段。
|
||||||
|
|
||||||
|
## 3. 数据层:`SeriesBucket` 扩展
|
||||||
|
|
||||||
|
文件:`Features/Trends/SeriesBucket.swift`(改)
|
||||||
|
|
||||||
|
### 3.1 新增 `kind` 区分两段
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum SeriesKind { case monitor, lab } // monitor=长期监测预设/自定义/血压;lab=按名分组的化验项
|
||||||
|
|
||||||
|
struct SeriesBucket: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let unit: String
|
||||||
|
let lines: [SeriesLine]
|
||||||
|
let latestDate: Date
|
||||||
|
let kind: SeriesKind // 新增
|
||||||
|
let sourceIndicatorIDs: [String] // 新增:本桶包含的 Indicator persistentModelID 字符串,供详情页定位来源
|
||||||
|
// ... SeriesLine / Point 不变
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 `build` 流程改为两段
|
||||||
|
|
||||||
|
1. **seriesKey 段(原逻辑,kind=.monitor)**:血压合并、单系列预设、自定义。这些桶里的 Indicator 标记为「已消费」。
|
||||||
|
2. **name 段(新,kind=.lab)**:对**所有没有 seriesKey** 的 Indicator,按 `normalizedKey(name, unit)` 分桶;每桶 ≥ `minPoints` 才保留。参考范围从该桶**最新一条** Indicator 的 `range` 字符串解析。
|
||||||
|
3. 两段合并返回,各自按 `latestDate` 倒序。详情/列表按 `kind` 分段。
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// name 归一化:trim + 小写 + 折叠内部空白;unit 同样 trim。key = "name|unit"
|
||||||
|
static func normalizedKey(name: String, unit: String) -> String
|
||||||
|
|
||||||
|
// 解析参考范围字符串 → ClosedRange<Double>?
|
||||||
|
// 支持 "3.9-6.1" / "3.9~6.1" / "3.9 - 6.1";单边("<5.2"/">40"/"≤120")暂返回 nil(图不画带,正常)
|
||||||
|
static func parseRange(_ raw: String) -> ClosedRange<Double>?
|
||||||
|
```
|
||||||
|
|
||||||
|
> **去重**:有 seriesKey 的指标只进 monitor 段;无 seriesKey 的只进 lab 段。即使同名也不混。
|
||||||
|
> **状态着色**:lab 段每个 Point 的 `status` 直接取 Indicator.status(已由 VL/录入判定),无需重算。
|
||||||
|
|
||||||
|
## 4. UI:健康日历移至主页
|
||||||
|
|
||||||
|
### 4.1 `CalendarOverviewView`(新文件 `Features/Calendar/CalendarOverviewView.swift`)
|
||||||
|
|
||||||
|
把现 `TrendsView` 的日历部分**原样抽出**为独立页:`modeSwitch`(月/年)+ `anchorBar`(◀ 年月 ▶)+ `calendarBody`(`CalendarMonthGrid`/`CalendarYearGrid`)+ `legend` + 月视图下的 `dayDetailInline`。
|
||||||
|
|
||||||
|
- 自带 `@Query`(indicators/reports/diaries/symptoms/profiles/customMetrics)。
|
||||||
|
- 接收可选 `initialDate`(从主页某天进入时定位选中)。
|
||||||
|
- 包在 `NavigationStack`,标题「健康日历」,右上「完成」关闭(用于 fullScreenCover)。
|
||||||
|
- `CalendarMonthGrid` / `CalendarYearGrid` / `CalendarMarkers` / `DayDetailSheet` **不改**,直接复用。
|
||||||
|
|
||||||
|
### 4.2 `HomeCalendarCard`(新文件 `Features/Home/HomeCalendarCard.swift`)
|
||||||
|
|
||||||
|
自包含组件(对齐 `TodayRemindersCard` 模式):
|
||||||
|
|
||||||
|
- 自带 `@Query`,`CalendarData.build` 计算标记。
|
||||||
|
- **当前周横条**:周一→周日 7 个紧凑日格(日期数字 + 标记圆点,复用 `DayMarks` 颜色规则:异常红 / 报告灰 / 正常绿 / 日记浅灰;有进行中症状则该格底色淡 amber)。今天高亮。
|
||||||
|
- 顶部标题「健康日历」+ 右侧「本月 N 天有记录 ›」。
|
||||||
|
- 整卡可点 → `fullScreenCover(CalendarOverviewView())`;点某一天 → 带 `initialDate` 进入。
|
||||||
|
- 样式走 `.tjCard()`,放在主页 `greeting` 之后、`TodayRemindersCard` 之前。
|
||||||
|
|
||||||
|
### 4.3 `HomeView` 改动
|
||||||
|
|
||||||
|
`body` 的 VStack 在 `greeting` 后插入 `HomeCalendarCard()`。其余不动。
|
||||||
|
|
||||||
|
## 5. UI:趋势 Tab 重构
|
||||||
|
|
||||||
|
### 5.1 `TrendsView`(重写)
|
||||||
|
|
||||||
|
移除所有日历相关代码(已迁到主页)。新结构:
|
||||||
|
|
||||||
|
- header「趋势」。
|
||||||
|
- 若无可成趋势的桶 → 空状态(「还没有可成趋势的指标 / 同一指标记录满 2 次后会出现在这里」)。
|
||||||
|
- 否则两段:
|
||||||
|
- **长期监测**(`kind == .monitor`):标题 + 计数。
|
||||||
|
- **化验指标趋势**(`kind == .lab`):标题 + 计数。
|
||||||
|
- 每段 `ForEach` 渲染 `TrendRow`,点击 push/present `TrendDetailView`。
|
||||||
|
- 导航:`TrendsView` 包 `NavigationStack`,行用 `NavigationLink` 进详情(趋势 Tab 当前无 NavigationStack,新增之)。
|
||||||
|
|
||||||
|
### 5.2 `TrendRow`(新文件 `Features/Trends/TrendRow.swift`)
|
||||||
|
|
||||||
|
紧凑行:
|
||||||
|
|
||||||
|
- 左:指标名 + 「N 条 · 近 X 个月」副标题。
|
||||||
|
- 中:mini sparkline(小号 `Chart`,height≈36,无坐标轴,单/双线,异常点红)。
|
||||||
|
- 右:最新值 + 单位(异常红)+ chevron。
|
||||||
|
- `.tjCard(bordered: true)`。
|
||||||
|
|
||||||
|
### 5.3 `TrendDetailView`(新文件 `Features/Trends/TrendDetailView.swift`)
|
||||||
|
|
||||||
|
接收 `bucket: SeriesBucket`,自带 `@Query` 用于数据点→来源跳转。
|
||||||
|
|
||||||
|
- **大图表**(height≈220):复用 `SeriesChartCard` 的绘制逻辑(参考范围带 + catmullRom 折线 + 点 + 双线图例),但加坐标轴、按所选时间范围裁剪 domain。
|
||||||
|
- **时间范围 chips**:全部 / 近1年 / 近6月 / 近3月(仅当跨度 > 该范围才显示对应 chip)。切换裁剪图表点 + 重算 domain + 重算统计。
|
||||||
|
- **统计摘要卡**:最新值(带状态)/ 对比上次(Δ 绝对值+百分比+升降箭头,跨参考范围边界标红)/ 最低 / 最高 / 平均 / 记录数 / 时间跨度。文案模板拼装,不走 LLM。
|
||||||
|
- **AI 解读占位**:一行灰字「AI 解读即将上线」(预留,不接 LLM)。
|
||||||
|
- **数据点列表**(倒序):日期 + 值+单位 + 状态箭头/徽章;`onTapGesture` → `DayDetailSheet(date:)`(复用现有 sheet,给出当天来源上下文)。
|
||||||
|
- 标题 = bucket.title。
|
||||||
|
|
||||||
|
血压(双线)在详情页:统计摘要按「收缩/舒张」分别给最新值;列表每行显示「收缩/舒张」两值。
|
||||||
|
|
||||||
|
## 6. 受影响文件清单
|
||||||
|
|
||||||
|
**新增**
|
||||||
|
- `Features/Calendar/CalendarOverviewView.swift`
|
||||||
|
- `Features/Home/HomeCalendarCard.swift`
|
||||||
|
- `Features/Trends/TrendRow.swift`
|
||||||
|
- `Features/Trends/TrendDetailView.swift`
|
||||||
|
|
||||||
|
**修改**
|
||||||
|
- `Features/Trends/SeriesBucket.swift`(加 kind / sourceIndicatorIDs / name 段 / parseRange)
|
||||||
|
- `Features/Trends/TrendsView.swift`(删日历,重写为趋势列表 + NavigationStack)
|
||||||
|
- `Features/Home/HomeView.swift`(插入 HomeCalendarCard)
|
||||||
|
- `康康.xcodeproj/project.pbxproj`(新文件加入 target — 若用 file-system-synchronized group 则免改;需确认)
|
||||||
|
|
||||||
|
**不改**:`CalendarMonthGrid/YearGrid/Markers/DayDetailSheet`、`SeriesChartCard`(详情页复用其绘制思路,可抽 helper 或直接内置)、Models、xcstrings、RootView/TabBar、录入流程。
|
||||||
|
|
||||||
|
## 7. 验证
|
||||||
|
|
||||||
|
- 构建无错误/无新警告(`DEVELOPER_DIR` 指完整 Xcode,touch 强制重编 — 见记忆 build-from-cli)。
|
||||||
|
- 主页:日历卡显示当前周标记;点卡进总览;月/年切换;点某天→当日详情正确。
|
||||||
|
- 趋势:制造同名指标 ≥2 条(如手动录两次「血红蛋白」或两份报告同含一项)→ 出现在「化验指标趋势」段;预设监测仍在「长期监测」段;详情图表/统计/数据点跳转正确;血压双线正常。
|
||||||
|
- 空状态:全新库时两个页面都给出友好空态。
|
||||||
|
|
||||||
|
## 8. 风险与回退
|
||||||
|
|
||||||
|
- **range 解析覆盖不全**:单边区间("<5.2")暂不画带,图仍可用 —— 可接受,后续增强。
|
||||||
|
- **lab 段噪声**:同名但单位不同的指标会分成两桶(key 含 unit)—— 正确行为。若用户名字录入不一致(「血红蛋白」vs「Hb」)会分开 —— demo 可接受,不做模糊归并。
|
||||||
|
- **pbxproj**:若新文件未自动入 target,构建会报 missing symbol;届时手动加 build file 引用。
|
||||||
121
docs/superpowers/specs/2026-06-10-voice-diary-design.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# 语音健康日记(语音转文字 + AI 整理)设计
|
||||||
|
|
||||||
|
> 2026-06-10 · 在「健康记录」(`DiaryQuickSheet`)里加语音输入:iOS 端侧语音识别实时转写,停止后由本地 LLM 整理成健康日记草稿,可编辑后保存。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
「健康记录」目前只能手打文字(`DiaryQuickSheet` → `DiaryEntry`),已有「AI 医生角度多轮追问」辅助。口述比打字门槛低得多,尤其适合身体不适时记录。
|
||||||
|
|
||||||
|
现有两个本地模型(Qwen3.5-2B 文本、Qwen3-VL 视觉)都没有音频编码器,无法做 ASR;引入 Whisper 类模型要 +0.5~1.5GB 体积和一条新推理链路,不可接受。`SFSpeechRecognizer` 支持强制端侧识别(`requiresOnDeviceRecognition = true`),中文质量够用、零体积,与「100% 本地」卖点完全一致。
|
||||||
|
|
||||||
|
## 决策(已与用户确认)
|
||||||
|
|
||||||
|
| 维度 | 决定 |
|
||||||
|
|---|---|
|
||||||
|
| 交互形态 | 说完 → 自动调 LLM 整理成日记草稿(非纯听写) |
|
||||||
|
| 整理样式 | 自适应:口述短 → 一段通顺的话;口述长且多方面 → 自动分点 |
|
||||||
|
| 入口 | `DiaryQuickSheet` 输入框旁麦克风按钮(不动 RecordSheet 骨架) |
|
||||||
|
| 转写链路 | 流式实时转写(AVAudioEngine buffer → 实时字幕),不落盘音频 |
|
||||||
|
| ASR 引擎 | `SFSpeechRecognizer` 端侧;不引入 Whisper;不做云端回退 |
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
DiaryQuickSheet(mic 按钮 + 录音面板)
|
||||||
|
├─► SpeechDictationService(新)── AVAudioEngine + SFSpeechRecognizer(端侧)
|
||||||
|
└─► DiaryAssistService.organize(transcript:)(新方法)──► AIRuntime ──► MNN/MLX
|
||||||
|
```
|
||||||
|
|
||||||
|
符合模块边界:UI 不直接碰 AIRuntime;语音采集是系统能力,封装成独立 Service。
|
||||||
|
|
||||||
|
## 组件
|
||||||
|
|
||||||
|
### 1. `SpeechDictationService`(新,`Services/`,`@MainActor`)
|
||||||
|
|
||||||
|
封装 AVAudioEngine 麦克风采集 + `SFSpeechAudioBufferRecognitionRequest` 流式识别。
|
||||||
|
|
||||||
|
接口:
|
||||||
|
|
||||||
|
- `static var isAvailable: Bool` — 本机是否支持**端侧**中文识别(`supportsOnDeviceRecognition` + locale 检查;模拟器/老机型为 false)
|
||||||
|
- `func requestAuthorization() async -> Bool` — 麦克风 + 语音识别两个权限一起申请
|
||||||
|
- `func start(onPartial: @escaping (String) -> Void) throws` — 开始录音,partial 结果实时回调(录音面板字幕)
|
||||||
|
- `func stop() async -> String` — 停止并返回最终转写稿
|
||||||
|
|
||||||
|
实现要点:
|
||||||
|
|
||||||
|
- `requiresOnDeviceRecognition = true`(硬性,识别内容不出设备)
|
||||||
|
- `addsPunctuation = true`(自动标点)
|
||||||
|
- locale 跟随系统,不支持端侧时 `isAvailable = false`
|
||||||
|
- **不写任何音频文件**,buffer 即用即弃
|
||||||
|
- 录音上限 3 分钟,到点自动 stop
|
||||||
|
|
||||||
|
### 2. `DiaryAssistService.organize(transcript:)`(新方法)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func organize(transcript: String) async throws -> (text: String, decodeRate: Double)
|
||||||
|
```
|
||||||
|
|
||||||
|
- prompt 加在 `AI/Prompts/DiaryAssistPrompts.swift`:`organizePrompt(transcript:)`
|
||||||
|
- few-shot 两例:短口述 → 一段第一人称通顺文本;长口述(症状/用药/饮食多方面)→ 分点
|
||||||
|
- **硬性约束写进 prompt:只重组语言,不得增删改任何数值、单位、药名、时间**(健康数据,2B 模型改数即事故)
|
||||||
|
- 转写稿超长先截断(保护 context),非流式,await 完整结果
|
||||||
|
- 走 AIRuntime actor 队列,与「多轮追问」「拍照识别」自然串行
|
||||||
|
|
||||||
|
### 3. `DiaryQuickSheet` UI 改动
|
||||||
|
|
||||||
|
- 内容输入框 trailing 加 mic 按钮(`isAvailable == false` 时整个隐藏)
|
||||||
|
- 录音态:输入框下方展开录音面板 —— 实时字幕区 + 脉冲动画(sparkles/waveform `symbolEffect`)+「停止」按钮
|
||||||
|
- 整理态:面板转「AI 整理中」(复用 `AIFlowBar` + tok/s),可取消
|
||||||
|
- 完成:整理稿**追加**进输入框(沿用 `appendToContent`,不覆盖已写内容);面板收起
|
||||||
|
- 完成后显示一次性「改用原话」pill:点击把刚追加的整理稿替换为原始转写稿(原始稿在本次 sheet 生命周期内持有;再次录音或手动编辑该段后 pill 消失)
|
||||||
|
- 整理稿入框后,既有「AI 多轮追问」功能照常可用,无需特殊处理
|
||||||
|
|
||||||
|
## 状态机
|
||||||
|
|
||||||
|
```
|
||||||
|
idle ──(点 mic,权限 OK)──► recording ──(停止/3min 到点)──► organizing ──► done(回 idle)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 实时字幕只显示在录音面板,**停止前不进输入框**
|
||||||
|
- `organizing` 期间 mic 按钮与「AI 追问」按钮禁用(AIRuntime 串行,避免排队困惑)
|
||||||
|
|
||||||
|
## 错误处理(红线 #5:全部有回退,不卡死)
|
||||||
|
|
||||||
|
| 故障 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 权限被拒 | 弹说明 alert + 「前往设置」跳系统设置 |
|
||||||
|
| 本机不支持端侧识别(含模拟器) | mic 按钮隐藏,静默降级为纯手打 |
|
||||||
|
| 识别中途出错 | 已拿到的 partial 文本照常进 organizing |
|
||||||
|
| 转写结果为空 | 提示「没听清,再试一次」,回 idle |
|
||||||
|
| LLM 未就绪 / 整理失败 | **原始转写稿直接追加进输入框** + 提示「AI 整理失败,已填入原话」 |
|
||||||
|
|
||||||
|
不做云端识别回退(红线 #1:不引入云服务)。
|
||||||
|
|
||||||
|
## 权限(project.pbxproj 新增两条 INFOPLIST_KEY)
|
||||||
|
|
||||||
|
- `NSMicrophoneUsageDescription`:康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。
|
||||||
|
- `NSSpeechRecognitionUsageDescription`:语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- `organize` prompt:`DebugAIRunner` 加自检入口(短/长两条样例口述,肉眼验自适应样式 + 数值不被改动)
|
||||||
|
- 录音链路:真机手测清单(权限首次申请、录音字幕、3 分钟自动停、整理失败回退、「改用原话」)
|
||||||
|
- 模拟器:验证 `isAvailable == false` 时 mic 按钮隐藏
|
||||||
|
|
||||||
|
## 范围边界(不做)
|
||||||
|
|
||||||
|
- 症状 / AI 问答的语音入口
|
||||||
|
- 音频文件保存或回放
|
||||||
|
- Whisper / 任何新模型
|
||||||
|
- Live Activity 集成(前台短流程,无必要)
|
||||||
|
- 多语言听写优化(locale 跟系统,不支持即降级)
|
||||||
|
|
||||||
|
## 卖点映射(§12)
|
||||||
|
|
||||||
|
1. 降低记录门槛 → 卖点 1(影像档案之外的日常记录闭环)
|
||||||
|
2. 「系统端侧 ASR + 本地 LLM 整理」全链路不出设备 → 卖点 2(100% 本地)
|
||||||
|
3. 日记语料变多 → 卖点 3(本地 RAG 长期记忆)
|
||||||
|
|
||||||
|
## 排期
|
||||||
|
|
||||||
|
清单外新功能(红线 #6),本设计即立项讨论结论。工作量约 1~1.5 天,独立小分支插队,不挤占 C1/VL 主线。
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 「身体档案」输入框语音输入 设计
|
||||||
|
|
||||||
|
> 2026-06-10 · 在「身体档案」(`HealthExportSheet`)底部聊天输入框加端侧语音听写,复用 `SpeechDictationService`,识别文字实时流进输入框。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
「身体档案」composer 是聊天式输入(提问/诉求 → 发送 → LLM 对话/生成报告)。与日记不同,这里输入的内容马上交给 LLM,**不需要"整理"加工**;口述原话直接进输入框即正确行为(类似系统键盘听写)。
|
||||||
|
|
||||||
|
## 决策(已与用户确认)
|
||||||
|
|
||||||
|
| 维度 | 决定 |
|
||||||
|
|---|---|
|
||||||
|
| 交互 | 听写直接流进输入框:点 mic 开始,实时上屏;再点停止;用户自查后手动发送 |
|
||||||
|
| LLM | 不调用(无整理步骤、不自动发送) |
|
||||||
|
| 复用 | `SpeechDictationService`(**@State 持有**,防视图重建丢实例)、权限 alert 文案、3 分钟看门狗、onDisappear abort |
|
||||||
|
| UI | mic 按钮放 TextField 与发送键之间;`isAvailable == false` 隐藏;录音中变红色停止态(脉冲动画) |
|
||||||
|
|
||||||
|
## 组件
|
||||||
|
|
||||||
|
### 1. `SpeechDictationService.merge(prefix:partial:)`(新,static 纯函数)
|
||||||
|
|
||||||
|
听写文本拼接规则,唯一可单测的逻辑:
|
||||||
|
- `prefix` 为空 → 返回 `partial`
|
||||||
|
- `prefix` 以空白/换行结尾 → `prefix + partial`
|
||||||
|
- 其余 → `prefix + " " + partial`
|
||||||
|
|
||||||
|
### 2. `HealthExportSheet` 改动
|
||||||
|
|
||||||
|
- `@State dictation` + `isDictating` + `dictationPrefix` + 看门狗 Task
|
||||||
|
- 点 mic:申请权限(拒绝 → alert 跳设置,与日记同文案)→ 记录 `dictationPrefix = draftQuestion` → start,每个 partial:`draftQuestion = merge(prefix:partial:)`
|
||||||
|
- 再点:`stop()`,最终稿同 merge 落定;**stop 返回空时保留输入框现状**(partial 已实时在框里,天然兜底,不提示「没听清」)
|
||||||
|
- 3 分钟看门狗自动停(防麦克风悬挂)
|
||||||
|
|
||||||
|
## 冲突防护
|
||||||
|
|
||||||
|
- 录音中:TextField 与发送按钮、「生成整理报告」按钮禁用(防手输与 partial 互相覆盖、防录音中发送)
|
||||||
|
- `isAnswering / isGeneratingReport` 时 mic 禁用
|
||||||
|
- `onDisappear` abort
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- `merge(prefix:partial:)` 3 个单测(空前缀 / 空白结尾前缀 / 普通前缀)
|
||||||
|
- 真机手测:听写上屏、停止落定、已有文字保留、权限拒绝、3 分钟自动停
|
||||||
|
|
||||||
|
## 不做(YAGNI)
|
||||||
|
|
||||||
|
快捷问答弹窗 / 个人资料 Form 等其他输入处的语音;自动发送;录音面板;LLM 整理。
|
||||||
210
docs/踩坑与排查记录.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# 康康 · 踩坑与排查记录
|
||||||
|
|
||||||
|
> 本地推理 / SwiftData / 端侧模型这类问题不好复现也不好搜,踩过的坑按统一模板记在这里,方便回查。
|
||||||
|
> 新增条目往最上面加(倒序),模板见文末。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-09 · 生成身体档案报告时,LLM 逐行复读死循环
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
多轮「身体档案」对话点生成报告后,「## 关键指标」整段陷入死循环:同一行
|
||||||
|
`⚠️ 收缩压 (107 mmHg)` 连续重复几十遍,最后被 maxTokens 截断成半行「⚠️ 收缩」。
|
||||||
|
(本质是小模型 **repetition / degeneration loop**,不是数据真有几十条。)
|
||||||
|
|
||||||
|
### 根因(确认)
|
||||||
|
采样器**完全没有重复惩罚**,叠加低温 → 几乎必然复读。两个后端都有问题:
|
||||||
|
|
||||||
|
| 后端 | 位置 | 原配置 | 问题 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| MNN(主) | `MNNLLMBridge.mm` `initWithConfigPath` 的 `set_config` | `temperature 0.3, topP 0.85` | 无 `penalty` |
|
||||||
|
| MLX(兜底) | `LLMSession.swift` `GenerateParameters` | `temperature 0.3, topP 0.85` | 无 `repetitionPenalty` |
|
||||||
|
|
||||||
|
关键细节(读 MNN 源码 `transformers/llm/engine/src/`):
|
||||||
|
- `llmconfig.hpp`:`mixed_samplers` 默认 `{topK, tfs, typical, topP, min_p, temperature}` —— **不含 `penalty`**;
|
||||||
|
`penalty` / `ngram_factor` 默认 `1.0`(=全关)。
|
||||||
|
- `sampler.cpp` `configMixed`:只会把 `penalty`「**移到链首(如果存在)**」,**不会自动插入**。
|
||||||
|
所以光设 `"penalty":1.1` 没用,必须把 `"penalty"` 显式写进 `mixed_samplers`。
|
||||||
|
- `sampler.cpp` `stepPenalty`:`repetition_penalty` 对 logits 乘法惩罚;**n-gram 命中整段重复时惩罚直接升到 `max_penalty`** —— 这正是掐断「整行复读」最有效的开关。
|
||||||
|
|
||||||
|
**为什么低温反而更糟**:temperature 0.3 接近贪心,一旦吐出 `收缩压 (107 mmHg)\n`,
|
||||||
|
最高概率的后续就是再吐一遍同样的行,无惩罚就永远出不来。
|
||||||
|
|
||||||
|
### 排查过程(可复用思路)
|
||||||
|
1. 看现象先判定是「数据重复」还是「生成复读」—— 被截断成半行 `收缩` 说明是 token 级复读,不是数据。
|
||||||
|
2. `grep -niE "penalty|temperature|top_?p|sampler" 康康/AI/` 一把定位两个后端的采样配置 → 都没 penalty。
|
||||||
|
3. 不猜 MNN 配置键,直接读构建用的源码 `MNN_SRC=/Users/xuhuayong/apps/MNN-src`
|
||||||
|
的 `llmconfig.hpp` / `sampler.cpp`,确认键名、默认值、`mixed_samplers` 不自动插 penalty。
|
||||||
|
4. MLX 侧读 SPM checkout 的 `MLXLMCommon/Evaluate.swift`,确认 `GenerateParameters` 有
|
||||||
|
`repetitionPenalty: Float?` + `repetitionContextSize: Int`。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- **MNN** `MNNLLMBridge.mm`:`set_config` 显式开重复惩罚 +
|
||||||
|
把 `penalty` 放进 mixed 链首:
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"jinja":{"context":{"enable_thinking":false}},
|
||||||
|
"sampler_type":"mixed",
|
||||||
|
"mixed_samplers":["penalty","topK","topP","temperature"],
|
||||||
|
"temperature":0.3,"topP":0.85,"topK":40,
|
||||||
|
"penalty":1.1,"n_gram":8,"ngram_factor":1.05
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(注意:JSON merge-patch 对数组是**整体替换**,所以这里会覆盖掉默认 `mixed_samplers`,符合预期。)
|
||||||
|
- **MLX** `LLMSession.swift`:`GenerateParameters(..., repetitionPenalty: 1.1, repetitionContextSize: 64)`。
|
||||||
|
|
||||||
|
取值都偏保守:`penalty 1.1` / `ngram_factor 1.05` 是业界常用档(MNN 自带 omni 默认 1.05),
|
||||||
|
低温 + 轻惩罚既能掐复读,又不破坏 JSON / 结构化输出的稳定性。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
- `xcodebuild ... -destination generic/platform=iOS` 编译通过(两个后端均编进)。
|
||||||
|
- ⚠️ **真机/模拟器跑一遍多轮导出生成报告**,确认不再复读 —— 复读属推理期行为,单测覆盖不到,必须实跑。
|
||||||
|
|
||||||
|
### 预防 / 相关注意
|
||||||
|
- 任何新增的「长文本生成」(非 JSON 抽取)都走同一套带惩罚的采样参数,别再裸 temperature。
|
||||||
|
- **相关隐患(未修,留观)**:`HealthExportService.retrieveDialogueSnapshot` 取指标时
|
||||||
|
**没有 `prefix` 截断**(窗口检索版 `retrieve` 截了 `prefix(20)`)。指标极多时 prompt 会膨胀、
|
||||||
|
也更易诱发复读。若复发,优先给 dialogue snapshot 也加上限。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 以下几条据 W1–W2(2026-05~06)记忆补记,细节以代码/提交为准。
|
||||||
|
|
||||||
|
## 2026-06-09 · MNN 路径 Qwen3.5 强制思考,只吐 `<think>` / JSON 解析失败
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
MNN 真机路径上模型自检只显示 `<think>` 思考过程,AI 辅助拿不到 JSON(解析失败);
|
||||||
|
同样的 prompt 走 MLX 兜底却正常。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
模型自带 `config.json`(taobao-mnn 预转换件)写死 `"jinja":{"context":{"enable_thinking":true}}`,
|
||||||
|
Qwen3.5 聊天模板据此每个 assistant 回合硬塞 `<think>\n` 开思考,吞掉 token 预算。
|
||||||
|
**prompt 里的 `/no_think` 对 MNN 无效** —— 模板只读 `enable_thinking`,不看文本软开关。
|
||||||
|
只在真机爆是因为 MLX 经 swift-transformers 套模板时不传 `enable_thinking` → 走 else 空 think 块,天然不思考。
|
||||||
|
(这点从仓库代码看不出来,config.json 是下载/旁路导入的模型产物,不在 git 里。)
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
`MNNLLMBridge.mm` 在 `createLLM` 后、`load()` 前 merge-patch 关闭:
|
||||||
|
`set_config("{\"jinja\":{\"context\":{\"enable_thinking\":false}}}")`。不改模型文件、不动字节校验。`stripThink` 保留兜底。
|
||||||
|
|
||||||
|
### 预防
|
||||||
|
再遇 MNN 只出思考 / JSON 解析失败,先查 `config.json` 的 `enable_thinking`,别去调 `/no_think` 或加大预算。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-07 · 「记录指标·拍照识别」VL 直读化验单不稳 → 改 Vision OCR + LLM
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
Qwen-VL 直读密集小字化验单经常返回 `{"indicators":[]}`(读不出指标)。
|
||||||
|
|
||||||
|
### 根因 / 决策
|
||||||
|
小模型 VL 对密集中文小字不稳。改链路:`DocumentScanner 整页扫描 → Apple Vision OCR(zh-Hans/Hant/en)
|
||||||
|
→ Qwen3 LLM 解析(VLPrompts.indicatorsFromText)→ stripThink → parseIndicatorsJSON → 确认页人工校对 → 存`。
|
||||||
|
Vision OCR 是系统框架、100% 本地,不违反隐私红线。
|
||||||
|
|
||||||
|
### 预防
|
||||||
|
这条路**不要改回 VL 直读**。VL 仍只用于「体检报告归档」整份解读,两者分开。OCR 行分组偶有错位,靠确认页人工校正兜底。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-01 · git 全量 push 撞 HTTP 413(历史里有 165MB 构建产物)
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
`git push` 到 myv0(Gitea 反代有上传体积限制)报 **HTTP 413**。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
旧 commit 误把 `build/` 构建产物提交进库(最大单文件 xcarchive DWARF **165MB**),后来虽 `git rm --cached` + `.gitignore`,
|
||||||
|
但对象仍留在历史 → `.git` 87MB,全量 push 超反代上限。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
对主仓库 `git filter-repo --path build/ --invert-paths --force` 从全历史剥离 → `.git` 87M→2.9M,不再 413。
|
||||||
|
注意:① 重写了所有 commit hash(内容不变),旧克隆需重新 clone;② filter-repo 会移除所有 remote,事后须重新 `git remote add origin`;③ 凭证不写入 `.git/config`。
|
||||||
|
|
||||||
|
### 预防
|
||||||
|
`build/` 必须在 `.gitignore`;别把构建产物 / 大二进制提交进库。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-31 · 快拍 VL 识别时 App 自动退出(jetsam OOM,非崩溃)
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
iPhone 15 Pro Max 上 VL 识别时 App 直接退出。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
不是代码崩溃(catch 只切 warning 屏,Swift 报错不会杀进程),是 **OS 内存超限 jetsam kill**。三因叠加:
|
||||||
|
① 无 entitlement(8GB 设备默认单 App 上限 ~3GB,VL ~3GB 常驻冲过);② 从不卸载模型(LLM ~1GB + VL ~3GB 同驻 → 4GB+);③ 没设 MLX cache 上限。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
① 新建 `康康.entitlements` 加 `com.apple.developer.kernel.increased-memory-limit=true`;
|
||||||
|
② `AIRuntime` 加 `unloadLLM/unloadVL` 做**常驻互斥**(两大模型永不同时驻留)+ actor 内**串行推理闸门**(GPU 同一时刻只一个解码/加载);
|
||||||
|
③ `GPU.set(cacheLimit: 256MB)`,启动调一次。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
编译 + 单测通过。⚠️ **真机 OOM 是否真消失仍需 iPhone 15 Pro Max 实测**(本机无法跑真机)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-30 · 每次重打包 SwiftData 数据被清空
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
W2 期每次重新打包安装,本地数据全没了。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
`KangkangApp.swift` 里 `ModelContainer` 创建失败的 catch 块原本**直接删 store 文件**。
|
||||||
|
SwiftData 只对纯增量改动自动轻量迁移;一旦 schema 改动超纲(最常见:**给已存在 `@Model` 新增「非可选且无内联默认值」属性**)→ 迁移抛错 → 进 catch → 删库。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
catch 改为把旧 store(含 `-wal`/`-shm`)挪到 `Application Support/StoreBackups/<时间戳>/` 再重建,不删除。
|
||||||
|
|
||||||
|
### 预防
|
||||||
|
给已存在 `@Model` 加属性**一律给可选或内联默认值**(如 `var x: String = "daily"`),才走轻量迁移。正式发布前升级为 `VersionedSchema` + `SchemaMigrationPlan`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## (无明确日期)· 编辑 Localizable.xcstrings 炸出上万行噪声 diff
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
改 `Localizable.xcstrings` 新增 3 个 key,却产生 ~15000 行 diff。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
仓库里该文件是 **Xcode 规范格式**(`"key" : {` 冒号两侧带空格、2 空格缩进、key 按 Xcode 排序、结尾无换行);
|
||||||
|
用 `python json.dump(indent=2)` 重写会把分隔符变成 `": "` 且顺序不同 → 几乎每行都 diff。
|
||||||
|
|
||||||
|
### 修复 / 正确做法
|
||||||
|
基于 HEAD 原始文本做**文本插入**:把新 key 块按 Xcode 格式(` "<key>" : ` + `separators=(',', ' : ')` 的 value)拼到 strings 段末尾,保持结尾无换行。**不要整文件 json.dump 回写**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:命令行编译方式(排查时拿真实错误/警告)
|
||||||
|
|
||||||
|
- 系统默认是 Command Line Tools,裸 `xcodebuild` 不可用,需显式指向完整 Xcode:
|
||||||
|
`export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`
|
||||||
|
- **必须用独立 derivedDataPath**(如 `-derivedDataPath /tmp/kk-derived-xxx`),否则和 Xcode 抢同一把 `build.db` 锁报 `database is locked`(不是代码错)。
|
||||||
|
- 增量编译会吞警告:要看某文件警告先 `touch` 它强制重编,再 grep `error:|warning:|BUILD (SUCCEEDED|FAILED)`。
|
||||||
|
- 工程是 Swift 5 + `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`;跨到 `nonisolated` 调 MainActor 成员的隔离警告(标 "error in Swift 6 mode")在 Swift 5 下不阻塞构建。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模板(复制下面这段新增条目)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## YYYY-MM-DD · 一句话标题
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
(用户看到什么 / 怎么触发)
|
||||||
|
|
||||||
|
### 根因(确认)
|
||||||
|
(定位到的真正原因,不是猜测;贴关键文件:行)
|
||||||
|
|
||||||
|
### 排查过程
|
||||||
|
(怎么一步步定位的,方便下次复用思路)
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
(改了什么,贴 diff 要点或配置)
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
(怎么确认修好了;不能单测的要写明需实跑)
|
||||||
|
|
||||||
|
### 预防 / 相关注意
|
||||||
|
(怎么避免再犯;顺带发现的隐患)
|
||||||
|
```
|
||||||
112
scripts/build-launch.sh
Executable file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PROJECT="${PROJECT:-$ROOT_DIR/康康.xcodeproj}"
|
||||||
|
SCHEME="${SCHEME:-康康}"
|
||||||
|
APP_NAME="${APP_NAME:-$SCHEME}"
|
||||||
|
CONFIGURATION="${CONFIGURATION:-Debug}"
|
||||||
|
BUNDLE_ID="${BUNDLE_ID:-com.xuhuayong.kangkang}"
|
||||||
|
DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$ROOT_DIR/build/DerivedData}"
|
||||||
|
SIMULATOR_NAME="${SIMULATOR_NAME:-iPhone 16 Pro}"
|
||||||
|
SCREENSHOT_PATH="${SCREENSHOT_PATH:-$ROOT_DIR/build/screenshots/${SCHEME}-launch.png}"
|
||||||
|
|
||||||
|
require_tool() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo "error: required tool not found: $1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_full_xcode() {
|
||||||
|
local developer_dir
|
||||||
|
developer_dir="$(xcode-select -p 2>/dev/null || true)"
|
||||||
|
if [[ "$developer_dir" != *"/Xcode.app/Contents/Developer"* ]]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
error: active developer directory is not a full Xcode install:
|
||||||
|
${developer_dir:-<unset>}
|
||||||
|
|
||||||
|
Select Xcode before running this script:
|
||||||
|
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_udid() {
|
||||||
|
sed -n 's/.*(\([0-9A-Fa-f-]\{36\}\)).*/\1/p' | head -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
find_simulator_udid() {
|
||||||
|
if [[ -n "${SIMULATOR_UDID:-}" ]]; then
|
||||||
|
echo "$SIMULATOR_UDID"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local udid
|
||||||
|
udid="$(xcrun simctl list devices available | grep -F "$SIMULATOR_NAME" | extract_udid || true)"
|
||||||
|
if [[ -n "$udid" ]]; then
|
||||||
|
echo "$udid"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
udid="$(
|
||||||
|
xcrun simctl list devices available |
|
||||||
|
awk '/-- iOS / { in_ios = 1; next } /-- / { in_ios = 0 } in_ios && /iPhone/ { print; exit }' |
|
||||||
|
extract_udid || true
|
||||||
|
)"
|
||||||
|
if [[ -n "$udid" ]]; then
|
||||||
|
echo "$udid"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "error: no available iOS simulator found. Install an iPhone simulator in Xcode." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
require_tool xcode-select
|
||||||
|
require_tool xcodebuild
|
||||||
|
require_tool xcrun
|
||||||
|
require_full_xcode
|
||||||
|
|
||||||
|
local simulator_udid app_path
|
||||||
|
simulator_udid="$(find_simulator_udid)"
|
||||||
|
|
||||||
|
echo "Project: $PROJECT"
|
||||||
|
echo "Scheme: $SCHEME"
|
||||||
|
echo "Configuration: $CONFIGURATION"
|
||||||
|
echo "Simulator: ${SIMULATOR_UDID:-$SIMULATOR_NAME} ($simulator_udid)"
|
||||||
|
|
||||||
|
xcodebuild \
|
||||||
|
-project "$PROJECT" \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-configuration "$CONFIGURATION" \
|
||||||
|
-destination "id=$simulator_udid" \
|
||||||
|
-derivedDataPath "$DERIVED_DATA_PATH" \
|
||||||
|
build
|
||||||
|
|
||||||
|
app_path="$DERIVED_DATA_PATH/Build/Products/${CONFIGURATION}-iphonesimulator/${APP_NAME}.app"
|
||||||
|
if [[ ! -d "$app_path" ]]; then
|
||||||
|
echo "error: built app not found at $app_path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
xcrun simctl boot "$simulator_udid" >/dev/null 2>&1 || true
|
||||||
|
xcrun simctl bootstatus "$simulator_udid" -b
|
||||||
|
if [[ "${OPEN_SIMULATOR:-1}" == "1" ]]; then
|
||||||
|
open -a Simulator --args -CurrentDeviceUDID "$simulator_udid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
xcrun simctl install "$simulator_udid" "$app_path"
|
||||||
|
xcrun simctl launch "$simulator_udid" "$BUNDLE_ID"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$SCREENSHOT_PATH")"
|
||||||
|
sleep "${SCREENSHOT_DELAY_SECONDS:-2}"
|
||||||
|
xcrun simctl io "$simulator_udid" screenshot "$SCREENSHOT_PATH"
|
||||||
|
|
||||||
|
echo "Launched $BUNDLE_ID"
|
||||||
|
echo "Screenshot: $SCREENSHOT_PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
51
scripts/build-mnn-xcframework.sh
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# 构建 MNN.xcframework(device arm64 + simulator arm64),含 LLM 引擎 + SME2。
|
||||||
|
# 产物输出到 康康/../Frameworks/MNN.xcframework(被 .gitignore,不入库,防历史膨胀)。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# MNN_SRC=/path/to/MNN sh scripts/build-mnn-xcframework.sh
|
||||||
|
# 需求:CMake 3.14+、Xcode、约 10-40 分钟。
|
||||||
|
#
|
||||||
|
# 关键 flag:
|
||||||
|
# MNN_BUILD_LLM=ON —— 编入 llm 引擎(并导出 llm/llm.hpp),自动开 MNN_LOW_MEMORY
|
||||||
|
# MNN_BUILD_LLM_OMNI=ON —— VL(图→文)所需:多模态 Omni + OpenCV 图像解码。
|
||||||
|
# 统一模型(Qwen3.5-2B-MNN 一肩挑文本+视觉)必须开。
|
||||||
|
# MNN_SME2=ON —— CMake 默认 ON,A19/iPhone17 运行时经 KleidiAI 自动启用,A17 回退 NEON
|
||||||
|
# MNN_METAL=OFF —— 考核走 CPU+SME2,关 Metal 保持精简
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MNN_SRC="${MNN_SRC:-/Users/xuhuayong/apps/MNN-src}"
|
||||||
|
OUT_DIR="$(cd "$(dirname "$0")/.." && pwd)/Frameworks"
|
||||||
|
TOOLCHAIN_NEW="${MNN_SRC}/cmake/ios.toolchain.new.cmake"
|
||||||
|
EXTRA="-DMNN_BUILD_LLM=ON -DMNN_BUILD_LLM_OMNI=ON -DMNN_METAL=OFF -DMNN_ARM82=true -DMNN_SME2=ON"
|
||||||
|
COMMON="-DCMAKE_BUILD_TYPE=Release -DENABLE_BITCODE=0 -DMNN_AAPL_FMWK=1 -DMNN_SEP_BUILD=0 -DMNN_BUILD_SHARED_LIBS=false -DMNN_USE_THREAD_POOL=OFF"
|
||||||
|
|
||||||
|
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
|
||||||
|
cd "$MNN_SRC"
|
||||||
|
|
||||||
|
# 新版 ios-cmake toolchain(支持 SIMULATORARM64;MNN 自带的旧版只支持 x86_64 模拟器)
|
||||||
|
if [ ! -f "$TOOLCHAIN_NEW" ]; then
|
||||||
|
curl -sL "https://raw.githubusercontent.com/leetal/ios-cmake/master/ios.toolchain.cmake" -o "$TOOLCHAIN_NEW"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# device arm64
|
||||||
|
rm -rf build-dev-arm64 && mkdir build-dev-arm64 && cd build-dev-arm64
|
||||||
|
cmake .. $COMMON $EXTRA -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN_NEW" -DPLATFORM=OS64 -DDEPLOYMENT_TARGET=17.0
|
||||||
|
make MNN -j16
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# simulator arm64
|
||||||
|
rm -rf build-sim-arm64 && mkdir build-sim-arm64 && cd build-sim-arm64
|
||||||
|
cmake .. $COMMON $EXTRA -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN_NEW" -DPLATFORM=SIMULATORARM64 -DDEPLOYMENT_TARGET=17.0
|
||||||
|
make MNN -j16
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 合成 xcframework
|
||||||
|
rm -rf "$OUT_DIR/MNN.xcframework"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
xcrun xcodebuild -create-xcframework \
|
||||||
|
-framework build-dev-arm64/MNN.framework \
|
||||||
|
-framework build-sim-arm64/MNN.framework \
|
||||||
|
-output "$OUT_DIR/MNN.xcframework"
|
||||||
|
|
||||||
|
echo "✅ 输出: $OUT_DIR/MNN.xcframework"
|
||||||
68
scripts/fetch-qwen3vl.sh
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 下载 Qwen3-VL-4B-Instruct-4bit(MLX 4bit)全量文件到本地镜像目录,并逐个校验字节数。
|
||||||
|
# 字节数权威来源:康康/AI/ModelManifest.swift(HF API blobs=true,2026-05 核对)。
|
||||||
|
# 用法: bash scripts/fetch-qwen3vl.sh
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
REPO="mlx-community/Qwen3-VL-4B-Instruct-4bit"
|
||||||
|
BASE="https://huggingface.co/${REPO}/resolve/main"
|
||||||
|
# 目标 = 康康仓库内的 Models/(已被 .gitignore 忽略,App 旁路导入也认这个目录名)。
|
||||||
|
# 可用环境变量 KK_MODELS_DIR 覆盖根目录(如指向另一块盘)。
|
||||||
|
ROOT="${KK_MODELS_DIR:-/Users/xuhuayong/apps/康康/Models}"
|
||||||
|
DEST="$ROOT/Qwen3-VL-4B-Instruct-4bit"
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
|
||||||
|
# 文件名:期望字节数(与 ModelManifest.swift 的 .vl 清单一一对应)
|
||||||
|
FILES=(
|
||||||
|
"config.json:7137"
|
||||||
|
"model.safetensors:3093767283"
|
||||||
|
"model.safetensors.index.json:64742"
|
||||||
|
"tokenizer.json:11422654"
|
||||||
|
"tokenizer_config.json:5445"
|
||||||
|
"vocab.json:2776833"
|
||||||
|
"merges.txt:1671853"
|
||||||
|
"special_tokens_map.json:613"
|
||||||
|
"added_tokens.json:707"
|
||||||
|
"generation_config.json:269"
|
||||||
|
"chat_template.json:5502"
|
||||||
|
"chat_template.jinja:5292"
|
||||||
|
"preprocessor_config.json:782"
|
||||||
|
"video_preprocessor_config.json:817"
|
||||||
|
)
|
||||||
|
|
||||||
|
fsize() { stat -f%z "$1" 2>/dev/null || echo 0; }
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
for entry in "${FILES[@]}"; do
|
||||||
|
name="${entry%%:*}"; want="${entry##*:}"; out="$DEST/$name"
|
||||||
|
if [[ -f "$out" && "$(fsize "$out")" == "$want" ]]; then
|
||||||
|
echo "SKIP $name (已完整 $want)"; continue
|
||||||
|
fi
|
||||||
|
echo "GET $name (期望 $want 字节)"
|
||||||
|
curl -fL -C - --retry 5 --retry-delay 3 --connect-timeout 30 \
|
||||||
|
-o "$out" "$BASE/$name" || { echo " !! 下载失败 $name"; fail=1; continue; }
|
||||||
|
have="$(fsize "$out")"
|
||||||
|
if [[ "$have" != "$want" ]]; then
|
||||||
|
echo " !! 字节不符 $name: 实得 $have / 期望 $want"; fail=1
|
||||||
|
else
|
||||||
|
echo " OK $name $have"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 大权重额外做 SHA256 校验(HF LFS oid,密码学级,字节数相同也能查出脏数据)。
|
||||||
|
WEIGHT_SHA="90eeb02604181dbcccd0a30a1f550a4a8928ca7dcbee4aee1449239306cfdfca"
|
||||||
|
if [[ -f "$DEST/model.safetensors" ]]; then
|
||||||
|
echo "校验 model.safetensors SHA256(约需 10 余秒)..."
|
||||||
|
got="$(shasum -a 256 "$DEST/model.safetensors" | awk '{print $1}')"
|
||||||
|
if [[ "$got" == "$WEIGHT_SHA" ]]; then
|
||||||
|
echo " ✓ SHA256 匹配"
|
||||||
|
else
|
||||||
|
echo " !! SHA256 不符: 实得 $got / 期望 $WEIGHT_SHA"; fail=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "================================================"
|
||||||
|
total=$(du -sh "$DEST" 2>/dev/null | cut -f1)
|
||||||
|
echo "目录: $DEST (合计 $total)"
|
||||||
|
if [[ "$fail" == "0" ]]; then echo "✅ 全部 14 个文件下载并校验通过(权重含 SHA256)"; else echo "❌ 有文件失败,重跑本脚本可断点续传"; fi
|
||||||
|
exit "$fail"
|
||||||
88
scripts/release-testflight.sh
Executable file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 一键发布 TestFlight:archive → export → 上传 App Store Connect
|
||||||
|
# 用法:
|
||||||
|
# ./scripts/release-testflight.sh # 用当前 build 号
|
||||||
|
# BUMP=1 ./scripts/release-testflight.sh # 自动递增 build 号后再发布
|
||||||
|
# 认证:依赖 Xcode 已登录的 Apple ID(Xcode → Settings → Accounts)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PROJECT="${PROJECT:-$ROOT_DIR/康康.xcodeproj}"
|
||||||
|
SCHEME="${SCHEME:-康康}"
|
||||||
|
CONFIGURATION="${CONFIGURATION:-Release}"
|
||||||
|
BUILD_DIR="$ROOT_DIR/build/Release"
|
||||||
|
ARCHIVE_PATH="$BUILD_DIR/${SCHEME}.xcarchive"
|
||||||
|
EXPORT_PATH="$BUILD_DIR/export"
|
||||||
|
EXPORT_PLIST="$BUILD_DIR/ExportOptions.plist"
|
||||||
|
TEAM_ID="${TEAM_ID:-F2C8C774FG}"
|
||||||
|
|
||||||
|
require_full_xcode() {
|
||||||
|
local developer_dir
|
||||||
|
developer_dir="$(xcode-select -p 2>/dev/null || true)"
|
||||||
|
if [[ "$developer_dir" != *"/Xcode.app/Contents/Developer"* ]]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
error: 当前 developer directory 不是完整 Xcode:
|
||||||
|
${developer_dir:-<unset>}
|
||||||
|
请先执行:
|
||||||
|
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_full_xcode
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
|
# 可选:递增 build 号
|
||||||
|
if [[ "${BUMP:-0}" == "1" ]]; then
|
||||||
|
CURRENT=$(sed -n 's/.*CURRENT_PROJECT_VERSION = \([0-9]*\);.*/\1/p' "$PROJECT/project.pbxproj" | head -1)
|
||||||
|
NEXT=$((CURRENT + 1))
|
||||||
|
sed -i '' "s/CURRENT_PROJECT_VERSION = $CURRENT;/CURRENT_PROJECT_VERSION = $NEXT;/g" "$PROJECT/project.pbxproj"
|
||||||
|
echo "==> Build 号: $CURRENT → $NEXT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUILD_NUM=$(sed -n 's/.*CURRENT_PROJECT_VERSION = \([0-9]*\);.*/\1/p' "$PROJECT/project.pbxproj" | head -1)
|
||||||
|
VERSION=$(sed -n 's/.*MARKETING_VERSION = \([0-9.]*\);.*/\1/p' "$PROJECT/project.pbxproj" | head -1)
|
||||||
|
echo "==> 发布 v$VERSION ($BUILD_NUM)"
|
||||||
|
|
||||||
|
echo "==> [1/3] Archive..."
|
||||||
|
rm -rf "$ARCHIVE_PATH"
|
||||||
|
xcodebuild archive \
|
||||||
|
-project "$PROJECT" \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-configuration "$CONFIGURATION" \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-archivePath "$ARCHIVE_PATH" \
|
||||||
|
-allowProvisioningUpdates
|
||||||
|
|
||||||
|
echo "==> [2/3] 生成 ExportOptions.plist..."
|
||||||
|
cat > "$EXPORT_PLIST" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>method</key>
|
||||||
|
<string>app-store-connect</string>
|
||||||
|
<key>destination</key>
|
||||||
|
<string>upload</string>
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>$TEAM_ID</string>
|
||||||
|
<key>uploadSymbols</key>
|
||||||
|
<true/>
|
||||||
|
<key>manageAppVersionAndBuildNumber</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "==> [3/3] Export 并上传 App Store Connect..."
|
||||||
|
rm -rf "$EXPORT_PATH"
|
||||||
|
xcodebuild -exportArchive \
|
||||||
|
-archivePath "$ARCHIVE_PATH" \
|
||||||
|
-exportOptionsPlist "$EXPORT_PLIST" \
|
||||||
|
-exportPath "$EXPORT_PATH" \
|
||||||
|
-allowProvisioningUpdates
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ v$VERSION ($BUILD_NUM) 已上传。App Store Connect 处理完成后(约 5-15 分钟)即可在 TestFlight 分发。"
|
||||||
|
echo " https://appstoreconnect.apple.com/apps"
|
||||||
53
scripts/upload-qwen3vl.sh
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 把本地 Models/Qwen3-VL-4B-Instruct-4bit/ 的 14 个文件上传到模型分发服务器,
|
||||||
|
# 使 App 的「模型管理 · 下载」能拉到新 VL 模型(否则用户点下载会 404)。
|
||||||
|
#
|
||||||
|
# 服务器:Caddy(file_server browse),web 根 = /srv/models,SSH = root@101.132.124.52。
|
||||||
|
# App 下载 URL 形如:https://file.myv0.com/Qwen3-VL-4B-Instruct-4bit/<file>
|
||||||
|
# → openresty(终止 HTTPS)回源到 Caddy :80(root /srv/models)。
|
||||||
|
# → 所以远端目标目录 = /srv/models/Qwen3-VL-4B-Instruct-4bit/。
|
||||||
|
#
|
||||||
|
# 认证:已用 ssh-copy-id 装好本机公钥,走免密 key;脚本内不含任何密码。
|
||||||
|
# 用法: bash scripts/upload-qwen3vl.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LOCAL_DIR="/Users/xuhuayong/apps/康康/Models/Qwen3-VL-4B-Instruct-4bit"
|
||||||
|
SSH_HOST="root@101.132.124.52"
|
||||||
|
REMOTE_ROOT="/srv/models"
|
||||||
|
REMOTE_SUBDIR="Qwen3-VL-4B-Instruct-4bit"
|
||||||
|
REMOTE_DIR="$REMOTE_ROOT/$REMOTE_SUBDIR"
|
||||||
|
|
||||||
|
# 上传前本地完整性自检(逐字节,14 文件全 SKIP 才算齐)。
|
||||||
|
bash "$(dirname "$0")/fetch-qwen3vl.sh" >/dev/null || { echo "本地文件不完整,先跑 fetch-qwen3vl.sh 修复再上传"; exit 1; }
|
||||||
|
echo "本地 14 文件校验通过,开始上传 → $SSH_HOST:$REMOTE_DIR/"
|
||||||
|
|
||||||
|
ssh -o ConnectTimeout=20 "$SSH_HOST" "mkdir -p '$REMOTE_DIR'"
|
||||||
|
|
||||||
|
# rsync 断点续传(-P=--partial --progress),--inplace 适合大文件。
|
||||||
|
# 注意:macOS 自带 rsync 2.6.9 不支持 --info=progress2,用 -P 即可。
|
||||||
|
rsync -avP --inplace \
|
||||||
|
-e "ssh -o ConnectTimeout=20" \
|
||||||
|
"$LOCAL_DIR/" "$SSH_HOST:$REMOTE_DIR/"
|
||||||
|
|
||||||
|
echo "✅ rsync 上传完成,开始远端校验..."
|
||||||
|
|
||||||
|
# 远端逐文件大小核对(与本地 ModelManifest 的 14 文件一致)。
|
||||||
|
ssh "$SSH_HOST" "cd '$REMOTE_DIR' && ls -la && echo '--- 总大小 ---' && du -sh ."
|
||||||
|
|
||||||
|
cat <<'TIP'
|
||||||
|
──────────────────────────────────────────────
|
||||||
|
上传完成。建议再从公网验证一次(应全部 HTTP 200,content-length 与本地一致):
|
||||||
|
|
||||||
|
for f in config.json model.safetensors model.safetensors.index.json \
|
||||||
|
tokenizer.json tokenizer_config.json vocab.json merges.txt \
|
||||||
|
special_tokens_map.json added_tokens.json generation_config.json \
|
||||||
|
chat_template.json chat_template.jinja preprocessor_config.json \
|
||||||
|
video_preprocessor_config.json; do
|
||||||
|
curl -sI "https://file.myv0.com/Qwen3-VL-4B-Instruct-4bit/$f" \
|
||||||
|
| awk -v F="$f" '/^HTTP/{c=$2} tolower($1)=="content-length:"{s=$2} END{printf "%-32s %s %s\n",F,c,s}'
|
||||||
|
done
|
||||||
|
|
||||||
|
旧模型 Qwen2.5-VL-3B 仍在服务器上;确认新版可用后再删旧目录:
|
||||||
|
ssh root@101.132.124.52 'rm -rf /srv/models/Qwen2.5-VL-3B-Instruct-4bit'
|
||||||
|
──────────────────────────────────────────────
|
||||||
|
TIP
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0003 /* MLXLLM */; };
|
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0003 /* MLXLLM */; };
|
||||||
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0004 /* MLXLMCommon */; };
|
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0004 /* MLXLMCommon */; };
|
||||||
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0006 /* MLXVLM */; };
|
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0006 /* MLXVLM */; };
|
||||||
|
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = FEEDFACE000000000000F001 /* MNN.xcframework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
5E463CF92FC403BB0089145B /* 康康.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "康康.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
5E463CF92FC403BB0089145B /* 康康.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "康康.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
5E463D082FC403BC0089145B /* 康康Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "康康Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
5E463D082FC403BC0089145B /* 康康Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "康康Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
5E463D122FC403BC0089145B /* 康康UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "康康UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
5E463D122FC403BC0089145B /* 康康UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "康康UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
FEEDFACE000000000000F001 /* MNN.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MNN.xcframework; path = Frameworks/MNN.xcframework; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -61,6 +63,7 @@
|
|||||||
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */,
|
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */,
|
||||||
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */,
|
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */,
|
||||||
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */,
|
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */,
|
||||||
|
FEEDFACE000000000000F002 /* MNN.xcframework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -88,6 +91,7 @@
|
|||||||
5E463D0B2FC403BC0089145B /* 康康Tests */,
|
5E463D0B2FC403BC0089145B /* 康康Tests */,
|
||||||
5E463D152FC403BC0089145B /* 康康UITests */,
|
5E463D152FC403BC0089145B /* 康康UITests */,
|
||||||
5E463CFA2FC403BB0089145B /* Products */,
|
5E463CFA2FC403BB0089145B /* Products */,
|
||||||
|
FEEDFACE000000000000F001 /* MNN.xcframework */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -183,7 +187,7 @@
|
|||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2600;
|
LastSwiftUpdateCheck = 2600;
|
||||||
LastUpgradeCheck = 2600;
|
LastUpgradeCheck = 2650;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
5E463CF82FC403BB0089145B = {
|
5E463CF82FC403BB0089145B = {
|
||||||
CreatedOnToolsVersion = 26.0.1;
|
CreatedOnToolsVersion = 26.0.1;
|
||||||
@@ -199,16 +203,19 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "康康" */;
|
buildConfigurationList = 5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "康康" */;
|
||||||
developmentRegion = en;
|
developmentRegion = "zh-Hans";
|
||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
|
"zh-Hans",
|
||||||
|
ja,
|
||||||
|
ko,
|
||||||
);
|
);
|
||||||
mainGroup = 5E463CF02FC403BB0089145B;
|
mainGroup = 5E463CF02FC403BB0089145B;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */,
|
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 5E463CFA2FC403BB0089145B /* Products */;
|
productRefGroup = 5E463CFA2FC403BB0089145B /* Products */;
|
||||||
@@ -289,6 +296,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
@@ -318,6 +326,7 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
@@ -341,6 +350,7 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
};
|
};
|
||||||
@@ -351,6 +361,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
@@ -380,6 +391,7 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
@@ -396,6 +408,7 @@
|
|||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -405,14 +418,27 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
|
FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Frameworks";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "康康";
|
||||||
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
|
||||||
|
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
|
||||||
|
INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。";
|
||||||
|
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
||||||
|
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -423,24 +449,25 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--";
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "康康/康康-Bridging-Header.h";
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@@ -450,14 +477,27 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
|
FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Frameworks";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "康康";
|
||||||
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
|
||||||
|
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
|
||||||
|
INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。";
|
||||||
|
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "康康需要使用麦克风进行语音记录,识别全程在本机完成,声音不会上传。";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
||||||
|
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "语音转文字使用 iOS 端侧识别,内容不会发送给 Apple 或任何服务器。";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -468,24 +508,25 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--";
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "康康/康康-Bridging-Header.h";
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -495,23 +536,24 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--Tests";
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/康康.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/康康";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/康康.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/康康";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
@@ -522,23 +564,24 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--Tests";
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/康康.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/康康";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/康康.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/康康";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
@@ -548,23 +591,24 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--UITests";
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_TARGET_NAME = "康康";
|
TEST_TARGET_NAME = "康康";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
@@ -574,23 +618,24 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--UITests";
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_TARGET_NAME = "康康";
|
TEST_TARGET_NAME = "康康";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
@@ -638,12 +683,12 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */ = {
|
5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/ml-explore/mlx-swift-examples";
|
repositoryURL = "https://github.com/ml-explore/mlx-swift-lm";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = upToNextMajorVersion;
|
kind = exactVersion;
|
||||||
minimumVersion = 2.29.1;
|
version = 2.31.3;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
@@ -651,17 +696,17 @@
|
|||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
FEED000000000000DEAD0003 /* MLXLLM */ = {
|
FEED000000000000DEAD0003 /* MLXLLM */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
|
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
|
||||||
productName = MLXLLM;
|
productName = MLXLLM;
|
||||||
};
|
};
|
||||||
FEED000000000000DEAD0004 /* MLXLMCommon */ = {
|
FEED000000000000DEAD0004 /* MLXLMCommon */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
|
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
|
||||||
productName = MLXLMCommon;
|
productName = MLXLMCommon;
|
||||||
};
|
};
|
||||||
FEED000000000000DEAD0006 /* MLXVLM */ = {
|
FEED000000000000DEAD0006 /* MLXVLM */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
|
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-lm" */;
|
||||||
productName = MLXVLM;
|
productName = MLXVLM;
|
||||||
};
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "6b8265ebd61c6fdfca835dd1f90f17439ca9abc5c11a8b7b5db8790be0349e4d",
|
"originHash" : "facc0ac7c70363ea20f6cd1235de91dea6b06f0d00190946045a6c8ae753abc2",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "gzipswift",
|
"identity" : "eventsource",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/1024jp/GzipSwift",
|
"location" : "https://github.com/mattt/EventSource.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "731037f6cc2be2ec01562f6597c1d0aa3fe6fd05",
|
"revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e",
|
||||||
"version" : "6.0.1"
|
"version" : "1.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -15,17 +15,35 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/ml-explore/mlx-swift",
|
"location" : "https://github.com/ml-explore/mlx-swift",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "072b684acaae80b6a463abab3a103732f33774bf",
|
"revision" : "dc43e62d7055353c7f99fa071a4e71d29dfddc44",
|
||||||
"version" : "0.29.1"
|
"version" : "0.31.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identity" : "mlx-swift-examples",
|
"identity" : "mlx-swift-lm",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/ml-explore/mlx-swift-examples",
|
"location" : "https://github.com/ml-explore/mlx-swift-lm",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "9bff95ca5f0b9e8c021acc4d71a2bbe4a7441631",
|
"revision" : "25b00d4e22e61ec9c41efda47990cd2084ec87ff",
|
||||||
"version" : "2.29.1"
|
"version" : "2.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-asn1",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-asn1.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab",
|
||||||
|
"version" : "1.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-atomics",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-atomics.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
||||||
|
"version" : "1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -37,6 +55,24 @@
|
|||||||
"version" : "1.5.1"
|
"version" : "1.5.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-crypto",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-crypto.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1",
|
||||||
|
"version" : "4.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-huggingface",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/huggingface/swift-huggingface.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b721959445b617d0bf03910b2b4aced345fd93bf",
|
||||||
|
"version" : "0.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-jinja",
|
"identity" : "swift-jinja",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -46,6 +82,15 @@
|
|||||||
"version" : "2.3.6"
|
"version" : "2.3.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-nio",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-nio.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95",
|
||||||
|
"version" : "2.100.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-numerics",
|
"identity" : "swift-numerics",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -55,13 +100,31 @@
|
|||||||
"version" : "1.1.1"
|
"version" : "1.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-system",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-system.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "669763cfd5806a67e21972d7e5e2d6b80b1ea985",
|
||||||
|
"version" : "1.6.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-transformers",
|
"identity" : "swift-transformers",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/huggingface/swift-transformers",
|
"location" : "https://github.com/huggingface/swift-transformers",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "a2e184dddb4757bc943e77fbe99ac6786c53f0b2",
|
"revision" : "58c4bc11963a140358d791f678a60a2745a23146",
|
||||||
"version" : "1.0.0"
|
"version" : "1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "yyjson",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/ibireme/yyjson.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "8b4a38dc994a110abaec8a400615567bd996105f",
|
||||||
|
"version" : "0.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2600"
|
LastUpgradeVersion = "2650"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import MLX
|
||||||
|
|
||||||
enum AIRuntimeError: Error, LocalizedError {
|
enum AIRuntimeError: Error, LocalizedError {
|
||||||
case notReady
|
case notReady
|
||||||
@@ -7,13 +8,20 @@ enum AIRuntimeError: Error, LocalizedError {
|
|||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .notReady: return "AI 模型尚未准备好"
|
case .notReady: return String(appLoc: "AI 模型尚未准备好")
|
||||||
case .modelLoadFailed(let m): return "模型加载失败:\(m)"
|
case .modelLoadFailed(let m): return String(appLoc: "模型加载失败:\(m)")
|
||||||
case .inferenceFailed(let m): return "推理失败:\(m)"
|
case .inferenceFailed(let m): return String(appLoc: "推理失败:\(m)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 推理优先级。interactive = 用户正在屏幕前等(识别/问答/自检);
|
||||||
|
/// background = 预生成(报告摘要等),排队让行、解码中可被协作式抢占。
|
||||||
|
nonisolated enum InferencePriority: Sendable, Equatable {
|
||||||
|
case interactive
|
||||||
|
case background
|
||||||
|
}
|
||||||
|
|
||||||
actor AIRuntime {
|
actor AIRuntime {
|
||||||
static let shared = AIRuntime()
|
static let shared = AIRuntime()
|
||||||
|
|
||||||
@@ -28,30 +36,144 @@ actor AIRuntime {
|
|||||||
private(set) var vlStatus: Status = .notReady
|
private(set) var vlStatus: Status = .notReady
|
||||||
private(set) var lastDecodeRate: Double = 0
|
private(set) var lastDecodeRate: Double = 0
|
||||||
|
|
||||||
|
/// 末次文本生成的性能统计(性能自检页消费;两后端归一)。
|
||||||
|
private(set) var lastGenerateStats: GenerateStats?
|
||||||
|
|
||||||
|
/// 当前实际生效的后端标签(性能自检 / PPT 截图用)。
|
||||||
|
var activeBackendLabel: String {
|
||||||
|
if InferenceEngine.current == .mnn, mnnStatus == .ready {
|
||||||
|
return InferenceEngine.cpuSupportsSME2 ? "MNN · SME2" : "MNN · NEON"
|
||||||
|
}
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
return "MLX · CPU(模拟器)"
|
||||||
|
#else
|
||||||
|
return "MLX · GPU"
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
private var llmSession: LLMSession?
|
private var llmSession: LLMSession?
|
||||||
private var vlSession: VLSession?
|
private var vlSession: VLSession?
|
||||||
|
|
||||||
private init() {}
|
// MARK: - MNN 后端(CPU/SME2,挑战赛考核路径)
|
||||||
|
// .mnn 引擎下,文本生成与 VL(图→文)由同一个 Qwen3.5-2B 多模态 MNN 模型全包(已实测)。
|
||||||
/// 加载模型。首次调用会真正加载,后续幂等。
|
// 模拟器无 MNN,VL 回退 MLX 的 Qwen3-VL-4B。
|
||||||
func prepare() async throws {
|
private let mnn = MNNBackend()
|
||||||
switch status {
|
private(set) var mnnStatus: Status = .notReady
|
||||||
case .ready:
|
/// MNN 模型目录(下载/旁路导入到 Models/Qwen3.5-2B-MNN)。
|
||||||
return
|
nonisolated static var mnnModelFolder: URL {
|
||||||
case .loading:
|
ModelStore.shared.localURL(for: .mnnLLM)
|
||||||
// 已有其他调用方在加载;本次 prepare 直接返回,
|
|
||||||
// 调用方需稍后 await prepare() 再判 status,或自行轮询 / 显示加载 UI。
|
|
||||||
// W3 引入 prepare 队列时优化。
|
|
||||||
return
|
|
||||||
case .error, .notReady:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard ModelStore.shared.isReady(.llm) else {
|
// MARK: - 串行推理闸门(§3.1 OOM 防护的真正落地)
|
||||||
|
//
|
||||||
|
// actor 只串行化「方法入口」,但 generate() 同步返回流、真正解码在内部 Task;
|
||||||
|
// analyzeReport 也在 await 期间让出 actor。若不加闸门,LLM 流正在解码时触发 VL,
|
||||||
|
// 两个模型会同时在 GPU 上解码 → 冲过单 App 内存上限被 jetsam 杀
|
||||||
|
//(MEMORY 记录的「in-flight 流并发窄口」)。
|
||||||
|
//
|
||||||
|
// 这里用 actor 内信号量(count = 1):所有「会占显存的重活」(解码 + 模型加载)
|
||||||
|
// 进入前先 await acquireGate(),结束后 releaseGate()。actor 串行执行保证
|
||||||
|
// gateBusy / gateWaiters 的读写天然无并发。
|
||||||
|
private struct GateWaiter {
|
||||||
|
let priority: InferencePriority
|
||||||
|
let cont: CheckedContinuation<Void, Never>
|
||||||
|
}
|
||||||
|
private var gateBusy = false
|
||||||
|
private var gateHolderPriority: InferencePriority = .interactive
|
||||||
|
private var preemptRequested = false
|
||||||
|
private var gateWaiters: [GateWaiter] = []
|
||||||
|
|
||||||
|
/// interactive 排到所有 background 等待者之前;同优先级保持 FIFO。纯函数,单测覆盖。
|
||||||
|
nonisolated static func gateInsertionIndex(of priority: InferencePriority,
|
||||||
|
in waiting: [InferencePriority]) -> Int {
|
||||||
|
guard priority == .interactive else { return waiting.count }
|
||||||
|
return waiting.firstIndex(of: .background) ?? waiting.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private func acquireGate(_ priority: InferencePriority = .interactive) async {
|
||||||
|
if !gateBusy {
|
||||||
|
gateBusy = true
|
||||||
|
gateHolderPriority = priority
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 前台请求撞上后台持有者:请其让位 —— 后台解码循环在下一个 token 抛 CancellationError。
|
||||||
|
if priority == .interactive, gateHolderPriority == .background {
|
||||||
|
preemptRequested = true
|
||||||
|
}
|
||||||
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
||||||
|
let idx = Self.gateInsertionIndex(of: priority, in: gateWaiters.map(\.priority))
|
||||||
|
gateWaiters.insert(GateWaiter(priority: priority, cont: cont), at: idx)
|
||||||
|
}
|
||||||
|
// 被 releaseGate 唤醒时即已持有闸门(gateBusy 保持 true)。
|
||||||
|
}
|
||||||
|
|
||||||
|
private func releaseGate() {
|
||||||
|
preemptRequested = false
|
||||||
|
if gateWaiters.isEmpty {
|
||||||
|
gateBusy = false
|
||||||
|
} else {
|
||||||
|
// 把闸门直接交给队首等待者,gateBusy 维持 true,不留空窗。
|
||||||
|
let next = gateWaiters.removeFirst()
|
||||||
|
gateHolderPriority = next.priority
|
||||||
|
next.cont.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 后台持有者每收到一个 token 查一次:前台在排队就让位。
|
||||||
|
private func shouldPreempt(_ priority: InferencePriority) -> Bool {
|
||||||
|
priority == .background && preemptRequested
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
/// App 启动时调用一次:给 MLX 的 GPU 缓冲池设上限,避免 reuse cache 在大模型常驻之上
|
||||||
|
/// 继续膨胀、把峰值推过单 App 内存上限。仅真机生效(模拟器走 CPU,且部分 Metal 路径会 abort)。
|
||||||
|
/// 与 increased-memory-limit entitlement + LLM/VL 互斥卸载配合,三管齐下防 jetsam OOM。
|
||||||
|
nonisolated static func configureMLXMemory() {
|
||||||
|
#if !targetEnvironment(simulator)
|
||||||
|
// 256MB cache 上限:够复用、不至于在 3GB 模型之上再囤几百 MB 空闲缓冲。
|
||||||
|
MLX.Memory.cacheLimit = 256 * 1024 * 1024
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载文本模型。首次调用会真正加载,后续幂等。
|
||||||
|
/// 按当前引擎路由:.mnn → MNN(CPU/SME2);.mlx → 现有 MLX(GPU)。
|
||||||
|
func prepare() async throws {
|
||||||
|
// 选了 MNN 且模型已就绪才走 MNN;否则(选 MLX,或 MNN 模型尚未下载)回退 MLX,
|
||||||
|
// 保证过渡期 App 始终可用。引擎指示器(Phase 5)展示实际生效后端。
|
||||||
|
let mnnReady = ModelStore.shared.isComplete(for: .mnnLLM)
|
||||||
|
if InferenceEngine.current == .mnn, mnnReady {
|
||||||
|
try await prepareMNN()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 走 MLX:先卸 MNN 释放内存(单模型常驻策略)。
|
||||||
|
await unloadMNN()
|
||||||
|
// 已有其他调用方在加载时,轮询等其结束再判定结果。
|
||||||
|
// 不能像旧实现那样裸 return:那会让调用方误以为已 ready,随后 generate 的
|
||||||
|
// `guard status == .ready` 失败 → 用户撞上「假错误屏」(模型其实正常加载中)。
|
||||||
|
while status == .loading {
|
||||||
|
try await Task.sleep(nanoseconds: 80_000_000)
|
||||||
|
}
|
||||||
|
if status == .ready { return }
|
||||||
|
|
||||||
|
// 用 isComplete(逐文件字节校验)而非 isReady(只看 config.json):config.json 最小最先下完,
|
||||||
|
// 半下载时 isReady 仍 true 会让加载在残缺 safetensors 上崩溃。与 ModelDownloadService 的
|
||||||
|
// 完成判据保持一致(它也用 isComplete)。
|
||||||
|
guard ModelStore.shared.isComplete(for: .llm) else {
|
||||||
status = .error("LLM 模型未就绪")
|
status = .error("LLM 模型未就绪")
|
||||||
throw AIRuntimeError.notReady
|
throw AIRuntimeError.notReady
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 进闸门:等所有在跑的推理(可能是 VL 解码)结束,再卸 VL + 载 LLM,
|
||||||
|
// 避免「VL 解码 + LLM 加载」内存峰值叠加 OOM。
|
||||||
|
await acquireGate()
|
||||||
|
defer { releaseGate() }
|
||||||
|
// 拿到闸门后复查:排队期间可能已被别的调用方加载好,避免重复 load。
|
||||||
|
if status == .ready { return }
|
||||||
|
|
||||||
|
// OOM 闸门(§3.1):LLM(~1GB)与 VL(~3GB)不可同时常驻,叠加会冲过单 App 内存上限被 jetsam 杀。
|
||||||
|
unloadVL()
|
||||||
|
|
||||||
status = .loading
|
status = .loading
|
||||||
do {
|
do {
|
||||||
let session = try await LLMSession.load(
|
let session = try await LLMSession.load(
|
||||||
@@ -65,33 +187,129 @@ actor AIRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载 MNN 文本模型。幂等。单模型常驻:载入前卸掉 MLX 的 LLM/VL 释放内存。
|
||||||
|
private func prepareMNN() async throws {
|
||||||
|
while mnnStatus == .loading {
|
||||||
|
try await Task.sleep(nanoseconds: 80_000_000)
|
||||||
|
}
|
||||||
|
if mnnStatus == .ready { return }
|
||||||
|
|
||||||
|
let folder = Self.mnnModelFolder
|
||||||
|
guard ModelStore.shared.isComplete(for: .mnnLLM) else {
|
||||||
|
mnnStatus = .error("MNN 模型未就绪")
|
||||||
|
throw AIRuntimeError.notReady
|
||||||
|
}
|
||||||
|
|
||||||
|
await acquireGate()
|
||||||
|
defer { releaseGate() }
|
||||||
|
if mnnStatus == .ready { return }
|
||||||
|
|
||||||
|
// 单模型常驻:卸 MLX LLM/VL,避免与 MNN 模型叠加占内存。
|
||||||
|
unloadLLM()
|
||||||
|
unloadVL()
|
||||||
|
|
||||||
|
mnnStatus = .loading
|
||||||
|
do {
|
||||||
|
try await mnn.load(folderURL: folder)
|
||||||
|
mnnStatus = .ready
|
||||||
|
} catch {
|
||||||
|
mnnStatus = .error("\(error)")
|
||||||
|
throw AIRuntimeError.modelLoadFailed("\(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卸载 MNN,释放桥与权重。幂等。
|
||||||
|
private func unloadMNN() async {
|
||||||
|
guard mnnStatus != .notReady else { return }
|
||||||
|
await mnn.unload()
|
||||||
|
mnnStatus = .notReady
|
||||||
|
MLX.Memory.clearCache()
|
||||||
|
}
|
||||||
|
|
||||||
/// 流式生成。调用前应先 await prepare()。
|
/// 流式生成。调用前应先 await prepare()。
|
||||||
/// 注意:返回流是同步创建的,但跨 actor 调用 LLMSession 需要 await。
|
/// 注意:返回流是同步创建的,但跨 actor 调用 LLMSession 需要 await。
|
||||||
func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream<TokenChunk, Error> {
|
/// priority = .background 时排队让行、解码中可被前台请求按 token 抢占(CancellationError 透传)。
|
||||||
|
func generate(prompt: String,
|
||||||
|
maxTokens: Int = 256,
|
||||||
|
priority: InferencePriority = .interactive) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||||
|
if InferenceEngine.current == .mnn, mnnStatus == .ready {
|
||||||
|
return mnnGenerate(prompt: prompt, maxTokens: maxTokens, priority: priority)
|
||||||
|
}
|
||||||
// 在 actor 隔离上下文中捕获快照,Task 内不再访问 self.status / self.llmSession
|
// 在 actor 隔离上下文中捕获快照,Task 内不再访问 self.status / self.llmSession
|
||||||
let snapshotStatus = status
|
let snapshotStatus = status
|
||||||
let snapshotSession = llmSession
|
let snapshotSession = llmSession
|
||||||
|
|
||||||
return AsyncThrowingStream { continuation in
|
return AsyncThrowingStream { continuation in
|
||||||
Task {
|
let task = Task {
|
||||||
guard snapshotStatus == .ready, let session = snapshotSession else {
|
guard snapshotStatus == .ready, let session = snapshotSession else {
|
||||||
continuation.finish(throwing: AIRuntimeError.notReady)
|
continuation.finish(throwing: AIRuntimeError.notReady)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 进闸门:保证本次 LLM 解码与任何 VL 解码 / 模型加载串行,绝不并发占显存。
|
||||||
|
await self.acquireGate(priority)
|
||||||
do {
|
do {
|
||||||
// session.generate 跨 actor 边界,需要 await
|
// session.generate 跨 actor 边界,需要 await
|
||||||
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
||||||
for try await chunk in stream {
|
for try await chunk in stream {
|
||||||
|
// 消费者(UI)提前关闭/取消时,下面的 checkCancellation 让本 Task 尽快退出,
|
||||||
|
// 连带丢弃 session 流并触发其 onTermination,停止底层 MLX 解码,不空耗 GPU。
|
||||||
|
try Task.checkCancellation()
|
||||||
|
// 后台任务让位:前台请求在排队时,下一个 token 处主动退出。
|
||||||
|
if self.shouldPreempt(priority) { throw CancellationError() }
|
||||||
// Task 闭包在 generate() 内启动,继承 AIRuntime 的 actor 隔离;
|
// Task 闭包在 generate() 内启动,继承 AIRuntime 的 actor 隔离;
|
||||||
// 调用同 actor 的 recordRate 不需要 await
|
// 调用同 actor 的 recordRate 不需要 await
|
||||||
self.recordRate(chunk.decodeRate)
|
self.recordRate(chunk.decodeRate)
|
||||||
continuation.yield(chunk)
|
continuation.yield(chunk)
|
||||||
}
|
}
|
||||||
|
self.lastGenerateStats = await session.lastStats
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
|
} catch is CancellationError {
|
||||||
|
// 取消/抢占以 CancellationError 透传,调用方据此区分「让位」与「真失败」。
|
||||||
|
continuation.finish(throwing: CancellationError())
|
||||||
} catch {
|
} catch {
|
||||||
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||||
}
|
}
|
||||||
|
// 正常结束 / 异常 / 取消(checkCancellation 抛出后被上面 catch 吞掉)都会走到这,
|
||||||
|
// 闸门一定释放,不会死锁后续推理。
|
||||||
|
self.releaseGate()
|
||||||
}
|
}
|
||||||
|
// 消费者取消/流终止时取消内部 Task(与 LLMSession / HealthExportService 一致)。
|
||||||
|
continuation.onTermination = { _ in task.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MNN(CPU/SME2)文本流式生成。结构与 MLX 分支一致:进闸门、串行解码、记录速率。
|
||||||
|
private func mnnGenerate(prompt: String,
|
||||||
|
maxTokens: Int,
|
||||||
|
priority: InferencePriority) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||||
|
let ready = (mnnStatus == .ready)
|
||||||
|
return AsyncThrowingStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
guard ready else {
|
||||||
|
continuation.finish(throwing: AIRuntimeError.notReady)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await self.acquireGate(priority)
|
||||||
|
do {
|
||||||
|
let stream = await self.mnn.generate(prompt: prompt, maxTokens: maxTokens)
|
||||||
|
for try await chunk in stream {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
// 后台任务让位:前台请求在排队时,下一个 token 处主动退出
|
||||||
|
//(流终止触发 MNNBackend.onTermination → bridge.cancel())。
|
||||||
|
if self.shouldPreempt(priority) { throw CancellationError() }
|
||||||
|
self.recordRate(chunk.decodeRate)
|
||||||
|
continuation.yield(chunk)
|
||||||
|
}
|
||||||
|
self.lastGenerateStats = await self.mnn.lastStats
|
||||||
|
continuation.finish()
|
||||||
|
} catch is CancellationError {
|
||||||
|
continuation.finish(throwing: CancellationError())
|
||||||
|
} catch {
|
||||||
|
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||||
|
}
|
||||||
|
self.releaseGate()
|
||||||
|
}
|
||||||
|
continuation.onTermination = { _ in task.cancel() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,22 +321,37 @@ actor AIRuntime {
|
|||||||
|
|
||||||
/// 加载 VL 模型。幂等,首调真正 load。
|
/// 加载 VL 模型。幂等,首调真正 load。
|
||||||
func prepareVL() async throws {
|
func prepareVL() async throws {
|
||||||
switch vlStatus {
|
// 选了 MNN 且多模态模型就绪:VL 复用同一个 MNN 模型(文本+视觉一肩挑),走 prepareMNN。
|
||||||
case .ready, .loading:
|
if InferenceEngine.current == .mnn, ModelStore.shared.isComplete(for: .mnnLLM) {
|
||||||
|
try await prepareMNN()
|
||||||
return
|
return
|
||||||
case .error, .notReady:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
while vlStatus == .loading {
|
||||||
|
try await Task.sleep(nanoseconds: 80_000_000)
|
||||||
|
}
|
||||||
|
if vlStatus == .ready { return }
|
||||||
|
|
||||||
guard ModelStore.shared.isReady(.vl) else {
|
// MLX VL 改用 .llm 的 Qwen3.5-2B 多模态(VLMModelFactory 走 qwen3_5 视觉路径),
|
||||||
|
// 不再单独需要 Qwen3-VL-4B。用 isComplete 排除半下载,与下载服务判据一致。
|
||||||
|
guard ModelStore.shared.isComplete(for: .llm) else {
|
||||||
vlStatus = .error("VL 模型未就绪")
|
vlStatus = .error("VL 模型未就绪")
|
||||||
throw AIRuntimeError.notReady
|
throw AIRuntimeError.notReady
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 进闸门:等所有在跑的推理(可能是 LLM 文本流)结束,再卸 LLM + 载 VL。
|
||||||
|
// —— 这正是「指标速记识别时 App 自动退出」的主因防护。
|
||||||
|
await acquireGate()
|
||||||
|
defer { releaseGate() }
|
||||||
|
if vlStatus == .ready { return }
|
||||||
|
|
||||||
|
// OOM 闸门(§3.1):加载 VL(~3GB)前先卸 LLM(~1GB),否则两者常驻叠加冲过内存上限被 jetsam 杀。
|
||||||
|
unloadLLM()
|
||||||
|
await unloadMNN()
|
||||||
|
|
||||||
vlStatus = .loading
|
vlStatus = .loading
|
||||||
do {
|
do {
|
||||||
let session = try await VLSession.load(
|
let session = try await VLSession.load(
|
||||||
folderURL: ModelStore.shared.localURL(for: .vl)
|
folderURL: ModelStore.shared.localURL(for: .llm)
|
||||||
)
|
)
|
||||||
self.vlSession = session
|
self.vlSession = session
|
||||||
vlStatus = .ready
|
vlStatus = .ready
|
||||||
@@ -128,15 +361,46 @@ actor AIRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 卸载(OOM 闸门)
|
||||||
|
|
||||||
|
/// 卸载 LLM,释放 ModelContainer 引用并清 MLX 显存缓存。幂等。
|
||||||
|
/// 注:只在持有推理闸门时调用(prepareVL 内),此刻不会有 LLM 流在解码,卸载即时生效。
|
||||||
|
private func unloadLLM() {
|
||||||
|
guard llmSession != nil else { return }
|
||||||
|
llmSession = nil
|
||||||
|
status = .notReady
|
||||||
|
MLX.Memory.clearCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卸载 VL,释放 ModelContainer 引用并清 MLX 显存缓存。幂等。
|
||||||
|
private func unloadVL() {
|
||||||
|
guard vlSession != nil else { return }
|
||||||
|
vlSession = nil
|
||||||
|
vlStatus = .notReady
|
||||||
|
MLX.Memory.clearCache()
|
||||||
|
}
|
||||||
|
|
||||||
/// 图像 → JSON 字符串(由 VLPrompts.reportExtraction 引导)。
|
/// 图像 → JSON 字符串(由 VLPrompts.reportExtraction 引导)。
|
||||||
/// 调用方负责解析 + 失败回退(§3.2)。
|
/// 调用方负责解析 + 失败回退(§3.2)。
|
||||||
/// AIRuntime 是 actor,本调用与 LLM.generate() 自然串行,不会 OOM。
|
/// 推理闸门保证本调用与 LLM.generate() 的解码串行,不会同时占显存 OOM。
|
||||||
func analyzeReport(imageURLs: [URL],
|
func analyzeReport(imageURLs: [URL],
|
||||||
prompt: String,
|
prompt: String,
|
||||||
maxTokens: Int = 512) async throws -> String {
|
maxTokens: Int = 512) async throws -> String {
|
||||||
|
// 选了 MNN 且就绪:图→文走同一个 MNN 多模态模型。
|
||||||
|
if InferenceEngine.current == .mnn, mnnStatus == .ready {
|
||||||
|
await acquireGate()
|
||||||
|
defer { releaseGate() }
|
||||||
|
do {
|
||||||
|
return try await mnn.analyze(imageURLs: imageURLs, prompt: prompt, maxTokens: maxTokens)
|
||||||
|
} catch {
|
||||||
|
throw AIRuntimeError.inferenceFailed("\(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
guard vlStatus == .ready, let session = vlSession else {
|
guard vlStatus == .ready, let session = vlSession else {
|
||||||
throw AIRuntimeError.notReady
|
throw AIRuntimeError.notReady
|
||||||
}
|
}
|
||||||
|
await acquireGate()
|
||||||
|
defer { releaseGate() }
|
||||||
do {
|
do {
|
||||||
return try await session.analyze(
|
return try await session.analyze(
|
||||||
imageURLs: imageURLs,
|
imageURLs: imageURLs,
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ enum DownloadError: Error, LocalizedError {
|
|||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .badStatus(let code):
|
case .badStatus(let code):
|
||||||
return "下载失败(HTTP \(code))"
|
return String(appLoc: "下载失败(HTTP \(code))")
|
||||||
case .sizeMismatch(let expected, let got):
|
case .sizeMismatch(let expected, let got):
|
||||||
return "文件大小校验失败(预期 \(expected),实际 \(got))"
|
return String(appLoc: "文件大小校验失败(预期 \(expected),实际 \(got))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,12 +74,12 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
|
|||||||
let fileHandle = try FileHandle(forWritingTo: part)
|
let fileHandle = try FileHandle(forWritingTo: part)
|
||||||
try fileHandle.seekToEnd()
|
try fileHandle.seekToEnd()
|
||||||
|
|
||||||
lock.lock()
|
lock.withLock {
|
||||||
self.handle = fileHandle
|
self.handle = fileHandle
|
||||||
self.written = offset
|
self.written = offset
|
||||||
self.onProgress = onProgress
|
self.onProgress = onProgress
|
||||||
self.responseError = nil
|
self.responseError = nil
|
||||||
lock.unlock()
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
if offset > 0 {
|
if offset > 0 {
|
||||||
|
|||||||
19
康康/AI/GenerateStats.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 单次生成的性能统计,两后端(MNN / MLX)归一。
|
||||||
|
/// MNN 取自 LlmContext(prefill_us / decode_us);MLX 取自 GenerateCompletionInfo。
|
||||||
|
struct GenerateStats: Sendable, Equatable {
|
||||||
|
var promptTokens: Int
|
||||||
|
var genTokens: Int
|
||||||
|
/// prefill(读入 prompt)耗时,秒。
|
||||||
|
var prefillSeconds: Double
|
||||||
|
/// decode(逐 token 生成)耗时,秒。
|
||||||
|
var decodeSeconds: Double
|
||||||
|
|
||||||
|
var prefillTokensPerSecond: Double {
|
||||||
|
prefillSeconds > 0 ? Double(promptTokens) / prefillSeconds : 0
|
||||||
|
}
|
||||||
|
var decodeTokensPerSecond: Double {
|
||||||
|
decodeSeconds > 0 ? Double(genTokens) / decodeSeconds : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
77
康康/AI/InferenceEngine.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 端侧推理引擎选择。
|
||||||
|
/// - mnn:Qwen + MNN + SME2(CPU),挑战赛考核路径,真机默认。
|
||||||
|
/// - mlx:Qwen + MLX(Metal GPU),兜底 / 对照。模拟器只有它可用。
|
||||||
|
nonisolated enum InferenceEngine: String, CaseIterable, Sendable {
|
||||||
|
case mnn
|
||||||
|
case mlx
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .mnn: return "MNN · CPU/SME2"
|
||||||
|
case .mlx: return "MLX · GPU"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本构建/设备是否可用。MNN 仅 device 切片有真实内核,模拟器回退 MLX。
|
||||||
|
var isAvailable: Bool {
|
||||||
|
switch self {
|
||||||
|
case .mlx: return true
|
||||||
|
case .mnn: return MNNLLMBridge.isAvailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 持久化(UserDefaults,跨 actor 安全)
|
||||||
|
|
||||||
|
private static let key = "kk.inferenceEngine"
|
||||||
|
|
||||||
|
/// 由偏好(可能是 .auto)解析出的、本次调用实际使用的具体引擎。
|
||||||
|
/// AIRuntime / MeView 等消费方只看这个,永远拿到 .mnn 或 .mlx。
|
||||||
|
/// 解析后仍做一次可用性兜底,保证总有可用引擎。
|
||||||
|
static var current: InferenceEngine {
|
||||||
|
let resolved = preference.resolved
|
||||||
|
return resolved.isAvailable ? resolved : .mlx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 运行时探测:CPU 是否支持 SME2(A19/iPhone17+)。用于 UI 展示加速状态。
|
||||||
|
static var cpuSupportsSME2: Bool { MNNLLMBridge.cpuSupportsSME2() }
|
||||||
|
|
||||||
|
// MARK: - 用户偏好(auto / mnn / mlx)
|
||||||
|
|
||||||
|
/// 用户在设置页的选择。默认 .auto:按本机配置自动择优。
|
||||||
|
/// 与具体引擎共用同一 UserDefaults key——历史写入的 "mnn"/"mlx" 仍兼容。
|
||||||
|
static var preference: EnginePreference {
|
||||||
|
get {
|
||||||
|
let raw = UserDefaults.standard.string(forKey: key)
|
||||||
|
return raw.flatMap(EnginePreference.init(rawValue:)) ?? .auto
|
||||||
|
}
|
||||||
|
set { UserDefaults.standard.set(newValue.rawValue, forKey: key) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 推理引擎的「用户偏好」,比具体引擎多一个 .auto。
|
||||||
|
/// - auto:按本机配置自动选——真机优先 MNN(考核路径,含 SME2/NEON),
|
||||||
|
/// MNN 不可用(模拟器)时回退 MLX。
|
||||||
|
nonisolated enum EnginePreference: String, CaseIterable, Sendable {
|
||||||
|
case auto
|
||||||
|
case mnn
|
||||||
|
case mlx
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .auto: return "自动"
|
||||||
|
case .mnn: return InferenceEngine.mnn.displayName
|
||||||
|
case .mlx: return InferenceEngine.mlx.displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把偏好解析成具体引擎(不做可用性兜底,那一步留给 `InferenceEngine.current`)。
|
||||||
|
var resolved: InferenceEngine {
|
||||||
|
switch self {
|
||||||
|
case .mnn: return .mnn
|
||||||
|
case .mlx: return .mlx
|
||||||
|
case .auto: return InferenceEngine.mnn.isAvailable ? .mnn : .mlx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@ import MLXLMCommon
|
|||||||
actor LLMSession {
|
actor LLMSession {
|
||||||
let container: ModelContainer
|
let container: ModelContainer
|
||||||
|
|
||||||
|
/// 末次生成统计(取自流末尾的 .info 完成事件,性能自检用)。
|
||||||
|
private(set) var lastStats: GenerateStats?
|
||||||
|
|
||||||
|
private func record(_ s: GenerateStats) { lastStats = s }
|
||||||
|
|
||||||
init(container: ModelContainer) {
|
init(container: ModelContainer) {
|
||||||
self.container = container
|
self.container = container
|
||||||
}
|
}
|
||||||
@@ -45,10 +50,16 @@ actor LLMSession {
|
|||||||
let task = Task {
|
let task = Task {
|
||||||
do {
|
do {
|
||||||
try await Self.withDeviceOverride {
|
try await Self.withDeviceOverride {
|
||||||
|
// 低温:本 App 文本任务多为"直答/JSON 抽取",高温随机性会经常吐成非 JSON。
|
||||||
|
// 0.3 + topP 0.85 让输出更确定、JSON 更稳(与 MNN set_config 降温对齐)。
|
||||||
|
// repetitionPenalty:低温 + 无惩罚时,长文本(如「关键指标」列表)会逐行复读
|
||||||
|
// 进入死循环;1.1 的重复惩罚 + 64 token 上下文窗口掐断复读(与 MNN penalty 对齐)。
|
||||||
let parameters = GenerateParameters(
|
let parameters = GenerateParameters(
|
||||||
maxTokens: maxTokens,
|
maxTokens: maxTokens,
|
||||||
temperature: Float(0.6),
|
temperature: Float(0.3),
|
||||||
topP: Float(0.9)
|
topP: Float(0.85),
|
||||||
|
repetitionPenalty: Float(1.1),
|
||||||
|
repetitionContextSize: 64
|
||||||
)
|
)
|
||||||
|
|
||||||
try await container.perform { (context: ModelContext) in
|
try await container.perform { (context: ModelContext) in
|
||||||
@@ -72,9 +83,14 @@ actor LLMSession {
|
|||||||
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
|
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
|
||||||
continuation.yield(TokenChunk(text: text, decodeRate: rate))
|
continuation.yield(TokenChunk(text: text, decodeRate: rate))
|
||||||
|
|
||||||
case .info:
|
case .info(let info):
|
||||||
// 生成完成统计,是流的最后一个事件
|
// 生成完成统计,是流的最后一个事件
|
||||||
break
|
await self.record(GenerateStats(
|
||||||
|
promptTokens: info.promptTokenCount,
|
||||||
|
genTokens: info.generationTokenCount,
|
||||||
|
prefillSeconds: info.promptTime,
|
||||||
|
decodeSeconds: info.generateTime
|
||||||
|
))
|
||||||
|
|
||||||
case .toolCall:
|
case .toolCall:
|
||||||
// 纯文本生成不会触发,switch 穷举
|
// 纯文本生成不会触发,switch 穷举
|
||||||
|
|||||||
55
康康/AI/MNN/MNNLLMBridge.h
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// MNNLLMBridge.h
|
||||||
|
// 康康
|
||||||
|
//
|
||||||
|
// Objective-C 接口,封装 MNN-LLM(Qwen)的加载与流式推理。
|
||||||
|
// 真实实现在 .mm 中以 ObjC++ 调用 <MNN/llm/llm.hpp>;模拟器下编为可用性返回 NO 的桩
|
||||||
|
// (MNN.framework 仅 device arm64 切片有真实 CPU/SME2 内核,模拟器走 MLX 兜底)。
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
/// 末次生成的性能统计(取自 MNN LlmContext)。
|
||||||
|
@interface MNNGenerateStats : NSObject
|
||||||
|
@property (nonatomic, readonly) int promptTokens;
|
||||||
|
@property (nonatomic, readonly) int genTokens;
|
||||||
|
@property (nonatomic, readonly) double prefillMs;
|
||||||
|
@property (nonatomic, readonly) double decodeMs;
|
||||||
|
/// 解码速率 tok/s = genTokens / (decodeMs/1000)。demo 卖点 #6 / Live Activity 用。
|
||||||
|
@property (nonatomic, readonly) double decodeTokensPerSecond;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface MNNLLMBridge : NSObject
|
||||||
|
|
||||||
|
/// 本构建是否含真实 MNN 运行时(device=YES,simulator 桩=NO)。
|
||||||
|
+ (BOOL)isAvailable;
|
||||||
|
/// CPU 是否支持 SME2(运行时探测);A19/iPhone17 YES,A17/iPhone15Pro NO。仅用于 UI 展示加速状态。
|
||||||
|
+ (BOOL)cpuSupportsSME2;
|
||||||
|
|
||||||
|
/// 用 MNN llm 的 config.json 路径加载模型(目录含 llm.mnn / 权重 / tokenizer)。失败返回 nil。
|
||||||
|
- (nullable instancetype)initWithConfigPath:(NSString *)configPath;
|
||||||
|
|
||||||
|
@property (nonatomic, readonly) BOOL isLoaded;
|
||||||
|
|
||||||
|
/// 纯文本流式生成。onToken 每解码出一段文本回调一次(在调用线程,同步阻塞直到生成结束)。
|
||||||
|
/// 返回末次统计。
|
||||||
|
- (MNNGenerateStats *)generateText:(NSString *)prompt
|
||||||
|
maxTokens:(int)maxTokens
|
||||||
|
onToken:(void (^)(NSString *piece))onToken;
|
||||||
|
|
||||||
|
/// 图→文(VL,需 MNN_BUILD_LLM_OMNI 构建)。imagePaths 为本地文件路径。
|
||||||
|
/// 当前文本构建未含 OMNI 时返回 nil 并置 error。
|
||||||
|
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths
|
||||||
|
prompt:(NSString *)prompt
|
||||||
|
maxTokens:(int)maxTokens
|
||||||
|
onToken:(void (^)(NSString *piece))onToken
|
||||||
|
error:(NSError *_Nullable *_Nullable)error;
|
||||||
|
|
||||||
|
/// 请求取消当前生成(best-effort:置标志,后续 token 不再回调)。
|
||||||
|
- (void)cancel;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
210
康康/AI/MNN/MNNLLMBridge.mm
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
//
|
||||||
|
// MNNLLMBridge.mm
|
||||||
|
// 康康
|
||||||
|
//
|
||||||
|
// ObjC++ 实现。device 真机用 <MNN/llm/llm.hpp>;模拟器编为桩(返回不可用,上层回退 MLX)。
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "MNNLLMBridge.h"
|
||||||
|
#include <sys/sysctl.h>
|
||||||
|
|
||||||
|
// MARK: - 性能统计(私有 readwrite 重声明)
|
||||||
|
@interface MNNGenerateStats ()
|
||||||
|
@property (nonatomic, readwrite) int promptTokens;
|
||||||
|
@property (nonatomic, readwrite) int genTokens;
|
||||||
|
@property (nonatomic, readwrite) double prefillMs;
|
||||||
|
@property (nonatomic, readwrite) double decodeMs;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation MNNGenerateStats
|
||||||
|
- (double)decodeTokensPerSecond {
|
||||||
|
return self.decodeMs > 0 ? (self.genTokens / (self.decodeMs / 1000.0)) : 0;
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
|
// MARK: - SME2 / 可用性探测(device + simulator 都可编)
|
||||||
|
|
||||||
|
static BOOL kk_sysctlFlag(const char *name) {
|
||||||
|
int64_t v = 0; size_t sz = sizeof(v);
|
||||||
|
if (sysctlbyname(name, &v, &sz, NULL, 0) != 0) return NO;
|
||||||
|
return v != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if TARGET_OS_SIMULATOR
|
||||||
|
|
||||||
|
// ============ 模拟器桩:无真实 MNN ============
|
||||||
|
@implementation MNNLLMBridge
|
||||||
|
+ (BOOL)isAvailable { return NO; }
|
||||||
|
+ (BOOL)cpuSupportsSME2 { return NO; }
|
||||||
|
- (nullable instancetype)initWithConfigPath:(NSString *)configPath { return nil; }
|
||||||
|
- (BOOL)isLoaded { return NO; }
|
||||||
|
- (MNNGenerateStats *)generateText:(NSString *)prompt maxTokens:(int)maxTokens
|
||||||
|
onToken:(void (^)(NSString *))onToken { return [MNNGenerateStats new]; }
|
||||||
|
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths prompt:(NSString *)prompt
|
||||||
|
maxTokens:(int)maxTokens onToken:(void (^)(NSString *))onToken
|
||||||
|
error:(NSError **)error {
|
||||||
|
if (error) *error = [NSError errorWithDomain:@"MNN" code:-1
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey: @"MNN 在模拟器不可用"}];
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
- (void)cancel {}
|
||||||
|
@end
|
||||||
|
|
||||||
|
#else
|
||||||
|
|
||||||
|
// ============ 真机:真实 MNN-LLM ============
|
||||||
|
// MNN 第三方头文件的文档注释不规范,会触发一堆 -Wdocumentation 警告(Executor/
|
||||||
|
// Tensor/Interpreter/ImageProcess.hpp)。只在解析 MNN 头时关掉该警告,不影响本项目。
|
||||||
|
#pragma clang diagnostic push
|
||||||
|
#pragma clang diagnostic ignored "-Wdocumentation"
|
||||||
|
#include <MNN/llm/llm.hpp>
|
||||||
|
#pragma clang diagnostic pop
|
||||||
|
#include <string>
|
||||||
|
#include <ostream>
|
||||||
|
#include <streambuf>
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
using MNN::Transformer::Llm;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
/// 把 MNN 写入 ostream 的解码文本转成 NSString 回调;按 UTF-8 完整边界聚合,避免截断多字节。
|
||||||
|
class TokenStreamBuf : public std::streambuf {
|
||||||
|
public:
|
||||||
|
TokenStreamBuf(void (^onToken)(NSString *), std::atomic<bool> *cancel)
|
||||||
|
: _onToken(onToken), _cancel(cancel) {}
|
||||||
|
void flush() {
|
||||||
|
if (_pending.empty()) return;
|
||||||
|
emitPending(); // 末尾尽力 emit(即便非完整 UTF-8 也交出去)
|
||||||
|
_pending.clear();
|
||||||
|
}
|
||||||
|
protected:
|
||||||
|
std::streamsize xsputn(const char *s, std::streamsize n) override {
|
||||||
|
append(s, (size_t)n);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
int overflow(int c) override {
|
||||||
|
if (c != EOF) { char ch = (char)c; append(&ch, 1); }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
private:
|
||||||
|
void append(const char *s, size_t n) {
|
||||||
|
if (_cancel && _cancel->load()) return; // 已取消,吞掉不回调
|
||||||
|
_pending.append(s, n);
|
||||||
|
// 仅当整个 pending 是合法 UTF-8 才 emit(token 通常是完整字/词,边界自然对齐)
|
||||||
|
NSString *str = [[NSString alloc] initWithBytes:_pending.data()
|
||||||
|
length:_pending.size()
|
||||||
|
encoding:NSUTF8StringEncoding];
|
||||||
|
if (str) { if (_onToken) _onToken(str); _pending.clear(); }
|
||||||
|
}
|
||||||
|
void emitPending() {
|
||||||
|
NSString *str = [[NSString alloc] initWithBytes:_pending.data()
|
||||||
|
length:_pending.size()
|
||||||
|
encoding:NSUTF8StringEncoding];
|
||||||
|
if (str && _onToken) _onToken(str);
|
||||||
|
}
|
||||||
|
void (^_onToken)(NSString *);
|
||||||
|
std::atomic<bool> *_cancel;
|
||||||
|
std::string _pending;
|
||||||
|
};
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
@implementation MNNLLMBridge {
|
||||||
|
Llm *_llm;
|
||||||
|
std::atomic<bool> _cancel;
|
||||||
|
BOOL _loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (BOOL)isAvailable { return YES; }
|
||||||
|
|
||||||
|
+ (BOOL)cpuSupportsSME2 {
|
||||||
|
// Apple 通过 sysctl 暴露 ARM 特性位:FEAT_SME2(A19/iPhone17+)。
|
||||||
|
return kk_sysctlFlag("hw.optional.arm.FEAT_SME2");
|
||||||
|
}
|
||||||
|
|
||||||
|
- (nullable instancetype)initWithConfigPath:(NSString *)configPath {
|
||||||
|
self = [super init];
|
||||||
|
if (!self) return nil;
|
||||||
|
_cancel = false;
|
||||||
|
_llm = Llm::createLLM(std::string(configPath.UTF8String));
|
||||||
|
if (_llm == nullptr) return nil;
|
||||||
|
// load 前以 merge-patch 调三件事(只翻这几个叶子,保留 chat_template 等其余配置):
|
||||||
|
// ① enable_thinking=false:config.json 默认 true,模板会给每个 assistant 回合硬塞
|
||||||
|
// <think>\n 开启思考,吞掉 token 预算并污染 JSON(prompt 里的 /no_think 对此模板无效)。
|
||||||
|
// ② 降温:config.json 默认 temperature=1.0 对结构化 JSON 太高,随机性大→经常吐成非 JSON。
|
||||||
|
// 本 App 所有任务都是"直答/JSON",压到 0.3 + topP 0.85 让输出更确定、JSON 更稳。
|
||||||
|
// ③ 重复惩罚:MNN 默认 mixed_samplers 不含 "penalty"、penalty/ngram_factor=1.0(全关),
|
||||||
|
// 叠加低温 → 长文本(如「关键指标」列表)会陷入逐行复读死循环(收缩压 107 mmHg ×N)。
|
||||||
|
// 显式把 "penalty" 放进 mixed 链首,开 repetition penalty(1.1)+ n-gram 惩罚(ngram_factor 1.05):
|
||||||
|
// n-gram 命中整段重复时惩罚升到 max_penalty,直接掐断逐行复读。
|
||||||
|
_llm->set_config("{"
|
||||||
|
"\"jinja\":{\"context\":{\"enable_thinking\":false}},"
|
||||||
|
"\"sampler_type\":\"mixed\","
|
||||||
|
"\"mixed_samplers\":[\"penalty\",\"topK\",\"topP\",\"temperature\"],"
|
||||||
|
"\"temperature\":0.3,\"topP\":0.85,\"topK\":40,"
|
||||||
|
"\"penalty\":1.1,\"n_gram\":8,\"ngram_factor\":1.05"
|
||||||
|
"}");
|
||||||
|
_loaded = _llm->load();
|
||||||
|
if (!_loaded) { Llm::destroy(_llm); _llm = nullptr; return nil; }
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)dealloc {
|
||||||
|
if (_llm) { Llm::destroy(_llm); _llm = nullptr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)isLoaded { return _loaded; }
|
||||||
|
|
||||||
|
- (void)cancel { _cancel = true; }
|
||||||
|
|
||||||
|
// 统一生成:full 已是最终 prompt(文本,或含 <img>路径</img> 标签)。
|
||||||
|
// 多模态模型 createLLM 返回 Omni,response 解析 <img> 标签并对路径 CV::imread(OMNI 框架内)。
|
||||||
|
- (MNNGenerateStats *)runResponse:(NSString *)full
|
||||||
|
maxTokens:(int)maxTokens
|
||||||
|
onToken:(void (^)(NSString *))onToken {
|
||||||
|
_cancel = false;
|
||||||
|
TokenStreamBuf buf(onToken, &_cancel);
|
||||||
|
std::ostream os(&buf);
|
||||||
|
if (_llm) {
|
||||||
|
_llm->response(std::string(full.UTF8String), &os, nullptr, maxTokens);
|
||||||
|
}
|
||||||
|
buf.flush();
|
||||||
|
return [self statsFromContext];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (MNNGenerateStats *)generateText:(NSString *)prompt
|
||||||
|
maxTokens:(int)maxTokens
|
||||||
|
onToken:(void (^)(NSString *))onToken {
|
||||||
|
return [self runResponse:prompt maxTokens:maxTokens onToken:onToken];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths
|
||||||
|
prompt:(NSString *)prompt
|
||||||
|
maxTokens:(int)maxTokens
|
||||||
|
onToken:(void (^)(NSString *))onToken
|
||||||
|
error:(NSError **)error {
|
||||||
|
// 在 prompt 前拼 <img>本地路径</img>;Omni 解析标签并对路径 imread(需 OMNI 框架)。
|
||||||
|
NSMutableString *full = [NSMutableString string];
|
||||||
|
for (NSString *p in imagePaths) {
|
||||||
|
[full appendFormat:@"<img>%@</img>", p];
|
||||||
|
}
|
||||||
|
[full appendString:prompt];
|
||||||
|
return [self runResponse:full maxTokens:maxTokens onToken:onToken];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (MNNGenerateStats *)statsFromContext {
|
||||||
|
MNNGenerateStats *s = [MNNGenerateStats new];
|
||||||
|
if (_llm) {
|
||||||
|
const MNN::Transformer::LlmContext *ctx = _llm->getContext();
|
||||||
|
if (ctx) {
|
||||||
|
s.promptTokens = ctx->prompt_len;
|
||||||
|
s.genTokens = ctx->gen_seq_len;
|
||||||
|
s.prefillMs = ctx->prefill_us / 1000.0;
|
||||||
|
s.decodeMs = ctx->decode_us / 1000.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
#endif
|
||||||
113
康康/AI/MNNBackend.swift
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// MNN(CPU / SME2)推理后端,封装 `MNNLLMBridge` 的文本流式生成。
|
||||||
|
/// 与 `LLMSession`/`VLSession` 同款 actor 隔离;跨调用的串行化由上游 `AIRuntime` 闸门保证。
|
||||||
|
///
|
||||||
|
/// 文本与视觉(图→文)由同一个 Qwen3.5-2B 多模态 MNN 模型承担:`generate` 走文本,
|
||||||
|
/// `analyze` 把图片拼成 <img> 标签交给 Omni 内核 imread 解码(需 OMNI 构建,xcframework 已含)。
|
||||||
|
/// 已实测可用,真机走此单模型全包路径;模拟器无 MNN,VL 仍回退 MLX(见 `AIRuntime`)。
|
||||||
|
actor MNNBackend {
|
||||||
|
private var bridge: MNNLLMBridge?
|
||||||
|
|
||||||
|
/// 末次生成统计(供 AIRuntime 在流结束后取走,性能自检用)。
|
||||||
|
private(set) var lastStats: GenerateStats?
|
||||||
|
|
||||||
|
private func record(_ s: GenerateStats) { lastStats = s }
|
||||||
|
|
||||||
|
var isLoaded: Bool { bridge?.isLoaded ?? false }
|
||||||
|
|
||||||
|
/// 从 MNN 模型目录加载(目录含 MNN llm 的 config.json + llm.mnn + 权重 + tokenizer)。
|
||||||
|
func load(folderURL: URL) throws {
|
||||||
|
let configPath = folderURL.appendingPathComponent("config.json").path
|
||||||
|
guard FileManager.default.fileExists(atPath: configPath) else {
|
||||||
|
throw AIRuntimeError.notReady
|
||||||
|
}
|
||||||
|
guard let b = MNNLLMBridge(configPath: configPath) else {
|
||||||
|
throw AIRuntimeError.modelLoadFailed("MNN createLLM/load 失败")
|
||||||
|
}
|
||||||
|
bridge = b
|
||||||
|
}
|
||||||
|
|
||||||
|
func unload() { bridge = nil }
|
||||||
|
|
||||||
|
/// 文本流式生成。`bridge.generateText` 同步阻塞、逐段回调,放在 detached 线程跑,
|
||||||
|
/// 把每段文本 yield 成 `TokenChunk`(含即时 tok/s)。流被取消时调用 `bridge.cancel()`。
|
||||||
|
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||||
|
guard let bridge else {
|
||||||
|
return AsyncThrowingStream { $0.finish(throwing: AIRuntimeError.notReady) }
|
||||||
|
}
|
||||||
|
let box = MNNUncheckedBox(bridge)
|
||||||
|
return AsyncThrowingStream { continuation in
|
||||||
|
let meter = MNNRateMeter()
|
||||||
|
let task = Task.detached(priority: .userInitiated) {
|
||||||
|
let stats = box.value.generateText(prompt, maxTokens: Int32(maxTokens)) { piece in
|
||||||
|
let rate = meter.tick()
|
||||||
|
continuation.yield(TokenChunk(text: piece, decodeRate: rate))
|
||||||
|
}
|
||||||
|
// ObjC 统计对象先抽成 Sendable 的 GenerateStats 再跨 actor 记录。
|
||||||
|
await self.record(GenerateStats(
|
||||||
|
promptTokens: Int(stats.promptTokens),
|
||||||
|
genTokens: Int(stats.genTokens),
|
||||||
|
prefillSeconds: stats.prefillMs / 1000.0,
|
||||||
|
decodeSeconds: stats.decodeMs / 1000.0
|
||||||
|
))
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
continuation.onTermination = { _ in
|
||||||
|
box.value.cancel()
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图→文(VL)。一次性收集(JSON 抽取不需流式)。桥接里把图片路径拼成 <img> 标签,
|
||||||
|
/// MNN Omni 内部 imread 加载(需 OMNI 框架);blocking 调用放 detached 线程。
|
||||||
|
func analyze(imageURLs: [URL], prompt: String, maxTokens: Int) async throws -> String {
|
||||||
|
guard let bridge else { throw AIRuntimeError.notReady }
|
||||||
|
let paths = imageURLs.map(\.path)
|
||||||
|
let box = MNNUncheckedBox(bridge)
|
||||||
|
return try await withCheckedThrowingContinuation { cont in
|
||||||
|
Task.detached(priority: .userInitiated) {
|
||||||
|
let sink = MNNTextSink()
|
||||||
|
do {
|
||||||
|
let stats = try box.value.analyzeImages(paths, prompt: prompt, maxTokens: Int32(maxTokens)) { piece in
|
||||||
|
sink.append(piece)
|
||||||
|
}
|
||||||
|
await self.record(GenerateStats(
|
||||||
|
promptTokens: Int(stats.promptTokens),
|
||||||
|
genTokens: Int(stats.genTokens),
|
||||||
|
prefillSeconds: stats.prefillMs / 1000.0,
|
||||||
|
decodeSeconds: stats.decodeMs / 1000.0
|
||||||
|
))
|
||||||
|
cont.resume(returning: sink.text)
|
||||||
|
} catch {
|
||||||
|
cont.resume(throwing: AIRuntimeError.inferenceFailed(error.localizedDescription))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单线程串行回调聚合文本,无竞争。
|
||||||
|
private nonisolated final class MNNTextSink: @unchecked Sendable {
|
||||||
|
private(set) var text = ""
|
||||||
|
func append(_ s: String) { text += s }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把非 Sendable 的 ObjC 桥对象安全带过 detached 边界。
|
||||||
|
/// 安全性来自 `AIRuntime` 闸门:同一时刻只有一个生成在跑,桥不会被并发访问。
|
||||||
|
private nonisolated struct MNNUncheckedBox<T>: @unchecked Sendable {
|
||||||
|
let value: T
|
||||||
|
init(_ value: T) { self.value = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 即时解码速率计:回调在单线程串行调用,内部计数无竞争。
|
||||||
|
private nonisolated final class MNNRateMeter: @unchecked Sendable {
|
||||||
|
private let start = Date()
|
||||||
|
private var produced = 0
|
||||||
|
func tick() -> Double {
|
||||||
|
produced += 1
|
||||||
|
let elapsed = Date().timeIntervalSince(start)
|
||||||
|
return elapsed > 0 ? Double(produced) / elapsed : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ struct ModelFile: Equatable, Sendable {
|
|||||||
/// 只列加载必需的功能文件,排除 README.md / .gitattributes(省下载)。
|
/// 只列加载必需的功能文件,排除 README.md / .gitattributes(省下载)。
|
||||||
/// 字节数与服务器素材逐一核对一致,见
|
/// 字节数与服务器素材逐一核对一致,见
|
||||||
/// docs/superpowers/specs/2026-05-29-model-download-design.md 附录 A。
|
/// docs/superpowers/specs/2026-05-29-model-download-design.md 附录 A。
|
||||||
enum ModelManifest {
|
nonisolated enum ModelManifest {
|
||||||
/// 自建 Caddy 静态服务(用户自建 HTTPS 反代)。
|
/// 自建 Caddy 静态服务(用户自建 HTTPS 反代)。
|
||||||
/// 备选纯 IP(需 App 端 ATS 例外): http://101.132.124.52:5244/
|
/// 备选纯 IP(需 App 端 ATS 例外): http://101.132.124.52:5244/
|
||||||
static let baseURL = URL(string: "https://file.myv0.com/")!
|
static let baseURL = URL(string: "https://file.myv0.com/")!
|
||||||
@@ -18,30 +18,59 @@ enum ModelManifest {
|
|||||||
static func files(for kind: ModelKind) -> [ModelFile] {
|
static func files(for kind: ModelKind) -> [ModelFile] {
|
||||||
switch kind {
|
switch kind {
|
||||||
case .llm:
|
case .llm:
|
||||||
|
// Qwen3.5-2B-4bit:多模态仓库,但走 LLMModelFactory 的 qwen3_5 文本路径加载。
|
||||||
|
// 字节数取自 mlx-community/Qwen3.5-2B-4bit 仓库实际 blob 大小(HF API,2026-06 核对)。
|
||||||
|
// 该仓库 tokenizer 体系为 vocab.json + tokenizer.json(无 merges.txt /
|
||||||
|
// special_tokens_map.json / added_tokens.json),chat_template 改为 .jinja。
|
||||||
|
// 一并镜像视觉预处理配置(preprocessor / processor / video_preprocessor),
|
||||||
|
// 文本加载用不到但体积可忽略,保持与仓库一致避免漏文件。
|
||||||
return [
|
return [
|
||||||
ModelFile(path: "config.json", bytes: 937),
|
ModelFile(path: "config.json", bytes: 3_113),
|
||||||
ModelFile(path: "model.safetensors", bytes: 968_080_210),
|
ModelFile(path: "model.safetensors", bytes: 1_722_271_785),
|
||||||
ModelFile(path: "model.safetensors.index.json", bytes: 49_731),
|
ModelFile(path: "model.safetensors.index.json", bytes: 81_722),
|
||||||
|
ModelFile(path: "tokenizer.json", bytes: 19_989_343),
|
||||||
|
ModelFile(path: "tokenizer_config.json", bytes: 1_139),
|
||||||
|
ModelFile(path: "vocab.json", bytes: 6_722_759),
|
||||||
|
ModelFile(path: "chat_template.jinja", bytes: 7_755),
|
||||||
|
ModelFile(path: "preprocessor_config.json", bytes: 390),
|
||||||
|
ModelFile(path: "processor_config.json", bytes: 1_300),
|
||||||
|
ModelFile(path: "video_preprocessor_config.json", bytes: 385),
|
||||||
|
]
|
||||||
|
case .vl:
|
||||||
|
// Qwen3-VL-4B-Instruct-4bit:字节数取自 mlx-community 仓库实际 blob 大小
|
||||||
|
// (HF API blobs=true,2026-05 核对),用于总进度计算与下载后大小校验。
|
||||||
|
// 策略:完整镜像仓库的全部运行文件(仅排除 README.md / .gitattributes),
|
||||||
|
// 与标准 mlx-vlm 加载环境保持一致,避免漏文件导致 VLMModelFactory 加载失败。
|
||||||
|
// 同时带两份 chat_template(.json 旧约定 + .jinja 新约定)与 video 预处理配置,
|
||||||
|
// 以兼容不同版本 swift-transformers / Qwen3VLProcessor 的读取路径。
|
||||||
|
return [
|
||||||
|
ModelFile(path: "config.json", bytes: 7_137),
|
||||||
|
ModelFile(path: "model.safetensors", bytes: 3_093_767_283),
|
||||||
|
ModelFile(path: "model.safetensors.index.json", bytes: 64_742),
|
||||||
ModelFile(path: "tokenizer.json", bytes: 11_422_654),
|
ModelFile(path: "tokenizer.json", bytes: 11_422_654),
|
||||||
ModelFile(path: "tokenizer_config.json", bytes: 9_706),
|
ModelFile(path: "tokenizer_config.json", bytes: 5_445),
|
||||||
ModelFile(path: "vocab.json", bytes: 2_776_833),
|
ModelFile(path: "vocab.json", bytes: 2_776_833),
|
||||||
ModelFile(path: "merges.txt", bytes: 1_671_853),
|
ModelFile(path: "merges.txt", bytes: 1_671_853),
|
||||||
ModelFile(path: "special_tokens_map.json", bytes: 613),
|
ModelFile(path: "special_tokens_map.json", bytes: 613),
|
||||||
ModelFile(path: "added_tokens.json", bytes: 707),
|
ModelFile(path: "added_tokens.json", bytes: 707),
|
||||||
|
ModelFile(path: "generation_config.json", bytes: 269),
|
||||||
|
ModelFile(path: "chat_template.json", bytes: 5_502),
|
||||||
|
ModelFile(path: "chat_template.jinja", bytes: 5_292),
|
||||||
|
ModelFile(path: "preprocessor_config.json", bytes: 782),
|
||||||
|
ModelFile(path: "video_preprocessor_config.json", bytes: 817),
|
||||||
]
|
]
|
||||||
case .vl:
|
case .mnnLLM:
|
||||||
|
// taobao-mnn/Qwen3.5-2B-MNN 预转换 MNN 格式(HF API 实测,2026-06)。
|
||||||
|
// 运行时必需:config.json(MNN llm 配置)+ llm_config.json(超参)+ llm.mnn(图)
|
||||||
|
// + llm.mnn.weight(量化权重 ~1.1GB)+ tokenizer.txt + visual.mnn(多模态,文本路径不用但配置含 mllm)。
|
||||||
|
// 排除 README/.gitattributes 与可读 dump(llm.mnn.json / export_args.json)。
|
||||||
return [
|
return [
|
||||||
ModelFile(path: "config.json", bytes: 1_659),
|
ModelFile(path: "config.json", bytes: 652),
|
||||||
ModelFile(path: "model.safetensors", bytes: 3_073_720_461),
|
ModelFile(path: "llm_config.json", bytes: 8_692),
|
||||||
ModelFile(path: "model.safetensors.index.json", bytes: 108_307),
|
ModelFile(path: "llm.mnn", bytes: 2_148_136),
|
||||||
ModelFile(path: "tokenizer.json", bytes: 11_421_896),
|
ModelFile(path: "llm.mnn.weight", bytes: 1_176_647_702),
|
||||||
ModelFile(path: "tokenizer_config.json", bytes: 7_256),
|
ModelFile(path: "tokenizer.txt", bytes: 6_465_727),
|
||||||
ModelFile(path: "vocab.json", bytes: 2_776_833),
|
ModelFile(path: "visual.mnn", bytes: 488_096),
|
||||||
ModelFile(path: "merges.txt", bytes: 1_671_853),
|
|
||||||
ModelFile(path: "special_tokens_map.json", bytes: 613),
|
|
||||||
ModelFile(path: "added_tokens.json", bytes: 605),
|
|
||||||
ModelFile(path: "chat_template.json", bytes: 1_050),
|
|
||||||
ModelFile(path: "preprocessor_config.json", bytes: 350),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
nonisolated enum ModelKind: String, CaseIterable {
|
nonisolated enum ModelKind: String, CaseIterable {
|
||||||
/// 与 HuggingFace mlx-community 仓库名一一对应,也是沙盒 Models/ 下的子目录名。
|
/// 也是沙盒 Models/ 下的子目录名 / CDN 路径段。
|
||||||
case llm = "Qwen3-1.7B-4bit"
|
/// 同一个 Qwen3.5-2B,两种格式两种引擎:
|
||||||
case vl = "Qwen2.5-VL-3B-Instruct-4bit"
|
/// - mnnLLM:MNN(CPU/SME2,考核路径)文本+视觉一肩挑,taobao-mnn 预转换。iPhone17+(A19/SME2)主用,只露它。
|
||||||
|
/// - llm:MLX(GPU)兜底,Qwen3.5-2B-4bit 多模态(同时兜底文本与视觉,走 qwen3_5)。
|
||||||
|
/// - vl:已废弃(MLX VL 改走 .llm 多模态),保留枚举避免动一圈穷举 switch,不再下载/展示。
|
||||||
|
case llm = "Qwen3.5-2B-4bit"
|
||||||
|
case vl = "Qwen3-VL-4B-Instruct-4bit"
|
||||||
|
case mnnLLM = "Qwen3.5-2B-MNN"
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .llm: return "Qwen3-1.7B"
|
case .llm: return "Qwen3.5-2B (MLX)"
|
||||||
case .vl: return "Qwen2.5-VL-3B"
|
case .vl: return "Qwen3-VL-4B"
|
||||||
|
case .mnnLLM: return "Qwen3.5-2B (MNN/SME2)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +23,12 @@ nonisolated enum ModelKind: String, CaseIterable {
|
|||||||
|
|
||||||
/// 用于判定该模型是否已就绪的最小标志文件
|
/// 用于判定该模型是否已就绪的最小标志文件
|
||||||
var sentinelFilename: String { "config.json" }
|
var sentinelFilename: String { "config.json" }
|
||||||
|
|
||||||
|
/// 面向用户的模型集合:模型管理页 / 下载全部 / 就绪计数对外只暴露统一的
|
||||||
|
/// Qwen3.5-2B(MNN,文本+视觉全包,iPhone17+ 走它)。
|
||||||
|
/// MLX 的 .llm/.vl 仅作模拟器与兜底路径,保留枚举与下载能力(旁路导入仍可单独导),
|
||||||
|
/// 但不在「我的 · 模型管理」展示,也不计入「下载全部」与就绪计数。
|
||||||
|
static let userFacing: [ModelKind] = [.mnnLLM]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `@unchecked Sendable`:rootURL 是 let,方法只读 filesystem(线程安全),
|
/// `@unchecked Sendable`:rootURL 是 let,方法只读 filesystem(线程安全),
|
||||||
@@ -132,7 +144,7 @@ enum ModelStoreError: Error, LocalizedError {
|
|||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .missingConfig:
|
case .missingConfig:
|
||||||
return "所选文件夹缺少 config.json,不是有效的模型目录"
|
return String(appLoc: "所选文件夹缺少 config.json,不是有效的模型目录")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
康康/AI/Prompts/DiaryAssistPrompts.swift
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 「健康记录」写入时,让 LLM 从医生问诊角度提 3-4 个追问。
|
||||||
|
/// 输出严格 JSON,每个 question 带 dim(问诊维度)+ q(展示)+ fill(可一键追加的模板)。
|
||||||
|
///
|
||||||
|
/// 为什么要 `dim`(对齐 2026-05-30 prompt 优化):
|
||||||
|
/// 1.7B 模型对「不要重复」这类否定指令遵循很差,且先验会把每轮问题都拉向同一簇症状。
|
||||||
|
/// 改成「从固定维度清单里挑,每条标注 dim,跨轮排除已覆盖维度」这种正向结构约束后,
|
||||||
|
/// 去重从「字面比对」升级为「按维度结构去重」,轮内扎堆和轮间换皮重复都能压住。
|
||||||
|
enum DiaryAssistPrompts {
|
||||||
|
|
||||||
|
/// 固定问诊维度清单。模型每条问题必须正好归属其中一个;UI 累积已覆盖维度回传下一轮。
|
||||||
|
/// 顺序即展示/示例顺序,改动需同步 few-shot。
|
||||||
|
static let dimensions: [String] = [
|
||||||
|
"起病诱因", "症状性质", "伴随症状", "加重缓解",
|
||||||
|
"持续频率", "既往家族史", "用药过敏", "生活方式",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// - content: 用户当前全文。
|
||||||
|
/// - coveredDimensions: 之前各轮已经问过(或记录里已写明)的维度名,本轮必须避开。
|
||||||
|
/// 第一轮传空数组。
|
||||||
|
static func suggest(content: String, coveredDimensions: [String] = []) -> String {
|
||||||
|
let covered = coveredDimensions.filter { !$0.isEmpty }
|
||||||
|
let coveredSet = Set(covered)
|
||||||
|
let allowed = dimensions.filter { !coveredSet.contains($0) }
|
||||||
|
let allowedLine = allowed.isEmpty ? "(已基本问全)" : allowed.joined(separator: "、")
|
||||||
|
// 正向约束:1.7B 对「只能从这些里挑」比对「严禁选这些」遵循更好。
|
||||||
|
let scopeRule = covered.isEmpty
|
||||||
|
? ""
|
||||||
|
: "\n- 已问过的维度【不要再问】:\(covered.joined(separator: "、"))。本轮只能从这些还没问的维度里挑:\(allowedLine)。"
|
||||||
|
|
||||||
|
return """
|
||||||
|
你是社区医生的小助手。用户写了一段身体状态的健康记录,信息可能不够完整。
|
||||||
|
请从医生问诊角度提出 3-4 个最值得追问的问题,帮用户把这条记录补全。
|
||||||
|
|
||||||
|
【问诊维度清单】每个问题必须正好归属其中一个,并用 dim 标注:
|
||||||
|
1. 起病诱因 —— 何时开始、有无诱因
|
||||||
|
2. 症状性质 —— 部位、性质、程度
|
||||||
|
3. 伴随症状 —— 是否伴随其他不适
|
||||||
|
4. 加重缓解 —— 什么情况下加重或缓解
|
||||||
|
5. 持续频率 —— 持续多久、多频繁、是否反复发作
|
||||||
|
6. 既往家族史 —— 以前是否有类似、家族相关史
|
||||||
|
7. 用药过敏 —— 在服药物、过敏史
|
||||||
|
8. 生活方式 —— 睡眠、饮食、运动习惯、压力
|
||||||
|
|
||||||
|
硬性规则:
|
||||||
|
- 本轮每个问题必须来自【不同】维度,严禁两条落在同一维度(例如不能两条都问"伴随症状")。\(scopeRule)
|
||||||
|
- 只问【最新记录】里还没写明的事。方括号 `[xxx]` 表示该话题已被提出、只是细节待填,【不要】再作为新问题重复它。
|
||||||
|
- 不给诊断、不给用药建议、不写「建议就医」。
|
||||||
|
- q ≤ 20 字,像真人医生在问;fill 是采纳后追加到原文的中文补充句,可含方括号占位符如 [时间] [部位]。
|
||||||
|
- 至少 3 条,最多 4 条。
|
||||||
|
|
||||||
|
只输出严格 JSON,不要解释、不要 markdown 围栏、不要 <think> 标签。结构:
|
||||||
|
{"questions":[{"dim":"<清单里的一个维度名>","q":"<问题>","fill":"<补充句模板>"}]}
|
||||||
|
|
||||||
|
示例 1(第一轮,记录:头痛了一上午):
|
||||||
|
{"questions":[
|
||||||
|
{"dim":"起病诱因","q":"具体什么时候开始的?","fill":"症状从 [时间] 开始,"},
|
||||||
|
{"dim":"症状性质","q":"是哪种性质的头痛?","fill":"部位/性质是 [部位/胀痛/刺痛],"},
|
||||||
|
{"dim":"伴随症状","q":"还伴有其他不适吗?","fill":"还伴有 [症状],"},
|
||||||
|
{"dim":"生活方式","q":"最近睡眠和压力怎么样?","fill":"近期睡眠 [小时]、压力 [情况],"}
|
||||||
|
]}
|
||||||
|
|
||||||
|
示例 2(后续轮,已覆盖维度:起病诱因、症状性质、伴随症状):
|
||||||
|
{"questions":[
|
||||||
|
{"dim":"加重缓解","q":"做什么会加重或缓解?","fill":"[活动/休息] 时会 [加重/缓解],"},
|
||||||
|
{"dim":"持续频率","q":"这种情况反复或持续多久了?","fill":"已持续/反复 [时长/频率],"},
|
||||||
|
{"dim":"既往家族史","q":"以前有过类似情况吗?","fill":"既往类似 [有/无,频率],"}
|
||||||
|
]}
|
||||||
|
|
||||||
|
现在输出 JSON。
|
||||||
|
本轮可选维度:\(allowedLine)
|
||||||
|
【最新记录】:
|
||||||
|
\(content)
|
||||||
|
|
||||||
|
Output: /no_think
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 语音口述 → 日记整理
|
||||||
|
|
||||||
|
/// 口述转写稿截断上限(字符)。2B 模型 context 保护:超长口述只取前面部分。
|
||||||
|
static let organizeTranscriptLimit = 1200
|
||||||
|
|
||||||
|
/// 把语音转写稿整理成健康日记草稿。自适应样式:内容少 → 一段通顺的话;
|
||||||
|
/// 多方面 → 按「方面:内容」分行。
|
||||||
|
/// 红线(spec 2026-06-10-voice-diary §2):只重组语言,严禁增删改任何数值、单位、药名、时间——
|
||||||
|
/// 2B 模型把 140/90 改成 130/90 即健康数据事故,所以规则放第一条并配 few-shot 强化。
|
||||||
|
static func organize(transcript: String) -> String {
|
||||||
|
let trimmed = String(transcript.prefix(organizeTranscriptLimit))
|
||||||
|
return """
|
||||||
|
你是健康记录助手。下面是用户口述身体状态的语音转写原话,可能口语化、有重复、缺标点。
|
||||||
|
请把它整理成一条清晰的健康日记。
|
||||||
|
|
||||||
|
硬性规则:
|
||||||
|
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
|
||||||
|
- 只重组语言:去掉口头语和重复;用第一人称;不加入原话没有的事实。
|
||||||
|
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
|
||||||
|
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。
|
||||||
|
- 不诊断、不给用药建议、不写「建议就医」。
|
||||||
|
- 只输出整理后的日记正文,不要解释、不要 markdown 围栏、不要 <think> 标签。
|
||||||
|
|
||||||
|
示例 1(口述:那个今天早上起来有点头晕然后我量了下血压140 90比平时高一点没吃早饭就出门了):
|
||||||
|
今天早上起来有点头晕,量了血压 140/90,比平时高一点。没吃早饭就出门了。
|
||||||
|
|
||||||
|
示例 2(口述:今天头晕了一上午下午好点了血压早上量的140 90嗯缬沙坦吃了降脂药忘了吃早饭没吃中午吃的清淡晚上散步了半小时):
|
||||||
|
症状:头晕了一上午,下午好转。
|
||||||
|
血压:早上 140/90。
|
||||||
|
用药:缬沙坦已服,降脂药忘服。
|
||||||
|
饮食:早饭未吃,午餐清淡。
|
||||||
|
运动:晚上散步半小时。
|
||||||
|
|
||||||
|
【口述原话】:
|
||||||
|
\(trimmed)
|
||||||
|
|
||||||
|
Output: /no_think
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
185
康康/AI/Prompts/HealthExportPrompts.swift
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 「导出身体档案」用到的两个 LLM prompt:
|
||||||
|
/// 1. `intentExtraction` —— 抽取时间窗 + 指标/症状关键词,只输出 JSON
|
||||||
|
/// 2. `reportGeneration` —— 拼真实数据后生成给医生看的 Markdown
|
||||||
|
///
|
||||||
|
/// 解析逻辑见 `HealthExportService`(§3.2 失败回退红线:
|
||||||
|
/// 抽不出 JSON → 用 30 天 + 空关键词兜底,流程不中断)。
|
||||||
|
enum HealthExportPrompts {
|
||||||
|
|
||||||
|
// MARK: - 意图抽取
|
||||||
|
|
||||||
|
/// `intentExtraction(userPrompt:)` 把用户原话拼到模板末尾。
|
||||||
|
/// 期望输出形如:
|
||||||
|
/// ```json
|
||||||
|
/// {"time_range_days":30,
|
||||||
|
/// "keywords":["体温","血压"],
|
||||||
|
/// "symptom_keywords":["感冒","咳嗽"],
|
||||||
|
/// "intent":"cold_consult",
|
||||||
|
/// "intent_label_cn":"感冒就诊"}
|
||||||
|
/// ```
|
||||||
|
static func intentExtraction(userPrompt: String) -> String {
|
||||||
|
"""
|
||||||
|
你是健康数据助手。读用户的请求,只输出严格 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||||
|
|
||||||
|
字段说明(全部必填):
|
||||||
|
{
|
||||||
|
"time_range_days": int, // 回溯天数,默认 30,最大 365
|
||||||
|
"keywords": [string], // 指标关键词(中文,如「血压」「血糖」「体温」「肝功」),无则 []
|
||||||
|
"symptom_keywords": [string], // 症状关键词,无则 []
|
||||||
|
"intent": string, // 英文 snake_case 标签,如 "cold_consult"
|
||||||
|
"intent_label_cn": string // 中文短语,会作为报告标题副题,如 "感冒就诊"
|
||||||
|
}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 时间未指定 → 30
|
||||||
|
- 「最近一个月」→ 30,「最近三个月」→ 90,「最近半年」→ 180
|
||||||
|
- 关键词要中文,常见健康指标 / 症状词
|
||||||
|
- intent 简短,4-25 字符,小写下划线
|
||||||
|
|
||||||
|
示例 1:
|
||||||
|
User: 我感冒3天了,要把最近一个月的健康情况给医生看
|
||||||
|
Output: {"time_range_days":30,"keywords":["体温","血压","脉搏"],"symptom_keywords":["感冒","咳嗽","咽喉痛","发烧"],"intent":"cold_consult","intent_label_cn":"感冒就诊"}
|
||||||
|
|
||||||
|
示例 2:
|
||||||
|
User: 我最近血糖好像不稳,把上次体检前后的化验单整理一下
|
||||||
|
Output: {"time_range_days":90,"keywords":["血糖","糖化血红蛋白","胰岛素"],"symptom_keywords":[],"intent":"glucose_review","intent_label_cn":"血糖复查"}
|
||||||
|
|
||||||
|
现在请输出 JSON:
|
||||||
|
User: \(userPrompt)
|
||||||
|
Output: /no_think
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 报告生成
|
||||||
|
|
||||||
|
/// `reportGeneration(userPrompt:intentLabelCN:dataJSON:)` 拼好后流式生成 Markdown。
|
||||||
|
static func reportGeneration(userPrompt: String,
|
||||||
|
intentLabelCN: String,
|
||||||
|
dataJSON: String) -> String {
|
||||||
|
let labelLine = intentLabelCN.isEmpty
|
||||||
|
? "# 就诊摘要"
|
||||||
|
: "# 就诊摘要 — \(intentLabelCN)"
|
||||||
|
return """
|
||||||
|
你是健康数据整理员。任务是把下面【真实数据】(JSON)里**已经存在**的内容,
|
||||||
|
原样整理成一份给社区医生看的就诊摘要。这是**抽取 / 搬运**任务,不是创作。
|
||||||
|
|
||||||
|
【最重要的铁律 —— 违反即失败】
|
||||||
|
- 只能使用【真实数据】JSON 里**真实出现过**的内容。
|
||||||
|
- 严禁编造或推测任何数字、日期、症状、药物、检查结果、诊断,哪怕看起来很合理。
|
||||||
|
- JSON 里没有的信息,对应小节一律写「无记录」,不要补全、不要举例、不要套用常见病例模板。
|
||||||
|
- 数值必须原样照搬(含单位与参考范围);status 为 high/low/abnormal 的指标前加 ⚠️。
|
||||||
|
- 「主诉」「本人疑问」可参考【本人原话】,但不得加入原话与数据里都没有的症状。
|
||||||
|
|
||||||
|
输出格式:
|
||||||
|
- 严格 Markdown,标题用 # / ##,不要 markdown 围栏,不要输出 JSON,不写「数据」二字。
|
||||||
|
- 不给诊断意见、用药建议或「建议就医」。全文中文,简洁,医生 30 秒能扫完。
|
||||||
|
- 严格按以下 6 段(顺序与标题固定):
|
||||||
|
\(labelLine)
|
||||||
|
## 主诉
|
||||||
|
## 本人背景
|
||||||
|
## 近期症状(按时间倒序)
|
||||||
|
## 关键指标(异常项优先)
|
||||||
|
## 在服药与过敏
|
||||||
|
## 本人疑问
|
||||||
|
|
||||||
|
—— 格式示例(只示范「无记录」与数值写法,内容请勿照抄)——
|
||||||
|
真实数据:{"profile":{},"symptoms":[],"indicators":[{"name":"体温","value":"38.5","unit":"℃","range":"36-37.2","status":"high","date":"2026-05-01"}],"reports":[],"diaries":[],"time_window":{"from":"2026-04-02","to":"2026-05-02"}}
|
||||||
|
输出:
|
||||||
|
# 就诊摘要 — 近期健康摘要
|
||||||
|
## 主诉
|
||||||
|
无记录
|
||||||
|
## 本人背景
|
||||||
|
无记录
|
||||||
|
## 近期症状(按时间倒序)
|
||||||
|
无记录
|
||||||
|
## 关键指标(异常项优先)
|
||||||
|
⚠️ 体温 38.5 ℃(参考 36-37.2,2026-05-01)
|
||||||
|
## 在服药与过敏
|
||||||
|
无记录
|
||||||
|
## 本人疑问
|
||||||
|
无记录
|
||||||
|
—— 示例结束(以上咳嗽/体温等仅示范格式,切勿出现在你的输出里)——
|
||||||
|
|
||||||
|
现在,严格根据下面这份【真实数据】生成;数据里没有的就写「无记录」,**禁止编造**:
|
||||||
|
|
||||||
|
【真实数据】:
|
||||||
|
\(dataJSON)
|
||||||
|
|
||||||
|
【本人原话】:\(userPrompt)
|
||||||
|
|
||||||
|
再次强调:只整理上面【真实数据】里真实出现过的内容,禁止编造任何数字/日期/症状/药物。
|
||||||
|
直接输出 Markdown,不要思考过程,不要 <think> 标签:
|
||||||
|
/no_think
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 多轮导出对话
|
||||||
|
|
||||||
|
/// 多轮导出页里,用户每次提问时用这个 prompt 回答。
|
||||||
|
/// 输入上下文限定为本地指标 + 健康日记,回答只做解释/归纳,不持久化。
|
||||||
|
static func dialogueAnswer(latestQuestion: String,
|
||||||
|
transcript: String,
|
||||||
|
dataJSON: String) -> String {
|
||||||
|
"""
|
||||||
|
你是康康的本地健康档案助手。请根据【本地健康记录】回答用户最新问题。
|
||||||
|
|
||||||
|
铁律:
|
||||||
|
- 只能使用【本地健康记录】和【多轮对话】里已有的信息。
|
||||||
|
- 禁止诊断、禁止用药/剂量建议、禁止急诊判断。
|
||||||
|
- 数据里没有的信息,直接说「记录里没有」,不要编造。
|
||||||
|
- 重点围绕指标和健康日记做大白话解释,回答要短,最多 5 条要点。
|
||||||
|
- 如果用户的目标是给医生看,可以提示稍后点击「生成整理报告」。
|
||||||
|
|
||||||
|
【本地健康记录】:
|
||||||
|
\(dataJSON)
|
||||||
|
|
||||||
|
【多轮对话】:
|
||||||
|
\(transcript.isEmpty ? "无" : transcript)
|
||||||
|
|
||||||
|
【用户最新问题】:
|
||||||
|
\(latestQuestion)
|
||||||
|
|
||||||
|
直接输出中文回答,不要 Markdown 标题,不要 <think>:
|
||||||
|
/no_think
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对话结束后,把整段交流整理成一份给医生看的 Markdown 报告。
|
||||||
|
static func dialogueReportGeneration(transcript: String,
|
||||||
|
dataJSON: String) -> String {
|
||||||
|
"""
|
||||||
|
你是健康数据整理员。请把【多轮对话】和【本地健康记录】整理成一份给医生看的摘要报告。
|
||||||
|
这是抽取 / 搬运任务,不是医疗诊断。
|
||||||
|
|
||||||
|
铁律:
|
||||||
|
- 只能使用【本地健康记录】和【多轮对话】里真实出现的信息。
|
||||||
|
- 禁止编造数字、日期、症状、药物、检查结果、诊断。
|
||||||
|
- 禁止给诊断意见、用药建议、剂量建议或急诊判断。
|
||||||
|
- JSON 里没有的信息,对应小节写「无记录」。
|
||||||
|
- 指标 status 为 high/low/abnormal 的项目前加 ⚠️。
|
||||||
|
|
||||||
|
输出要求:
|
||||||
|
- 严格 Markdown,不要 markdown 围栏,不要输出 JSON。
|
||||||
|
- 中文,简洁,医生 30 秒能扫完。
|
||||||
|
- 严格按以下段落:
|
||||||
|
# 就诊摘要
|
||||||
|
## 本次想解决的问题
|
||||||
|
## 相关健康日记
|
||||||
|
## 相关指标
|
||||||
|
## 已知背景
|
||||||
|
## 本人关心的问题
|
||||||
|
## 可带给医生确认的要点
|
||||||
|
|
||||||
|
【本地健康记录】:
|
||||||
|
\(dataJSON)
|
||||||
|
|
||||||
|
【多轮对话】:
|
||||||
|
\(transcript.isEmpty ? "无" : transcript)
|
||||||
|
|
||||||
|
直接输出 Markdown,不要思考过程,不要 <think>:
|
||||||
|
/no_think
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
44
康康/AI/Prompts/InsightPrompts.swift
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 本地解读类 prompt:报告大白话摘要 + 趋势一句话解读。
|
||||||
|
/// 红线:不诊断、不荐药;称呼「你」,不出现「患者」(产品定位:自我健康记录)。
|
||||||
|
nonisolated enum InsightPrompts {
|
||||||
|
|
||||||
|
/// 报告整体大白话摘要(归档后台预生成,写回 Report.summary)。
|
||||||
|
static func reportPlainSummary(title: String, typeLabel: String, indicatorLines: String) -> String {
|
||||||
|
"""
|
||||||
|
你是健康档案助手。下面是一份报告的指标列表,请用大白话给本人(称「你」)写 2~3 句整体解读:
|
||||||
|
- 第 1 句:总体情况(共几项、几项异常)。
|
||||||
|
- 之后:点名最值得留意的异常项,用生活化语言说明偏高/偏低意味着什么方向。
|
||||||
|
- 不诊断疾病、不推荐药物或剂量;异常较多时建议「带上报告咨询医生」。
|
||||||
|
- 只输出正文文字,不要标题、列表、JSON、markdown。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
输入:血常规(化验单),指标:白细胞 5.2 (3.5-9.5) normal;血红蛋白 118 (130-175) low;血小板 210 (125-350) normal
|
||||||
|
输出:这份血常规共 3 项,2 项正常,血红蛋白略低于参考范围。血红蛋白偏低通常与贫血方向有关,平时可以多补充含铁食物;如果还伴随乏力头晕,建议带上报告咨询医生。
|
||||||
|
|
||||||
|
现在的报告:\(title)(\(typeLabel))
|
||||||
|
指标:
|
||||||
|
\(indicatorLines)
|
||||||
|
只输出 2~3 句正文。/no_think
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 趋势一句话解读(TrendDetailView,按数据指纹缓存)。
|
||||||
|
static func trendInsight(title: String, unit: String, rangeText: String, dataLines: String) -> String {
|
||||||
|
"""
|
||||||
|
你是健康档案助手。下面是「\(title)」的历史记录(单位 \(unit)\(rangeText)),请用大白话给本人(称「你」)写 1~2 句趋势解读:
|
||||||
|
- 说清整体走向(上升/下降/平稳/波动)和当前值与参考范围的关系。
|
||||||
|
- 不诊断疾病、不推荐药物;持续异常时温和建议「复查或咨询医生」。
|
||||||
|
- 只输出正文文字,不要标题、列表、JSON。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
输入:体重,单位 kg,记录:2026-04-01 72.5 / 2026-04-15 71.8 / 2026-05-01 71.2
|
||||||
|
输出:近一个月你的体重稳步下降了约 1.3kg,节奏平缓,继续保持现在的习惯就好。
|
||||||
|
|
||||||
|
现在的记录:
|
||||||
|
\(dataLines)
|
||||||
|
只输出 1~2 句正文。/no_think
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
43
康康/AI/Prompts/IntentPrompts.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 「长按 + 语音直达」prompt:端侧语音转写文本 → LLM(MNN/SME2 主链路)分类到新建入口。
|
||||||
|
/// 输出契约:严格 JSON `{"intent":"…"}`;解析失败/超时 → VoiceIntentService 回退关键词匹配(§3.2)。
|
||||||
|
nonisolated enum IntentPrompts {
|
||||||
|
|
||||||
|
static func classify(_ utterance: String) -> String {
|
||||||
|
classifyTemplate.replacingOccurrences(of: "{{TEXT}}", with: String(utterance.prefix(120)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let classifyTemplate: String = #"""
|
||||||
|
你是健康 App 的语音意图分类器。用户长按「新建」按钮说了一句话,判断 ta 想打开哪个功能。
|
||||||
|
请只输出一段合法 JSON,格式 {"intent":"<分类>"},不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||||
|
|
||||||
|
分类(只能选下面其中一个):
|
||||||
|
- "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态
|
||||||
|
- "medication" 记录用药、拍药盒、吃了什么药
|
||||||
|
- "symptom" 记录症状,哪里不舒服(头疼、咳嗽、发烧、头晕…)
|
||||||
|
- "indicator" 记录指标数值(血压、血糖、体重、心率、体温…)
|
||||||
|
- "archive" 归档整份体检报告/化验单(拍报告存档)
|
||||||
|
- "export" 生成给医生看的身体档案/健康总结
|
||||||
|
- "reminder" 设置周期提醒
|
||||||
|
- "unknown" 无法判断
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。
|
||||||
|
- 只是陈述吃了什么药 → "medication";只是陈述哪里不舒服 → "symptom"。
|
||||||
|
- 既像日记又提到具体数值时,以数值为准 → "indicator"。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
"帮我记一下今天的血压,高压128低压85" → {"intent":"indicator"}
|
||||||
|
"我今天有点头疼,想记录一下" → {"intent":"symptom"}
|
||||||
|
"刚买了一盒降压药,拍一下存进去" → {"intent":"medication"}
|
||||||
|
"今天睡得不错,写个日记" → {"intent":"diary"}
|
||||||
|
"把这份体检报告存档" → {"intent":"archive"}
|
||||||
|
"每天早上八点提醒我量血压" → {"intent":"reminder"}
|
||||||
|
"整理一份给医生看的健康总结" → {"intent":"export"}
|
||||||
|
|
||||||
|
现在判断下面这句话,只输出 JSON。/no_think
|
||||||
|
|
||||||
|
用户的话:{{TEXT}}
|
||||||
|
"""#
|
||||||
|
}
|
||||||
64
康康/AI/Prompts/MedicationPrompts.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 「拍药盒入档」prompt:Vision OCR 出药盒/说明书/处方文字后,
|
||||||
|
/// 交 LLM(Qwen,MNN/SME2 主链路)结构化抽药品名 + 规格 + 用法。
|
||||||
|
/// 输出契约:严格 JSON;解析失败 → UI 回退手动录入(§3.2 失败回退红线)。
|
||||||
|
/// 注意:只做"识别入档",不做剂量推荐/用药提醒(§1 明确不做)。
|
||||||
|
nonisolated enum MedicationPrompts {
|
||||||
|
|
||||||
|
static func medicationsFromText(_ ocrText: String) -> String {
|
||||||
|
// ≤5 张合并 OCR,放宽到 2400(单张 1200 偏小,易把背面用法截掉)。
|
||||||
|
medicationsFromTextTemplate
|
||||||
|
.replacingOccurrences(of: "{{OCR_TEXT}}", with: VLPrompts.clipOCR(ocrText, limit: 2400))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let medicationsFromTextTemplate: String = #"""
|
||||||
|
你是药品包装识别助手。下面是对一种药品的多张照片(药盒正面/背面/说明书/处方单)做 OCR 得到的纯文本,各张之间用「---」分隔,可能有错字、换行混乱或无关噪声。
|
||||||
|
请从中提取药品信息,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||||
|
|
||||||
|
JSON schema(严格):
|
||||||
|
{
|
||||||
|
"medications": [
|
||||||
|
{
|
||||||
|
"name": string, // 药品名,见下方「name 怎么填」
|
||||||
|
"strength": string, // 规格,如 "80mg"、"0.5g×24片";识别不出填 ""
|
||||||
|
"usage": string // 用法用量,如 "每日一次,一次一粒";包装上没有就填 ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
name 怎么填(关键,别搞混):
|
||||||
|
- 药品名 = 通用名(化学/药典名),这是要填进 name 的主体。中文药名照中文写,英文药名(如 "Metformin"、"Amoxicillin")就照英文原样抄,不要翻译、不要丢。
|
||||||
|
- 若包装上同时印有商品名/商标名(厂商起的牌子名,如 "代文""泰诺""Tylenol"),把它放在通用名后的括号里,例如 "缬沙坦胶囊(代文)"。只读到商品名、读不到通用名时,就直接用商品名当 name。
|
||||||
|
- 生产厂家/公司名/品牌 LOGO 文字(如 "XX药业有限公司""诺华""拜耳")不是药名,一律不要当 name,也不要塞进括号。
|
||||||
|
|
||||||
|
通用规则:
|
||||||
|
- 只提取药品本身;"国药准字"批准文号、生产厂家、批号、有效期、条形码、贮藏、二维码一律忽略。
|
||||||
|
- 多张照片通常是同一种药的不同面,合并成一条,不要因为来自不同照片就重复输出;处方单可能有多种药,才分多条。
|
||||||
|
- 不要发明药品。名称读不清的整条跳过;strength / usage 读不清就填 "",不要编造。
|
||||||
|
- 不要输出任何服药建议或剂量调整建议,只抄录包装上已有的文字。
|
||||||
|
- 同一药品只输出一次。
|
||||||
|
|
||||||
|
示例 1(药盒,含商品名 + 厂商):
|
||||||
|
输入 OCR 文本: 代文 缬沙坦胶囊 80mg×7粒 国药准字H20103521 北京诺华制药有限公司
|
||||||
|
输出:
|
||||||
|
{"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 文本:
|
||||||
|
{{OCR_TEXT}}
|
||||||
|
"""#
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// VL 模型(Qwen2.5-VL)用于体检 / 化验单识别的 prompt 模板。
|
/// VL 模型(Qwen3-VL)用于体检 / 化验单识别的 prompt 模板。
|
||||||
/// 输出契约:严格 JSON,无任何解释文字、markdown 围栏或前后缀。
|
/// 输出契约:严格 JSON,无任何解释文字、markdown 围栏或前后缀。
|
||||||
/// 解析失败 → CaptureService 回退到手动录入(§3.2 失败回退红线)。
|
/// 解析失败 → CaptureService 回退到手动录入(§3.2 失败回退红线)。
|
||||||
enum VLPrompts {
|
nonisolated enum VLPrompts {
|
||||||
|
|
||||||
/// 输出 JSON 的字段定义(写进 prompt 里教模型):
|
/// 输出 JSON 的字段定义(写进 prompt 里教模型):
|
||||||
/// ```
|
/// ```
|
||||||
@@ -20,16 +20,56 @@ enum VLPrompts {
|
|||||||
/// "value": "3.84",
|
/// "value": "3.84",
|
||||||
/// "unit": "mmol/L",
|
/// "unit": "mmol/L",
|
||||||
/// "range": "< 3.40",
|
/// "range": "< 3.40",
|
||||||
/// "status": "high|low|normal"
|
/// "status": "high|low|normal",
|
||||||
|
/// "source_page": 1,
|
||||||
|
/// "source_box": [0.18, 0.42, 0.68, 0.49]
|
||||||
/// }
|
/// }
|
||||||
/// ]
|
/// ]
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
/// `kind` 字段省略 —— UI 由 indicators 数量决定走 A2(单项)或 B3(多项)。
|
/// `kind` 字段省略 —— UI 由 indicators 数量决定走 A2(单项)或 B3(多项)。
|
||||||
|
|
||||||
static let reportExtraction: String = #"""
|
/// VL 模型不知"今天"是哪天,且 few-shot 示例里写死了日期,
|
||||||
|
/// 必须把当天日期显式注入 prompt,模型在无报告日期时才会用对正确的回退值。
|
||||||
|
/// ocrText 非空时把 Vision OCR 的结果作为参考文本注入 —— Vision 抄数字比
|
||||||
|
/// 2B 多模态读密集小字稳;版面与表格结构仍以图片为准。
|
||||||
|
static func reportExtraction(today: Date = .now, ocrText: String = "") -> String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
let todayStr = f.string(from: today)
|
||||||
|
let ocrSection: String
|
||||||
|
if ocrText.isEmpty {
|
||||||
|
ocrSection = ""
|
||||||
|
} else {
|
||||||
|
ocrSection = """
|
||||||
|
|
||||||
|
|
||||||
|
OCR 参考文本(系统对同一报告做文字识别的结果,可能有错字、串行或漏行;版面与表格结构以图片为准,但数值、小数点以 OCR 文字更可靠):
|
||||||
|
\(clipOCR(ocrText))
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
return reportExtractionTemplate
|
||||||
|
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
|
||||||
|
.replacingOccurrences(of: "{{OCR_SECTION}}", with: ocrSection)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OCR 文本截断:限制进入 prompt 的体量(2B 模型上下文有限)。截到最后一个完整行。
|
||||||
|
static func clipOCR(_ text: String, limit: Int = 1800) -> String {
|
||||||
|
guard text.count > limit else { return text }
|
||||||
|
let clipped = String(text.prefix(limit))
|
||||||
|
if let lastNewline = clipped.lastIndex(of: "\n") {
|
||||||
|
return String(clipped[..<lastNewline]) + "\n(后续内容过长已截断)"
|
||||||
|
}
|
||||||
|
return clipped + "\n(后续内容过长已截断)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let reportExtractionTemplate: String = #"""
|
||||||
你是一个医学体检报告识别助手。请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
你是一个医学体检报告识别助手。请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||||
|
|
||||||
|
今天的日期是 {{TODAY}}。
|
||||||
|
|
||||||
JSON schema(严格):
|
JSON schema(严格):
|
||||||
{
|
{
|
||||||
"title": string,
|
"title": string,
|
||||||
@@ -44,7 +84,9 @@ JSON schema(严格):
|
|||||||
"value": string,
|
"value": string,
|
||||||
"unit": string,
|
"unit": string,
|
||||||
"range": string,
|
"range": string,
|
||||||
"status": "high" | "low" | "normal"
|
"status": "high" | "low" | "normal",
|
||||||
|
"source_page": number,
|
||||||
|
"source_box": [number, number, number, number]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -52,20 +94,182 @@ JSON schema(严格):
|
|||||||
规则:
|
规则:
|
||||||
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"。
|
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"。
|
||||||
- range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象。
|
- range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象。
|
||||||
- 无法识别的字段填空字符串(institution / summary)或合理默认值(report_date 用今天)。
|
- 无法识别的字段填空字符串(institution / summary)。
|
||||||
- 不要发明指标。看不清的整行跳过。
|
- report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。
|
||||||
|
- 不要发明指标。数值看不清的整行跳过;但**没有参考范围不是跳过的理由**,结论页叙述式文字(如「总胆红素: 23.0(μmol/L)↑」)同样要提取,range 填 "",status 按箭头/「偏高」等标记判断。
|
||||||
- 化验单一般 type = "lab",体检套餐 = "checkup"。
|
- 化验单一般 type = "lab",体检套餐 = "checkup"。
|
||||||
|
- source_page 是该指标所在图片页码,从 1 开始。
|
||||||
|
- source_box 是该指标整行在该页图片里的归一化矩形 [x,y,width,height],左上角为 (0,0),右下角为 (1,1)。尽量框住指标名、数值、单位、参考范围和异常标记所在整行;不确定位置时填 [0,0,0,0]。
|
||||||
|
|
||||||
示例 1(化验单 · 单项):
|
示例 1(化验单 · 单项):
|
||||||
输入: 一张化验单照片,只能看清「低密度脂蛋白 3.84 mmol/L 参考 <3.40」
|
输入: 一张化验单照片,只能看清「低密度脂蛋白 3.84 mmol/L 参考 <3.40」
|
||||||
输出:
|
输出:
|
||||||
{"title":"低密度脂蛋白单项","type":"lab","report_date":"2026-05-25","institution":"","page_count":1,"summary":"","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]}
|
{"title":"低密度脂蛋白单项","type":"lab","report_date":"2026-05-25","institution":"","page_count":1,"summary":"","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.18,0.42,0.68,0.08]}]}
|
||||||
|
|
||||||
示例 2(体检 · 多项):
|
示例 2(体检 · 多项):
|
||||||
输入: 一份春季体检,3 项可读
|
输入: 一份春季体检,3 项可读
|
||||||
输出:
|
输出:
|
||||||
{"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"},{"name":"谷丙转氨酶","value":"32","unit":"U/L","range":"9 - 50","status":"normal"},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]}
|
{"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:
|
现在请识别图片并输出 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: - 局部小框识别(指标速记)
|
||||||
|
|
||||||
|
/// 指标速记专用:输入是报告/化验单的**局部照片**(常常只有一两行指标)。
|
||||||
|
/// 只要 indicators 数组,不要报告标题/机构/日期等元信息 —— 这条路径只存数值,不建 Report。
|
||||||
|
static func regionExtraction(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 regionExtractionTemplate.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let regionExtractionTemplate: String = #"""
|
||||||
|
你是一个医学化验单识别助手。下面给你的是一张化验单/体检报告的**局部照片**,通常只框住了一两行指标。
|
||||||
|
照片内容可能是表格行,也可能是**结论页的叙述式文字**(如「九、检验:(1)总胆红素(TB): 23.0(μmol/L)↑」),两种都要提取。
|
||||||
|
请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||||
|
|
||||||
|
今天的日期是 {{TODAY}}。
|
||||||
|
|
||||||
|
JSON schema(严格):
|
||||||
|
{
|
||||||
|
"indicators": [
|
||||||
|
{
|
||||||
|
"name": string,
|
||||||
|
"value": string,
|
||||||
|
"unit": string,
|
||||||
|
"range": string,
|
||||||
|
"status": "high" | "low" | "normal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 凡是「指标名 + 数值」清楚可读的,都要提取——**没有参考范围不是跳过的理由**。只有数值本身看不清才跳过,绝不发明指标。
|
||||||
|
- status 判断优先级:① 文字旁的箭头或标记(↑/H/偏高 → "high",↓/L/偏低 → "low")最优先;② 没有标记时再用 value 与 range 比较;③ 都没有 → "normal"。
|
||||||
|
- range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象;照片里没有参考范围就填 ""。
|
||||||
|
- 识别不出单位/范围就填空字符串,不要编造。
|
||||||
|
- name 用规范指标名;如果同一行重复出现指标名(如「总胆红素(TB): 总胆红素: 23.0」),只取一次。
|
||||||
|
- 不要输出 title / institution / date / summary 等任何报告级字段,只输出 indicators 数组。
|
||||||
|
|
||||||
|
示例 1(表格单行):
|
||||||
|
输入: 局部照片,清楚可读「低密度脂蛋白 3.84 mmol/L 参考 <3.40 ↑」
|
||||||
|
输出:
|
||||||
|
{"indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]}
|
||||||
|
|
||||||
|
示例 2(表格两行):
|
||||||
|
输入: 局部照片,清楚可读「尿酸 486 μmol/L 208-428」与「空腹血糖 5.2 mmol/L 3.9-6.1」
|
||||||
|
输出:
|
||||||
|
{"indicators":[{"name":"尿酸","value":"486","unit":"μmol/L","range":"208 - 428","status":"high"},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]}
|
||||||
|
|
||||||
|
示例 3(结论页叙述式 · 无参考范围,只有箭头):
|
||||||
|
输入: 局部照片,体检结论文字「九、检验: (1)总胆红素(TB): 总胆红素: 23.0(μmol/L)↑」,周围还有其他结论文字
|
||||||
|
输出:
|
||||||
|
{"indicators":[{"name":"总胆红素","value":"23.0","unit":"μmol/L","range":"","status":"high"}]}
|
||||||
|
|
||||||
|
现在请识别这张局部照片并输出 JSON:
|
||||||
|
"""#
|
||||||
|
|
||||||
|
// MARK: - OCR 文本 → 指标(LLM 解析,非 VL)
|
||||||
|
|
||||||
|
/// 「拍照识别」新链路:先用 Vision OCR 把化验单读成纯文本,再用 Qwen3-1.7B 从文本结构化抽指标。
|
||||||
|
/// 比让 3B VL 直接读密集小字稳得多。输入是 OCR 文本(可能有错字/错位/噪声)。
|
||||||
|
static func indicatorsFromText(_ 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 indicatorsFromTextTemplate
|
||||||
|
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
|
||||||
|
.replacingOccurrences(of: "{{OCR_TEXT}}", with: ocrText)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let indicatorsFromTextTemplate: String = #"""
|
||||||
|
你是医学化验单/体检报告的结构化助手。下面是对一张报告做 OCR 得到的纯文本,可能有错字、错位、多余符号或换行混乱。
|
||||||
|
请从中提取所有「指标名 + 数值」,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||||
|
|
||||||
|
今天的日期是 {{TODAY}}。
|
||||||
|
|
||||||
|
JSON schema(严格):
|
||||||
|
{
|
||||||
|
"indicators": [
|
||||||
|
{
|
||||||
|
"name": string,
|
||||||
|
"value": string,
|
||||||
|
"unit": string,
|
||||||
|
"range": string,
|
||||||
|
"status": "high" | "low" | "normal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 只提取「有明确数值」的检验/体检指标;页眉、医院名、医生签名、采样时间、栏目标题、OCR 噪声一律忽略。
|
||||||
|
- status 判断优先级:① 文本里的箭头/标记(↑/H/偏高 → "high",↓/L/偏低 → "low")最优先;② 没有标记时用 value 与 range 比较;③ 都没有 → "normal"。
|
||||||
|
- range 保留原文(如 "3.9 - 6.1"、"< 3.40"、"208 - 428");OCR 把破折号写成 "--" / "~" 都归一成 " - ";没有参考范围就填 ""。
|
||||||
|
- 单位识别不出就填 "",不要编造;不要发明指标;同一指标只输出一次。
|
||||||
|
- name 用规范中文指标名(行内重复的去掉,英文缩写括注可保留)。
|
||||||
|
- 数值明显是 OCR 乱码(字母混入数字)且无法判断的,跳过该行。
|
||||||
|
|
||||||
|
示例 OCR 文本:
|
||||||
|
淋巴细胞数 3.0 1.8 -- 6.3 X10^9/L
|
||||||
|
尿酸 486 208-428 μmol/L
|
||||||
|
总胆红素(TB): 23.0 (μmol/L) ↑
|
||||||
|
对应输出:
|
||||||
|
{"indicators":[{"name":"淋巴细胞数","value":"3.0","unit":"X10^9/L","range":"1.8 - 6.3","status":"normal"},{"name":"尿酸","value":"486","unit":"μmol/L","range":"208 - 428","status":"high"},{"name":"总胆红素","value":"23.0","unit":"μmol/L","range":"","status":"high"}]}
|
||||||
|
|
||||||
|
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
|
||||||
|
|
||||||
|
OCR 文本:
|
||||||
|
{{OCR_TEXT}}
|
||||||
"""#
|
"""#
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import MLX
|
|||||||
import MLXVLM
|
import MLXVLM
|
||||||
import MLXLMCommon
|
import MLXLMCommon
|
||||||
|
|
||||||
/// 封装 MLX VL 模型(Qwen2.5-VL)的图像 → 文本推理。
|
/// 封装 MLX VL 模型(Qwen3-VL)的图像 → 文本推理。
|
||||||
/// 与 LLMSession 同款 actor 隔离,串行化由上游 AIRuntime 统一保证。
|
/// 与 LLMSession 同款 actor 隔离,串行化由上游 AIRuntime 统一保证。
|
||||||
actor VLSession {
|
actor VLSession {
|
||||||
let container: ModelContainer
|
let container: ModelContainer
|
||||||
|
|||||||
64
康康/App/FontScale.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 全局字体放大档位。面向视力不佳 / 老年用户:放大整个 App 的字号。
|
||||||
|
/// 倍率作用于所有走 `Font.tjScaled` / `Font.tjTitle` 等的字体(即全 App 固定字号)。
|
||||||
|
enum FontScale: String, CaseIterable, Identifiable {
|
||||||
|
case standard, large, extraLarge, huge
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
/// 字号倍率。超大档位有限,避免密集布局严重溢出。
|
||||||
|
var multiplier: CGFloat {
|
||||||
|
switch self {
|
||||||
|
case .standard: return 1.0
|
||||||
|
case .large: return 1.2
|
||||||
|
case .extraLarge: return 1.4
|
||||||
|
case .huge: return 1.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .standard: return String(appLoc: "标准")
|
||||||
|
case .large: return String(appLoc: "大")
|
||||||
|
case .extraLarge: return String(appLoc: "特大")
|
||||||
|
case .huge: return String(appLoc: "超大")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var detail: String {
|
||||||
|
switch self {
|
||||||
|
case .standard: return String(appLoc: "默认字号")
|
||||||
|
case .large: return String(appLoc: "字号放大 20%")
|
||||||
|
case .extraLarge: return String(appLoc: "字号放大 40%")
|
||||||
|
case .huge: return String(appLoc: "字号放大 60%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全 App 单例。持久化字体档位;切换后由根视图 `.id` 触发整树重建即时生效(同语言切换机制)。
|
||||||
|
@Observable
|
||||||
|
final class FontScaleManager {
|
||||||
|
static let shared = FontScaleManager()
|
||||||
|
|
||||||
|
private let storageKey = "appFontScale"
|
||||||
|
|
||||||
|
private(set) var scale: FontScale
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let saved = UserDefaults.standard.string(forKey: storageKey)
|
||||||
|
scale = FontScale(rawValue: saved ?? "") ?? .standard
|
||||||
|
appFontScale = scale.multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ newScale: FontScale) {
|
||||||
|
guard newScale != scale else { return }
|
||||||
|
scale = newScale
|
||||||
|
UserDefaults.standard.set(newScale.rawValue, forKey: storageKey)
|
||||||
|
appFontScale = newScale.multiplier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// nonisolated 快照:`Font.tjScaled` 是 static func,在任意上下文按值读取倍率。
|
||||||
|
/// 只由 `FontScaleManager`(MainActor)写入;读为快照,无竞态影响(同 Localization 的 appLocBundle 模式)。
|
||||||
|
nonisolated(unsafe) var appFontScale: CGFloat = 1.0
|
||||||
@@ -3,6 +3,14 @@ import SwiftData
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct KangkangApp: App {
|
struct KangkangApp: App {
|
||||||
|
@State private var lang = LanguageManager.shared
|
||||||
|
@State private var fontScale = FontScaleManager.shared
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// 启动即给 MLX 显存缓存设上限,配合 entitlement + LLM/VL 互斥卸载防 jetsam OOM。
|
||||||
|
AIRuntime.configureMLXMemory()
|
||||||
|
}
|
||||||
|
|
||||||
var sharedModelContainer: ModelContainer = {
|
var sharedModelContainer: ModelContainer = {
|
||||||
let schema = Schema([
|
let schema = Schema([
|
||||||
Indicator.self,
|
Indicator.self,
|
||||||
@@ -15,31 +23,89 @@ struct KangkangApp: App {
|
|||||||
MetricReminder.self,
|
MetricReminder.self,
|
||||||
CustomMonitorMetric.self,
|
CustomMonitorMetric.self,
|
||||||
HealthExport.self,
|
HealthExport.self,
|
||||||
|
CustomReminder.self,
|
||||||
|
Medication.self,
|
||||||
])
|
])
|
||||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||||
do {
|
// 建库后给 store 文件补 .completeUnlessOpen 保护(§6),两条创建路径共用。
|
||||||
return try ModelContainer(for: schema, configurations: [config])
|
func makeContainer() throws -> ModelContainer {
|
||||||
} catch {
|
let container = try ModelContainer(for: schema, configurations: [config])
|
||||||
// Demo 阶段 schema 仍在演进:旧 store 与新 schema 不兼容且无正式迁移时,
|
KangkangApp.protectStore(at: config.url)
|
||||||
// 自动迁移会失败导致启动崩溃。这里重置本地 store 重建(测试数据可丢)。
|
return container
|
||||||
// ⚠️ 生产环境必须改为正式的 SwiftData 迁移方案,不能静默删数据。
|
|
||||||
print("⚠️ ModelContainer 创建失败,重置本地 store 重建: \(error)")
|
|
||||||
let fm = FileManager.default
|
|
||||||
let storePath = config.url.path
|
|
||||||
for path in [storePath, storePath + "-wal", storePath + "-shm"] {
|
|
||||||
try? fm.removeItem(atPath: path)
|
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
return try ModelContainer(for: schema, configurations: [config])
|
return try makeContainer()
|
||||||
} catch {
|
} catch {
|
||||||
fatalError("Could not create ModelContainer even after reset: \(error)")
|
// Demo 阶段 schema 仍在演进:某次改动若超出 SwiftData 自动轻量迁移能力
|
||||||
|
// (最常见:给已存在的 @Model 新增「非可选且无内联默认值」的属性),自动迁移会抛错。
|
||||||
|
// 这里不再静默删库,而是把旧 store 连同 -wal/-shm 整体挪到带时间戳的备份目录后重建——
|
||||||
|
// 既保证 App 能启动,又让旧数据可手动恢复(挪不动才降级为删除)。
|
||||||
|
// ⚠️ 正式发布前仍应改为 VersionedSchema + SchemaMigrationPlan 的正式迁移。
|
||||||
|
// 注:新增 @Model 属性请一律给「可选」或「内联默认值」,即可走轻量迁移、不触发本兜底。
|
||||||
|
print("⚠️ ModelContainer 创建失败,备份旧 store 后重建: \(error)")
|
||||||
|
KangkangApp.backupIncompatibleStore(at: config.url)
|
||||||
|
do {
|
||||||
|
return try makeContainer()
|
||||||
|
} catch {
|
||||||
|
fatalError("Could not create ModelContainer even after store reset: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// 给 SwiftData store(含 `-wal`/`-shm`)补 `.completeUnlessOpen` 文件保护:
|
||||||
|
/// 设备锁屏时静态加密,但已打开的库仍可读写——对运行中的 SQLite 安全,
|
||||||
|
/// 不用 `.complete` 以免锁屏时后台/Live Activity 访问 store 崩溃。对应 CLAUDE.md §6。
|
||||||
|
/// (默认未指定保护类时 iOS 仅给 CompleteUntilFirstUserAuthentication,这里升级一档。)
|
||||||
|
private static func protectStore(at storeURL: URL) {
|
||||||
|
let fm = FileManager.default
|
||||||
|
for suffix in ["", "-wal", "-shm"] {
|
||||||
|
let path = storeURL.path + suffix
|
||||||
|
guard fm.fileExists(atPath: path) else { continue }
|
||||||
|
try? fm.setAttributes([.protectionKey: FileProtectionType.completeUnlessOpen],
|
||||||
|
ofItemAtPath: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把与新 schema 不兼容的旧 store(含 `-wal` / `-shm`)挪到
|
||||||
|
/// `Application Support/StoreBackups/<时间戳>/`,而不是直接删除。
|
||||||
|
/// 既清出路径让新库能建起来,又把旧数据留作可手动恢复的备份;挪不动时才降级为删除。
|
||||||
|
private static func backupIncompatibleStore(at storeURL: URL) {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
fmt.dateFormat = "yyyyMMdd-HHmmss"
|
||||||
|
let stamp = fmt.string(from: Date())
|
||||||
|
let backupDir = storeURL.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("StoreBackups/\(stamp)", isDirectory: true)
|
||||||
|
try? fm.createDirectory(at: backupDir, withIntermediateDirectories: true)
|
||||||
|
// 备份副本同样要加密(否则等于把全量健康数据明文留在低保护目录)。
|
||||||
|
try? fm.setAttributes([.protectionKey: FileProtectionType.completeUnlessOpen],
|
||||||
|
ofItemAtPath: backupDir.path)
|
||||||
|
for suffix in ["", "-wal", "-shm"] {
|
||||||
|
let src = URL(fileURLWithPath: storeURL.path + suffix)
|
||||||
|
guard fm.fileExists(atPath: src.path) else { continue }
|
||||||
|
let dst = backupDir.appendingPathComponent(src.lastPathComponent)
|
||||||
|
do {
|
||||||
|
try fm.moveItem(at: src, to: dst)
|
||||||
|
try? fm.setAttributes([.protectionKey: FileProtectionType.completeUnlessOpen],
|
||||||
|
ofItemAtPath: dst.path)
|
||||||
|
} catch {
|
||||||
|
try? fm.removeItem(at: src) // 挪不动就删,至少保证能启动
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
|
AppLockContainer {
|
||||||
RootView()
|
RootView()
|
||||||
|
.environment(\.locale, lang.locale)
|
||||||
|
// 语言 / 字体档位切换 → 整树重建,即时生效(固定字号经 tjScaled 读新倍率)。
|
||||||
|
.id("\(lang.current.rawValue)-\(fontScale.scale.rawValue)")
|
||||||
|
}
|
||||||
|
// 设计系统是纯浅色(背景恒为 sand)。锁定 light:否则系统深色模式下,
|
||||||
|
// 未显式设色的 Text/TextField 走 .primary 变白,在浅背景上看不见(如日记输入框)。
|
||||||
|
.preferredColorScheme(.light)
|
||||||
}
|
}
|
||||||
.modelContainer(sharedModelContainer)
|
.modelContainer(sharedModelContainer)
|
||||||
}
|
}
|
||||||
|
|||||||
152
康康/App/Localization.swift
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ObjectiveC
|
||||||
|
|
||||||
|
/// App 支持的界面语言。`system` = 跟随系统;其余对应 .lproj / String Catalog 语言。
|
||||||
|
enum AppLanguage: String, CaseIterable, Identifiable {
|
||||||
|
case system
|
||||||
|
case zhHans = "zh-Hans"
|
||||||
|
case en
|
||||||
|
case ja
|
||||||
|
case ko
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
/// 选择器里展示的名字。各语言用其**本族语**显示(行业惯例,不本地化),仅「跟随系统」随 App 语言。
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .system: return String(appLoc: "跟随系统")
|
||||||
|
case .zhHans: return "简体中文"
|
||||||
|
case .en: return "English"
|
||||||
|
case .ja: return "日本語"
|
||||||
|
case .ko: return "한국어"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// nil = 跟随系统;否则为 .lproj / Locale 标识。
|
||||||
|
var localeIdentifier: String? {
|
||||||
|
self == .system ? nil : rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 语言选择器图标。各语言用本族语代表字区分(中 / A / あ / 가),
|
||||||
|
/// 「跟随系统」非具体语言,用地球符号。代表字与 `displayName` 一样不本地化。
|
||||||
|
enum PickerIcon: Equatable {
|
||||||
|
case symbol(String) // SF Symbol 名
|
||||||
|
case glyph(String) // 本族语代表字
|
||||||
|
}
|
||||||
|
|
||||||
|
var pickerIcon: PickerIcon {
|
||||||
|
switch self {
|
||||||
|
case .system: return .symbol("globe")
|
||||||
|
case .zhHans: return .glyph("中")
|
||||||
|
case .en: return .glyph("A")
|
||||||
|
case .ja: return .glyph("あ")
|
||||||
|
case .ko: return .glyph("가")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全 App 单例。负责:持久化选择、维护当前语言的 lproj bundle 与 locale。
|
||||||
|
/// - `Text("…")` 走根视图注入的环境 `\.locale`(+ Bundle 重定向)即时切换;
|
||||||
|
/// - `String(appLoc:)` 显式绑定本管理器的 bundle/locale,不受 `.current` 限制,同样即时切换。
|
||||||
|
/// 切换后由根视图 `.id(current)` 触发整树重建,无需重启。
|
||||||
|
@Observable
|
||||||
|
final class LanguageManager {
|
||||||
|
static let shared = LanguageManager()
|
||||||
|
|
||||||
|
private let storageKey = "appLanguage"
|
||||||
|
|
||||||
|
private(set) var current: AppLanguage
|
||||||
|
/// 当前语言对应的 .lproj bundle(system 或缺失时为 .main)。缓存,切换时更新。
|
||||||
|
private(set) var lprojBundle: Bundle = .main
|
||||||
|
/// 当前解析后的 locale(system 时为 .autoupdatingCurrent)。
|
||||||
|
private(set) var resolvedLocale: Locale = .autoupdatingCurrent
|
||||||
|
|
||||||
|
/// 供 SwiftUI 环境使用(日期/数字格式化 + Text 语言)。
|
||||||
|
var locale: Locale { resolvedLocale }
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let saved = UserDefaults.standard.string(forKey: storageKey)
|
||||||
|
current = AppLanguage(rawValue: saved ?? "") ?? .system
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ language: AppLanguage) {
|
||||||
|
guard language != current else { return }
|
||||||
|
current = language
|
||||||
|
UserDefaults.standard.set(language.rawValue, forKey: storageKey)
|
||||||
|
// 同步 AppleLanguages:保证下次冷启动解析正确,并与系统「设置 → App → 语言」一致。
|
||||||
|
if let id = language.localeIdentifier {
|
||||||
|
UserDefaults.standard.set([id], forKey: "AppleLanguages")
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: "AppleLanguages")
|
||||||
|
}
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply() {
|
||||||
|
if let id = current.localeIdentifier {
|
||||||
|
resolvedLocale = Locale(identifier: id)
|
||||||
|
if let path = Bundle.main.path(forResource: id, ofType: "lproj"),
|
||||||
|
let b = Bundle(path: path) {
|
||||||
|
lprojBundle = b
|
||||||
|
} else {
|
||||||
|
lprojBundle = .main
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolvedLocale = .autoupdatingCurrent
|
||||||
|
lprojBundle = .main
|
||||||
|
}
|
||||||
|
Bundle.redirectMain(to: current.localeIdentifier)
|
||||||
|
// 同步 nonisolated 快照,供 String(appLoc:) 在非 MainActor 上下文读取。
|
||||||
|
appLocBundle = lprojBundle
|
||||||
|
appLocLocale = resolvedLocale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// nonisolated 快照:`String(appLoc:)` 可能在非 MainActor 上下文被调用
|
||||||
|
/// (LocalizedError.errorDescription、nonisolated 枚举 label、static 解析器…)。
|
||||||
|
/// 只由 `LanguageManager.apply()`(MainActor)写入,切换语言时刷新;读为快照,无竞态影响。
|
||||||
|
nonisolated(unsafe) private var appLocBundle: Bundle = .main
|
||||||
|
nonisolated(unsafe) private var appLocLocale: Locale = .autoupdatingCurrent
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
/// 尊重「我的 · 语言」选择的本地化(可即时切换)。
|
||||||
|
/// 等价 `String(localized:)`,但显式绑定当前所选语言的 bundle + locale,
|
||||||
|
/// 因此不受 `Locale.current`(系统/启动时语言)限制。
|
||||||
|
nonisolated init(appLoc key: String.LocalizationValue) {
|
||||||
|
self = String(localized: key, bundle: appLocBundle, locale: appLocLocale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bundle 运行时重定向(供 Text / NSLocalizedString 双保险)
|
||||||
|
|
||||||
|
/// 关联对象 key(需稳定地址,文件级全局即可)。
|
||||||
|
private var redirectBundleKey: UInt8 = 0
|
||||||
|
|
||||||
|
/// 把 `Bundle.main` 的字符串查表重定向到指定语言的 .lproj。
|
||||||
|
private final class LocalizedMainBundle: Bundle, @unchecked Sendable {
|
||||||
|
override func localizedString(forKey key: String, value: String?, table tableName: String?) -> String {
|
||||||
|
if let target = objc_getAssociatedObject(self, &redirectBundleKey) as? Bundle {
|
||||||
|
return target.localizedString(forKey: key, value: value, table: tableName)
|
||||||
|
}
|
||||||
|
return super.localizedString(forKey: key, value: value, table: tableName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Bundle {
|
||||||
|
/// language == nil → 跟随系统(走默认解析)。
|
||||||
|
static func redirectMain(to language: String?) {
|
||||||
|
if !(Bundle.main is LocalizedMainBundle) {
|
||||||
|
object_setClass(Bundle.main, LocalizedMainBundle.self)
|
||||||
|
}
|
||||||
|
let target: Bundle?
|
||||||
|
if let language,
|
||||||
|
let path = Bundle.main.path(forResource: language, ofType: "lproj"),
|
||||||
|
let bundle = Bundle(path: path) {
|
||||||
|
target = bundle
|
||||||
|
} else {
|
||||||
|
target = nil
|
||||||
|
}
|
||||||
|
objc_setAssociatedObject(Bundle.main, &redirectBundleKey, target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 540 B |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 511 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 540 B After Width: | Height: | Size: 990 B |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 68 KiB |
34
康康/DesignSystem/AIDisclaimer.swift
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 全 App 统一的 AI 免责声明(上架合规:医疗类内容必须声明不做诊断)。
|
||||||
|
/// 任何展示 AI 生成的归纳/解读/建议文本的地方,都应在结果末尾附上 `AIDisclaimerFooter`;
|
||||||
|
/// 离开 App 的文本(复制/分享)用 `AIDisclaimer.appended(to:)` 把声明带上。
|
||||||
|
enum AIDisclaimer {
|
||||||
|
/// 面向用户展示的完整声明文本。
|
||||||
|
static let text =
|
||||||
|
"本内容由本机本地 AI 依据你录入的健康记录自动归纳整理,仅供个人健康管理与就医沟通参考," +
|
||||||
|
"不构成医学诊断、治疗建议或专业医疗意见;具体健康问题请咨询执业医师。"
|
||||||
|
|
||||||
|
/// 复制/分享时把声明追加到正文末尾(分隔线 + 声明),让文本离开 App 也带着免责。
|
||||||
|
static func appended(to body: String) -> String {
|
||||||
|
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return "\(trimmed)\n\n———\n\(text)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 结果下方的免责声明脚注:小字、弱色、信息图标。放在任何 AI 生成文本块末尾。
|
||||||
|
struct AIDisclaimerFooter: View {
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 6) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.font(.tjScaled( 10))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(AIDisclaimer.text)
|
||||||
|
.font(.tjScaled( 10))
|
||||||
|
.lineSpacing(2)
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
康康/DesignSystem/AIFlowBar.swift
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Apple Intelligence 式多彩流光线:蓝→紫→粉→橙→青,横向无缝循环流动。
|
||||||
|
/// 全 App「AI 计算中」时刻的统一视觉点缀(日记 AI 辅助、身体档案报告生成/检索等待)。
|
||||||
|
///
|
||||||
|
/// 注意:这条线的颜色是刻意走出 `Tj.Palette` 单色系统的 AI 高光点缀(应产品要求的
|
||||||
|
/// Apple 风格),仅此组件如此;其余 UI 仍严格守 §9 单色 token。
|
||||||
|
struct AIFlowBar: View {
|
||||||
|
var height: CGFloat = 3
|
||||||
|
/// 流动一整圈的秒数,越小越快。
|
||||||
|
var cycle: Double = 1.0
|
||||||
|
|
||||||
|
@State private var phase: CGFloat = 0
|
||||||
|
|
||||||
|
/// 颜色重复一遍:offset 走完一个整段时首尾同色,循环无缝。
|
||||||
|
private static let flow: [Color] = {
|
||||||
|
let base: [Color] = [
|
||||||
|
Color(red: 0.35, green: 0.47, blue: 0.98), // 蓝
|
||||||
|
Color(red: 0.62, green: 0.36, blue: 0.92), // 紫
|
||||||
|
Color(red: 0.96, green: 0.40, blue: 0.62), // 粉
|
||||||
|
Color(red: 1.00, green: 0.55, blue: 0.30), // 橙
|
||||||
|
Color(red: 0.30, green: 0.80, blue: 0.92), // 青
|
||||||
|
]
|
||||||
|
return base + base
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let w = geo.size.width
|
||||||
|
Capsule()
|
||||||
|
.fill(LinearGradient(colors: Self.flow,
|
||||||
|
startPoint: .leading, endPoint: .trailing))
|
||||||
|
.frame(width: w * 2)
|
||||||
|
.offset(x: phase)
|
||||||
|
.onAppear {
|
||||||
|
phase = 0
|
||||||
|
withAnimation(.linear(duration: cycle).repeatForever(autoreverses: false)) {
|
||||||
|
phase = -w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: height)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ struct TjLockChip: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "lock.fill")
|
Image(systemName: "lock.fill")
|
||||||
.font(.system(size: 9, weight: .semibold))
|
.font(.tjScaled( 9, weight: .semibold))
|
||||||
Text("本地加密")
|
Text("本地加密")
|
||||||
.font(.system(size: 10))
|
.font(.tjScaled( 10))
|
||||||
.tracking(0.5)
|
.tracking(0.5)
|
||||||
}
|
}
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
@@ -44,7 +44,7 @@ struct TjBadge: View {
|
|||||||
var style: TjBadgeStyle = .neutral
|
var style: TjBadgeStyle = .neutral
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 10, weight: .semibold))
|
.font(.tjScaled( 10, weight: .semibold))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(style.fg)
|
.foregroundStyle(style.fg)
|
||||||
.padding(.horizontal, 7)
|
.padding(.horizontal, 7)
|
||||||
@@ -66,7 +66,7 @@ struct TjPlaceholder: View {
|
|||||||
DiagonalStripes(spacing: 7, color: dark ? Color.white.opacity(0.04) : Color.black.opacity(0.05))
|
DiagonalStripes(spacing: 7, color: dark ? Color.white.opacity(0.04) : Color.black.opacity(0.05))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.tracking(0.5)
|
.tracking(0.5)
|
||||||
.foregroundStyle(dark ? Color.white.opacity(0.5) : Tj.Palette.text3)
|
.foregroundStyle(dark ? Color.white.opacity(0.5) : Tj.Palette.text3)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -101,7 +101,7 @@ struct TjPrimaryButton: ButtonStyle {
|
|||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
.font(.system(size: fontSize, weight: .semibold))
|
.font(.tjScaled( fontSize, weight: .semibold))
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
.padding(.horizontal, horizontalPadding)
|
.padding(.horizontal, horizontalPadding)
|
||||||
@@ -118,7 +118,7 @@ struct TjGhostButton: ButtonStyle {
|
|||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
.font(.system(size: fontSize, weight: .semibold))
|
.font(.tjScaled( fontSize, weight: .semibold))
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
.padding(.horizontal, horizontalPadding)
|
.padding(.horizontal, horizontalPadding)
|
||||||
|
|||||||
@@ -39,10 +39,18 @@ enum Tj {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Font {
|
extension Font {
|
||||||
static func tjTitle(_ size: CGFloat = 30) -> Font { .system(size: size, weight: .bold, design: .default) }
|
/// 全 App 字体的唯一缩放出口。按全局档位 `appFontScale` 放大字号(老年/视力辅助)。
|
||||||
static func tjH2(_ size: CGFloat = 18) -> Font { .system(size: size, weight: .bold, design: .default) }
|
/// 所有固定字号都经 `.system(size:)` → 机械迁移为 `.tjScaled(` 走这里;改档位 + 根视图重建即全局生效。
|
||||||
static func tjMono(_ size: CGFloat = 11) -> Font { .system(size: size, weight: .regular, design: .monospaced) }
|
static func tjScaled(_ size: CGFloat,
|
||||||
static func tjSerifBody(_ size: CGFloat = 17) -> Font { .system(size: size, weight: .regular, design: .default) }
|
weight: Font.Weight = .regular,
|
||||||
|
design: Font.Design = .default) -> Font {
|
||||||
|
.system(size: size * appFontScale, weight: weight, design: design)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func tjTitle(_ size: CGFloat = 30) -> Font { .tjScaled(size, weight: .bold) }
|
||||||
|
static func tjH2(_ size: CGFloat = 18) -> Font { .tjScaled(size, weight: .bold) }
|
||||||
|
static func tjMono(_ size: CGFloat = 11) -> Font { .tjScaled(size, design: .monospaced) }
|
||||||
|
static func tjSerifBody(_ size: CGFloat = 17) -> Font { .tjScaled(size) }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
private enum ArchiveStep: Hashable {
|
|
||||||
case guide
|
|
||||||
case scan
|
|
||||||
case meta
|
|
||||||
case progress
|
|
||||||
case result
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ArchiveFlow: View {
|
|
||||||
var onClose: () -> Void
|
|
||||||
|
|
||||||
@State private var step: ArchiveStep = .guide
|
|
||||||
@State private var capturedPages: Int = 1
|
|
||||||
@State private var totalPages: Int = 3
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
switch step {
|
|
||||||
case .guide:
|
|
||||||
B1GuideView(
|
|
||||||
onSingle: { withAnimation { totalPages = 1; step = .scan } },
|
|
||||||
onMulti: { withAnimation { totalPages = 3; step = .scan } },
|
|
||||||
onSkip: onClose
|
|
||||||
)
|
|
||||||
.transition(.opacity)
|
|
||||||
|
|
||||||
case .scan:
|
|
||||||
B2ScanView(
|
|
||||||
onShoot: { capturedPages = min(capturedPages + 1, totalPages) },
|
|
||||||
onDone: { withAnimation { step = .meta } },
|
|
||||||
onClose: onClose,
|
|
||||||
page: capturedPages,
|
|
||||||
total: totalPages
|
|
||||||
)
|
|
||||||
.transition(.opacity)
|
|
||||||
|
|
||||||
case .meta:
|
|
||||||
B3MetaView(
|
|
||||||
onAnalyze: { withAnimation { step = .progress } },
|
|
||||||
onBack: { withAnimation { step = .scan } }
|
|
||||||
)
|
|
||||||
.transition(.opacity)
|
|
||||||
|
|
||||||
case .progress:
|
|
||||||
B4ProgressView(onComplete: {
|
|
||||||
withAnimation { step = .result }
|
|
||||||
})
|
|
||||||
.transition(.opacity)
|
|
||||||
|
|
||||||
case .result:
|
|
||||||
B5ResultView(
|
|
||||||
onSave: onClose,
|
|
||||||
onBack: { withAnimation { step = .meta } }
|
|
||||||
)
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,42 +14,95 @@ struct ArchiveListView: View {
|
|||||||
@Query(sort: \Symptom.startedAt, order: .reverse)
|
@Query(sort: \Symptom.startedAt, order: .reverse)
|
||||||
private var symptoms: [Symptom]
|
private var symptoms: [Symptom]
|
||||||
|
|
||||||
|
@Query(sort: \HealthExport.createdAt, order: .reverse)
|
||||||
|
private var exports: [HealthExport]
|
||||||
|
|
||||||
|
@Query(sort: \CustomReminder.updatedAt, order: .reverse)
|
||||||
|
private var customReminders: [CustomReminder]
|
||||||
|
|
||||||
|
@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, medicationLibrary }
|
||||||
|
|
||||||
@State private var filter: TimelineKind? = nil
|
@State private var filter: TimelineKind? = nil
|
||||||
@State private var endingSymptom: Symptom?
|
@State private var endingSymptom: Symptom?
|
||||||
|
@State private var selectedEntry: TimelineEntry?
|
||||||
|
@State private var selectedGroup: IndicatorGroup?
|
||||||
|
@State private var route: Route?
|
||||||
|
|
||||||
|
/// 顶部搜索:点放大镜展开搜索框,按条目标题(指标/报告/症状/日记名)实时过滤,与分类 chip 叠加。
|
||||||
|
@State private var searching = false
|
||||||
|
@State private var query = ""
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private var allEntries: [TimelineEntry] {
|
private var allEntries: [TimelineEntry] {
|
||||||
let mapped =
|
let mapped =
|
||||||
TimelineEntry.from(indicators: indicators) +
|
TimelineEntry.aggregatedIndicators(indicators) +
|
||||||
reports.map(TimelineEntry.from(report:)) +
|
reports.map(TimelineEntry.from(report:)) +
|
||||||
diaries.map(TimelineEntry.from(diary:)) +
|
diaries.map(TimelineEntry.from(diary:)) +
|
||||||
symptoms.map(TimelineEntry.from(symptom:))
|
symptoms.map(TimelineEntry.from(symptom:))
|
||||||
let filtered = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
|
let byKind = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
|
||||||
return filtered.sorted { $0.date > $1.date }
|
let q = query.trimmingCharacters(in: .whitespaces)
|
||||||
|
let byQuery = q.isEmpty ? byKind : byKind.filter { $0.title.localizedCaseInsensitiveContains(q) }
|
||||||
|
return byQuery.sorted { $0.date > $1.date }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var grouped: [(section: DateSection, items: [TimelineEntry])] {
|
|
||||||
TimelineGrouping.group(allEntries)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var totalCount: Int { allEntries.count }
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
NavigationStack {
|
||||||
header
|
content
|
||||||
|
.navigationDestination(item: $route) { route in
|
||||||
|
switch route {
|
||||||
|
case .exports: HealthExportListView()
|
||||||
|
case .reminders: RemindersListView()
|
||||||
|
case .medicationLibrary: MedicationLibraryView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var content: some View {
|
||||||
|
// 聚合(含血压配对 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(.horizontal, 20)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
.padding(.bottom, 14)
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
filterChips
|
if reminderTotal > 0 {
|
||||||
|
reminderBoard
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 药品库入口:始终显示——它是「管理常用药」的浏览/管理目的地,空库时也要能找到来添加。
|
||||||
|
medicationBoard
|
||||||
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 14)
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
if allEntries.isEmpty {
|
filterChips
|
||||||
|
.padding(.bottom, searching ? 10 : 14)
|
||||||
|
|
||||||
|
if searching {
|
||||||
|
searchField
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) {
|
LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) {
|
||||||
ForEach(grouped, id: \.section) { group in
|
ForEach(groups, id: \.section) { group in
|
||||||
Section {
|
Section {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
ForEach(group.items) { entry in
|
ForEach(group.items) { entry in
|
||||||
@@ -71,12 +124,21 @@ struct ArchiveListView: View {
|
|||||||
.sheet(item: $endingSymptom) { sym in
|
.sheet(item: $endingSymptom) { sym in
|
||||||
SymptomEndSheet(symptom: sym)
|
SymptomEndSheet(symptom: sym)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $selectedEntry) { entry in
|
||||||
|
if let d = detail(for: entry) {
|
||||||
|
TimelineEntryDetailView(detail: d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedGroup) { group in
|
||||||
|
IndicatorSeriesDetailView(group: group)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func rowView(for entry: TimelineEntry) -> some View {
|
private func rowView(for entry: TimelineEntry) -> some View {
|
||||||
if entry.kind == .symptom, entry.isOngoing,
|
if entry.kind == .symptom, entry.isOngoing,
|
||||||
let sym = symptoms.first(where: { "symptom-\($0.persistentModelID)" == entry.id }) {
|
let sym = symptoms.first(where: { "symptom-\($0.persistentModelID)" == entry.id }) {
|
||||||
|
// 进行中症状:点 → 标记结束 sheet(沿用原交互)
|
||||||
Button {
|
Button {
|
||||||
endingSymptom = sym
|
endingSymptom = sym
|
||||||
} label: {
|
} label: {
|
||||||
@@ -84,26 +146,224 @@ struct ArchiveListView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
} else {
|
} else {
|
||||||
|
// 其余条目:指标 → 同类聚合详情(横向翻页 + 趋势);报告/日记/已结束症状 → 只读详情
|
||||||
|
Button {
|
||||||
|
guard let d = detail(for: 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)
|
TimelineRow(entry: entry)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
/// 把时间线条目反查回源记录。逻辑统一收敛到 `TimelineDetail.resolve`(主页/档案库共用)。
|
||||||
|
private func detail(for entry: TimelineEntry) -> TimelineDetail? {
|
||||||
|
TimelineDetail.resolve(for: entry,
|
||||||
|
indicators: indicators, reports: reports,
|
||||||
|
diaries: diaries, symptoms: symptoms)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func header(total: Int) -> some View {
|
||||||
HStack(alignment: .lastTextBaseline) {
|
HStack(alignment: .lastTextBaseline) {
|
||||||
Text("记录")
|
Text("记录")
|
||||||
.font(.tjTitle(26))
|
.font(.tjTitle(26))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text(totalCount == 0 ? "" : "\(totalCount) 条")
|
Text(total == 0 ? "" : String(appLoc: "\(total) 条"))
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if !exports.isEmpty {
|
||||||
|
Button { route = .exports } label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
Text("导出历史")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
}
|
}
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(Capsule().fill(Tj.Palette.ink))
|
||||||
|
}
|
||||||
|
.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: - 提醒任务汇总卡
|
||||||
|
|
||||||
|
/// 两类提醒(自由 + 指标记录)合计,含已关闭。
|
||||||
|
private var reminderTotal: Int { customReminders.count + metricReminders.count }
|
||||||
|
private var reminderEnabledCount: Int {
|
||||||
|
customReminders.filter(\.enabled).count + metricReminders.filter(\.enabled).count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按 updatedAt 倒序合并,取前 3 条标题做预览(标题是用户数据,不本地化)。
|
||||||
|
private var reminderTitlePreview: [String] {
|
||||||
|
let merged: [(title: String, at: Date)] =
|
||||||
|
customReminders.map { ($0.title, $0.updatedAt) } +
|
||||||
|
metricReminders.map { ($0.displayName, $0.updatedAt) }
|
||||||
|
return merged.sorted { $0.at > $1.at }.prefix(3).map(\.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var reminderCountLabel: String {
|
||||||
|
reminderEnabledCount == reminderTotal
|
||||||
|
? String(appLoc: "\(reminderTotal) 个提醒任务")
|
||||||
|
: String(appLoc: "\(reminderTotal) 个提醒任务 · \(reminderEnabledCount) 个开启中")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var reminderTitleLine: String {
|
||||||
|
let joined = reminderTitlePreview.joined(separator: " · ")
|
||||||
|
return reminderTotal > reminderTitlePreview.count ? joined + " …" : joined
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点击进提醒中心(RemindersListView)统一管理;卡片本身只展示。
|
||||||
|
private var reminderBoard: some View {
|
||||||
|
Button { route = .reminders } label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(reminderEnabledCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||||
|
Image(systemName: "bell.fill")
|
||||||
|
.font(.tjScaled( 16))
|
||||||
|
.foregroundStyle(reminderEnabledCount > 0 ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(reminderCountLabel)
|
||||||
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
if !reminderTitlePreview.isEmpty {
|
||||||
|
Text(reminderTitleLine)
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 药品库入口卡
|
||||||
|
|
||||||
|
/// 主标题:空库「药品库」,有药「药品库 · N 种常用药」。
|
||||||
|
private var medicationCountLabel: String {
|
||||||
|
medications.isEmpty
|
||||||
|
? String(appLoc: "药品库")
|
||||||
|
: String(appLoc: "药品库 · \(medications.count) 种常用药")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 副标题:空库给引导文案;有药取前 3 个药名预览(药名是用户数据,不本地化)。
|
||||||
|
private var medicationPreviewLine: String {
|
||||||
|
if medications.isEmpty { return String(appLoc: "拍药盒或手动添加常用药") }
|
||||||
|
let names = medications.prefix(3).map(\.name).joined(separator: " · ")
|
||||||
|
return medications.count > 3 ? names + " …" : names
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点击进药品库(MedicationLibraryView,push 形态)统一管理;卡片本身只展示。
|
||||||
|
private var medicationBoard: some View {
|
||||||
|
Button { route = .medicationLibrary } label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(medications.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft)
|
||||||
|
Image(systemName: "pills.fill")
|
||||||
|
.font(.tjScaled( 16))
|
||||||
|
.foregroundStyle(medications.isEmpty ? Tj.Palette.text3 : Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(medicationCountLabel)
|
||||||
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(medicationPreviewLine)
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var filterChips: some View {
|
private var filterChips: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
chip(label: "全部", selected: filter == nil) { filter = nil }
|
chip(label: String(appLoc: "全部"), selected: filter == nil) { filter = nil }
|
||||||
ForEach(TimelineKind.allCases) { kind in
|
ForEach(TimelineKind.allCases) { kind in
|
||||||
chip(label: kind.label, selected: filter == kind) {
|
chip(label: kind.label, selected: filter == kind) {
|
||||||
filter = filter == kind ? nil : kind
|
filter = filter == kind ? nil : kind
|
||||||
@@ -117,7 +377,7 @@ struct ArchiveListView: View {
|
|||||||
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
||||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -134,14 +394,14 @@ struct ArchiveListView: View {
|
|||||||
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
|
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text(section.label)
|
Text(section.label)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.tracking(0.5)
|
.tracking(0.5)
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Tj.Palette.lineSoft)
|
.fill(Tj.Palette.lineSoft)
|
||||||
.frame(height: 1)
|
.frame(height: 1)
|
||||||
Text("\(count)")
|
Text("\(count)")
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -150,13 +410,19 @@ struct ArchiveListView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 14) {
|
let q = query.trimmingCharacters(in: .whitespaces)
|
||||||
|
let isSearchMiss = !q.isEmpty
|
||||||
|
return VStack(spacing: 14) {
|
||||||
Spacer()
|
Spacer()
|
||||||
TjPlaceholder(label: "还没有任何记录\n点底部 + 号开始")
|
TjPlaceholder(label: isSearchMiss
|
||||||
|
? String(appLoc: "没有匹配「\(q)」的记录")
|
||||||
|
: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
|
||||||
.frame(width: 240, height: 140)
|
.frame(width: 240, height: 140)
|
||||||
Text(filter == nil ? "记录会按时间归类显示" : "这个类别下没有记录")
|
if !isSearchMiss {
|
||||||
.font(.system(size: 13))
|
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
|
||||||
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -166,6 +432,8 @@ struct ArchiveListView: View {
|
|||||||
#Preview {
|
#Preview {
|
||||||
ArchiveListView()
|
ArchiveListView()
|
||||||
.modelContainer(for: [
|
.modelContainer(for: [
|
||||||
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self
|
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self,
|
||||||
|
HealthExport.self, ChatTurn.self, UserProfile.self,
|
||||||
|
MetricReminder.self, CustomMonitorMetric.self
|
||||||
], inMemory: true)
|
], inMemory: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct B1GuideView: View {
|
|
||||||
var onSingle: () -> Void
|
|
||||||
var onMulti: () -> Void
|
|
||||||
var onSkip: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
HStack {
|
|
||||||
Button(action: onSkip) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.system(size: 18, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button(action: onSkip) {
|
|
||||||
Text("跳过")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
|
||||||
.fill(Tj.Palette.ink)
|
|
||||||
Image(systemName: "doc.text.fill")
|
|
||||||
.font(.system(size: 26, weight: .medium))
|
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
|
||||||
}
|
|
||||||
.frame(width: 60, height: 60)
|
|
||||||
.padding(.bottom, 18)
|
|
||||||
|
|
||||||
Text("归档一份\n关键报告")
|
|
||||||
.font(.system(size: 30, weight: .bold))
|
|
||||||
.lineSpacing(6)
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
.padding(.bottom, 12)
|
|
||||||
|
|
||||||
Text("推荐拍清晰的\(Text("整张图").underline()),多页报告可一次完成扫描。原图与解读全部本地加密保存,永不上传。")
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
|
||||||
.lineSpacing(6)
|
|
||||||
.padding(.bottom, 26)
|
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
OptCard(title: "单张报告", sub: "一张图,几秒搞定", hint: "化验单 · 处方", badge: nil, action: onSingle)
|
|
||||||
OptCard(title: "多页报告", sub: "像扫描文档一样翻页拍摄", hint: "体检报告 · 影像报告", badge: "推荐", action: onMulti)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 18)
|
|
||||||
|
|
||||||
HStack(alignment: .top, spacing: 10) {
|
|
||||||
Image(systemName: "lock.fill")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
|
||||||
.padding(.top, 2)
|
|
||||||
Text("所有照片以 AES 加密存于本机沙盒。康康 服务端无法访问。")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
|
||||||
.lineSpacing(4)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
||||||
.fill(Tj.Palette.sand2)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.top, 20)
|
|
||||||
.padding(.bottom, 20)
|
|
||||||
}
|
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct OptCard: View {
|
|
||||||
let title: String
|
|
||||||
let sub: String
|
|
||||||
let hint: String
|
|
||||||
let badge: String?
|
|
||||||
let action: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: action) {
|
|
||||||
HStack(spacing: 14) {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
||||||
.fill(Tj.Palette.sand2)
|
|
||||||
Image(systemName: "doc.text")
|
|
||||||
.font(.system(size: 18, weight: .regular))
|
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
|
||||||
}
|
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text(title)
|
|
||||||
.font(.system(size: 15, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
if let badge {
|
|
||||||
TjBadge(text: badge, style: .ink)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text("\(sub) · \(hint)")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.system(size: 14, weight: .medium))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
.tjCard(bordered: true)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
B1GuideView(
|
|
||||||
onSingle: { print("单张报告") },
|
|
||||||
onMulti: { print("多页报告") },
|
|
||||||
onSkip: { print("跳过") }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct B2ScanView: View {
|
|
||||||
var onShoot: () -> Void
|
|
||||||
var onDone: () -> Void
|
|
||||||
var onClose: () -> Void
|
|
||||||
var page: Int = 2
|
|
||||||
var total: Int = 3
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Color(red: 0.04, green: 0.047, blue: 0.04).ignoresSafeArea()
|
|
||||||
|
|
||||||
mockPaper
|
|
||||||
|
|
||||||
DetectedEdge()
|
|
||||||
.stroke(Color(red: 0.95, green: 0.78, blue: 0.45),
|
|
||||||
style: StrokeStyle(lineWidth: 2, dash: [6, 4]))
|
|
||||||
.opacity(0.95)
|
|
||||||
.padding(.horizontal, 30)
|
|
||||||
.padding(.top, 140)
|
|
||||||
.padding(.bottom, 200)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
topBar
|
|
||||||
Spacer()
|
|
||||||
detectedBadge
|
|
||||||
Spacer()
|
|
||||||
thumbnails
|
|
||||||
bottomControls
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var mockPaper: some View {
|
|
||||||
VStack(spacing: 2) {
|
|
||||||
Text("体 检 报 告 (第 \(page) 页)")
|
|
||||||
.font(.system(size: 12, weight: .bold))
|
|
||||||
.padding(.bottom, 4)
|
|
||||||
ForEach(reportRows, id: \.0) { row in
|
|
||||||
HStack {
|
|
||||||
Text(row.0).frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
Text(row.1)
|
|
||||||
Text(row.2).foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
.font(.system(size: 9, design: .monospaced))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background(Color(red: 0.97, green: 0.95, blue: 0.89).opacity(0.95))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
|
|
||||||
.rotation3DEffect(.degrees(8), axis: (x: 1, y: 0, z: 0))
|
|
||||||
.rotationEffect(.degrees(-1))
|
|
||||||
.shadow(color: .black.opacity(0.6), radius: 20, x: 0, y: 12)
|
|
||||||
.padding(.horizontal, 40)
|
|
||||||
.padding(.top, 160)
|
|
||||||
.padding(.bottom, 220)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var reportRows: [(String, String, String)] {
|
|
||||||
[
|
|
||||||
("总胆固醇", "5.42", "3.10–5.18"),
|
|
||||||
("甘油三酯", "1.78", "0.45–1.70"),
|
|
||||||
("低密度脂蛋白", "3.84↑", "<3.40"),
|
|
||||||
("高密度脂蛋白", "1.21", ">1.04"),
|
|
||||||
("载脂蛋白 A1", "1.42", "1.00–1.60"),
|
|
||||||
("载脂蛋白 B", "1.04", "0.55–1.05"),
|
|
||||||
("谷丙转氨酶", "28", "9–50"),
|
|
||||||
("谷草转氨酶", "24", "15–40"),
|
|
||||||
("空腹血糖", "5.4", "3.9–6.1"),
|
|
||||||
("糖化血红蛋白", "5.7", "4.0–6.0"),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
private var topBar: some View {
|
|
||||||
HStack {
|
|
||||||
Button(action: onClose) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.system(size: 18, weight: .semibold))
|
|
||||||
.foregroundStyle(Color.white)
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text("\(page)").font(.system(size: 12, design: .monospaced))
|
|
||||||
Text(" / \(total) · 像扫描文档一样对准")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
}
|
|
||||||
.foregroundStyle(Color.white)
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(Capsule().fill(Color(red: 0.08, green: 0.11, blue: 0.094).opacity(0.7)))
|
|
||||||
Spacer()
|
|
||||||
Color.clear.frame(width: 36, height: 36)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.top, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var detectedBadge: some View {
|
|
||||||
Text("已识别边框 · 将自动透视校正")
|
|
||||||
.font(.system(size: 10, weight: .semibold))
|
|
||||||
.tracking(0.4)
|
|
||||||
.foregroundStyle(Color(red: 0.10, green: 0.115, blue: 0.094))
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(Capsule().fill(Color(red: 0.95, green: 0.78, blue: 0.45)))
|
|
||||||
.padding(.top, 140)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var thumbnails: some View {
|
|
||||||
HStack {
|
|
||||||
PageThumbStack(index: 1)
|
|
||||||
Spacer()
|
|
||||||
Text("已拍 1 页")
|
|
||||||
.font(.system(size: 11, design: .monospaced))
|
|
||||||
.foregroundStyle(Color.white.opacity(0.7))
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 18)
|
|
||||||
.padding(.bottom, 24)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var bottomControls: some View {
|
|
||||||
HStack {
|
|
||||||
Color.clear.frame(width: 60, height: 60)
|
|
||||||
Spacer()
|
|
||||||
Button(action: onShoot) {
|
|
||||||
ZStack {
|
|
||||||
Circle().fill(Tj.Palette.paper)
|
|
||||||
Circle().strokeBorder(Color.white.opacity(0.4), lineWidth: 4)
|
|
||||||
}
|
|
||||||
.frame(width: 72, height: 72)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
Spacer()
|
|
||||||
Button(action: onDone) {
|
|
||||||
Text("完成")
|
|
||||||
.font(.system(size: 14, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(Capsule().fill(Color.white.opacity(0.1)))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 32)
|
|
||||||
.padding(.bottom, 40)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct DetectedEdge: Shape {
|
|
||||||
func path(in rect: CGRect) -> Path {
|
|
||||||
var p = Path()
|
|
||||||
let w = rect.width
|
|
||||||
let h = rect.height
|
|
||||||
p.move(to: CGPoint(x: w * 0.04, y: h * 0.05))
|
|
||||||
p.addLine(to: CGPoint(x: w * 0.92, y: h * 0.02))
|
|
||||||
p.addLine(to: CGPoint(x: w * 0.96, y: h * 0.96))
|
|
||||||
p.addLine(to: CGPoint(x: 0, y: h * 1.0))
|
|
||||||
p.closeSubpath()
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PageThumbStack: View {
|
|
||||||
let index: Int
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
|
||||||
.fill(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.7))
|
|
||||||
.frame(width: 56, height: 76)
|
|
||||||
.rotationEffect(.degrees(2))
|
|
||||||
.offset(x: 4, y: 4)
|
|
||||||
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2)
|
|
||||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
|
||||||
.fill(Color(red: 0.97, green: 0.95, blue: 0.89).opacity(0.85))
|
|
||||||
.frame(width: 56, height: 76)
|
|
||||||
.rotationEffect(.degrees(-1))
|
|
||||||
.offset(x: 2, y: 2)
|
|
||||||
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2)
|
|
||||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
|
||||||
.fill(Tj.Palette.paper)
|
|
||||||
.frame(width: 56, height: 76)
|
|
||||||
.overlay(
|
|
||||||
Text("p.\(index)")
|
|
||||||
.font(.system(size: 10, design: .monospaced))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
)
|
|
||||||
.shadow(color: .black.opacity(0.4), radius: 4, x: 0, y: 2)
|
|
||||||
}
|
|
||||||
.frame(width: 64, height: 84, alignment: .topLeading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct B3MetaView: View {
|
|
||||||
var onAnalyze: () -> Void
|
|
||||||
var onBack: () -> Void
|
|
||||||
|
|
||||||
@State private var selectedType = 0
|
|
||||||
private let types = ["体检报告", "化验单", "影像报告", "处方", "其他"]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
header
|
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
Text("报告类型")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.tracking(0.5)
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
|
|
||||||
typeChips.padding(.bottom, 20)
|
|
||||||
|
|
||||||
FormRow(label: "报告日期", value: "2026 / 05 / 25", subtle: false)
|
|
||||||
FormRow(label: "出具机构", value: "协和医院体检中心", subtle: true)
|
|
||||||
FormRow(label: "备注", value: "春季年度体检", subtle: true)
|
|
||||||
|
|
||||||
Text("已拍页面(3 页)")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.tracking(0.5)
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
.padding(.top, 20)
|
|
||||||
.padding(.bottom, 10)
|
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
ForEach(1...3, id: \.self) { n in
|
|
||||||
PageCard(index: n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 18)
|
|
||||||
.padding(.bottom, 18)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
Button(action: onAnalyze) {
|
|
||||||
Text("开始 AI 解读").frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(TjPrimaryButton())
|
|
||||||
|
|
||||||
Text("预计耗时 5–8 秒 · 端侧 SME2 加速")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 18)
|
|
||||||
.padding(.bottom, 14)
|
|
||||||
}
|
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
|
||||||
}
|
|
||||||
|
|
||||||
private var header: some View {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Button(action: onBack) {
|
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
.font(.system(size: 18, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
}
|
|
||||||
Text("归档信息")
|
|
||||||
.font(.system(size: 15, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.top, 4)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var typeChips: some View {
|
|
||||||
let columns = [GridItem(.adaptive(minimum: 60, maximum: 200), spacing: 8)]
|
|
||||||
return LazyVGrid(columns: columns, alignment: .leading, spacing: 8) {
|
|
||||||
ForEach(Array(types.enumerated()), id: \.offset) { idx, t in
|
|
||||||
Button { selectedType = idx } label: {
|
|
||||||
Text(t)
|
|
||||||
.font(.system(size: 12, weight: .medium))
|
|
||||||
.foregroundStyle(idx == selectedType ? Tj.Palette.paper : Tj.Palette.text2)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(
|
|
||||||
Capsule().fill(idx == selectedType ? Tj.Palette.ink : Tj.Palette.sand2)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct FormRow: View {
|
|
||||||
let label: String
|
|
||||||
let value: String
|
|
||||||
let subtle: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text2)
|
|
||||||
Spacer()
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Text(value)
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundStyle(subtle ? Tj.Palette.text3 : Tj.Palette.text)
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.system(size: 11, weight: .medium))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.overlay(alignment: .top) {
|
|
||||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct PageCard: View {
|
|
||||||
let index: Int
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
||||||
.fill(Tj.Palette.paper)
|
|
||||||
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06),
|
|
||||||
radius: 2, x: 0, y: 1)
|
|
||||||
TjPlaceholder(label: "p.\(index)", radius: 4)
|
|
||||||
.padding(6)
|
|
||||||
}
|
|
||||||
.aspectRatio(0.72, contentMode: .fit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct B4ProgressView: View {
|
|
||||||
var onComplete: () -> Void
|
|
||||||
|
|
||||||
@State private var step: Int = 1
|
|
||||||
@State private var pulse = false
|
|
||||||
@State private var glow = false
|
|
||||||
@State private var rotate: Double = 0
|
|
||||||
@State private var elapsed: Double = 0.2
|
|
||||||
|
|
||||||
private let lineLabels = [
|
|
||||||
"正在本地识别第 1 / 3 页…",
|
|
||||||
"正在本地识别第 2 / 3 页…",
|
|
||||||
"正在本地识别第 3 / 3 页…",
|
|
||||||
"提取指标 · 共 28 项",
|
|
||||||
"生成整体摘要…",
|
|
||||||
]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
backgroundGradient.ignoresSafeArea()
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
Spacer()
|
|
||||||
chip.padding(.bottom, 36)
|
|
||||||
|
|
||||||
Text("本地 AI · 正在解读")
|
|
||||||
.font(.system(size: 22, weight: .semibold))
|
|
||||||
.tracking(1)
|
|
||||||
.foregroundStyle(Color.white.opacity(0.95))
|
|
||||||
.padding(.bottom, 6)
|
|
||||||
|
|
||||||
Text("QWEN2.5-VL · ON-DEVICE · SME2")
|
|
||||||
.font(.system(size: 11, design: .monospaced))
|
|
||||||
.tracking(0.5)
|
|
||||||
.foregroundStyle(Color.white.opacity(0.55))
|
|
||||||
.padding(.bottom, 30)
|
|
||||||
|
|
||||||
lineList
|
|
||||||
.padding(.horizontal, 28)
|
|
||||||
|
|
||||||
speedBadge.padding(.top, 32)
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text("本地处理中 · 不会上传任何内容")
|
|
||||||
.font(.system(size: 10, design: .monospaced))
|
|
||||||
.tracking(0.5)
|
|
||||||
.foregroundStyle(Color.white.opacity(0.45))
|
|
||||||
.padding(.bottom, 30)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 28)
|
|
||||||
}
|
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.onAppear { startAnimations() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var backgroundGradient: some View {
|
|
||||||
RadialGradient(
|
|
||||||
colors: [
|
|
||||||
Color(red: 0.22, green: 0.21, blue: 0.18),
|
|
||||||
Color(red: 0.13, green: 0.12, blue: 0.10),
|
|
||||||
Color(red: 0.08, green: 0.075, blue: 0.06),
|
|
||||||
],
|
|
||||||
center: .init(x: 0.5, y: 0.3),
|
|
||||||
startRadius: 60,
|
|
||||||
endRadius: 700
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var chip: some View {
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(Color(red: 0.93, green: 0.75, blue: 0.40).opacity(glow ? 0.18 : 0.0))
|
|
||||||
.frame(width: 176, height: 176)
|
|
||||||
.blur(radius: 30)
|
|
||||||
|
|
||||||
Circle()
|
|
||||||
.strokeBorder(Color.white.opacity(0.18),
|
|
||||||
style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
|
|
||||||
.frame(width: 140, height: 140)
|
|
||||||
.rotationEffect(.degrees(rotate))
|
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color(red: 0.36, green: 0.34, blue: 0.30),
|
|
||||||
Color(red: 0.22, green: 0.21, blue: 0.18)],
|
|
||||||
startPoint: .topLeading, endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
||||||
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.frame(width: 96, height: 96)
|
|
||||||
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 12)
|
|
||||||
.overlay(ChipGlyph())
|
|
||||||
.overlay(alignment: .topTrailing) {
|
|
||||||
Circle()
|
|
||||||
.fill(Color(red: 0.95, green: 0.78, blue: 0.40))
|
|
||||||
.frame(width: 6, height: 6)
|
|
||||||
.opacity(pulse ? 1 : 0.35)
|
|
||||||
.shadow(color: Color(red: 0.95, green: 0.78, blue: 0.40), radius: 6)
|
|
||||||
.padding(10)
|
|
||||||
}
|
|
||||||
.scaleEffect(pulse ? 1.06 : 1.0)
|
|
||||||
.opacity(pulse ? 0.92 : 1.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lineList: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
ForEach(Array(lineLabels.enumerated()), id: \.offset) { idx, label in
|
|
||||||
LineRow(
|
|
||||||
text: label,
|
|
||||||
done: step > idx + 1,
|
|
||||||
active: step == idx + 1,
|
|
||||||
isLast: idx == lineLabels.count - 1
|
|
||||||
)
|
|
||||||
.opacity(step >= idx + 1 ? 1 : 0)
|
|
||||||
.offset(y: step >= idx + 1 ? 0 : 6)
|
|
||||||
.animation(.easeOut(duration: 0.4).delay(Double(idx) * 0.05), value: step)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var speedBadge: some View {
|
|
||||||
Text(String(format: "已处理 %.1fs · 比云端快 4.2×", elapsed))
|
|
||||||
.font(.system(size: 10, design: .monospaced))
|
|
||||||
.tracking(0.6)
|
|
||||||
.foregroundStyle(Color.white.opacity(0.75))
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(Capsule().fill(Color.white.opacity(0.08)))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startAnimations() {
|
|
||||||
withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) {
|
|
||||||
pulse.toggle()
|
|
||||||
}
|
|
||||||
withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) {
|
|
||||||
glow.toggle()
|
|
||||||
}
|
|
||||||
withAnimation(.linear(duration: 14).repeatForever(autoreverses: false)) {
|
|
||||||
rotate = 360
|
|
||||||
}
|
|
||||||
|
|
||||||
Task {
|
|
||||||
for _ in 0..<lineLabels.count {
|
|
||||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
|
||||||
await MainActor.run {
|
|
||||||
withAnimation { step += 1 }
|
|
||||||
elapsed += 0.9
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try? await Task.sleep(nanoseconds: 600_000_000)
|
|
||||||
await MainActor.run { onComplete() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct LineRow: View {
|
|
||||||
let text: String
|
|
||||||
let done: Bool
|
|
||||||
let active: Bool
|
|
||||||
let isLast: Bool
|
|
||||||
|
|
||||||
@State private var dotPulse = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(done
|
|
||||||
? Color(red: 0.95, green: 0.78, blue: 0.40)
|
|
||||||
: Color.white.opacity(0.12))
|
|
||||||
if done {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
.font(.system(size: 8, weight: .bold))
|
|
||||||
.foregroundStyle(Color(red: 0.10, green: 0.115, blue: 0.094))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 14, height: 14)
|
|
||||||
|
|
||||||
Text(text)
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundStyle(done ? Color.white.opacity(0.95) : Color.white.opacity(0.45))
|
|
||||||
|
|
||||||
if active {
|
|
||||||
Spacer()
|
|
||||||
Text("···")
|
|
||||||
.font(.system(size: 10, design: .monospaced))
|
|
||||||
.foregroundStyle(Color.white.opacity(dotPulse ? 0.9 : 0.4))
|
|
||||||
.onAppear {
|
|
||||||
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
|
|
||||||
dotPulse.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ChipGlyph: View {
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
|
||||||
.strokeBorder(Color.white.opacity(0.8), lineWidth: 1.4)
|
|
||||||
.frame(width: 28, height: 28)
|
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
|
||||||
.fill(Color(red: 0.95, green: 0.78, blue: 0.40).opacity(0.35))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
|
||||||
.strokeBorder(Color(red: 0.95, green: 0.78, blue: 0.40), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.frame(width: 16, height: 16)
|
|
||||||
|
|
||||||
innerCross
|
|
||||||
outerPins
|
|
||||||
}
|
|
||||||
.frame(width: 56, height: 56)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var innerCross: some View {
|
|
||||||
Canvas { ctx, size in
|
|
||||||
let amber = Color(red: 0.95, green: 0.78, blue: 0.40)
|
|
||||||
let stroke = GraphicsContext.Shading.color(amber)
|
|
||||||
let cx = size.width / 2
|
|
||||||
let cy = size.height / 2
|
|
||||||
|
|
||||||
let pairs: [(CGPoint, CGPoint)] = [
|
|
||||||
(CGPoint(x: cx, y: cy - 8), CGPoint(x: cx, y: cy - 4)),
|
|
||||||
(CGPoint(x: cx, y: cy + 4), CGPoint(x: cx, y: cy + 8)),
|
|
||||||
(CGPoint(x: cx - 8, y: cy), CGPoint(x: cx - 4, y: cy)),
|
|
||||||
(CGPoint(x: cx + 4, y: cy), CGPoint(x: cx + 8, y: cy)),
|
|
||||||
]
|
|
||||||
for (s, e) in pairs {
|
|
||||||
var p = Path()
|
|
||||||
p.move(to: s)
|
|
||||||
p.addLine(to: e)
|
|
||||||
ctx.stroke(p, with: stroke, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 56, height: 56)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var outerPins: some View {
|
|
||||||
Canvas { ctx, size in
|
|
||||||
let pinColor = GraphicsContext.Shading.color(Color.white.opacity(0.45))
|
|
||||||
let cx = size.width / 2
|
|
||||||
let cy = size.height / 2
|
|
||||||
let halfChip: CGFloat = 14
|
|
||||||
let outsideStart: CGFloat = 20
|
|
||||||
let outsideEnd: CGFloat = 26
|
|
||||||
|
|
||||||
let positions: [CGFloat] = [-8, 0, 8]
|
|
||||||
|
|
||||||
for offset in positions {
|
|
||||||
// top
|
|
||||||
var p = Path()
|
|
||||||
p.move(to: CGPoint(x: cx + offset, y: cy - outsideEnd))
|
|
||||||
p.addLine(to: CGPoint(x: cx + offset, y: cy - halfChip))
|
|
||||||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
|
||||||
|
|
||||||
// bottom
|
|
||||||
p = Path()
|
|
||||||
p.move(to: CGPoint(x: cx + offset, y: cy + halfChip))
|
|
||||||
p.addLine(to: CGPoint(x: cx + offset, y: cy + outsideEnd))
|
|
||||||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
|
||||||
|
|
||||||
// left
|
|
||||||
p = Path()
|
|
||||||
p.move(to: CGPoint(x: cx - outsideEnd, y: cy + offset))
|
|
||||||
p.addLine(to: CGPoint(x: cx - halfChip, y: cy + offset))
|
|
||||||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
|
||||||
|
|
||||||
// right
|
|
||||||
p = Path()
|
|
||||||
p.move(to: CGPoint(x: cx + halfChip, y: cy + offset))
|
|
||||||
p.addLine(to: CGPoint(x: cx + outsideStart + 2, y: cy + offset))
|
|
||||||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 56, height: 56)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
B4ProgressView(onComplete: {})
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct B5IndicatorData {
|
|
||||||
let name: String
|
|
||||||
let value: String
|
|
||||||
let unit: String
|
|
||||||
let range: String
|
|
||||||
let status: IndicatorStatus
|
|
||||||
let note: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct B5ResultView: View {
|
|
||||||
var onSave: () -> Void
|
|
||||||
var onBack: () -> Void
|
|
||||||
|
|
||||||
@State private var expandedIndex: Int? = 0
|
|
||||||
@State private var normalsExpanded = false
|
|
||||||
|
|
||||||
let abnormal: [B5IndicatorData] = [
|
|
||||||
.init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high,
|
|
||||||
note: "超过参考上限 0.44。建议关注饮食结构,3 个月内复查。"),
|
|
||||||
.init(name: "甘油三酯 TG", value: "1.78", unit: "mmol/L", range: "0.45–1.70", status: .high, note: nil),
|
|
||||||
.init(name: "尿酸 UA", value: "428", unit: "μmol/L", range: "150–420", status: .high, note: nil),
|
|
||||||
.init(name: "维生素 D", value: "18", unit: "ng/mL", range: "30–100", status: .low, note: nil),
|
|
||||||
]
|
|
||||||
let normalCount = 24
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
header
|
|
||||||
|
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
reportMeta.padding(.bottom, 16)
|
|
||||||
summaryCard.padding(.bottom, 18)
|
|
||||||
SectionLabel("异常项", count: abnormal.count, accent: .brick)
|
|
||||||
.padding(.bottom, 10)
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
ForEach(Array(abnormal.enumerated()), id: \.offset) { idx, it in
|
|
||||||
IndicatorRow(item: it, expanded: expandedIndex == idx) {
|
|
||||||
withAnimation { expandedIndex = (expandedIndex == idx) ? nil : idx }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.bottom, 18)
|
|
||||||
|
|
||||||
SectionLabel("正常项", count: normalCount, accent: .leaf)
|
|
||||||
.padding(.bottom, 10)
|
|
||||||
normalCollapsed
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 18)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Button(action: onSave) {
|
|
||||||
Text("保存归档").frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(TjPrimaryButton())
|
|
||||||
|
|
||||||
Button { } label: {
|
|
||||||
Image(systemName: "square.and.arrow.up")
|
|
||||||
.font(.system(size: 16, weight: .semibold))
|
|
||||||
}
|
|
||||||
.buttonStyle(TjGhostButton(horizontalPadding: 16))
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 18)
|
|
||||||
.padding(.bottom, 14)
|
|
||||||
.padding(.top, 10)
|
|
||||||
}
|
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
|
||||||
}
|
|
||||||
|
|
||||||
private var header: some View {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Button(action: onBack) {
|
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
.font(.system(size: 18, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button { } label: {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "photo")
|
|
||||||
Text("查看原图")
|
|
||||||
}
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var reportMeta: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
TjBadge(text: "体检报告", style: .ink)
|
|
||||||
Text("3 页")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
Spacer()
|
|
||||||
TjLockChip()
|
|
||||||
}
|
|
||||||
Text("2026 春季年度体检")
|
|
||||||
.font(.system(size: 22, weight: .bold))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
Text("2026 / 05 / 25 · 协和医院体检中心")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var summaryCard: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Text("整体摘记")
|
|
||||||
.font(.system(size: 12, weight: .semibold))
|
|
||||||
.tracking(0.3)
|
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
|
||||||
.fixedSize()
|
|
||||||
Rectangle().fill(Tj.Palette.line).frame(height: 1)
|
|
||||||
Text("本机摘要")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
.fixedSize()
|
|
||||||
}
|
|
||||||
.padding(.bottom, 12)
|
|
||||||
|
|
||||||
HStack(spacing: 14) {
|
|
||||||
Stat(n: "28", label: "总项")
|
|
||||||
Stat(n: "3", label: "偏高", tone: .brick)
|
|
||||||
Stat(n: "1", label: "偏低", tone: .amber)
|
|
||||||
Stat(n: "24", label: "正常", tone: .leaf)
|
|
||||||
}
|
|
||||||
.padding(.bottom, 14)
|
|
||||||
|
|
||||||
Text("本次共检测 28 项,\(Text("3 项偏高").fontWeight(.semibold).underline(color: Tj.Palette.brick))(血脂相关 2 项 + 尿酸)、\(Text("1 项偏低").fontWeight(.semibold).underline(color: Tj.Palette.amber))(维生素 D)。整体趋势提示代谢风险有所抬升,建议优化饮食并复查血脂。")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
.lineSpacing(6)
|
|
||||||
.padding(.bottom, 12)
|
|
||||||
|
|
||||||
TjDashedDivider().padding(.bottom, 10)
|
|
||||||
|
|
||||||
Text("仅供参考,不构成医疗建议")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.italic()
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
.padding(.leading, 20)
|
|
||||||
.padding(.trailing, 20)
|
|
||||||
.padding(.vertical, 20)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
Tj.Palette.paper
|
|
||||||
.overlay(alignment: .leading) {
|
|
||||||
Tj.Palette.brick.frame(width: 3)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous))
|
|
||||||
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06), radius: 0, x: 0, y: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var normalCollapsed: some View {
|
|
||||||
Button { withAnimation { normalsExpanded.toggle() } } label: {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
TjBadge(text: "\(normalCount)", style: .leaf)
|
|
||||||
Text("谷丙转氨酶、空腹血糖、糖化血红蛋白…")
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
|
||||||
.lineLimit(1)
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: normalsExpanded ? "chevron.up" : "chevron.down")
|
|
||||||
.font(.system(size: 12, weight: .medium))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.tjCard(bordered: true)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct Stat: View {
|
|
||||||
let n: String
|
|
||||||
let label: String
|
|
||||||
var tone: Tone = .ink
|
|
||||||
|
|
||||||
enum Tone { case ink, brick, amber, leaf }
|
|
||||||
|
|
||||||
var color: Color {
|
|
||||||
switch tone {
|
|
||||||
case .ink: return Tj.Palette.text
|
|
||||||
case .brick: return Tj.Palette.brick
|
|
||||||
case .amber: return Color(red: 0.59, green: 0.45, blue: 0.27)
|
|
||||||
case .leaf: return Tj.Palette.leaf
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(n)
|
|
||||||
.font(.system(size: 24, weight: .semibold))
|
|
||||||
.foregroundStyle(color)
|
|
||||||
Text(label)
|
|
||||||
.font(.system(size: 10))
|
|
||||||
.tracking(0.5)
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SectionLabel: View {
|
|
||||||
let title: String
|
|
||||||
let count: Int
|
|
||||||
let accent: AccentKind
|
|
||||||
|
|
||||||
enum AccentKind { case brick, leaf }
|
|
||||||
|
|
||||||
init(_ title: String, count: Int, accent: AccentKind) {
|
|
||||||
self.title = title
|
|
||||||
self.count = count
|
|
||||||
self.accent = accent
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
|
||||||
.fill(accent == .brick ? Tj.Palette.brick : Tj.Palette.leaf)
|
|
||||||
.frame(width: 4, height: 14)
|
|
||||||
Text(title).font(.system(size: 13, weight: .semibold)).foregroundStyle(Tj.Palette.text)
|
|
||||||
Text("· \(count)").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct IndicatorRow: View {
|
|
||||||
let item: B5IndicatorData
|
|
||||||
let expanded: Bool
|
|
||||||
let onTap: () -> Void
|
|
||||||
|
|
||||||
var statusBadge: TjBadgeStyle {
|
|
||||||
switch item.status {
|
|
||||||
case .high: return .brick
|
|
||||||
case .low: return .amber
|
|
||||||
case .normal: return .leaf
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var statusWord: String {
|
|
||||||
switch item.status {
|
|
||||||
case .high: return "偏高"
|
|
||||||
case .low: return "偏低"
|
|
||||||
case .normal: return "正常"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var valueColor: Color {
|
|
||||||
switch item.status {
|
|
||||||
case .high: return Tj.Palette.brick
|
|
||||||
case .low: return Color(red: 0.55, green: 0.45, blue: 0.32)
|
|
||||||
case .normal: return Tj.Palette.text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: onTap) {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
HStack(alignment: .top, spacing: 12) {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text(item.name)
|
|
||||||
.font(.system(size: 14, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
.lineLimit(1)
|
|
||||||
TjBadge(text: statusWord, style: statusBadge)
|
|
||||||
}
|
|
||||||
Text("范围 \(item.range) \(item.unit)")
|
|
||||||
.font(.system(size: 11, design: .monospaced))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
Spacer(minLength: 8)
|
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
|
||||||
Text(item.value)
|
|
||||||
.font(.system(size: 22, weight: .semibold))
|
|
||||||
.foregroundStyle(valueColor)
|
|
||||||
Text(item.unit)
|
|
||||||
.font(.system(size: 10, design: .monospaced))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if expanded, let note = item.note {
|
|
||||||
TjDashedDivider()
|
|
||||||
Text(note)
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
|
||||||
.lineSpacing(5)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
|
||||||
.fill(Tj.Palette.paper)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
|
||||||
.strokeBorder(
|
|
||||||
item.status != .normal
|
|
||||||
? Color(red: 0.78, green: 0.68, blue: 0.48).opacity(0.5)
|
|
||||||
: Tj.Palette.lineSoft,
|
|
||||||
lineWidth: 1
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
192
康康/Features/Archive/HealthExportDetailView.swift
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 单条「导出身体档案」详情。只读 Markdown + 复制 / 分享 / 删除。
|
||||||
|
struct HealthExportDetailView: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let export: HealthExport
|
||||||
|
|
||||||
|
@State private var copiedFlash: Bool = false
|
||||||
|
@State private var showDeleteConfirm = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
metaBar
|
||||||
|
promptBlock
|
||||||
|
MarkdownView(text: export.content)
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
|
||||||
|
AIDisclaimerFooter()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
actionRow
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.alert("永久删除这份导出?", isPresented: $showDeleteConfirm) {
|
||||||
|
Button("删除", role: .destructive) {
|
||||||
|
ctx.delete(export)
|
||||||
|
try? ctx.save()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
Button("取消", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("删除后无法恢复。源记录(指标、症状等)不受影响。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .center, 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))
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("身体档案 · 历史导出")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(Self.absoluteDate(export.createdAt))
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
TjLockChip()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(Tj.Palette.sand)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var metaBar: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
TjBadge(text: export.modelTag, style: .neutral)
|
||||||
|
if export.decodeRate > 0 {
|
||||||
|
Text(String(format: "%.1f tok/s", export.decodeRate))
|
||||||
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let from = export.inferredTimeFromDate, let to = export.inferredTimeToDate {
|
||||||
|
Text("\(Self.shortDate(from)) — \(Self.shortDate(to))")
|
||||||
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var promptBlock: some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "quote.opening")
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(export.prompt)
|
||||||
|
.font(.tjScaled( 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.sand2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var actionRow: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button { copy() } label: {
|
||||||
|
Label(copiedFlash ? "已复制" : "复制", systemImage: copiedFlash ? "checkmark" : "doc.on.doc")
|
||||||
|
}
|
||||||
|
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
|
||||||
|
|
||||||
|
ShareLink(item: AIDisclaimer.appended(to: export.content)) {
|
||||||
|
Label("分享", systemImage: "square.and.arrow.up")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.tracking(1)
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1))
|
||||||
|
.contentShape(Capsule()) // 纯描边胶囊:内边距区也可点
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
showDeleteConfirm = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.font(.tjScaled( 15, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(Circle().strokeBorder(Tj.Palette.brick.opacity(0.4), lineWidth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Tj.Palette.paper)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copy() {
|
||||||
|
UIPasteboard.general.string = AIDisclaimer.appended(to: export.content)
|
||||||
|
copiedFlash = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
|
||||||
|
copiedFlash = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func absoluteDate(_ d: Date) -> String {
|
||||||
|
d.formatted(.dateTime.year().month().day().hour().minute())
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shortDate(_ d: Date) -> String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.dateFormat = "MM-dd"
|
||||||
|
return f.string(from: d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let exp = HealthExport(
|
||||||
|
prompt: "我感冒3天了,把最近一个月的健康情况给医生看",
|
||||||
|
content: """
|
||||||
|
# 就诊摘要 — 感冒就诊
|
||||||
|
|
||||||
|
## 主诉
|
||||||
|
本人男,38 岁,感冒 3 天未愈。
|
||||||
|
|
||||||
|
## 本人背景
|
||||||
|
- 高血压 2 年
|
||||||
|
- 在服药:缬沙坦 80mg qd
|
||||||
|
""",
|
||||||
|
inferredTimeFromDate: Calendar.current.date(byAdding: .day, value: -30, to: .now),
|
||||||
|
inferredTimeToDate: .now,
|
||||||
|
inferredIntent: "cold_consult",
|
||||||
|
decodeRate: 24.3
|
||||||
|
)
|
||||||
|
return HealthExportDetailView(export: exp)
|
||||||
|
}
|
||||||
137
康康/Features/Archive/HealthExportListView.swift
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 「我的导出」全部历史列表。从 ArchiveListView 顶部 strip 的「查看全部」进入。
|
||||||
|
struct HealthExportListView: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Query(sort: \HealthExport.createdAt, order: .reverse)
|
||||||
|
private var exports: [HealthExport]
|
||||||
|
|
||||||
|
@State private var selected: HealthExport?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
header
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
|
if exports.isEmpty {
|
||||||
|
empty
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 12) {
|
||||||
|
ForEach(exports) { exp in
|
||||||
|
Button {
|
||||||
|
selected = exp
|
||||||
|
} label: {
|
||||||
|
HealthExportRow(export: exp)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
delete(exp)
|
||||||
|
} label: {
|
||||||
|
Label("删除", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationTitle("我的导出")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.sheet(item: $selected) { exp in
|
||||||
|
HealthExportDetailView(export: exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .lastTextBaseline) {
|
||||||
|
Text("我的导出")
|
||||||
|
.font(.tjTitle(24))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(exports.isEmpty ? "" : String(appLoc: "\(exports.count) 份"))
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
TjLockChip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var empty: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Spacer()
|
||||||
|
TjPlaceholder(label: String(appLoc: "还没有导出过\n回到记录页右上角生成一份"))
|
||||||
|
.frame(width: 240, height: 140)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func delete(_ exp: HealthExport) {
|
||||||
|
ctx.delete(exp)
|
||||||
|
try? ctx.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表里一条行。
|
||||||
|
struct HealthExportRow: View {
|
||||||
|
let export: HealthExport
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
Text(export.promptPreview)
|
||||||
|
.font(.tjScaled( 14, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.tjScaled( 12, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(Self.relativeDate(export.createdAt))
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
if export.decodeRate > 0 {
|
||||||
|
Text(String(format: "%.1f tok/s", export.decodeRate))
|
||||||
|
.font(.tjScaled( 10, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let label = export.inferredLabelCN ?? export.inferredIntent {
|
||||||
|
TjBadge(text: label, style: .neutral)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func relativeDate(_ d: Date) -> String {
|
||||||
|
let f = RelativeDateTimeFormatter()
|
||||||
|
f.locale = Locale.current
|
||||||
|
f.unitsStyle = .full
|
||||||
|
return f.localizedString(for: d, relativeTo: .now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
HealthExportListView()
|
||||||
|
}
|
||||||
|
.modelContainer(for: [
|
||||||
|
Indicator.self, Report.self, DiaryEntry.self, Asset.self,
|
||||||
|
ChatTurn.self, Symptom.self, UserProfile.self,
|
||||||
|
MetricReminder.self, CustomMonitorMetric.self, HealthExport.self
|
||||||
|
], inMemory: true)
|
||||||
|
}
|
||||||
841
康康/Features/Archive/HealthExportSheet.swift
Normal file
@@ -0,0 +1,841 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 「导出身体档案」全屏 sheet。
|
||||||
|
/// 状态机:多轮问答 → running(retrieving → generating)→ completed / failed
|
||||||
|
struct HealthExportSheet: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
/// 可选:从历史「重新生成」时传入(暂时未启用,W3 接)。
|
||||||
|
let initialPrompt: String
|
||||||
|
|
||||||
|
@State private var turns: [HealthExportDialogueTurn] = []
|
||||||
|
@State private var draftQuestion: String = ""
|
||||||
|
@State private var phase: HealthExportService.Phase?
|
||||||
|
@State private var content: String = ""
|
||||||
|
@State private var rate: Double = 0
|
||||||
|
@State private var task: Task<Void, Never>?
|
||||||
|
@State private var error: Error?
|
||||||
|
@State private var completed: Bool = false
|
||||||
|
@State private var copiedFlash: Bool = false
|
||||||
|
@State private var answeringTurnID: UUID?
|
||||||
|
@State private var retrieval: HealthExportService.RetrievalSummary?
|
||||||
|
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
|
||||||
|
@FocusState private var questionFocused: Bool
|
||||||
|
|
||||||
|
// 快捷问答
|
||||||
|
@State private var promptStore = QuickPromptStore.shared
|
||||||
|
@State private var showAddPrompt = false
|
||||||
|
@State private var newPromptText = ""
|
||||||
|
|
||||||
|
init(initialPrompt: String = "") {
|
||||||
|
self.initialPrompt = initialPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isGeneratingReport: Bool { phase != nil && !completed && error == nil }
|
||||||
|
private var isAnswering: Bool { answeringTurnID != nil }
|
||||||
|
private var canAsk: Bool {
|
||||||
|
!isAnswering &&
|
||||||
|
!isGeneratingReport &&
|
||||||
|
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
/// 已有有效用户对话内容。
|
||||||
|
private var hasUserContent: Bool {
|
||||||
|
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可生成报告:有对话内容,或输入框里有文字(允许跳过多轮对话直接生成)。
|
||||||
|
private var canGenerateReport: Bool {
|
||||||
|
!isAnswering &&
|
||||||
|
!isGeneratingReport &&
|
||||||
|
(hasUserContent || !draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
introSection
|
||||||
|
|
||||||
|
ForEach(turns) { turn in
|
||||||
|
dialogueBubble(turn)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isGeneratingReport { phaseIndicator }
|
||||||
|
|
||||||
|
if !content.isEmpty {
|
||||||
|
reportCard
|
||||||
|
}
|
||||||
|
|
||||||
|
if let err = error { errorRow(err) }
|
||||||
|
Color.clear.frame(height: 1).id("bottom")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
.onChange(of: content) { _, _ in
|
||||||
|
withAnimation(.easeOut(duration: 0.12)) {
|
||||||
|
proxy.scrollTo("bottom", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: turns) { _, _ in
|
||||||
|
withAnimation(.easeOut(duration: 0.12)) {
|
||||||
|
proxy.scrollTo("bottom", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if completed {
|
||||||
|
actionRow
|
||||||
|
} else {
|
||||||
|
composer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.onAppear {
|
||||||
|
if !initialPrompt.isEmpty, draftQuestion.isEmpty, turns.isEmpty {
|
||||||
|
draftQuestion = initialPrompt
|
||||||
|
}
|
||||||
|
questionFocused = true
|
||||||
|
}
|
||||||
|
.onDisappear { task?.cancel() }
|
||||||
|
.alert("添加快捷问答", isPresented: $showAddPrompt) {
|
||||||
|
TextField("输入一句常用问题…", text: $newPromptText)
|
||||||
|
Button("取消", role: .cancel) { newPromptText = "" }
|
||||||
|
Button("添加") {
|
||||||
|
promptStore.add(prompt: newPromptText)
|
||||||
|
newPromptText = ""
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("保存后点一下,就能把这句话填进输入框")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 快捷问答
|
||||||
|
|
||||||
|
/// 内置 + 自定义快捷问答 chip 行;点 chip 填入输入框,末尾「+ 自定义」追加,长按自定义删除。
|
||||||
|
private var quickPromptRow: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(promptStore.all) { p in
|
||||||
|
quickPromptChip(p)
|
||||||
|
}
|
||||||
|
addQuickPromptChip
|
||||||
|
}
|
||||||
|
.padding(.vertical, 1) // 给 chip 描边留出像素,避免被 ScrollView 裁切
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func quickPromptChip(_ p: QuickPrompt) -> some View {
|
||||||
|
Button {
|
||||||
|
draftQuestion = p.prompt
|
||||||
|
questionFocused = true
|
||||||
|
} label: {
|
||||||
|
Text(p.title)
|
||||||
|
.font(.tjScaled( 12, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(Capsule().fill(Tj.Palette.sand2))
|
||||||
|
.overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
if !p.isBuiltin {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
promptStore.delete(p)
|
||||||
|
} label: {
|
||||||
|
Label("删除", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var addQuickPromptChip: some View {
|
||||||
|
Button { showAddPrompt = true } label: {
|
||||||
|
Label("自定义", systemImage: "plus")
|
||||||
|
.font(.tjScaled( 12, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(Capsule().fill(Tj.Palette.paper))
|
||||||
|
.overlay(
|
||||||
|
Capsule().strokeBorder(
|
||||||
|
Tj.Palette.line,
|
||||||
|
style: StrokeStyle(lineWidth: 1, dash: [3])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
Button { close() } label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(Circle().fill(Tj.Palette.sand2))
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("身体档案")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("先问清楚,再整理给医生")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
TjLockChip()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(Tj.Palette.sand)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dialogue
|
||||||
|
|
||||||
|
private var introSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("围绕你的指标和健康日记提问")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
|
||||||
|
quickPromptRow
|
||||||
|
|
||||||
|
Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dialogueBubble(_ turn: HealthExportDialogueTurn) -> some View {
|
||||||
|
let isUser = turn.role == .user
|
||||||
|
return HStack(alignment: .top, spacing: 8) {
|
||||||
|
if isUser { Spacer(minLength: 44) }
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(turn.role.transcriptLabel)
|
||||||
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
|
.foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
|
||||||
|
if !isUser, let summary = turnRetrievals[turn.id] {
|
||||||
|
RetrievalChipsView(summary: summary)
|
||||||
|
}
|
||||||
|
if turn.id == answeringTurnID && turn.text.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(turnRetrievals[turn.id] == nil
|
||||||
|
? "正在查看本地记录…"
|
||||||
|
: "正在根据这些记录回答…")
|
||||||
|
.font(.tjScaled( 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
AIFlowBar()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(turn.text)
|
||||||
|
.font(.tjScaled( 14))
|
||||||
|
.lineSpacing(3)
|
||||||
|
.foregroundStyle(isUser ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: 300, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(isUser ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(isUser ? Color.clear : Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
if !isUser { Spacer(minLength: 44) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var reportCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("整理好的报告")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
MarkdownView(text: content)
|
||||||
|
|
||||||
|
if completed {
|
||||||
|
Divider().background(Tj.Palette.lineSoft)
|
||||||
|
AIDisclaimerFooter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Phase indicator
|
||||||
|
|
||||||
|
private var phaseIndicator: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
phasePill(.extractingIntent)
|
||||||
|
arrow
|
||||||
|
phasePill(.retrieving)
|
||||||
|
arrow
|
||||||
|
phasePill(.generating)
|
||||||
|
}
|
||||||
|
if let retrieval {
|
||||||
|
RetrievalChipsView(summary: retrieval)
|
||||||
|
}
|
||||||
|
if phase == .generating && rate > 0 {
|
||||||
|
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
|
||||||
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
|
} else {
|
||||||
|
Text(phase?.label ?? "")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 计算中:多彩流光线(与日记 AI 辅助同一组件)
|
||||||
|
AIFlowBar().padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func phasePill(_ p: HealthExportService.Phase) -> some View {
|
||||||
|
let active = (p == phase)
|
||||||
|
let done = phaseOrder(p) < phaseOrder(phase ?? .extractingIntent)
|
||||||
|
let fill = active ? Tj.Palette.ink : (done ? Tj.Palette.leaf : Tj.Palette.sand2)
|
||||||
|
let fg = (active || done) ? Tj.Palette.paper : Tj.Palette.text3
|
||||||
|
return Text(p.label)
|
||||||
|
.font(.tjScaled( 11, weight: active ? .semibold : .regular))
|
||||||
|
.foregroundStyle(fg)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Capsule().fill(fill))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var arrow: some View {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.tjScaled( 10, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func phaseOrder(_ p: HealthExportService.Phase) -> Int {
|
||||||
|
switch p {
|
||||||
|
case .extractingIntent: return 0
|
||||||
|
case .retrieving: return 1
|
||||||
|
case .generating: return 2
|
||||||
|
case .completed: return 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Error
|
||||||
|
|
||||||
|
private func errorRow(_ err: Error) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
Text(err.localizedDescription)
|
||||||
|
.font(.tjScaled( 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
}
|
||||||
|
Button { reset() } label: { Text("返回修改") }
|
||||||
|
.buttonStyle(TjGhostButton(height: 40, fontSize: 13))
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.brickSoft.opacity(0.6))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Action row (completed)
|
||||||
|
|
||||||
|
private var actionRow: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button { copy() } label: {
|
||||||
|
Label(copiedFlash ? "已复制" : "复制", systemImage: copiedFlash ? "checkmark" : "doc.on.doc")
|
||||||
|
}
|
||||||
|
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
|
||||||
|
|
||||||
|
ShareLink(item: AIDisclaimer.appended(to: content)) {
|
||||||
|
Label("分享", systemImage: "square.and.arrow.up")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.tracking(1)
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1))
|
||||||
|
.contentShape(Capsule()) // 纯描边胶囊:内边距区也可点
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
Button { regenerate() } label: {
|
||||||
|
Label("重新整理", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 13, horizontalPadding: 16))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Tj.Palette.paper)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var composer: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("写下要整理什么,或先提问补充情况…", text: $draftQuestion, axis: .vertical)
|
||||||
|
.font(.tjScaled( 14))
|
||||||
|
.lineLimit(1...4)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(Tj.Palette.sand2)
|
||||||
|
)
|
||||||
|
.focused($questionFocused)
|
||||||
|
.disabled(isAnswering || isGeneratingReport)
|
||||||
|
|
||||||
|
Button { sendQuestion() } label: {
|
||||||
|
Image(systemName: "arrow.up")
|
||||||
|
.font(.tjScaled( 15, weight: .bold))
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(Circle().fill(canAsk ? Tj.Palette.ink : Tj.Palette.line))
|
||||||
|
}
|
||||||
|
.disabled(!canAsk)
|
||||||
|
.accessibilityLabel("发送问题")
|
||||||
|
}
|
||||||
|
|
||||||
|
if isGeneratingReport {
|
||||||
|
Button { stopGeneration() } label: {
|
||||||
|
Label("停止生成", systemImage: "stop.fill")
|
||||||
|
.font(.tjScaled( 14, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.brick, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else {
|
||||||
|
Button { startReportGeneration() } label: {
|
||||||
|
Label("生成整理报告", systemImage: "doc.text.below.ecg")
|
||||||
|
}
|
||||||
|
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
|
||||||
|
.disabled(!canGenerateReport)
|
||||||
|
.opacity(canGenerateReport ? 1 : 0.45)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Tj.Palette.paper)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func sendQuestion() {
|
||||||
|
let question = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !question.isEmpty, !isAnswering, !isGeneratingReport else { return }
|
||||||
|
draftQuestion = ""
|
||||||
|
questionFocused = false
|
||||||
|
|
||||||
|
let userTurn = HealthExportDialogueTurn.user(question)
|
||||||
|
let assistantTurn = HealthExportDialogueTurn.assistant("")
|
||||||
|
turns.append(userTurn)
|
||||||
|
turns.append(assistantTurn)
|
||||||
|
answeringTurnID = assistantTurn.id
|
||||||
|
|
||||||
|
let conversationForPrompt = turns.filter { $0.id != assistantTurn.id }
|
||||||
|
let stream = HealthExportService.shared.answer(
|
||||||
|
question: question,
|
||||||
|
conversation: conversationForPrompt,
|
||||||
|
in: ctx
|
||||||
|
)
|
||||||
|
task?.cancel()
|
||||||
|
task = Task { @MainActor in
|
||||||
|
do {
|
||||||
|
for try await event in stream {
|
||||||
|
switch event {
|
||||||
|
case .retrieved(let summary):
|
||||||
|
withAnimation(.snappy(duration: 0.25)) {
|
||||||
|
turnRetrievals[assistantTurn.id] = summary
|
||||||
|
}
|
||||||
|
case .token(let chunk):
|
||||||
|
appendToTurn(id: assistantTurn.id, text: chunk.text)
|
||||||
|
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
|
||||||
|
case .phaseChanged, .completed:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
answeringTurnID = nil
|
||||||
|
questionFocused = true
|
||||||
|
} catch {
|
||||||
|
answeringTurnID = nil
|
||||||
|
appendToTurn(id: assistantTurn.id, text: error.localizedDescription)
|
||||||
|
questionFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendToTurn(id: UUID, text: String) {
|
||||||
|
guard let idx = turns.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
turns[idx].text += text
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startReportGeneration() {
|
||||||
|
guard canGenerateReport else { return }
|
||||||
|
questionFocused = false
|
||||||
|
|
||||||
|
// 直接生成:输入框里有文字(快捷问答/手输)就把它作为一条诉求追加进对话,
|
||||||
|
// 不必先走多轮问答 —— 用户点一下「生成报告」即可。
|
||||||
|
let draft = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !draft.isEmpty {
|
||||||
|
turns.append(.user(draft))
|
||||||
|
draftQuestion = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
content = ""
|
||||||
|
rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示
|
||||||
|
error = nil
|
||||||
|
completed = false
|
||||||
|
retrieval = nil
|
||||||
|
phase = .retrieving
|
||||||
|
|
||||||
|
let stream = HealthExportService.shared.export(conversation: turns, in: ctx)
|
||||||
|
task?.cancel()
|
||||||
|
task = Task { @MainActor in
|
||||||
|
do {
|
||||||
|
for try await event in stream {
|
||||||
|
switch event {
|
||||||
|
case .phaseChanged(let ph):
|
||||||
|
phase = ph
|
||||||
|
case .retrieved(let summary):
|
||||||
|
withAnimation(.snappy(duration: 0.25)) { retrieval = summary }
|
||||||
|
case .token(let chunk):
|
||||||
|
content += chunk.text
|
||||||
|
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
|
||||||
|
case .completed:
|
||||||
|
completed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.error = error
|
||||||
|
self.phase = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func regenerate() {
|
||||||
|
completed = false
|
||||||
|
startReportGeneration()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止正在进行的报告生成:取消推理任务,回到可重新生成的干净态(已写的诉求保留在对话里)。
|
||||||
|
private func stopGeneration() {
|
||||||
|
task?.cancel()
|
||||||
|
task = nil
|
||||||
|
phase = nil
|
||||||
|
rate = 0
|
||||||
|
completed = false
|
||||||
|
content = ""
|
||||||
|
retrieval = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reset() {
|
||||||
|
task?.cancel()
|
||||||
|
task = nil
|
||||||
|
phase = nil
|
||||||
|
content = ""
|
||||||
|
rate = 0
|
||||||
|
error = nil
|
||||||
|
completed = false
|
||||||
|
answeringTurnID = nil
|
||||||
|
retrieval = nil
|
||||||
|
turnRetrievals = [:]
|
||||||
|
questionFocused = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copy() {
|
||||||
|
UIPasteboard.general.string = AIDisclaimer.appended(to: content)
|
||||||
|
copiedFlash = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
|
||||||
|
copiedFlash = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func close() {
|
||||||
|
task?.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 检索结果 chips(本地 RAG 可视化)
|
||||||
|
|
||||||
|
/// 生成开始前先把「本地 RAG 找到了什么」演出来:N 条记录 + 记录名 chips。
|
||||||
|
/// 结构化检索(不用 embedding)的天然优势 —— 每条命中都可解释、可展示(§12 卖点 3)。
|
||||||
|
private struct RetrievalChipsView: View {
|
||||||
|
let summary: HealthExportService.RetrievalSummary
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
if summary.totalCount == 0 {
|
||||||
|
Text("本地档案中暂无相关记录,将仅按你的描述整理")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
} else {
|
||||||
|
Text(String(appLoc: "已在本地档案中找到 \(summary.totalCount) 条相关记录"))
|
||||||
|
.font(.tjScaled( 11, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(Array(summary.chips.enumerated()), id: \.offset) { _, chip in
|
||||||
|
Text(chip)
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Capsule().fill(Tj.Palette.sand2))
|
||||||
|
.overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 简易 Markdown 渲染(行级)
|
||||||
|
|
||||||
|
/// 极简 Markdown 渲染器,够给医生看的报告就行。
|
||||||
|
/// 支持: `# 一级`、`## 二级`、`-` 列表、`**粗体**`(走 AttributedString 的 inline 解析)。
|
||||||
|
/// 不支持表格、代码块、链接 —— 报告生成 prompt 也不会让 LLM 输出这些。
|
||||||
|
struct MarkdownView: View {
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let blocks = Self.parse(text)
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
|
||||||
|
renderBlock(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func renderBlock(_ block: Block) -> some View {
|
||||||
|
switch block {
|
||||||
|
case .h1(let s):
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(inline(s))
|
||||||
|
.font(.tjScaled( 22, weight: .bold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Rectangle()
|
||||||
|
.fill(Tj.Palette.ink)
|
||||||
|
.frame(height: 1)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
case .h2(let s):
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
|
||||||
|
.fill(Tj.Palette.brick)
|
||||||
|
.frame(width: 3, height: 16)
|
||||||
|
Text(inline(s))
|
||||||
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
}
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
|
||||||
|
case .bullet(let s):
|
||||||
|
if let abnormalText = Self.extractAbnormal(s) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
Text(inline(abnormalText))
|
||||||
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||||
|
.fill(Tj.Palette.brickSoft.opacity(0.55))
|
||||||
|
)
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
|
||||||
|
.fill(Tj.Palette.brick)
|
||||||
|
.frame(width: 3)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||||
|
Circle()
|
||||||
|
.fill(Tj.Palette.text3)
|
||||||
|
.frame(width: 4, height: 4)
|
||||||
|
.padding(.top, 6)
|
||||||
|
Text(inline(s))
|
||||||
|
.font(.tjScaled( 14))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.leading, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .body(let s):
|
||||||
|
Text(inline(s))
|
||||||
|
.font(.tjScaled( 14))
|
||||||
|
.lineSpacing(3)
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
case .gap:
|
||||||
|
Spacer().frame(height: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 如果 bullet 文本以 ⚠️ 或常见异常关键词开头,返回 strip 掉前缀后的纯文本。
|
||||||
|
/// 否则返回 nil(表示不是异常项)。
|
||||||
|
private static func extractAbnormal(_ s: String) -> String? {
|
||||||
|
let trimmed = s.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.hasPrefix("⚠️") {
|
||||||
|
return trimmed.replacingOccurrences(of: "⚠️", with: "")
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
// 关键词兜底高亮,但排除否定语境(「无异常」「未见偏高」「没有偏低」等),
|
||||||
|
// 否则正常结论会被误标红。判断:信号词前最近 4 字内出现否定词即视为否定。
|
||||||
|
let negations = ["无", "未", "没"]
|
||||||
|
let abnormalSignals = ["偏高", "偏低", "异常", "过高", "过低"]
|
||||||
|
for sig in abnormalSignals {
|
||||||
|
guard let r = trimmed.range(of: sig) else { continue }
|
||||||
|
let window = String(trimmed[..<r.lowerBound].suffix(4))
|
||||||
|
if negations.contains(where: { window.contains($0) }) { continue }
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func inline(_ s: String) -> AttributedString {
|
||||||
|
// **bold** / *italic* / [text](url) 走 AttributedString markdown 解析
|
||||||
|
if let attr = try? AttributedString(
|
||||||
|
markdown: s,
|
||||||
|
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||||
|
) {
|
||||||
|
return attr
|
||||||
|
}
|
||||||
|
return AttributedString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 行级解析
|
||||||
|
|
||||||
|
enum Block {
|
||||||
|
case h1(String)
|
||||||
|
case h2(String)
|
||||||
|
case bullet(String)
|
||||||
|
case body(String)
|
||||||
|
case gap
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parse(_ raw: String) -> [Block] {
|
||||||
|
var out: [Block] = []
|
||||||
|
let lines = raw.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")
|
||||||
|
for line in lines {
|
||||||
|
let t = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if t.isEmpty {
|
||||||
|
// 连续空行折叠成一个 gap
|
||||||
|
if case .gap = out.last { continue }
|
||||||
|
out.append(.gap)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.hasPrefix("# ") {
|
||||||
|
out.append(.h1(String(t.dropFirst(2))))
|
||||||
|
} else if t.hasPrefix("## ") {
|
||||||
|
out.append(.h2(String(t.dropFirst(3))))
|
||||||
|
} else if t.hasPrefix("### ") {
|
||||||
|
out.append(.h2(String(t.dropFirst(4))))
|
||||||
|
} else if t.hasPrefix("- ") || t.hasPrefix("* ") {
|
||||||
|
out.append(.bullet(String(t.dropFirst(2))))
|
||||||
|
} else {
|
||||||
|
out.append(.body(t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("HealthExportSheet · 空状态") {
|
||||||
|
HealthExportSheet()
|
||||||
|
.modelContainer(for: [
|
||||||
|
Indicator.self, Report.self, DiaryEntry.self, Asset.self,
|
||||||
|
ChatTurn.self, Symptom.self, UserProfile.self,
|
||||||
|
MetricReminder.self, CustomMonitorMetric.self, HealthExport.self
|
||||||
|
], inMemory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("MarkdownView · 演示") {
|
||||||
|
ScrollView {
|
||||||
|
MarkdownView(text: """
|
||||||
|
# 就诊摘要 — 感冒就诊
|
||||||
|
|
||||||
|
## 主诉
|
||||||
|
本人男,38 岁,感冒 3 天未愈,主诉鼻塞、咳嗽、低烧。
|
||||||
|
|
||||||
|
## 本人背景
|
||||||
|
- 高血压 2 年
|
||||||
|
- 在服药:**缬沙坦 80mg qd**
|
||||||
|
- 过敏:青霉素
|
||||||
|
|
||||||
|
## 近期症状
|
||||||
|
- 2026-05-24 感冒(进行中,severity 2):鼻塞、低烧
|
||||||
|
- 2026-05-20 头痛(已结束)
|
||||||
|
|
||||||
|
## 关键指标
|
||||||
|
- ⚠️ 收缩压 142 mmHg (参考 <140) — 2026-05-26
|
||||||
|
- 体温 37.2 ℃ (参考 36-37) — 2026-05-25
|
||||||
|
""")
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand)
|
||||||
|
}
|
||||||
92
康康/Features/Archive/QuickPrompt.swift
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// 「身体档案」里的快捷问答:点一下把一句常用问题填进输入框。
|
||||||
|
/// 内置 3 条(不可删),用户可自定义追加(可删)。
|
||||||
|
struct QuickPrompt: Identifiable, Codable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
var title: String // chip 上显示的短标签
|
||||||
|
var prompt: String // 点击后填入输入框的完整问题
|
||||||
|
var isBuiltin: Bool
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), title: String, prompt: String, isBuiltin: Bool) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.prompt = prompt
|
||||||
|
self.isBuiltin = isBuiltin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 快捷问答存储:内置常量 + 自定义条目(UserDefaults JSON,无 SwiftData schema 迁移风险)。
|
||||||
|
/// 自定义条目只是 UI 便利项、不是健康记录,故不进 SwiftData。
|
||||||
|
@Observable
|
||||||
|
final class QuickPromptStore {
|
||||||
|
static let shared = QuickPromptStore()
|
||||||
|
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
private let storageKey = "kk.quickPrompts.custom.v1"
|
||||||
|
|
||||||
|
private(set) var custom: [QuickPrompt]
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
if let data = defaults.data(forKey: storageKey),
|
||||||
|
let decoded = try? JSONDecoder().decode([QuickPrompt].self, from: data) {
|
||||||
|
custom = decoded
|
||||||
|
} else {
|
||||||
|
custom = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内置在前、自定义在后,供 chip 行展示。
|
||||||
|
var all: [QuickPrompt] { Self.builtins + custom }
|
||||||
|
|
||||||
|
/// 追加一条自定义问答。空白忽略;标签自动取问题前几个字。
|
||||||
|
func add(prompt rawPrompt: String) {
|
||||||
|
let trimmed = rawPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
custom.append(QuickPrompt(title: Self.deriveTitle(trimmed),
|
||||||
|
prompt: trimmed,
|
||||||
|
isBuiltin: false))
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除一条自定义问答(内置不可删)。
|
||||||
|
func delete(_ p: QuickPrompt) {
|
||||||
|
guard !p.isBuiltin else { return }
|
||||||
|
custom.removeAll { $0.id == p.id }
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist() {
|
||||||
|
if let data = try? JSONEncoder().encode(custom) {
|
||||||
|
defaults.set(data, forKey: storageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自定义条目的短标签:压成单行,取前 8 个字,超出补省略号。
|
||||||
|
static func deriveTitle(_ prompt: String) -> String {
|
||||||
|
let oneLine = prompt.replacingOccurrences(of: "\n", with: " ")
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
let head = oneLine.prefix(8)
|
||||||
|
return oneLine.count > 8 ? "\(head)…" : String(head)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内置 3 条(首屏):覆盖「就诊 / 解读 / 速查」三类,数据依赖稳、不碰诊断红线。
|
||||||
|
static let builtins: [QuickPrompt] = [
|
||||||
|
QuickPrompt(
|
||||||
|
title: "就诊摘要",
|
||||||
|
prompt: "根据我最近的身体症状,结合历史指标,整理一份让门诊医生快速了解我情况的就诊摘要。",
|
||||||
|
isBuiltin: true
|
||||||
|
),
|
||||||
|
QuickPrompt(
|
||||||
|
title: "趋势解读",
|
||||||
|
prompt: "把我血压最近半年的变化讲清楚:是变好还是变差、要注意什么。",
|
||||||
|
isBuiltin: true
|
||||||
|
),
|
||||||
|
QuickPrompt(
|
||||||
|
title: "速答清单",
|
||||||
|
prompt: "把我的过敏史、正在吃的药、慢性病整理成一句话清单,方便就诊时快速回答医生。",
|
||||||
|
isBuiltin: true
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
291
康康/Features/Calendar/CalendarOverviewView.swift
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
enum CalendarMode: String, CaseIterable, Identifiable {
|
||||||
|
case month, year
|
||||||
|
var id: String { rawValue }
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .month: return String(appLoc: "月")
|
||||||
|
case .year: return String(appLoc: "年")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 健康日历总览页。从主页 HomeCalendarCard 进入。
|
||||||
|
/// 月/年切换 + 上下导航 + 图例 + 月视图下方当日详情。日历组件复用 CalendarMonthGrid / CalendarYearGrid。
|
||||||
|
struct CalendarOverviewView: View {
|
||||||
|
/// 进入时定位到的日期(从主页某天点入);nil → 今天。
|
||||||
|
var initialDate: Date = .now
|
||||||
|
/// fullScreenCover 形态下的关闭回调。
|
||||||
|
var onClose: (() -> Void)?
|
||||||
|
|
||||||
|
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
||||||
|
private var indicators: [Indicator]
|
||||||
|
|
||||||
|
@Query(sort: \Report.reportDate, order: .reverse)
|
||||||
|
private var reports: [Report]
|
||||||
|
|
||||||
|
@Query(sort: \DiaryEntry.createdAt, order: .reverse)
|
||||||
|
private var diaries: [DiaryEntry]
|
||||||
|
|
||||||
|
@Query(sort: \Symptom.startedAt, order: .reverse)
|
||||||
|
private var symptoms: [Symptom]
|
||||||
|
|
||||||
|
@State private var mode: CalendarMode = .month
|
||||||
|
@State private var anchor: Date = .now
|
||||||
|
@State private var selectedDate: Date = .now
|
||||||
|
|
||||||
|
private let calendar: Calendar = {
|
||||||
|
var c = Calendar(identifier: .gregorian)
|
||||||
|
c.firstWeekday = 2
|
||||||
|
c.locale = Locale.current
|
||||||
|
return c
|
||||||
|
}()
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private var data: CalendarData {
|
||||||
|
CalendarData.build(
|
||||||
|
indicators: indicators,
|
||||||
|
reports: reports,
|
||||||
|
diaries: diaries,
|
||||||
|
symptoms: symptoms
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
modeSwitch.padding(.top, 4)
|
||||||
|
anchorBar
|
||||||
|
calendarBody
|
||||||
|
legend
|
||||||
|
if mode == .month {
|
||||||
|
dayDetailInline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationTitle(String(appLoc: "健康日历"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button {
|
||||||
|
withAnimation(.snappy(duration: 0.2)) {
|
||||||
|
anchor = .now
|
||||||
|
selectedDate = .now
|
||||||
|
mode = .month
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("回到今天")
|
||||||
|
.font(.tjScaled( 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
if let onClose {
|
||||||
|
Button(action: onClose) {
|
||||||
|
Text("完成")
|
||||||
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
anchor = initialDate
|
||||||
|
selectedDate = initialDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dayDetailInline: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
DayDetailContent(
|
||||||
|
date: selectedDate,
|
||||||
|
indicators: indicators,
|
||||||
|
reports: reports,
|
||||||
|
diaries: diaries,
|
||||||
|
symptoms: symptoms,
|
||||||
|
showHeader: true
|
||||||
|
)
|
||||||
|
.padding(14)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
.animation(.snappy(duration: 0.2), value: selectedDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var modeSwitch: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(CalendarMode.allCases) { m in
|
||||||
|
Button {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
mode = m
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(m.label)
|
||||||
|
.font(.tjScaled( 13, weight: mode == m ? .semibold : .regular))
|
||||||
|
.foregroundStyle(mode == m ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(mode == m ? Tj.Palette.ink : Color.clear)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(3)
|
||||||
|
.background(Capsule().fill(Tj.Palette.paper))
|
||||||
|
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||||
|
.frame(maxWidth: 220)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var anchorBar: some View {
|
||||||
|
HStack {
|
||||||
|
Button { shiftAnchor(-1) } label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(Circle().fill(Tj.Palette.paper))
|
||||||
|
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(anchorTitle)
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
.animation(.snappy, value: anchor)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button { shiftAnchor(1) } label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(Circle().fill(Tj.Palette.paper))
|
||||||
|
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isAnchorAtFuture)
|
||||||
|
.opacity(isAnchorAtFuture ? 0.4 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var anchorTitle: String {
|
||||||
|
let style: Date.FormatStyle = mode == .month
|
||||||
|
? .dateTime.year().month()
|
||||||
|
: .dateTime.year()
|
||||||
|
return anchor.formatted(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var calendarBody: some View {
|
||||||
|
switch mode {
|
||||||
|
case .month:
|
||||||
|
CalendarMonthGrid(monthAnchor: anchor, data: data, selectedDate: selectedDate) { day in
|
||||||
|
withAnimation(.snappy(duration: 0.2)) {
|
||||||
|
selectedDate = day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
case .year:
|
||||||
|
CalendarYearGrid(
|
||||||
|
year: calendar.component(.year, from: anchor),
|
||||||
|
data: data
|
||||||
|
) { tappedMonth in
|
||||||
|
anchor = tappedMonth
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
mode = .month
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var legend: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("图例")
|
||||||
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
|
.tracking(0.5)
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
legendItem(color: Tj.Palette.brick, label: String(appLoc: "指标异常"))
|
||||||
|
legendItem(color: Tj.Palette.amber, label: String(appLoc: "症状持续中"))
|
||||||
|
legendItem(color: Tj.Palette.ink2, label: String(appLoc: "报告归档"))
|
||||||
|
legendItem(color: Tj.Palette.leaf, label: String(appLoc: "正常"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func legendItem(color: Color, label: String) -> some View {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 14, height: 6)
|
||||||
|
Text(label)
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isAnchorAtFuture: Bool {
|
||||||
|
switch mode {
|
||||||
|
case .month:
|
||||||
|
return calendar.isDate(anchor, equalTo: .now, toGranularity: .month) ||
|
||||||
|
anchor > .now
|
||||||
|
case .year:
|
||||||
|
let nowYear = calendar.component(.year, from: .now)
|
||||||
|
let anchorYear = calendar.component(.year, from: anchor)
|
||||||
|
return anchorYear >= nowYear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shiftAnchor(_ delta: Int) {
|
||||||
|
let component: Calendar.Component = (mode == .month) ? .month : .year
|
||||||
|
if let next = calendar.date(byAdding: component, value: delta, to: anchor) {
|
||||||
|
withAnimation(.snappy) {
|
||||||
|
anchor = next
|
||||||
|
if mode == .month {
|
||||||
|
if calendar.isDate(next, equalTo: .now, toGranularity: .month) {
|
||||||
|
selectedDate = .now
|
||||||
|
} else if let first = calendar.dateInterval(of: .month, for: next)?.start {
|
||||||
|
selectedDate = first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
CalendarOverviewView(onClose: {})
|
||||||
|
.modelContainer(for: [
|
||||||
|
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self
|
||||||
|
], inMemory: true)
|
||||||
|
}
|
||||||
@@ -8,8 +8,13 @@ struct CaptureReviewForm: View {
|
|||||||
@State var parsed: ParsedReport
|
@State var parsed: ParsedReport
|
||||||
let assets: [FileVault.SavedAsset]
|
let assets: [FileVault.SavedAsset]
|
||||||
let warning: String?
|
let warning: String?
|
||||||
|
/// 归档模式:只存原图 + 基本信息(标题/类型/日期/机构),隐藏指标区与摘要。
|
||||||
|
/// 报告归档不再逐项识别(逐项多模态在 2B 上易 OOM 卡死),见 CaptureService.extractReportMeta。
|
||||||
|
var metaOnly: Bool = false
|
||||||
let onSave: (ParsedReport) -> Void
|
let onSave: (ParsedReport) -> Void
|
||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
|
/// 「重新识别信息」回调。assets 为空(写图失败)时传 nil,banner 上不显示该按钮。
|
||||||
|
var onReanalyze: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -21,7 +26,9 @@ struct CaptureReviewForm: View {
|
|||||||
pageThumbnails
|
pageThumbnails
|
||||||
}
|
}
|
||||||
metaSection
|
metaSection
|
||||||
|
if !metaOnly {
|
||||||
indicatorSection
|
indicatorSection
|
||||||
|
}
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
actions
|
actions
|
||||||
}
|
}
|
||||||
@@ -36,10 +43,22 @@ struct CaptureReviewForm: View {
|
|||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
if let onReanalyze {
|
||||||
|
Button {
|
||||||
|
onReanalyze()
|
||||||
|
} label: {
|
||||||
|
Label("重新识别", systemImage: "arrow.clockwise")
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
@@ -53,14 +72,21 @@ struct CaptureReviewForm: View {
|
|||||||
|
|
||||||
private var pageThumbnails: some View {
|
private var pageThumbnails: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("已保存 \(assets.count) 页(端侧加密)")
|
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
|
||||||
|
if metaOnly {
|
||||||
|
Text("原图已加密保存,详情页随时可翻看放大。系统只识别报告日期与机构作为标签,不逐项录入数值。")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
|
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
|
||||||
if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) {
|
VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in
|
||||||
Image(uiImage: img)
|
Image(uiImage: img).resizable().scaledToFill()
|
||||||
.resizable()
|
} placeholder: { _ in
|
||||||
.scaledToFill()
|
Tj.Palette.paper
|
||||||
|
}
|
||||||
.frame(width: 84, height: 110)
|
.frame(width: 84, height: 110)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
.overlay(
|
.overlay(
|
||||||
@@ -72,19 +98,18 @@ struct CaptureReviewForm: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - meta(title / type / date / institution / summary)
|
// MARK: - meta(title / type / date / institution / summary)
|
||||||
|
|
||||||
private var metaSection: some View {
|
private var metaSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
sectionLabel("基本信息")
|
sectionLabel(String(appLoc: "基本信息"))
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
labeledField("标题") {
|
labeledField(String(appLoc: "标题")) {
|
||||||
TextField("如:春季年度体检", text: $parsed.title)
|
TextField("如:春季年度体检", text: $parsed.title)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
}
|
}
|
||||||
labeledField("类型") {
|
labeledField(String(appLoc: "类型")) {
|
||||||
Picker("", selection: $parsed.typeRaw) {
|
Picker("", selection: $parsed.typeRaw) {
|
||||||
ForEach(ReportType.allCases, id: \.rawValue) { t in
|
ForEach(ReportType.allCases, id: \.rawValue) { t in
|
||||||
Text(t.label).tag(t.rawValue)
|
Text(t.label).tag(t.rawValue)
|
||||||
@@ -92,22 +117,24 @@ struct CaptureReviewForm: View {
|
|||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
}
|
}
|
||||||
labeledField("报告日期") {
|
labeledField(String(appLoc: "报告日期")) {
|
||||||
DatePicker("", selection: $parsed.reportDate,
|
DatePicker("", selection: $parsed.reportDate,
|
||||||
in: ...Date.now,
|
in: ...Date.now,
|
||||||
displayedComponents: .date)
|
displayedComponents: .date)
|
||||||
.datePickerStyle(.compact)
|
.datePickerStyle(.compact)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.environment(\.locale, Locale(identifier: "zh_CN"))
|
.environment(\.locale, Locale.current)
|
||||||
}
|
}
|
||||||
labeledField("机构(可选)") {
|
labeledField(String(appLoc: "机构(可选)")) {
|
||||||
TextField("如:协和医院", text: $parsed.institution)
|
TextField("如:协和医院", text: $parsed.institution)
|
||||||
}
|
}
|
||||||
labeledField("摘要(可选)") {
|
if !metaOnly {
|
||||||
|
labeledField(String(appLoc: "摘要(可选)")) {
|
||||||
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
|
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
|
||||||
.lineLimit(1...3)
|
.lineLimit(1...3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(fieldBg)
|
.background(fieldBg)
|
||||||
.overlay(fieldBorder)
|
.overlay(fieldBorder)
|
||||||
@@ -117,7 +144,7 @@ struct CaptureReviewForm: View {
|
|||||||
private func labeledField<C: View>(_ label: String, @ViewBuilder content: () -> C) -> some View {
|
private func labeledField<C: View>(_ label: String, @ViewBuilder content: () -> C) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.tjScaled( 11, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
@@ -128,7 +155,7 @@ struct CaptureReviewForm: View {
|
|||||||
private var indicatorSection: some View {
|
private var indicatorSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
sectionLabel("指标(\(parsed.indicators.count) 项)")
|
sectionLabel(String(appLoc: "指标(\(parsed.indicators.count) 项)"))
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
parsed.indicators.append(
|
parsed.indicators.append(
|
||||||
@@ -136,34 +163,34 @@ struct CaptureReviewForm: View {
|
|||||||
)
|
)
|
||||||
} label: {
|
} label: {
|
||||||
Label("加一项", systemImage: "plus.circle")
|
Label("加一项", systemImage: "plus.circle")
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.tjScaled( 12, weight: .medium))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
if parsed.indicators.isEmpty {
|
if parsed.indicators.isEmpty {
|
||||||
Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片")
|
Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
ForEach(parsed.indicators.indices, id: \.self) { idx in
|
ForEach($parsed.indicators) { $indicator in
|
||||||
indicatorRow(idx)
|
indicatorRow($indicator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func indicatorRow(_ idx: Int) -> some View {
|
private func indicatorRow(_ binding: Binding<ParsedReport.ParsedIndicator>) -> some View {
|
||||||
let binding = $parsed.indicators[idx]
|
let id = binding.wrappedValue.id
|
||||||
return VStack(spacing: 8) {
|
return VStack(spacing: 8) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
TextField("指标名", text: binding.name)
|
TextField("指标名", text: binding.name)
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
parsed.indicators.remove(at: idx)
|
parsed.indicators.removeAll { $0.id == id }
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "minus.circle.fill")
|
Image(systemName: "minus.circle.fill")
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
@@ -173,7 +200,7 @@ struct CaptureReviewForm: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
TextField("数值", text: binding.value)
|
TextField("数值", text: binding.value)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
.font(.tjScaled( 14, weight: .semibold, design: .monospaced))
|
||||||
.frame(maxWidth: 90)
|
.frame(maxWidth: 90)
|
||||||
TextField("单位", text: binding.unit)
|
TextField("单位", text: binding.unit)
|
||||||
.frame(maxWidth: 80)
|
.frame(maxWidth: 80)
|
||||||
@@ -233,7 +260,7 @@ struct CaptureReviewForm: View {
|
|||||||
|
|
||||||
private func sectionLabel(_ t: String) -> some View {
|
private func sectionLabel(_ t: String) -> some View {
|
||||||
Text(t)
|
Text(t)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ struct PhotoPickerSheet: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
Image(systemName: "photo.on.rectangle.angled")
|
Image(systemName: "photo.on.rectangle.angled")
|
||||||
.font(.system(size: 56))
|
.font(.tjScaled( 56))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text("模拟器没有摄像头,从相册选一张化验单/体检报告")
|
Text("模拟器没有摄像头,从相册选一张化验单/体检报告")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ struct PhotoPickerSheet: View {
|
|||||||
maxSelectionCount: 5,
|
maxSelectionCount: 5,
|
||||||
matching: .images) {
|
matching: .images) {
|
||||||
Text("从相册选 ≤5 张")
|
Text("从相册选 ≤5 张")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.tjScaled( 14, weight: .semibold))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(Tj.Palette.ink)
|
.background(Tj.Palette.ink)
|
||||||
@@ -32,8 +32,14 @@ struct PhotoPickerSheet: View {
|
|||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("取消", action: onCancel)
|
Button(action: onCancel) {
|
||||||
|
Text("取消")
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.frame(minHeight: 44) // HIG 最小命中区
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
if loading {
|
if loading {
|
||||||
ProgressView().tint(Tj.Palette.ink)
|
ProgressView().tint(Tj.Palette.ink)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
/// 拍报告 → VL 识别 → 编辑 → 保存(图 + 结构化文本)
|
/// 拍报告 → VL 识别 → 编辑 → 保存(图 + 结构化文本)
|
||||||
/// 一条统一流程,替代原 A1-A3 / B1-B5 两套 mockup。
|
/// 一条统一流程,替代原 A1-A3 / B1-B5 两套 mockup。
|
||||||
@@ -16,11 +17,17 @@ struct UnifiedCaptureFlow: View {
|
|||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
let onClose: () -> Void
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
@AppStorage("hasSeenCaptureTip") private var hasSeenCaptureTip: Bool = false
|
||||||
@State private var phase: Phase = .idle
|
@State private var phase: Phase = .idle
|
||||||
|
@State private var analyzeTask: Task<Void, Never>? = nil
|
||||||
|
@State private var showTip: Bool = false
|
||||||
|
|
||||||
|
/// VL 单次推理超时(防止卡死);超时后 cancel 子任务,UI 走手动录入回退。
|
||||||
|
private let analyzeTimeoutSeconds: Int = 30
|
||||||
|
|
||||||
enum Phase {
|
enum Phase {
|
||||||
case idle
|
case idle
|
||||||
case analyzing(images: [UIImage])
|
case analyzing(images: [UIImage], assets: [FileVault.SavedAsset]?)
|
||||||
case editing(parsed: ParsedReport,
|
case editing(parsed: ParsedReport,
|
||||||
assets: [FileVault.SavedAsset],
|
assets: [FileVault.SavedAsset],
|
||||||
warning: String?)
|
warning: String?)
|
||||||
@@ -32,20 +39,30 @@ struct UnifiedCaptureFlow: View {
|
|||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
Button("取消") { onClose() }
|
Button("取消") { cancelAll() }
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(phaseTitle)
|
.navigationTitle(phaseTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
if !hasSeenCaptureTip { showTip = true }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showTip) {
|
||||||
|
CaptureTipSheet(onDismiss: {
|
||||||
|
hasSeenCaptureTip = true
|
||||||
|
showTip = false
|
||||||
|
})
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var phaseTitle: String {
|
private var phaseTitle: String {
|
||||||
switch phase {
|
switch phase {
|
||||||
case .idle: return "拍摄报告"
|
case .idle: return String(appLoc: "拍摄报告")
|
||||||
case .analyzing: return "本地识别中…"
|
case .analyzing: return String(appLoc: "本地识别中…")
|
||||||
case .editing: return "核对识别结果"
|
case .editing: return String(appLoc: "核对报告信息")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,21 +71,58 @@ struct UnifiedCaptureFlow: View {
|
|||||||
switch phase {
|
switch phase {
|
||||||
case .idle:
|
case .idle:
|
||||||
captureEntry
|
captureEntry
|
||||||
case .analyzing(let images):
|
case .analyzing(let images, _):
|
||||||
AnalyzingView(images: images)
|
AnalyzingView(
|
||||||
|
images: images,
|
||||||
|
timeoutSeconds: analyzeTimeoutSeconds,
|
||||||
|
onCancel: {
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
analyzeTask = nil
|
||||||
|
phase = .idle
|
||||||
|
}
|
||||||
|
)
|
||||||
case .editing(let parsed, let assets, let warning):
|
case .editing(let parsed, let assets, let warning):
|
||||||
CaptureReviewForm(
|
CaptureReviewForm(
|
||||||
parsed: parsed,
|
parsed: parsed,
|
||||||
assets: assets,
|
assets: assets,
|
||||||
warning: warning,
|
warning: warning,
|
||||||
|
metaOnly: true, // 归档只存原图 + meta,不逐项识别(§见 CaptureService.extractReportMeta)
|
||||||
onSave: { final in saveAll(parsed: final, assets: assets) },
|
onSave: { final in saveAll(parsed: final, assets: assets) },
|
||||||
onCancel: onClose
|
onCancel: cancelAll,
|
||||||
|
onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 取消统一入口
|
||||||
|
|
||||||
|
/// 取消推理 + 清理未保存到 SwiftData 的 Vault 孤儿图片,再关闭 sheet。
|
||||||
|
/// 工具栏「取消」与编辑表单底部「取消(图片不保留)」都走这里,
|
||||||
|
/// 保证「图片不保留」的隐私承诺(§6)真的成立,且 Vault 不被孤儿图片堆爆。
|
||||||
|
/// 仅清理 .analyzing/.editing 阶段的 assets;.idle 时还没写图,无需清理。
|
||||||
|
private func cancelAll() {
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
analyzeTask = nil
|
||||||
|
switch phase {
|
||||||
|
case .idle:
|
||||||
|
break
|
||||||
|
case .analyzing(_, let maybeAssets):
|
||||||
|
if let assets = maybeAssets { removeOrphans(assets) }
|
||||||
|
case .editing(_, let assets, _):
|
||||||
|
removeOrphans(assets)
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeOrphans(_ assets: [FileVault.SavedAsset]) {
|
||||||
|
for a in assets {
|
||||||
|
try? FileVault.shared.remove(relativePath: a.relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 入口:相机 / 相册
|
// MARK: - 入口:相机 / 相册
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
private var captureEntry: some View {
|
private var captureEntry: some View {
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
PhotoPickerSheet(
|
PhotoPickerSheet(
|
||||||
@@ -95,54 +149,92 @@ struct UnifiedCaptureFlow: View {
|
|||||||
|
|
||||||
private func startAnalyze(images: [UIImage]) {
|
private func startAnalyze(images: [UIImage]) {
|
||||||
guard !images.isEmpty else { onClose(); return }
|
guard !images.isEmpty else { onClose(); return }
|
||||||
phase = .analyzing(images: images)
|
analyzeTask?.cancel()
|
||||||
Task {
|
phase = .analyzing(images: images, assets: nil)
|
||||||
do {
|
let timeout = analyzeTimeoutSeconds
|
||||||
let result = try await CaptureService.shared.analyze(images: images)
|
analyzeTask = Task {
|
||||||
await MainActor.run {
|
// Step 1: 先把图写进 Vault(归档的核心价值就是「把原图存下来」,先保证它)。
|
||||||
phase = .editing(
|
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
|
||||||
parsed: result.parsed,
|
// 极端情况:用户在写图过程中按了「取消」,View 已 dismiss、cancelAll 看到的
|
||||||
assets: result.assets,
|
// phase 还是 .analyzing(_, nil),清不到这批刚写完的图 — 这里手动收尾。
|
||||||
warning: result.parsed.isEmpty
|
if Task.isCancelled {
|
||||||
? "识别没有读出指标,请手动补充"
|
for a in assets { try? FileVault.shared.remove(relativePath: a.relativePath) }
|
||||||
: nil
|
return
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch let CaptureError.parseFailed(msg) {
|
guard !assets.isEmpty else {
|
||||||
// 解析失败:仍然展示编辑表单,只是 indicators 为空,assets 已保存
|
|
||||||
await fallbackToManual(images: images, msg: "VL 输出无法解析:\(msg)")
|
|
||||||
} catch let CaptureError.inferenceFailed(msg) {
|
|
||||||
await fallbackToManual(images: images, msg: "推理失败:\(msg)")
|
|
||||||
} catch let CaptureError.modelNotReady {
|
|
||||||
await fallbackToManual(images: images, msg: "VL 模型未就绪,先手动录入")
|
|
||||||
} catch CaptureError.writeAssetFailed {
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
phase = .editing(
|
phase = .editing(
|
||||||
parsed: .empty(),
|
parsed: .empty(),
|
||||||
assets: [],
|
assets: [],
|
||||||
warning: "图片保存失败,手动录入并保留文本"
|
warning: String(appLoc: "图片保存失败,请重试")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
return
|
||||||
await fallbackToManual(images: images, msg: "未知错误:\(error.localizedDescription)")
|
}
|
||||||
|
// 把 assets 暴露给 phase,使工具栏「取消」也能找到孤儿清理。
|
||||||
|
await MainActor.run {
|
||||||
|
if case .analyzing(let imgs, _) = phase {
|
||||||
|
phase = .analyzing(images: imgs, assets: assets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: 轻量 meta 提取(OCR + 文本 LLM,只抽日期/机构/类型/标题)。
|
||||||
|
// 不再跑多模态逐项识别 —— 那在 2B 上又慢又会 OOM 卡死。watchdog 到点 cancel。
|
||||||
|
let watchdog = Task {
|
||||||
|
try? await Task.sleep(for: .seconds(timeout))
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
}
|
||||||
|
defer { watchdog.cancel() }
|
||||||
|
|
||||||
|
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
|
||||||
|
if Task.isCancelled {
|
||||||
|
await MainActor.run {
|
||||||
|
phase = .editing(parsed: .empty(), assets: assets,
|
||||||
|
warning: String(appLoc: "识别超时,已保存原图,请手动填写信息"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
phase = .editing(
|
||||||
|
parsed: meta,
|
||||||
|
assets: assets,
|
||||||
|
warning: recognized ? nil
|
||||||
|
: String(appLoc: "未能自动识别报告信息,已保存原图,可手动填写日期 / 机构")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fallbackToManual(images: [UIImage], msg: String) async {
|
/// 「重新识别信息」:复用已存 assets,不再写图,只重跑一次轻量 meta 提取。
|
||||||
// 即便 VL 失败,图片应当已经写入了 Vault(在 CaptureService.analyze 第 1 步)。
|
private func reanalyze(assets: [FileVault.SavedAsset]) {
|
||||||
// 但若是 writeAsset 之前的失败(modelNotReady / inferenceFailed),
|
analyzeTask?.cancel()
|
||||||
// 这里再补一次写,保证图不丢。
|
// 这里没有原始 UIImage,AnalyzingView 只把首张缩略图模糊后当背景,降采样到 600px 足够,
|
||||||
var assets: [FileVault.SavedAsset] = []
|
// 避免为「重新识别」把整页全分辨率原图(数十 MB)载进内存。
|
||||||
for img in images {
|
let thumbnails: [UIImage] = assets.compactMap {
|
||||||
if let a = try? FileVault.shared.writeJPEG(img) { assets.append(a) }
|
try? FileVault.shared.loadDownsampledImage(relativePath: $0.relativePath, maxPixelSize: 600)
|
||||||
|
}
|
||||||
|
phase = .analyzing(images: thumbnails, assets: assets)
|
||||||
|
let timeout = analyzeTimeoutSeconds
|
||||||
|
analyzeTask = Task {
|
||||||
|
let watchdog = Task {
|
||||||
|
try? await Task.sleep(for: .seconds(timeout))
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
}
|
||||||
|
defer { watchdog.cancel() }
|
||||||
|
|
||||||
|
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
|
||||||
|
if Task.isCancelled {
|
||||||
|
await MainActor.run {
|
||||||
|
phase = .editing(parsed: .empty(), assets: assets,
|
||||||
|
warning: String(appLoc: "识别超时,已保留原图"))
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
phase = .editing(
|
phase = .editing(parsed: meta, assets: assets,
|
||||||
parsed: .empty(),
|
warning: recognized ? nil
|
||||||
assets: assets,
|
: String(appLoc: "未能自动识别报告信息,可手动填写"))
|
||||||
warning: msg
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +243,7 @@ struct UnifiedCaptureFlow: View {
|
|||||||
private func saveAll(parsed final: ParsedReport,
|
private func saveAll(parsed final: ParsedReport,
|
||||||
assets: [FileVault.SavedAsset]) {
|
assets: [FileVault.SavedAsset]) {
|
||||||
let report = Report(
|
let report = Report(
|
||||||
title: final.title.isEmpty ? "拍摄识别" : final.title,
|
title: final.title.isEmpty ? String(appLoc: "拍摄识别") : final.title,
|
||||||
type: ReportType(rawValue: final.typeRaw) ?? .other,
|
type: ReportType(rawValue: final.typeRaw) ?? .other,
|
||||||
reportDate: final.reportDate,
|
reportDate: final.reportDate,
|
||||||
institution: final.institution.isEmpty ? nil : final.institution,
|
institution: final.institution.isEmpty ? nil : final.institution,
|
||||||
@@ -176,12 +268,21 @@ struct UnifiedCaptureFlow: View {
|
|||||||
range: ind.range,
|
range: ind.range,
|
||||||
status: ind.status,
|
status: ind.status,
|
||||||
capturedAt: final.reportDate,
|
capturedAt: final.reportDate,
|
||||||
report: report
|
report: report,
|
||||||
|
source: .report,
|
||||||
|
sourcePageIndex: ind.sourcePageIndex,
|
||||||
|
sourceBoxX: ind.sourceBoxX,
|
||||||
|
sourceBoxY: ind.sourceBoxY,
|
||||||
|
sourceBoxWidth: ind.sourceBoxWidth,
|
||||||
|
sourceBoxHeight: ind.sourceBoxHeight
|
||||||
)
|
)
|
||||||
ctx.insert(i)
|
ctx.insert(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
try? ctx.save()
|
try? ctx.save()
|
||||||
|
// 后台预生成大白话摘要:用户继续操作,详情页打开时秒开。
|
||||||
|
// 低优先级 —— 任何前台 AI 任务(再次拍照/问答)都会让它在下一个 token 让位。
|
||||||
|
Task { await ReportInsightService.shared.pregenerateIfNeeded(report: report, in: ctx) }
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,6 +291,11 @@ struct UnifiedCaptureFlow: View {
|
|||||||
|
|
||||||
private struct AnalyzingView: View {
|
private struct AnalyzingView: View {
|
||||||
let images: [UIImage]
|
let images: [UIImage]
|
||||||
|
let timeoutSeconds: Int
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
@State private var elapsed: Int = 0
|
||||||
|
private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
@@ -216,13 +322,78 @@ private struct AnalyzingView: View {
|
|||||||
Text("本地识别中")
|
Text("本地识别中")
|
||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("\(images.count) 页 · 100% 本地推理")
|
Text("\(images.count) 页 · 100% 本地推理 · 已用 \(elapsed)s")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
if elapsed >= timeoutSeconds - 5 {
|
||||||
|
Text("快超时了,>\(timeoutSeconds)s 会自动转为手动录入")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Text("取消识别 · 改为手动录入")
|
||||||
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.frame(minHeight: 44) // HIG 最小命中区
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.top, 4)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.onReceive(tick) { _ in elapsed += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 一次性使用提示
|
||||||
|
|
||||||
|
private struct CaptureTipSheet: View {
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "doc.viewfinder")
|
||||||
|
.font(.tjScaled( 28))
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
Text("拍报告的小贴士")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
tip(String(appLoc: "纸张铺平,避免反光、阴影"))
|
||||||
|
tip(String(appLoc: "整页入框,避免裁切到指标"))
|
||||||
|
tip(String(appLoc: "多页报告可连拍,系统自动透视校正"))
|
||||||
|
tip(String(appLoc: "识别全程在本地,图片不会上传"))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
onDismiss()
|
||||||
|
} label: {
|
||||||
|
Text("我知道了,开始拍")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(TjPrimaryButton())
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tip(_ text: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
|
.padding(.top, 2)
|
||||||
|
Text(text)
|
||||||
|
.font(.tjSerifBody())
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,79 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
/// 「健康记录」录入 sheet。
|
||||||
|
/// 主体仍是 DiaryEntry @Model;UI/文案改为面向健康记录,并加 AI 辅助区:
|
||||||
|
/// 让 Qwen3 从医生问诊角度提 3-4 个追问,用户可一键将「补充模板」追加到输入框。
|
||||||
|
/// 支持多轮——每轮把已问过的 q 传给 LLM 要求别重复;已采纳的 row 灰色 + ✓ 标记。
|
||||||
struct DiaryQuickSheet: View {
|
struct DiaryQuickSheet: View {
|
||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var content: String = ""
|
@State private var content: String = ""
|
||||||
@State private var createdAt: Date = .now
|
@State private var createdAt: Date = .now
|
||||||
|
/// 「拍药盒」分支:全屏扫描流程,识别后入药品库。
|
||||||
|
@State private var showMedicationScan = false
|
||||||
|
/// 「用药」分支:记一次服用(选药 + 剂量 + 时间),存为带「用药」tag 的日记。
|
||||||
|
@State private var showMedicationLog = false
|
||||||
|
/// 「记症状」分支:嵌套弹出 SymptomStartSheet(自带保存/取消,关闭后回到本页)。
|
||||||
|
@State private var showSymptomStart = false
|
||||||
|
|
||||||
private var canSubmit: Bool {
|
/// AI 辅助状态
|
||||||
|
enum AssistPhase {
|
||||||
|
case idle // 从未生成
|
||||||
|
case loading // 正在 LLM 调用
|
||||||
|
case ready // 有结果显示,等待下一轮 / 采纳 / 重试
|
||||||
|
case failed(Error) // 最近一次失败
|
||||||
|
}
|
||||||
|
@State private var phase: AssistPhase = .idle
|
||||||
|
@State private var questions: [DiaryAssistService.Question] = []
|
||||||
|
@State private var lastRate: Double = 0
|
||||||
|
@State private var currentRound: Int = 0
|
||||||
|
/// 累积已覆盖的问诊维度(question.dim),回传下一轮 prompt 用于按维度去重。
|
||||||
|
@State private var coveredDims: Set<String> = []
|
||||||
|
@State private var suggestTask: Task<Void, Never>?
|
||||||
|
/// 当前正在「就地填空」的 question id;nil = 没有展开的填空面板。
|
||||||
|
@State private var fillingId: UUID?
|
||||||
|
/// 当前填空面板各占位槽的输入值,长度 = 该模板占位数。
|
||||||
|
@State private var fillValues: [String] = []
|
||||||
|
/// 上一轮「再问一轮」没问出任何新维度(全被去重)时为 true,提示用户已覆盖主要维度。
|
||||||
|
@State private var exhaustedNote = false
|
||||||
|
/// sheet detent。默认 large,确保建议面板有足够展示空间。
|
||||||
|
/// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。
|
||||||
|
@State private var detent: PresentationDetent = .large
|
||||||
|
@FocusState private var contentFocused: Bool
|
||||||
|
|
||||||
|
// MARK: 语音输入状态(spec 2026-06-10-voice-diary)
|
||||||
|
|
||||||
|
enum VoicePhase: Equatable { case idle, recording, organizing }
|
||||||
|
@State private var voicePhase: VoicePhase = .idle
|
||||||
|
@State private var liveTranscript = ""
|
||||||
|
@State private var recordingSeconds = 0
|
||||||
|
/// 最近一次最终转写稿,「改用原话」回退用;再次录音时覆盖。
|
||||||
|
@State private var rawTranscript: String?
|
||||||
|
/// 刚追加进正文的整理稿,用于「改用原话」时在正文中定位替换。
|
||||||
|
/// 用户手动编辑掉该段(正文中找不到了)时 pill 自然消失。
|
||||||
|
@State private var organizedAppended: String?
|
||||||
|
/// 一次性提示条文案(整理失败已填原话 / 没听清等),开始新录音时清掉。
|
||||||
|
@State private var voiceNote: String?
|
||||||
|
@State private var voiceDeniedAlert = false
|
||||||
|
@State private var voiceFlowTask: Task<Void, Never>?
|
||||||
|
@State private var recordingWatchdog: Task<Void, Never>?
|
||||||
|
/// 必须 @State:struct View 重建(键盘收起/detent 变化都会触发)时普通 let 会换成
|
||||||
|
/// 全新实例,导致 stop() 落在没在录音的新服务上返回空串(「没听清」假错误),
|
||||||
|
/// 且真正在录音的老实例关不掉、麦克风悬挂。@State 保证视图身份期内实例唯一。
|
||||||
|
@State private var dictation = SpeechDictationService()
|
||||||
|
|
||||||
|
private var hasContent: Bool {
|
||||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
}
|
}
|
||||||
|
private var hasQuestions: Bool { !questions.isEmpty }
|
||||||
|
private var isLoading: Bool {
|
||||||
|
if case .loading = phase { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
|
||||||
|
private var canSubmit: Bool { hasContent }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -21,22 +84,78 @@ struct DiaryQuickSheet: View {
|
|||||||
.padding(.bottom, 14)
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("写日记")
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("健康记录")
|
||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("本机保存")
|
Text("本机保存")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 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) {
|
||||||
|
showMedicationLog = true
|
||||||
|
}
|
||||||
|
modeCard(icon: "camera.viewfinder", title: String(appLoc: "拍药盒"),
|
||||||
|
subtitle: String(appLoc: "识别入药品库"), active: false) {
|
||||||
|
showMedicationScan = true
|
||||||
|
}
|
||||||
|
modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"),
|
||||||
|
subtitle: String(appLoc: "持续追踪"), active: false) {
|
||||||
|
showSymptomStart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("内容")
|
HStack {
|
||||||
TextField("今天怎么样?", text: $content, axis: .vertical)
|
sectionLabel(String(appLoc: "内容"))
|
||||||
.lineLimit(4...10)
|
Spacer()
|
||||||
|
if SpeechDictationService.isAvailable, voicePhase == .idle {
|
||||||
|
Button(action: startVoice) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "mic.fill")
|
||||||
|
.font(.tjScaled(11, weight: .semibold))
|
||||||
|
Text("说一段")
|
||||||
|
.font(.tjScaled(12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(isLoading ? Tj.Palette.text3 : Tj.Palette.brick)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Capsule().strokeBorder(
|
||||||
|
isLoading ? Tj.Palette.line : Tj.Palette.brick.opacity(0.5),
|
||||||
|
lineWidth: 1))
|
||||||
|
.contentShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isLoading) // AI 追问生成中不抢 AIRuntime 队列
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextField("今天身体怎么样?吃了什么药、有什么感觉?",
|
||||||
|
text: $content, axis: .vertical)
|
||||||
|
.lineLimit(3...8)
|
||||||
|
.focused($contentFocused)
|
||||||
|
.onChange(of: content) { _, _ in exhaustedNote = false }
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(
|
.background(
|
||||||
@@ -47,18 +166,77 @@ struct DiaryQuickSheet: View {
|
|||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if voicePhase != .idle {
|
||||||
|
DiaryVoicePanel(
|
||||||
|
mode: voicePhase == .organizing
|
||||||
|
? .organizing
|
||||||
|
: .recording(elapsedSeconds: recordingSeconds),
|
||||||
|
transcript: liveTranscript,
|
||||||
|
onStop: stopVoiceAndOrganize,
|
||||||
|
onCancelOrganize: cancelOrganize
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let note = voiceNote {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.font(.tjScaled(11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(note)
|
||||||
|
.font(.tjScaled(11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let organized = organizedAppended,
|
||||||
|
rawTranscript != nil,
|
||||||
|
content.range(of: organized) != nil {
|
||||||
|
Button(action: revertToRawTranscript) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "arrow.uturn.backward")
|
||||||
|
.font(.tjScaled(10, weight: .semibold))
|
||||||
|
Text("改用原话")
|
||||||
|
.font(.tjScaled(11, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||||
|
.contentShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assistSection
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("时间")
|
sectionLabel(String(appLoc: "时间"))
|
||||||
DatePicker("", selection: $createdAt, in: ...Date.now)
|
DatePicker("", selection: $createdAt, in: ...Date.now)
|
||||||
.datePickerStyle(.compact)
|
.datePickerStyle(.compact)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
|
// 底部锚点,新一轮 question 进来后自动滚到这里
|
||||||
|
Color.clear.frame(height: 1).id("assist-bottom")
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 6)
|
||||||
Spacer(minLength: 12)
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
.onChange(of: questions.count) { old, new in
|
||||||
|
guard new > old else { return }
|
||||||
|
// 滚到新一轮的 round divider(让用户先看到「第 N 轮」的标签,
|
||||||
|
// 再依次看到这一轮的 questions)
|
||||||
|
let roundId = "round-\(questions[old].round)"
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||||
|
withAnimation(.easeOut(duration: 0.25)) {
|
||||||
|
proxy.scrollTo(roundId, anchor: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Button("取消") { dismiss() }
|
Button("取消") { dismiss() }
|
||||||
@@ -76,19 +254,619 @@ struct DiaryQuickSheet: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||||
.ignoresSafeArea(edges: .bottom)
|
.ignoresSafeArea(edges: .bottom)
|
||||||
)
|
)
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.medium, .large], selection: $detent)
|
||||||
.presentationDragIndicator(.hidden)
|
.presentationDragIndicator(.hidden)
|
||||||
.presentationBackground(Tj.Palette.sand)
|
.presentationBackground(Tj.Palette.sand)
|
||||||
.presentationCornerRadius(Tj.Radius.xl)
|
.presentationCornerRadius(Tj.Radius.xl)
|
||||||
|
.fullScreenCover(isPresented: $showMedicationScan) {
|
||||||
|
MedicationScanFlow(
|
||||||
|
onSave: { meds, images in
|
||||||
|
// 识别后入药品库(含原图),不再写日记。服用流水走「写日记 · 用药」模式。
|
||||||
|
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
|
||||||
|
dismiss()
|
||||||
|
},
|
||||||
|
onClose: { showMedicationScan = false }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showSymptomStart) {
|
||||||
|
// 嵌套 sheet:症状表单自带保存/取消;取消回到日记,不强行关闭。
|
||||||
|
SymptomStartSheet()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showMedicationLog) {
|
||||||
|
// 嵌套 sheet:用药记录表单自带保存/取消;保存后回到日记(不强行关闭)。
|
||||||
|
MedicationLogSheet()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
suggestTask?.cancel()
|
||||||
|
voiceFlowTask?.cancel()
|
||||||
|
recordingWatchdog?.cancel()
|
||||||
|
dictation.abort()
|
||||||
|
}
|
||||||
|
.alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $voiceDeniedAlert) {
|
||||||
|
Button(String(appLoc: "前往设置")) {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(String(appLoc: "取消"), role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AI 辅助区
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var assistSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
// section header
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
|
||||||
|
Spacer()
|
||||||
|
if hasQuestions {
|
||||||
|
Text("\(questions.count) 个建议")
|
||||||
|
.font(.tjScaled( 10, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
if lastRate > 0 {
|
||||||
|
Text(String(format: "%.1f tok/s", lastRate))
|
||||||
|
.font(.tjScaled( 10, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 累积的 questions 列表(多轮,带轮次分隔)
|
||||||
|
if hasQuestions {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(Array(questions.enumerated()), id: \.element.id) { idx, q in
|
||||||
|
if idx == 0 || questions[idx - 1].round != q.round {
|
||||||
|
roundDivider(round: q.round,
|
||||||
|
count: questions.filter { $0.round == q.round }.count)
|
||||||
|
.id("round-\(q.round)")
|
||||||
|
}
|
||||||
|
questionRow(index: roundLocalIndex(at: idx), question: q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AIDisclaimerFooter()
|
||||||
|
}
|
||||||
|
|
||||||
|
if exhaustedNote {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "checkmark.seal.fill")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
|
Text("已覆盖主要问诊维度;补充原文后可再追问")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部主操作按钮(状态机驱动)
|
||||||
|
phaseFooter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var phaseFooter: some View {
|
||||||
|
switch phase {
|
||||||
|
case .idle:
|
||||||
|
assistPrimaryButton(
|
||||||
|
icon: "sparkles",
|
||||||
|
label: canRequestSuggest
|
||||||
|
? String(appLoc: "让 AI 帮我想想还能记什么")
|
||||||
|
: String(appLoc: "先写几个字,AI 来帮忙补充"),
|
||||||
|
enabled: canRequestSuggest,
|
||||||
|
prominent: true,
|
||||||
|
action: requestSuggestions
|
||||||
|
)
|
||||||
|
|
||||||
|
case .loading:
|
||||||
|
assistLoadingIndicator
|
||||||
|
|
||||||
|
case .ready:
|
||||||
|
assistPrimaryButton(
|
||||||
|
icon: "arrow.clockwise",
|
||||||
|
label: canRequestSuggest
|
||||||
|
? String(appLoc: "再问一轮 · 让 AI 从新角度追问")
|
||||||
|
: String(appLoc: "更新一下原文,再让 AI 继续追问"),
|
||||||
|
enabled: canRequestSuggest,
|
||||||
|
action: requestSuggestions
|
||||||
|
)
|
||||||
|
|
||||||
|
case .failed(let err):
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
Text(err.localizedDescription)
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Button { requestSuggestions() } label: {
|
||||||
|
Text("重试")
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.brickSoft.opacity(0.5))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 辅助主按钮。`prominent` 为真走实心强调样式(填充 brick + 白字 + 轻投影,一眼可点),
|
||||||
|
/// 否则走低调描边样式(用于 .ready 的「再问一轮」)。
|
||||||
|
private func assistPrimaryButton(icon: String,
|
||||||
|
label: String,
|
||||||
|
enabled: Bool,
|
||||||
|
prominent: Bool = false,
|
||||||
|
action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
.font(.tjScaled( prominent ? 14 : 13, weight: .semibold))
|
||||||
|
.foregroundStyle(prominent
|
||||||
|
? (enabled ? Tj.Palette.paper : Tj.Palette.text3)
|
||||||
|
: (enabled ? Tj.Palette.ink : Tj.Palette.text3))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, prominent ? 14 : 11)
|
||||||
|
.background(assistButtonBackground(enabled: enabled, prominent: prominent))
|
||||||
|
// 纯描边背景、内部透明:补 contentShape 让整框可点(否则只有图标+文字本体能点)。
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func assistButtonBackground(enabled: Bool, prominent: Bool) -> some View {
|
||||||
|
let shape = RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
if prominent {
|
||||||
|
shape
|
||||||
|
.fill(enabled ? Tj.Palette.brick : Tj.Palette.brickSoft)
|
||||||
|
.shadow(color: enabled ? Tj.Palette.brick.opacity(0.30) : .clear,
|
||||||
|
radius: 8, x: 0, y: 3)
|
||||||
|
} else {
|
||||||
|
shape
|
||||||
|
.strokeBorder(
|
||||||
|
enabled ? Tj.Palette.ink : Tj.Palette.line,
|
||||||
|
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// .loading 等待态:安静的 paper 卡片,底部一条细窄的不确定进度条来回滑动(Linear/Vercel 式极简)。
|
||||||
|
/// 不用高亮扫光、不填强调色,避免刺眼;只靠细线 + sparkles 轻脉冲传达「在算」。
|
||||||
|
private var assistLoadingIndicator: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.symbolEffect(.pulse, options: .repeating)
|
||||||
|
Text(lastRate > 0
|
||||||
|
? String(format: String(appLoc: "AI 生成中 · %.1f tok/s"), lastRate)
|
||||||
|
: String(appLoc: "AI 生成中 · 本地推理"))
|
||||||
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button("取消") { cancelSuggestions() }
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 11)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
AIFlowBar().padding(.horizontal, 1)
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 给定整张 questions list 里 idx 位置的 question,返回它在自己 round 内的序号(1-based)。
|
||||||
|
private func roundLocalIndex(at idx: Int) -> Int {
|
||||||
|
let target = questions[idx].round
|
||||||
|
var count = 0
|
||||||
|
for i in 0...idx where questions[i].round == target {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 第 N 轮的分隔条 —— 让用户清楚下一轮 LLM 看到的是更新过的最新文本。
|
||||||
|
private func roundDivider(round: Int, count: Int) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath")
|
||||||
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
Text(round == 1
|
||||||
|
? String(appLoc: "第 1 轮 · \(count) 条")
|
||||||
|
: String(appLoc: "第 \(round) 轮 · 基于你刚才更新的文本 · \(count) 条"))
|
||||||
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
|
.tracking(0.3)
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
Rectangle()
|
||||||
|
.fill(Tj.Palette.line)
|
||||||
|
.frame(height: 1)
|
||||||
|
.mask(
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
ForEach(0..<60, id: \.self) { _ in
|
||||||
|
Rectangle().frame(width: 3, height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.top, round == 1 ? 0 : 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View {
|
||||||
|
let adopted = question.adopted
|
||||||
|
let filling = fillingId == question.id
|
||||||
|
return VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Text("\(index).")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick)
|
||||||
|
Text(question.q)
|
||||||
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
|
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text)
|
||||||
|
.strikethrough(adopted, color: Tj.Palette.text3)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
|
||||||
|
if adopted {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.tjScaled( 10, weight: .bold))
|
||||||
|
Text("已采纳")
|
||||||
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Capsule().fill(Tj.Palette.leafSoft))
|
||||||
|
} else if !filling {
|
||||||
|
Button { adopt(question) } label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
Text("采纳")
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Capsule().fill(Tj.Palette.ink))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filling {
|
||||||
|
QuestionFillPanel(
|
||||||
|
template: question.fill,
|
||||||
|
values: $fillValues,
|
||||||
|
onCommit: { assembled in commitAdoption(question, text: assembled) },
|
||||||
|
onCancel: { closeFill() }
|
||||||
|
)
|
||||||
|
} else if !question.fill.isEmpty && !adopted {
|
||||||
|
HStack(alignment: .top, spacing: 4) {
|
||||||
|
Text("将追加:")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(question.fill)
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.leading, 22)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(adopted ? Tj.Palette.sand2 : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
private func sectionLabel(_ text: String) -> some View {
|
private func sectionLabel(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 顶部入口三选一卡片(写日记 / 拍药盒 / 记症状)。active 表示当前所在模式。
|
||||||
|
/// 竖排紧凑布局:三卡并排在 iPhone 宽度下横排放不下完整文案。
|
||||||
|
private func modeCard(icon: String, title: String, subtitle: String,
|
||||||
|
active: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
VStack(spacing: 5) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.tjScaled( 15, weight: .medium))
|
||||||
|
.foregroundStyle(active ? Tj.Palette.paper : Tj.Palette.ink)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(Circle().fill(active ? Tj.Palette.ink : Tj.Palette.sand2))
|
||||||
|
Text(title)
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.tjScaled( 10))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(active ? Tj.Palette.ink : Tj.Palette.line,
|
||||||
|
lineWidth: active ? 1.5 : 1)
|
||||||
|
)
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 语音输入流程
|
||||||
|
|
||||||
|
private func startVoice() {
|
||||||
|
contentFocused = false
|
||||||
|
voiceNote = nil
|
||||||
|
voiceFlowTask = Task { @MainActor in
|
||||||
|
guard await dictation.requestAuthorization() else {
|
||||||
|
voiceDeniedAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
liveTranscript = ""
|
||||||
|
recordingSeconds = 0
|
||||||
|
try dictation.start { partial in liveTranscript = partial }
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .recording }
|
||||||
|
// 计时 + 3 分钟看门狗(到点自动停,行为与点「停止」一致)
|
||||||
|
recordingWatchdog = Task { @MainActor in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
guard !Task.isCancelled, voicePhase == .recording else { return }
|
||||||
|
recordingSeconds += 1
|
||||||
|
if recordingSeconds >= DiaryVoicePanel.maxRecordingSeconds {
|
||||||
|
stopVoiceAndOrganize()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
voiceNote = error.localizedDescription
|
||||||
|
voicePhase = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopVoiceAndOrganize() {
|
||||||
|
guard voicePhase == .recording else { return }
|
||||||
|
recordingWatchdog?.cancel()
|
||||||
|
voiceFlowTask = Task { @MainActor in
|
||||||
|
// 防御兜底:服务返回空(极端情况下实例丢失/最终结果丢失)时,
|
||||||
|
// 用 @State 里的实时字幕——那就是用户亲眼看到的已识别文字。
|
||||||
|
var transcript = (await dictation.stop())
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if transcript.isEmpty {
|
||||||
|
transcript = liveTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
liveTranscript = transcript
|
||||||
|
guard !transcript.isEmpty else {
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||||
|
voiceNote = String(appLoc: "没听清,再试一次")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rawTranscript = transcript
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .organizing }
|
||||||
|
do {
|
||||||
|
let result = try await DiaryAssistService.shared.organize(transcript: transcript)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
appendToContent(result.text)
|
||||||
|
organizedAppended = result.text
|
||||||
|
lastRate = result.decodeRate
|
||||||
|
} catch is CancellationError {
|
||||||
|
// cancelOrganize 已处理回退,这里只收尾
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
appendToContent(transcript) // 红线 #5:整理失败回退原话,不卡死
|
||||||
|
organizedAppended = nil
|
||||||
|
voiceNote = String(appLoc: "AI 整理失败,已填入原话")
|
||||||
|
}
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消整理:中断 LLM,直接填原话(与失败回退同路径)。
|
||||||
|
private func cancelOrganize() {
|
||||||
|
guard voicePhase == .organizing else { return }
|
||||||
|
voiceFlowTask?.cancel()
|
||||||
|
if let raw = rawTranscript {
|
||||||
|
appendToContent(raw)
|
||||||
|
organizedAppended = nil
|
||||||
|
voiceNote = String(appLoc: "已取消整理,填入原话")
|
||||||
|
}
|
||||||
|
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「改用原话」:把刚追加的整理稿替换为原始转写稿(spec §2:LLM 改数兜底)。
|
||||||
|
private func revertToRawTranscript() {
|
||||||
|
guard let raw = rawTranscript,
|
||||||
|
let organized = organizedAppended,
|
||||||
|
let range = content.range(of: organized, options: .backwards) else { return }
|
||||||
|
withAnimation(.snappy(duration: 0.18)) {
|
||||||
|
content = content.replacingCharacters(in: range, with: raw)
|
||||||
|
organizedAppended = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM,
|
||||||
|
/// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。
|
||||||
|
private func requestSuggestions() {
|
||||||
|
suggestTask?.cancel()
|
||||||
|
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let covered = Array(coveredDims)
|
||||||
|
// 1. 主动收起键盘 —— 否则建议面板被键盘吃掉一半
|
||||||
|
contentFocused = false
|
||||||
|
// 2. 确保 sheet 在 large(用户可能下拉到 medium 又触发 AI)
|
||||||
|
if detent != .large {
|
||||||
|
withAnimation(.snappy(duration: 0.25)) {
|
||||||
|
detent = .large
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exhaustedNote = false
|
||||||
|
phase = .loading
|
||||||
|
suggestTask = Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let result = try await DiaryAssistService.shared.suggest(
|
||||||
|
content: snapshotContent,
|
||||||
|
coveredDimensions: covered
|
||||||
|
)
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
// 客户端硬去重(不依赖 1.7B 听话):
|
||||||
|
// ① 维度已在往轮覆盖 → 丢;② 本轮内维度重复 → 丢;③ 文本与已有近似 → 丢。
|
||||||
|
let coveredSnapshot = coveredDims
|
||||||
|
var acceptedNorms = questions.map { Self.normalize($0.q) }
|
||||||
|
var batchDims = Set<String>()
|
||||||
|
let nextRound = currentRound + 1
|
||||||
|
let fresh = result.questions.compactMap { q -> DiaryAssistService.Question? in
|
||||||
|
let dim = q.dim.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let norm = Self.normalize(q.q)
|
||||||
|
if !dim.isEmpty, coveredSnapshot.contains(dim) { return nil }
|
||||||
|
if !dim.isEmpty, batchDims.contains(dim) { return nil }
|
||||||
|
if acceptedNorms.contains(where: { Self.isSimilar($0, norm) }) { return nil }
|
||||||
|
if !dim.isEmpty { batchDims.insert(dim) }
|
||||||
|
acceptedNorms.append(norm)
|
||||||
|
var stamped = q
|
||||||
|
stamped.round = nextRound
|
||||||
|
return stamped
|
||||||
|
}
|
||||||
|
withAnimation(.snappy(duration: 0.2)) {
|
||||||
|
if fresh.isEmpty {
|
||||||
|
exhaustedNote = true // 这轮没问出任何新维度
|
||||||
|
} else {
|
||||||
|
questions.append(contentsOf: fresh)
|
||||||
|
for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) }
|
||||||
|
currentRound = nextRound
|
||||||
|
exhaustedNote = false
|
||||||
|
}
|
||||||
|
lastRate = result.decodeRate
|
||||||
|
phase = .ready
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
phase = hasQuestions ? .ready : .idle
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
phase = .failed(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 简单归一化:去空白 + 折叠成统一形式,用于客户端去重比对。
|
||||||
|
private static func normalize(_ s: String) -> String {
|
||||||
|
s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.replacingOccurrences(of: " ", with: "")
|
||||||
|
.replacingOccurrences(of: "?", with: "?")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 近似判重:归一化后相等,或字符集 Jaccard ≥ 0.8(抓「会/下」这类换一两字的重复)。
|
||||||
|
private static func isSimilar(_ a: String, _ b: String) -> Bool {
|
||||||
|
if a == b { return true }
|
||||||
|
let sa = Set(a), sb = Set(b)
|
||||||
|
guard !sa.isEmpty, !sb.isEmpty else { return false }
|
||||||
|
let inter = sa.intersection(sb).count
|
||||||
|
let union = sa.union(sb).count
|
||||||
|
return union > 0 && Double(inter) / Double(union) >= 0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelSuggestions() {
|
||||||
|
suggestTask?.cancel()
|
||||||
|
phase = hasQuestions ? .ready : .idle
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 采纳:模板含 `[占位]` 时展开就地填空面板;无占位则直接把整句追加(并标记 adopted)。
|
||||||
|
/// 已采纳的 q 不会从列表里消失;其维度已在生成时计入 coveredDims,下一轮 prompt 会避开。
|
||||||
|
private func adopt(_ question: DiaryAssistService.Question) {
|
||||||
|
guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else {
|
||||||
|
// 无占位:直接采纳整句(空 fill 时退回到追加问题本身)。
|
||||||
|
commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
withAnimation(.snappy(duration: 0.18)) {
|
||||||
|
fillingId = question.id
|
||||||
|
fillValues = Array(repeating: "", count: DiaryFillTemplate.slotCount(question.fill))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭填空面板(取消)。
|
||||||
|
private func closeFill() {
|
||||||
|
withAnimation(.snappy(duration: 0.18)) {
|
||||||
|
fillingId = nil
|
||||||
|
fillValues = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交采纳:把(填好的)整句追加到正文,标记 adopted,收起面板。
|
||||||
|
private func commitAdoption(_ question: DiaryAssistService.Question, text: String) {
|
||||||
|
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
|
||||||
|
withAnimation(.snappy(duration: 0.18)) {
|
||||||
|
questions[idx].adopted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendToContent(text)
|
||||||
|
fillingId = nil
|
||||||
|
fillValues = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把一段补充文本追加到正文末尾(自动补换行,空文本忽略)。
|
||||||
|
private func appendToContent(_ text: String) {
|
||||||
|
let toAppend = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !toAppend.isEmpty else { return }
|
||||||
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
content = toAppend
|
||||||
|
} else if content.hasSuffix("\n") {
|
||||||
|
content += toAppend
|
||||||
|
} else {
|
||||||
|
content += "\n" + toAppend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func submit() {
|
private func submit() {
|
||||||
guard canSubmit else { return }
|
guard canSubmit else { return }
|
||||||
let entry = DiaryEntry(
|
let entry = DiaryEntry(
|
||||||
@@ -100,3 +878,7 @@ struct DiaryQuickSheet: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
DiaryQuickSheet()
|
||||||
|
}
|
||||||
|
|||||||
141
康康/Features/Diary/DiaryVoicePanel.swift
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 「健康记录」语音输入面板(spec 2026-06-10-voice-diary)。
|
||||||
|
/// 两种模式:recording(实时字幕 + 计时 + 停止)/ organizing(AI 整理中,可取消)。
|
||||||
|
/// 纯展示:状态由 DiaryQuickSheet 持有并传入。
|
||||||
|
struct DiaryVoicePanel: View {
|
||||||
|
enum Mode: Equatable {
|
||||||
|
case recording(elapsedSeconds: Int)
|
||||||
|
case organizing
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode: Mode
|
||||||
|
/// recording 时为实时字幕;organizing 时为已定稿的转写稿(置灰展示)。
|
||||||
|
let transcript: String
|
||||||
|
let onStop: () -> Void
|
||||||
|
let onCancelOrganize: () -> Void
|
||||||
|
|
||||||
|
/// 录音上限 3 分钟(超时由 DiaryQuickSheet 的看门狗触发 onStop)。
|
||||||
|
static let maxRecordingSeconds = 180
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
header
|
||||||
|
transcriptArea
|
||||||
|
if case .recording = mode {
|
||||||
|
stopButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if mode == .organizing {
|
||||||
|
AIFlowBar().padding(.horizontal, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var header: some View {
|
||||||
|
switch mode {
|
||||||
|
case .recording(let elapsed):
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "waveform")
|
||||||
|
.font(.tjScaled(12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||||
|
Text("正在听 · 识别在本机完成")
|
||||||
|
.font(.tjScaled(13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(Self.format(elapsed))
|
||||||
|
.font(.tjScaled(12, design: .monospaced))
|
||||||
|
.foregroundStyle(elapsed >= Self.maxRecordingSeconds - 30
|
||||||
|
? Tj.Palette.brick : Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
case .organizing:
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.tjScaled(12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.symbolEffect(.pulse, options: .repeating)
|
||||||
|
Text("AI 整理中 · 本地推理")
|
||||||
|
.font(.tjScaled(13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button("取消") { onCancelOrganize() }
|
||||||
|
.font(.tjScaled(12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var transcriptArea: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
Text(transcript.isEmpty ? String(appLoc: "开始说话…") : transcript)
|
||||||
|
.font(.tjScaled(14))
|
||||||
|
.foregroundStyle(transcriptColor)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Color.clear.frame(height: 1).id("tail")
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 120)
|
||||||
|
.onChange(of: transcript) { _, _ in
|
||||||
|
proxy.scrollTo("tail", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var transcriptColor: Color {
|
||||||
|
if transcript.isEmpty { return Tj.Palette.text3 }
|
||||||
|
return mode == .organizing ? Tj.Palette.text3 : Tj.Palette.text
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stopButton: some View {
|
||||||
|
Button(action: onStop) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "stop.circle.fill")
|
||||||
|
Text("说完了,整理成日记")
|
||||||
|
}
|
||||||
|
.font(.tjScaled(14, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.brick)
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func format(_ seconds: Int) -> String {
|
||||||
|
String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("录音中") {
|
||||||
|
DiaryVoicePanel(mode: .recording(elapsedSeconds: 23),
|
||||||
|
transcript: "今天早上起来有点头晕,量了血压一百四九十",
|
||||||
|
onStop: {}, onCancelOrganize: {})
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("整理中") {
|
||||||
|
DiaryVoicePanel(mode: .organizing,
|
||||||
|
transcript: "今天早上起来有点头晕,量了血压一百四九十",
|
||||||
|
onStop: {}, onCancelOrganize: {})
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
235
康康/Features/Diary/QuestionFillPanel.swift
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// AI 补充句模板(如「症状从 [时间] 开始,」)的一个片段:字面文本或待填占位槽。
|
||||||
|
enum FillSegment: Equatable {
|
||||||
|
case literal(String)
|
||||||
|
/// `label` 为方括号内原文(如 "时间" / "活动/休息");
|
||||||
|
/// `options` 为可一键填充的短词候选(`/` 分隔且都短时才有,否则空)。
|
||||||
|
case slot(label: String, options: [String])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把 `fill` 模板解析成有序片段、组装回填好的句子。纯值逻辑,便于复用与单测。
|
||||||
|
enum DiaryFillTemplate {
|
||||||
|
|
||||||
|
/// 解析模板为有序片段。无方括号时返回单个 `.literal`。
|
||||||
|
static func parse(_ template: String) -> [FillSegment] {
|
||||||
|
let chars = Array(template)
|
||||||
|
var segs: [FillSegment] = []
|
||||||
|
var i = 0
|
||||||
|
var literalStart = 0
|
||||||
|
func flushLiteral(upTo end: Int) {
|
||||||
|
if end > literalStart { segs.append(.literal(String(chars[literalStart..<end]))) }
|
||||||
|
}
|
||||||
|
while i < chars.count {
|
||||||
|
if chars[i] == "[",
|
||||||
|
let close = (i + 1 ..< chars.count).first(where: { chars[$0] == "]" }) {
|
||||||
|
flushLiteral(upTo: i)
|
||||||
|
let inner = String(chars[(i + 1)..<close])
|
||||||
|
segs.append(.slot(label: inner, options: options(from: inner)))
|
||||||
|
i = close + 1
|
||||||
|
literalStart = i
|
||||||
|
} else {
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushLiteral(upTo: chars.count)
|
||||||
|
return segs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 占位内 `/` 分隔、每段都短(≤5 字)、且 ≥2 段时,视为可点选的快填候选。
|
||||||
|
private static func options(from inner: String) -> [String] {
|
||||||
|
let tokens = inner.split(separator: "/")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
guard tokens.count >= 2, tokens.allSatisfy({ $0.count <= 5 }) else { return [] }
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模板里的占位槽数量。
|
||||||
|
static func slotCount(_ template: String) -> Int {
|
||||||
|
parse(template).reduce(0) { acc, seg in
|
||||||
|
if case .slot = seg { return acc + 1 }
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用 `values` 填充各槽组装成句:已填用输入值,留空回退为方括号内原文(去方括号,读起来仍自然)。
|
||||||
|
static func assemble(_ template: String, values: [String]) -> String {
|
||||||
|
var out = ""
|
||||||
|
var idx = 0
|
||||||
|
for seg in parse(template) {
|
||||||
|
switch seg {
|
||||||
|
case .literal(let t):
|
||||||
|
out += t
|
||||||
|
case .slot(let label, _):
|
||||||
|
let v = idx < values.count
|
||||||
|
? values[idx].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||||
|
out += v.isEmpty ? label : v
|
||||||
|
idx += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「采纳即就地填空」面板:每个 `[占位]` 一个输入框 + 快填 chip,顶部实时预览整句,
|
||||||
|
/// 底部「加入记录 / 取消」。确认时回传**填好的、无方括号**的整句。
|
||||||
|
struct QuestionFillPanel: View {
|
||||||
|
let template: String
|
||||||
|
@Binding var values: [String]
|
||||||
|
let onCommit: (String) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
private var segments: [FillSegment] { DiaryFillTemplate.parse(template) }
|
||||||
|
|
||||||
|
/// 抽出占位槽 + 其在 values 里的下标。
|
||||||
|
private var slots: [(index: Int, label: String, options: [String])] {
|
||||||
|
var result: [(Int, String, [String])] = []
|
||||||
|
var i = 0
|
||||||
|
for seg in segments {
|
||||||
|
if case let .slot(label, options) = seg {
|
||||||
|
result.append((i, label, options))
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
// 实时预览:已填值高亮,未填槽浅色下划线提示。
|
||||||
|
previewText
|
||||||
|
.font(.tjScaled( 13))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.sand2)
|
||||||
|
)
|
||||||
|
|
||||||
|
ForEach(slots, id: \.index) { slot in
|
||||||
|
slotEditor(index: slot.index, label: slot.label, options: slot.options)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Text("取消")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
// 背景仅描边、内部透明:.plain 按钮的命中区会只剩文字本身,
|
||||||
|
// 中间透明区点不到。补 contentShape 让整框可点。
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onCommit(DiaryFillTemplate.assemble(template, values: values))
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: "text.append")
|
||||||
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
Text("加入记录")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.ink)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, 22)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 子部件
|
||||||
|
|
||||||
|
/// 预览整句:literal 用正文色,已填值用 brick 加粗,未填槽用浅色下划线。
|
||||||
|
private var previewText: Text {
|
||||||
|
var result = Text("")
|
||||||
|
var idx = 0
|
||||||
|
for seg in segments {
|
||||||
|
switch seg {
|
||||||
|
case .literal(let t):
|
||||||
|
result = result + Text(t).foregroundStyle(Tj.Palette.text)
|
||||||
|
case .slot(let label, _):
|
||||||
|
let v = idx < values.count
|
||||||
|
? values[idx].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||||
|
if v.isEmpty {
|
||||||
|
result = result + Text(label).foregroundStyle(Tj.Palette.text3).underline()
|
||||||
|
} else {
|
||||||
|
result = result + Text(v).foregroundStyle(Tj.Palette.brick).fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
idx += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func slotEditor(index: Int, label: String, options: [String]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(label)
|
||||||
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
|
||||||
|
if !options.isEmpty {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(options, id: \.self) { opt in
|
||||||
|
let picked = bindingValue(index) == opt
|
||||||
|
Button { values[index] = opt } label: {
|
||||||
|
Text(opt)
|
||||||
|
.font(.tjScaled( 12, weight: picked ? .semibold : .regular))
|
||||||
|
.foregroundStyle(picked ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(picked ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Capsule().strokeBorder(Tj.Palette.line,
|
||||||
|
lineWidth: picked ? 0 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField(String(appLoc: "填写\(label)"), text: binding(index))
|
||||||
|
.font(.tjScaled( 13))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.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 func bindingValue(_ i: Int) -> String {
|
||||||
|
i < values.count ? values[i] : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func binding(_ i: Int) -> Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { i < values.count ? values[i] : "" },
|
||||||
|
set: { if i < values.count { values[i] = $0 } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
176
康康/Features/Home/HomeCalendarCard.swift
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 主页「健康日历」卡:当前一周横条 + 本月记录摘要。
|
||||||
|
/// 点整卡或某一天 → 打开 CalendarOverviewView 看月/年总览。自包含 @Query(对齐 TodayRemindersCard)。
|
||||||
|
struct HomeCalendarCard: View {
|
||||||
|
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
||||||
|
private var indicators: [Indicator]
|
||||||
|
|
||||||
|
@Query(sort: \Report.reportDate, order: .reverse)
|
||||||
|
private var reports: [Report]
|
||||||
|
|
||||||
|
@Query(sort: \DiaryEntry.createdAt, order: .reverse)
|
||||||
|
private var diaries: [DiaryEntry]
|
||||||
|
|
||||||
|
@Query(sort: \Symptom.startedAt, order: .reverse)
|
||||||
|
private var symptoms: [Symptom]
|
||||||
|
|
||||||
|
/// 打开总览时定位的日期(nil = 不展示)。
|
||||||
|
@State private var openDay: SelectedDay?
|
||||||
|
|
||||||
|
private let calendar: Calendar = {
|
||||||
|
var c = Calendar(identifier: .gregorian)
|
||||||
|
c.firstWeekday = 2
|
||||||
|
c.locale = Locale.current
|
||||||
|
return c
|
||||||
|
}()
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private var data: CalendarData {
|
||||||
|
CalendarData.build(
|
||||||
|
indicators: indicators,
|
||||||
|
reports: reports,
|
||||||
|
diaries: diaries,
|
||||||
|
symptoms: symptoms
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本周一 → 本周日。
|
||||||
|
private var weekDays: [Date] {
|
||||||
|
let today = calendar.startOfDay(for: .now)
|
||||||
|
let weekdayIndex = (calendar.component(.weekday, from: today) - calendar.firstWeekday + 7) % 7
|
||||||
|
guard let monday = calendar.date(byAdding: .day, value: -weekdayIndex, to: today) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本月有记录的天数(指标/报告/日记/症状任一)。
|
||||||
|
private var daysWithRecordsThisMonth: Int {
|
||||||
|
guard let interval = calendar.dateInterval(of: .month, for: .now) else { return 0 }
|
||||||
|
let count = calendar.range(of: .day, in: .month, for: .now)?.count ?? 30
|
||||||
|
var n = 0
|
||||||
|
for i in 0..<count {
|
||||||
|
guard let d = calendar.date(byAdding: .day, value: i, to: interval.start) else { continue }
|
||||||
|
if data.marks(for: d, calendar: calendar).hasAnyEvent ||
|
||||||
|
!data.ranges(touching: d, calendar: calendar).isEmpty {
|
||||||
|
n += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
header
|
||||||
|
weekStrip
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.tjCard(bordered: true)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { openDay = SelectedDay(date: .now) }
|
||||||
|
.fullScreenCover(item: $openDay) { day in
|
||||||
|
CalendarOverviewView(initialDate: day.date, onClose: { openDay = nil })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text("健康日历")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Text(summaryLine)
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var summaryLine: String {
|
||||||
|
let n = daysWithRecordsThisMonth
|
||||||
|
return n > 0 ? String(appLoc: "本月 \(n) 天有记录") : String(appLoc: "本月暂无记录")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekStrip: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(weekDays, id: \.self) { day in
|
||||||
|
dayCell(day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dayCell(_ day: Date) -> some View {
|
||||||
|
let marks = data.marks(for: day, calendar: calendar)
|
||||||
|
let ranges = data.ranges(touching: day, calendar: calendar)
|
||||||
|
let isToday = calendar.isDateInToday(day)
|
||||||
|
let hasSymptom = !ranges.isEmpty
|
||||||
|
|
||||||
|
return Button {
|
||||||
|
openDay = SelectedDay(date: day)
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 5) {
|
||||||
|
Text(weekdayLabel(day))
|
||||||
|
.font(.tjScaled( 10, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||||
|
.fill(cellFill(isToday: isToday, hasSymptom: hasSymptom))
|
||||||
|
if isToday {
|
||||||
|
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.ink, lineWidth: 1.2)
|
||||||
|
}
|
||||||
|
Text("\(calendar.component(.day, from: day))")
|
||||||
|
.font(.tjScaled( 14, weight: isToday ? .bold : .regular))
|
||||||
|
.foregroundStyle(isToday ? Tj.Palette.ink : Tj.Palette.text)
|
||||||
|
}
|
||||||
|
.frame(height: 38)
|
||||||
|
marksDots(marks)
|
||||||
|
.frame(height: 5)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func marksDots(_ marks: DayMarks) -> some View {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
if marks.abnormalCount > 0 {
|
||||||
|
dot(Tj.Palette.brick)
|
||||||
|
} else if marks.normalCount > 0 {
|
||||||
|
dot(Tj.Palette.leaf)
|
||||||
|
}
|
||||||
|
if marks.reportCount > 0 { dot(Tj.Palette.ink2) }
|
||||||
|
if marks.diaryCount > 0 { dot(Tj.Palette.text3.opacity(0.7)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dot(_ color: Color) -> some View {
|
||||||
|
Circle().fill(color).frame(width: 4, height: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cellFill(isToday: Bool, hasSymptom: Bool) -> Color {
|
||||||
|
if hasSymptom { return Tj.Palette.amber.opacity(0.18) }
|
||||||
|
if isToday { return Tj.Palette.sand2 }
|
||||||
|
return Tj.Palette.sand2.opacity(0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func weekdayLabel(_ day: Date) -> String {
|
||||||
|
let labels = [
|
||||||
|
String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"),
|
||||||
|
String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"),
|
||||||
|
String(appLoc: "日")
|
||||||
|
]
|
||||||
|
let idx = (calendar.component(.weekday, from: day) - calendar.firstWeekday + 7) % 7
|
||||||
|
return labels[idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,20 +16,21 @@ struct HomeView: View {
|
|||||||
@Query(sort: \Symptom.startedAt, order: .reverse)
|
@Query(sort: \Symptom.startedAt, order: .reverse)
|
||||||
private var symptoms: [Symptom]
|
private var symptoms: [Symptom]
|
||||||
|
|
||||||
|
/// 点「最近记录」某行 → 打开只读详情 sheet(与档案库 C1 同款交互)。
|
||||||
|
@State private var selectedEntry: TimelineEntry?
|
||||||
|
/// 点指标行 → 打开同类聚合详情(历次翻页 + 趋势,与档案库 C1 同款)。
|
||||||
|
@State private var selectedGroup: IndicatorGroup?
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private var recentEntries: [TimelineEntry] {
|
private var recentEntries: [TimelineEntry] {
|
||||||
let all =
|
let all =
|
||||||
TimelineEntry.from(indicators: indicators) +
|
TimelineEntry.aggregatedIndicators(indicators) +
|
||||||
reports.map(TimelineEntry.from(report:)) +
|
reports.map(TimelineEntry.from(report:)) +
|
||||||
diaries.map(TimelineEntry.from(diary:)) +
|
diaries.map(TimelineEntry.from(diary:)) +
|
||||||
symptoms.map(TimelineEntry.from(symptom:))
|
symptoms.map(TimelineEntry.from(symptom:))
|
||||||
return all.sorted { $0.date > $1.date }.prefix(6).map { $0 }
|
return all.sorted { $0.date > $1.date }.prefix(6).map { $0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var recentGrouped: [(section: DateSection, items: [TimelineEntry])] {
|
|
||||||
TimelineGrouping.group(recentEntries)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
@@ -37,6 +38,10 @@ struct HomeView: View {
|
|||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
.padding(.bottom, 18)
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
HomeCalendarCard()
|
||||||
|
|
||||||
|
TodayRemindersCard()
|
||||||
|
|
||||||
OngoingSymptomsCard()
|
OngoingSymptomsCard()
|
||||||
.padding(.bottom, 18)
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
@@ -49,13 +54,25 @@ struct HomeView: View {
|
|||||||
.padding(.bottom, 20)
|
.padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.sheet(item: $selectedEntry) { entry in
|
||||||
|
if let d = TimelineDetail.resolve(
|
||||||
|
for: entry,
|
||||||
|
indicators: indicators, reports: reports,
|
||||||
|
diaries: diaries, symptoms: symptoms
|
||||||
|
) {
|
||||||
|
TimelineEntryDetailView(detail: d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedGroup) { group in
|
||||||
|
IndicatorSeriesDetailView(group: group)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var greeting: some View {
|
private var greeting: some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(todayLine)
|
Text(todayLine)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text(greetingWord)
|
Text(greetingWord)
|
||||||
@@ -69,47 +86,65 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var todayLine: String {
|
private var todayLine: String {
|
||||||
let f = DateFormatter()
|
let now = Date()
|
||||||
f.locale = Locale(identifier: "zh_CN")
|
let day = now.formatted(.dateTime.month().day())
|
||||||
f.dateFormat = "M 月 d 日 · EEE"
|
let weekday = now.formatted(.dateTime.weekday(.abbreviated))
|
||||||
return f.string(from: Date())
|
return "\(day) · \(weekday)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var greetingWord: String {
|
private var greetingWord: String {
|
||||||
switch Calendar.current.component(.hour, from: Date()) {
|
switch Calendar.current.component(.hour, from: Date()) {
|
||||||
case 5..<12: return "早安"
|
case 5..<12: return String(appLoc: "早安")
|
||||||
case 12..<18: return "下午好"
|
case 12..<18: return String(appLoc: "下午好")
|
||||||
default: return "晚上好"
|
default: return String(appLoc: "晚上好")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var recentSection: some View {
|
private var recentSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
// 聚合(含血压配对 O(m²))在一次 body 内只算一次,再派生分组,避免 .isEmpty 与分组各算一遍。
|
||||||
|
let entries = recentEntries
|
||||||
|
let groups = TimelineGrouping.group(entries)
|
||||||
|
return VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(alignment: .lastTextBaseline) {
|
HStack(alignment: .lastTextBaseline) {
|
||||||
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: onTapArchive) {
|
Button(action: onTapArchive) {
|
||||||
Text("全部 ›")
|
Text("全部 ›")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
if recentEntries.isEmpty {
|
if entries.isEmpty {
|
||||||
emptyRecent
|
emptyRecent
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
ForEach(recentGrouped, id: \.section) { group in
|
ForEach(groups, id: \.section) { group in
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(group.section.label)
|
Text(group.section.label)
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.tracking(0.5)
|
.tracking(0.5)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
ForEach(group.items) { entry in
|
ForEach(group.items) { entry in
|
||||||
|
Button {
|
||||||
|
// 指标 → 同类聚合详情(历次 + 趋势);其余 → 只读详情。与档案库 C1 一致。
|
||||||
|
guard let d = TimelineDetail.resolve(
|
||||||
|
for: entry,
|
||||||
|
indicators: indicators, reports: reports,
|
||||||
|
diaries: diaries, symptoms: symptoms
|
||||||
|
) 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)
|
TimelineRow(entry: entry)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +156,7 @@ struct HomeView: View {
|
|||||||
private var emptyRecent: some View {
|
private var emptyRecent: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text("还没有任何记录,点底部 + 号开始第一条")
|
Text("还没有任何记录,点底部 + 号开始第一条")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -136,19 +171,19 @@ struct HomeView: View {
|
|||||||
|
|
||||||
Button(action: onTapArchive) {
|
Button(action: onTapArchive) {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
TjPlaceholder(label: "档案 · \(reports.count)")
|
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56, height: 56)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("我的报告档案")
|
Text("我的报告档案")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.tjScaled( 14, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("\(reports.count) 份 · \(indicators.count) 项指标 · 端侧加密")
|
Text("\(reports.count) 份 · \(indicators.count) 项指标 · 端侧加密")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ struct RecentItemRow: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("\(date) · \(type)")
|
Text("\(date) · \(type)")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(name)
|
Text(name)
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
@@ -47,7 +47,7 @@ struct RecentItemRow: View {
|
|||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
if let value {
|
if let value {
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
.font(.tjScaled( 12, weight: .semibold, design: .monospaced))
|
||||||
.foregroundStyle(status.valueColor)
|
.foregroundStyle(status.valueColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
|
|||||||
118
康康/Features/Home/TodayRemindersCard.swift
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// 主页「今日提醒」卡:汇总今天会触发的自由提醒(CustomReminder)+ 指标提醒(MetricReminder),
|
||||||
|
/// 按时间升序展示;已过点的行淡化(只表示「时间已过」,不代表已完成——本期不追踪打卡)。
|
||||||
|
/// 今天没有任何提醒 → 整卡隐藏(返回 EmptyView,与「持续中症状」卡同款)。
|
||||||
|
/// 卡内只读;点右上「全部 ›」打开提醒中心(RemindersListView)管理。
|
||||||
|
struct TodayRemindersCard: View {
|
||||||
|
@Query(sort: \CustomReminder.updatedAt, order: .reverse)
|
||||||
|
private var customReminders: [CustomReminder]
|
||||||
|
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
|
||||||
|
private var metricReminders: [MetricReminder]
|
||||||
|
|
||||||
|
@State private var showingCenter = false
|
||||||
|
/// 每分钟自走一次,用于刷新「今天」判定与「已过点」淡化(与 OngoingSymptomsCard 同款)。
|
||||||
|
@State private var tick: Date = .now
|
||||||
|
private let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
/// 今天会触发的提醒,自由提醒 + 指标提醒合并成统一行模型,按时间升序。
|
||||||
|
private var items: [TodayItem] {
|
||||||
|
let cal = Calendar.current
|
||||||
|
var arr: [TodayItem] = []
|
||||||
|
for r in customReminders where r.occurs(on: tick, calendar: cal) {
|
||||||
|
arr.append(TodayItem(id: "c-\(r.id.uuidString)",
|
||||||
|
hour: r.hour, minute: r.minute, title: r.title))
|
||||||
|
}
|
||||||
|
for r in metricReminders where r.occurs(on: tick, calendar: cal) {
|
||||||
|
arr.append(TodayItem(id: "m-\(r.metricId)",
|
||||||
|
hour: r.hour, minute: r.minute, title: r.displayName))
|
||||||
|
}
|
||||||
|
return arr.sorted { ($0.hour, $0.minute) < ($1.hour, $1.minute) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let rows = items
|
||||||
|
if rows.isEmpty {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
header(count: rows.count)
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(rows) { row($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
.onReceive(timer) { now in tick = now }
|
||||||
|
.sheet(isPresented: $showingCenter) {
|
||||||
|
// 列表页依赖外层 NavigationStack 提供标题栏;sheet 形态补「完成」按钮。
|
||||||
|
NavigationStack { RemindersListView(presentedAsSheet: true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func header(count: Int) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(Tj.Palette.amber)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
Text("今日提醒")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("\(count) 项")
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
Button { showingCenter = true } label: {
|
||||||
|
Text("全部 ›")
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ item: TodayItem) -> some View {
|
||||||
|
let isPast = item.isPast(now: tick)
|
||||||
|
return HStack(spacing: 12) {
|
||||||
|
Text(item.timeLabel)
|
||||||
|
.font(.tjScaled( 14, weight: .semibold).monospacedDigit())
|
||||||
|
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.ink)
|
||||||
|
.frame(width: 46, alignment: .leading)
|
||||||
|
Image(systemName: "bell.fill")
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.amber)
|
||||||
|
Text(item.title)
|
||||||
|
.font(.tjScaled( 15, weight: .medium))
|
||||||
|
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04),
|
||||||
|
radius: 2, x: 0, y: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「今日提醒」行的统一展示模型(自由提醒与指标提醒共用)。
|
||||||
|
private struct TodayItem: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let hour: Int
|
||||||
|
let minute: Int
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
var timeLabel: String { String(format: "%02d:%02d", hour, minute) }
|
||||||
|
|
||||||
|
/// 该提醒的时分是否早于此刻(同一天内「已过点」)。
|
||||||
|
func isPast(now: Date) -> Bool {
|
||||||
|
let c = Calendar.current.dateComponents([.hour, .minute], from: now)
|
||||||
|
let nowMinutes = (c.hour ?? 0) * 60 + (c.minute ?? 0)
|
||||||
|
return hour * 60 + minute < nowMinutes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,8 @@ enum CustomMetricNameConflict: Equatable {
|
|||||||
var warningText: String {
|
var warningText: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .none: return ""
|
case .none: return ""
|
||||||
case .builtin(let n): return "「\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块"
|
case .builtin(let n): return String(appLoc: "「\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块")
|
||||||
case .existingCustom(let n):return "已经有一个叫「\(n)」的自定义指标"
|
case .existingCustom(let n):return String(appLoc: "已经有一个叫「\(n)」的自定义指标")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ struct CustomMetricEditor: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
Text("保存后会出现在录入选项里")
|
Text("保存后会出现在录入选项里")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ struct CustomMetricEditor: View {
|
|||||||
|
|
||||||
private var nameSection: some View {
|
private var nameSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("名称")
|
sectionLabel(String(appLoc: "名称"))
|
||||||
TextField("例如:腰围 / 步数 / 睡眠时长", text: $name)
|
TextField("例如:腰围 / 步数 / 睡眠时长", text: $name)
|
||||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||||
.background(fieldBg)
|
.background(fieldBg)
|
||||||
@@ -147,10 +147,10 @@ struct CustomMetricEditor: View {
|
|||||||
if nameConflict != .none {
|
if nameConflict != .none {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
Text(nameConflict.warningText)
|
Text(nameConflict.warningText)
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -161,7 +161,7 @@ struct CustomMetricEditor: View {
|
|||||||
|
|
||||||
private var unitSection: some View {
|
private var unitSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("单位(可选)")
|
sectionLabel(String(appLoc: "单位(可选)"))
|
||||||
TextField("例如:cm / 步 / 小时", text: $unit)
|
TextField("例如:cm / 步 / 小时", text: $unit)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||||
@@ -172,26 +172,26 @@ struct CustomMetricEditor: View {
|
|||||||
private var rangeRow: some View {
|
private var rangeRow: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
sectionLabel("参考范围(可选)")
|
sectionLabel(String(appLoc: "参考范围(可选)"))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("用于自动判定 正常/偏高/偏低")
|
Text("用于自动判定 正常/偏高/偏低")
|
||||||
.font(.system(size: 10))
|
.font(.tjScaled( 10))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
rangeField(label: "下限", value: $lower, placeholder: "70")
|
rangeField(label: String(appLoc: "下限"), value: $lower, placeholder: "70")
|
||||||
Text("—").foregroundStyle(Tj.Palette.text3)
|
Text("—").foregroundStyle(Tj.Palette.text3)
|
||||||
rangeField(label: "上限", value: $upper, placeholder: "90")
|
rangeField(label: String(appLoc: "上限"), value: $upper, placeholder: "90")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rangeField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
private func rangeField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
Text(label).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||||
TextField(placeholder, text: value)
|
TextField(placeholder, text: value)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
.font(.tjScaled( 16, weight: .medium, design: .monospaced))
|
||||||
.padding(.horizontal, 12).padding(.vertical, 10)
|
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||||
.background(fieldBg).overlay(fieldBorder)
|
.background(fieldBg).overlay(fieldBorder)
|
||||||
}
|
}
|
||||||
@@ -199,7 +199,7 @@ struct CustomMetricEditor: View {
|
|||||||
|
|
||||||
private var iconSection: some View {
|
private var iconSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("图标")
|
sectionLabel(String(appLoc: "图标"))
|
||||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4),
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4),
|
||||||
spacing: 8) {
|
spacing: 8) {
|
||||||
ForEach(customMetricIconChoices, id: \.self) { sf in
|
ForEach(customMetricIconChoices, id: \.self) { sf in
|
||||||
@@ -207,7 +207,7 @@ struct CustomMetricEditor: View {
|
|||||||
icon = sf
|
icon = sf
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: sf)
|
Image(systemName: sf)
|
||||||
.font(.system(size: 20, weight: .medium))
|
.font(.tjScaled( 20, weight: .medium))
|
||||||
.foregroundStyle(icon == sf ? Tj.Palette.paper : Tj.Palette.ink)
|
.foregroundStyle(icon == sf ? Tj.Palette.paper : Tj.Palette.ink)
|
||||||
.frame(maxWidth: .infinity, minHeight: 44)
|
.frame(maxWidth: .infinity, minHeight: 44)
|
||||||
.background(
|
.background(
|
||||||
@@ -239,7 +239,7 @@ struct CustomMetricEditor: View {
|
|||||||
Image(systemName: "trash")
|
Image(systemName: "trash")
|
||||||
Text("删除这项自定义指标")
|
Text("删除这项自定义指标")
|
||||||
}
|
}
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
@@ -282,7 +282,7 @@ struct CustomMetricEditor: View {
|
|||||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
}
|
}
|
||||||
private func sectionLabel(_ t: String) -> some View {
|
private func sectionLabel(_ t: String) -> some View {
|
||||||
Text(t).font(.system(size: 12, weight: .semibold)).tracking(0.3)
|
Text(t).font(.tjScaled( 12, weight: .semibold)).tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,22 @@ private let labPresets: [IndicatorPreset] = [
|
|||||||
/// 无 seriesKey,不进 Trends。
|
/// 无 seriesKey,不进 Trends。
|
||||||
/// 3. **自由输入** — name/value/unit/range 全自己填,status 手动选。
|
/// 3. **自由输入** — name/value/unit/range 全自己填,status 手动选。
|
||||||
struct IndicatorQuickSheet: View {
|
struct IndicatorQuickSheet: View {
|
||||||
|
/// 「拍照识别」入口回调。由 RootView 注入:关闭本表单 → 打开 QuickRegionCaptureFlow(相机→VL→存)。
|
||||||
|
/// 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(\.modelContext) private var ctx
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Query private var profiles: [UserProfile]
|
@Query private var profiles: [UserProfile]
|
||||||
@@ -65,6 +81,32 @@ struct IndicatorQuickSheet: View {
|
|||||||
// 隐藏管理 sheet 触发态
|
// 隐藏管理 sheet 触发态
|
||||||
@State private var showHiddenSheet: Bool = false
|
@State private var showHiddenSheet: Bool = false
|
||||||
|
|
||||||
|
// 「再记一条」预选只应用一次
|
||||||
|
@State private var didApplyPrefill = false
|
||||||
|
|
||||||
|
// 顶部搜索:点放大镜展开搜索框,按名实时过滤长期监测预设 / 自定义指标 / 化验项快捷。
|
||||||
|
@State private var searchingMetrics = false
|
||||||
|
@State private var metricQuery = ""
|
||||||
|
|
||||||
|
private var isSearchingMetrics: Bool {
|
||||||
|
!metricQuery.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
private var filteredMonitorMetrics: [MonitorMetric] {
|
||||||
|
let q = metricQuery.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !q.isEmpty else { return visibleMonitorMetrics }
|
||||||
|
return visibleMonitorMetrics.filter { $0.displayName.localizedCaseInsensitiveContains(q) }
|
||||||
|
}
|
||||||
|
private var filteredCustomMetrics: [CustomMonitorMetric] {
|
||||||
|
let q = metricQuery.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !q.isEmpty else { return customMetrics }
|
||||||
|
return customMetrics.filter { $0.name.localizedCaseInsensitiveContains(q) }
|
||||||
|
}
|
||||||
|
private var filteredLabPresets: [IndicatorPreset] {
|
||||||
|
let q = metricQuery.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !q.isEmpty else { return labPresets }
|
||||||
|
return labPresets.filter { $0.name.localizedCaseInsensitiveContains(q) }
|
||||||
|
}
|
||||||
|
|
||||||
private static var defaultReminderTime: Date {
|
private static var defaultReminderTime: Date {
|
||||||
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
|
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
|
||||||
}
|
}
|
||||||
@@ -103,6 +145,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
cameraEntrySection
|
||||||
monitorGridSection
|
monitorGridSection
|
||||||
labPresetSection
|
labPresetSection
|
||||||
Divider().padding(.vertical, 4)
|
Divider().padding(.vertical, 4)
|
||||||
@@ -132,12 +175,14 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
footer
|
footer
|
||||||
}
|
}
|
||||||
|
.onAppear { applyPrefillIfNeeded() }
|
||||||
.task(id: longTermKey) { hydrateReminder() }
|
.task(id: longTermKey) { hydrateReminder() }
|
||||||
.background(
|
.background(
|
||||||
Tj.Palette.sand
|
Tj.Palette.sand
|
||||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||||
.ignoresSafeArea(edges: .bottom)
|
.ignoresSafeArea(edges: .bottom)
|
||||||
)
|
)
|
||||||
|
.preferredColorScheme(.light)
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.hidden)
|
.presentationDragIndicator(.hidden)
|
||||||
.presentationBackground(Tj.Palette.sand)
|
.presentationBackground(Tj.Palette.sand)
|
||||||
@@ -155,23 +200,124 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack {
|
VStack(spacing: 12) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
Text("记录指标")
|
Text("记录指标")
|
||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("本地处理 · 永不上传")
|
Text("本地处理 · 永不上传")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
searchToggle
|
||||||
|
}
|
||||||
|
if searchingMetrics { searchField }
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var searchToggle: some View {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.18)) {
|
||||||
|
searchingMetrics.toggle()
|
||||||
|
if !searchingMetrics { metricQuery = "" }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: searchingMetrics ? "xmark" : "magnifyingglass")
|
||||||
|
.font(.tjScaled( 14, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(Circle().fill(Tj.Palette.sand2))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(searchingMetrics ? String(appLoc: "关闭搜索") : String(appLoc: "搜索指标"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchField: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.tjScaled( 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
TextField(String(appLoc: "搜索指标名"), text: $metricQuery)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
|
if !metricQuery.isEmpty {
|
||||||
|
Button { metricQuery = "" } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(fieldBg)
|
||||||
|
.overlay(fieldBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 顶部「拍照识别」入口:并入原「指标速记」。点后由 RootView 切到相机 VL 流程。
|
||||||
|
@ViewBuilder
|
||||||
|
private var cameraEntrySection: some View {
|
||||||
|
if let onRequestCamera {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Button {
|
||||||
|
onRequestCamera()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.brick)
|
||||||
|
Image(systemName: "camera.fill")
|
||||||
|
.font(.tjScaled(18, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("拍照识别")
|
||||||
|
.font(.tjScaled(15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("拍化验单,VL 自动读出数值")
|
||||||
|
.font(.tjScaled(12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.tjScaled(14, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.tjCard(bordered: true)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
line
|
||||||
|
Text("或手动填写")
|
||||||
|
.font(.tjScaled(11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.fixedSize()
|
||||||
|
line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var line: some View {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Tj.Palette.lineSoft)
|
||||||
|
.frame(height: 1)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
private var monitorGridSection: some View {
|
private var monitorGridSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
sectionLabel("长期监测(进趋势)")
|
sectionLabel(String(appLoc: "长期监测(进趋势)"))
|
||||||
Spacer()
|
Spacer()
|
||||||
if !hiddenSet.isEmpty {
|
if !hiddenSet.isEmpty {
|
||||||
hiddenCountChip
|
hiddenCountChip
|
||||||
@@ -179,13 +325,19 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
let columns = [GridItem(.flexible()), GridItem(.flexible())]
|
let columns = [GridItem(.flexible()), GridItem(.flexible())]
|
||||||
LazyVGrid(columns: columns, spacing: 8) {
|
LazyVGrid(columns: columns, spacing: 8) {
|
||||||
ForEach(visibleMonitorMetrics) { m in
|
ForEach(filteredMonitorMetrics) { m in
|
||||||
monitorTile(m)
|
monitorTile(m)
|
||||||
}
|
}
|
||||||
ForEach(customMetrics) { cm in
|
ForEach(filteredCustomMetrics) { cm in
|
||||||
customTile(cm)
|
customTile(cm)
|
||||||
}
|
}
|
||||||
addCustomTile
|
// 搜索态下不显示「自定义(新建)」格,聚焦过滤结果。
|
||||||
|
if !isSearchingMetrics { addCustomTile }
|
||||||
|
}
|
||||||
|
if isSearchingMetrics, filteredMonitorMetrics.isEmpty, filteredCustomMetrics.isEmpty {
|
||||||
|
Text("没有匹配的长期监测指标")
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showHiddenSheet) {
|
.sheet(isPresented: $showHiddenSheet) {
|
||||||
@@ -217,18 +369,18 @@ struct IndicatorQuickSheet: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: cm.icon)
|
Image(systemName: cm.icon)
|
||||||
.font(.system(size: 18, weight: .medium))
|
.font(.tjScaled( 18, weight: .medium))
|
||||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft))
|
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(cm.name)
|
Text(cm.name)
|
||||||
.font(.system(size: 14, weight: selected ? .semibold : .medium))
|
.font(.tjScaled( 14, weight: selected ? .semibold : .medium))
|
||||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text("自定义")
|
Text("自定义")
|
||||||
.font(.system(size: 9, design: .monospaced))
|
.font(.tjScaled( 9, design: .monospaced))
|
||||||
.foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3)
|
.foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -246,13 +398,10 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
|
// 单一入口:进编辑器既能改也能删(编辑器内含删除按钮)。
|
||||||
|
// 旧实现两项 action 完全相同,第二项却标红 trash「编辑/删除」,看似直接删除实则打开编辑器,误导。
|
||||||
Button { editingCustom = CustomMetricEditTarget(metric: cm) } label: {
|
Button { editingCustom = CustomMetricEditTarget(metric: cm) } label: {
|
||||||
Label("编辑", systemImage: "pencil")
|
Label("编辑 / 删除", systemImage: "pencil")
|
||||||
}
|
|
||||||
Button(role: .destructive) {
|
|
||||||
editingCustom = CustomMetricEditTarget(metric: cm)
|
|
||||||
} label: {
|
|
||||||
Label("编辑/删除", systemImage: "trash")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,14 +412,14 @@ struct IndicatorQuickSheet: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.system(size: 18, weight: .semibold))
|
.font(.tjScaled( 18, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.background(
|
.background(
|
||||||
Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true)
|
Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true)
|
||||||
)
|
)
|
||||||
Text("自定义")
|
Text("自定义")
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -296,13 +445,13 @@ struct IndicatorQuickSheet: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: m.icon)
|
Image(systemName: m.icon)
|
||||||
.font(.system(size: 18, weight: .medium))
|
.font(.tjScaled( 18, weight: .medium))
|
||||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.amber.opacity(0.25)))
|
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.amber.opacity(0.25)))
|
||||||
|
|
||||||
Text(m.displayName)
|
Text(m.displayName)
|
||||||
.font(.system(size: 14, weight: selected ? .semibold : .medium))
|
.font(.tjScaled( 14, weight: selected ? .semibold : .medium))
|
||||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -327,12 +476,15 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
private var labPresetSection: some View {
|
private var labPresetSection: some View {
|
||||||
|
// 搜索且化验项无匹配:整段隐藏(避免只剩一个空标题)。
|
||||||
|
if !(isSearchingMetrics && filteredLabPresets.isEmpty) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("化验项快捷(不进趋势)")
|
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(labPresets) { p in
|
ForEach(filteredLabPresets) { p in
|
||||||
chip(p.name, selected: selectedLabPreset == p) {
|
chip(p.name, selected: selectedLabPreset == p) {
|
||||||
applyLab(p)
|
applyLab(p)
|
||||||
}
|
}
|
||||||
@@ -341,18 +493,19 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var bpFieldSection: some View {
|
private var bpFieldSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
sectionLabel("收缩 / 舒张")
|
sectionLabel(String(appLoc: "收缩 / 舒张"))
|
||||||
Spacer()
|
Spacer()
|
||||||
bpRangeHint
|
bpRangeHint
|
||||||
}
|
}
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
bpField(label: "收缩压", value: $systolic, placeholder: "120")
|
bpField(label: String(appLoc: "收缩压"), value: $systolic, placeholder: "120")
|
||||||
Text("/").font(.system(size: 22, weight: .light)).foregroundStyle(Tj.Palette.text3)
|
Text("/").font(.tjScaled( 22, weight: .light)).foregroundStyle(Tj.Palette.text3)
|
||||||
bpField(label: "舒张压", value: $diastolic, placeholder: "80")
|
bpField(label: String(appLoc: "舒张压"), value: $diastolic, placeholder: "80")
|
||||||
Text("mmHg").foregroundStyle(Tj.Palette.text3)
|
Text("mmHg").foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
bpStatusChips
|
bpStatusChips
|
||||||
@@ -361,10 +514,12 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private func bpField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
private func bpField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
Text(label).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||||
TextField(placeholder, text: value)
|
TextField(placeholder, text: value)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
.font(.system(size: 20, weight: .semibold, design: .monospaced))
|
.font(.tjScaled( 20, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.frame(width: 90)
|
.frame(width: 90)
|
||||||
@@ -383,11 +538,11 @@ struct IndicatorQuickSheet: View {
|
|||||||
let rangeText = "\(formatRange(sysRange)) / \(formatRange(diasRange))"
|
let rangeText = "\(formatRange(sysRange)) / \(formatRange(diasRange))"
|
||||||
return HStack(spacing: 4) {
|
return HStack(spacing: 4) {
|
||||||
Text(rangeText)
|
Text(rangeText)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
if personalized, let age = profile?.age {
|
if personalized, let age = profile?.age {
|
||||||
Text("· 按\(age)岁调整")
|
Text("· 按\(age)岁调整")
|
||||||
.font(.system(size: 10))
|
.font(.tjScaled( 10))
|
||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,10 +551,10 @@ struct IndicatorQuickSheet: View {
|
|||||||
private var bpStatusChips: some View {
|
private var bpStatusChips: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if let s = computedBPStatus(.systolic) {
|
if let s = computedBPStatus(.systolic) {
|
||||||
statusBadge("收缩 " + s.label, color: s.color)
|
statusBadge(String(appLoc: "收缩 ") + s.label, color: s.color)
|
||||||
}
|
}
|
||||||
if let s = computedBPStatus(.diastolic) {
|
if let s = computedBPStatus(.diastolic) {
|
||||||
statusBadge("舒张 " + s.label, color: s.color)
|
statusBadge(String(appLoc: "舒张 ") + s.label, color: s.color)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -407,9 +562,11 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private var nameSection: some View {
|
private var nameSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("指标名")
|
sectionLabel(String(appLoc: "指标名"))
|
||||||
TextField("例如:血红蛋白", text: $name)
|
TextField("例如:血红蛋白", text: $name)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(fieldBg)
|
.background(fieldBg)
|
||||||
@@ -427,20 +584,24 @@ struct IndicatorQuickSheet: View {
|
|||||||
private var valueRow: some View {
|
private var valueRow: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("数值")
|
sectionLabel(String(appLoc: "数值"))
|
||||||
TextField(monitorFieldPlaceholder, text: $value)
|
TextField(monitorFieldPlaceholder, text: $value)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
.font(.system(size: 18, weight: .semibold, design: .monospaced))
|
.font(.tjScaled( 18, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(fieldBg)
|
.background(fieldBg)
|
||||||
.overlay(fieldBorder)
|
.overlay(fieldBorder)
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("单位")
|
sectionLabel(String(appLoc: "单位"))
|
||||||
TextField("mmol/L", text: $unit)
|
TextField("mmol/L", text: $unit)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(fieldBg)
|
.background(fieldBg)
|
||||||
@@ -455,7 +616,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
private var rangeSection: some View {
|
private var rangeSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
sectionLabel("参考范围")
|
sectionLabel(String(appLoc: "参考范围"))
|
||||||
Spacer()
|
Spacer()
|
||||||
if let m = selectedMonitor, m != .bloodPressure {
|
if let m = selectedMonitor, m != .bloodPressure {
|
||||||
monitorRangeHint(m)
|
monitorRangeHint(m)
|
||||||
@@ -464,6 +625,8 @@ struct IndicatorQuickSheet: View {
|
|||||||
TextField("例如:< 3.40 或 3.9 - 6.1", text: $range)
|
TextField("例如:< 3.40 或 3.9 - 6.1", text: $range)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(fieldBg)
|
.background(fieldBg)
|
||||||
@@ -478,7 +641,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
return HStack(spacing: 4) {
|
return HStack(spacing: 4) {
|
||||||
if personalized, let age = profile?.age {
|
if personalized, let age = profile?.age {
|
||||||
Text("按\(age)岁调整")
|
Text("按\(age)岁调整")
|
||||||
.font(.system(size: 10))
|
.font(.tjScaled( 10))
|
||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -486,11 +649,11 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private var statusSection: some View {
|
private var statusSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("状态")
|
sectionLabel(String(appLoc: "状态"))
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
statusChip(.normal, label: "正常", color: Tj.Palette.leaf)
|
statusChip(.normal, label: String(appLoc: "正常"), color: Tj.Palette.leaf)
|
||||||
statusChip(.high, label: "偏高 ↑", color: Tj.Palette.brick)
|
statusChip(.high, label: String(appLoc: "偏高 ↑"), color: Tj.Palette.brick)
|
||||||
statusChip(.low, label: "偏低 ↓", color: Tj.Palette.amber)
|
statusChip(.low, label: String(appLoc: "偏低 ↓"), color: Tj.Palette.amber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,12 +661,12 @@ struct IndicatorQuickSheet: View {
|
|||||||
private var autoStatusHint: some View {
|
private var autoStatusHint: some View {
|
||||||
let auto = computedSingleStatus
|
let auto = computedSingleStatus
|
||||||
return HStack(spacing: 8) {
|
return HStack(spacing: 8) {
|
||||||
sectionLabel("状态(按数值自动判)")
|
sectionLabel(String(appLoc: "状态(按数值自动判)"))
|
||||||
if let s = auto {
|
if let s = auto {
|
||||||
statusBadge(s.label, color: s.color)
|
statusBadge(s.label, color: s.color)
|
||||||
} else {
|
} else {
|
||||||
Text("待输入")
|
Text("待输入")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -511,7 +674,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private var timeSection: some View {
|
private var timeSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("测量时间")
|
sectionLabel(String(appLoc: "测量时间"))
|
||||||
DatePicker("", selection: $capturedAt, in: ...Date.now)
|
DatePicker("", selection: $capturedAt, in: ...Date.now)
|
||||||
.datePickerStyle(.compact)
|
.datePickerStyle(.compact)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
@@ -520,9 +683,11 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private var noteSection: some View {
|
private var noteSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("备注(可选)")
|
sectionLabel(String(appLoc: "备注(可选)"))
|
||||||
TextField("例如:空腹采血", text: $note, axis: .vertical)
|
TextField("例如:空腹采血", text: $note, axis: .vertical)
|
||||||
.lineLimit(1...3)
|
.lineLimit(1...3)
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(fieldBg)
|
.background(fieldBg)
|
||||||
@@ -535,7 +700,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
private var reminderSection: some View {
|
private var reminderSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
sectionLabel("周期提醒")
|
sectionLabel(String(appLoc: "周期提醒"))
|
||||||
Spacer()
|
Spacer()
|
||||||
Toggle("", isOn: $reminderEnabled)
|
Toggle("", isOn: $reminderEnabled)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
@@ -549,7 +714,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("时间")
|
Text("时间")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer()
|
Spacer()
|
||||||
DatePicker("", selection: $reminderTime,
|
DatePicker("", selection: $reminderTime,
|
||||||
@@ -561,22 +726,22 @@ struct IndicatorQuickSheet: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("频率")
|
Text("频率")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(reminderFrequencyLabel)
|
Text(reminderFrequencyLabel)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
weekdayPickerRow
|
weekdayPickerRow
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
quickFreqChip("每天") {
|
quickFreqChip(String(appLoc: "每天")) {
|
||||||
reminderWeekdays = Set(1...7)
|
reminderWeekdays = Set(1...7)
|
||||||
}
|
}
|
||||||
quickFreqChip("工作日") {
|
quickFreqChip(String(appLoc: "工作日")) {
|
||||||
reminderWeekdays = Set([2, 3, 4, 5, 6])
|
reminderWeekdays = Set([2, 3, 4, 5, 6])
|
||||||
}
|
}
|
||||||
quickFreqChip("周末") {
|
quickFreqChip(String(appLoc: "周末")) {
|
||||||
reminderWeekdays = Set([1, 7])
|
reminderWeekdays = Set([1, 7])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -584,11 +749,11 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
if notifAuthBlocked {
|
if notifAuthBlocked {
|
||||||
Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开")
|
Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
} else {
|
} else {
|
||||||
Text("本机提醒 · 不发任何数据")
|
Text("本机提醒 · 不发任何数据")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -600,15 +765,23 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var reminderFrequencyLabel: String {
|
private var reminderFrequencyLabel: String {
|
||||||
if reminderWeekdays.count == 7 { return "每天" }
|
if reminderWeekdays.count == 7 { return String(appLoc: "每天") }
|
||||||
if reminderWeekdays.isEmpty { return "未选" }
|
if reminderWeekdays.isEmpty { return String(appLoc: "未选") }
|
||||||
let names = ["日", "一", "二", "三", "四", "五", "六"]
|
let names = [
|
||||||
|
String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"),
|
||||||
|
String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"),
|
||||||
|
String(appLoc: "六"),
|
||||||
|
]
|
||||||
let sorted = reminderWeekdays.sorted()
|
let sorted = reminderWeekdays.sorted()
|
||||||
return "每周 " + sorted.map { names[$0 - 1] }.joined()
|
return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var weekdayPickerRow: some View {
|
private var weekdayPickerRow: some View {
|
||||||
let names = ["一", "二", "三", "四", "五", "六", "日"]
|
let names = [
|
||||||
|
String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"),
|
||||||
|
String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"),
|
||||||
|
String(appLoc: "日"),
|
||||||
|
]
|
||||||
let weekdayValues = [2, 3, 4, 5, 6, 7, 1] // 周一到周日(Apple Calendar 编号)
|
let weekdayValues = [2, 3, 4, 5, 6, 7, 1] // 周一到周日(Apple Calendar 编号)
|
||||||
return HStack(spacing: 6) {
|
return HStack(spacing: 6) {
|
||||||
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
|
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
|
||||||
@@ -620,7 +793,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(names[idx])
|
Text(names[idx])
|
||||||
.font(.system(size: 13,
|
.font(.tjScaled( 13,
|
||||||
weight: reminderWeekdays.contains(w) ? .semibold : .regular))
|
weight: reminderWeekdays.contains(w) ? .semibold : .regular))
|
||||||
.foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
|
.foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.frame(maxWidth: .infinity, minHeight: 32)
|
.frame(maxWidth: .infinity, minHeight: 32)
|
||||||
@@ -642,7 +815,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View {
|
private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
@@ -750,7 +923,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private func sectionLabel(_ text: String) -> some View {
|
private func sectionLabel(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
@@ -758,7 +931,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
||||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -774,7 +947,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
manualStatus = value
|
manualStatus = value
|
||||||
} label: {
|
} label: {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
||||||
.foregroundStyle(selected ? Tj.Palette.paper : color)
|
.foregroundStyle(selected ? Tj.Palette.paper : color)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -787,7 +960,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private func statusBadge(_ label: String, color: Color) -> some View {
|
private func statusBadge(_ label: String, color: Color) -> some View {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(color)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
@@ -827,9 +1000,9 @@ struct IndicatorQuickSheet: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
Text("已隐藏 \(hiddenSet.count)")
|
Text("已隐藏 \(hiddenSet.count)")
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.tjScaled( 11, weight: .medium))
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 9, weight: .semibold))
|
.font(.tjScaled( 9, weight: .semibold))
|
||||||
}
|
}
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
@@ -862,6 +1035,29 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
// MARK: - apply preset
|
// MARK: - apply preset
|
||||||
|
|
||||||
|
/// 「再记一条」预选:seriesKey 命中长期监测预设 / 自定义指标则选中对应卡片(进趋势 + 自动判异常),
|
||||||
|
/// 否则按 name/unit/range 走自由输入。数值不预填——让用户填新读数。只应用一次。
|
||||||
|
private func applyPrefillIfNeeded() {
|
||||||
|
guard !didApplyPrefill, let p = prefill else { return }
|
||||||
|
didApplyPrefill = true
|
||||||
|
if let key = p.seriesKey {
|
||||||
|
if let m = MonitorMetric.allCases.first(where: { metric in
|
||||||
|
metric.fields.contains { $0.seriesKey == key }
|
||||||
|
}) {
|
||||||
|
applyMonitor(m)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let cm = customMetrics.first(where: { $0.seriesKey == key }) {
|
||||||
|
applyCustom(cm)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 无 seriesKey 或未匹配预设(化验项 / 报告 / 自由指标):自由输入预填,不带 seriesKey,不进趋势。
|
||||||
|
name = p.name
|
||||||
|
unit = p.unit
|
||||||
|
range = p.range
|
||||||
|
}
|
||||||
|
|
||||||
private func applyMonitor(_ m: MonitorMetric) {
|
private func applyMonitor(_ m: MonitorMetric) {
|
||||||
if selectedMonitor == m {
|
if selectedMonitor == m {
|
||||||
// 取消选择
|
// 取消选择
|
||||||
@@ -1074,9 +1270,9 @@ struct IndicatorQuickSheet: View {
|
|||||||
private extension IndicatorStatus {
|
private extension IndicatorStatus {
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .normal: return "正常"
|
case .normal: return String(appLoc: "正常")
|
||||||
case .high: return "偏高 ↑"
|
case .high: return String(appLoc: "偏高 ↑")
|
||||||
case .low: return "偏低 ↓"
|
case .low: return String(appLoc: "偏低 ↓")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,7 +1312,7 @@ private struct HiddenMonitorRestoreSheet: View {
|
|||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("完成") { dismiss() }
|
Button("完成") { dismiss() }
|
||||||
.font(.system(size: 14))
|
.font(.tjScaled( 14))
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -1141,13 +1337,13 @@ private struct HiddenMonitorRestoreSheet: View {
|
|||||||
private func row(_ m: MonitorMetric) -> some View {
|
private func row(_ m: MonitorMetric) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: m.icon)
|
Image(systemName: m.icon)
|
||||||
.font(.system(size: 16, weight: .medium))
|
.font(.tjScaled( 16, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.background(Circle().fill(Tj.Palette.amber.opacity(0.25)))
|
.background(Circle().fill(Tj.Palette.amber.opacity(0.25)))
|
||||||
|
|
||||||
Text(m.displayName)
|
Text(m.displayName)
|
||||||
.font(.system(size: 15, weight: .medium))
|
.font(.tjScaled( 15, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -1155,7 +1351,7 @@ private struct HiddenMonitorRestoreSheet: View {
|
|||||||
Button("显示") {
|
Button("显示") {
|
||||||
onRestore(m)
|
onRestore(m)
|
||||||
}
|
}
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
/// 「我的 · 关于」——本软件基本介绍、使用注意与免责声明。
|
/// 「我的 · 关于」——本软件基本介绍、使用注意与免责声明。
|
||||||
/// 纯静态阅读页,不调任何 Service / AIRuntime,复用现有 DesignSystem token。
|
/// 纯静态阅读页,不调任何 Service / AIRuntime,复用现有 DesignSystem token。
|
||||||
|
/// 文案按 App Store 上架合规口径撰写:避免绝对化用语、精确区分本地/联网行为、强化医疗免责。
|
||||||
struct AboutView: View {
|
struct AboutView: View {
|
||||||
/// 真实读取 Bundle 版本号,避免硬编码与实际发版脱节。
|
/// 真实读取 Bundle 版本号,避免硬编码与实际发版脱节。
|
||||||
private var versionText: String {
|
private var versionText: String {
|
||||||
@@ -19,52 +20,66 @@ struct AboutView: View {
|
|||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
header
|
header
|
||||||
|
|
||||||
section(icon: "sparkles", title: "这是什么") {
|
section(icon: "sparkles", title: String(appLoc: "这是什么")) {
|
||||||
paragraph(
|
paragraph(
|
||||||
"康康是一款以本地优先为设计原则的个人健康影像档案工具。" +
|
String(appLoc: "康康是一款以本地优先为设计原则的个人健康随记工具。") +
|
||||||
"你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;" +
|
String(appLoc: "你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;") +
|
||||||
"设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。"
|
String(appLoc: "设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
section(icon: "checklist", title: "主要功能") {
|
section(icon: "checklist", title: String(appLoc: "主要功能")) {
|
||||||
bullet("拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档")
|
bullet(String(appLoc: "拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档"))
|
||||||
bullet("通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明")
|
bullet(String(appLoc: "通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明"))
|
||||||
bullet("长期趋势:关注的指标可生成折线图和简要解读")
|
bullet(String(appLoc: "长期趋势:关注的指标可生成折线图和简要解读"))
|
||||||
bullet("本地问答:基于你自己的档案问答,引用可点击回链到原记录")
|
bullet(String(appLoc: "本地问答:基于你自己的档案问答,引用可点击回链到原记录"))
|
||||||
bullet("隐私优先:健康数据不上传、无需注册账号")
|
bullet(String(appLoc: "隐私优先:健康数据不上传、无需注册账号"))
|
||||||
}
|
}
|
||||||
|
|
||||||
section(icon: "lock.shield", title: "隐私保护") {
|
section(icon: "iphone", title: String(appLoc: "设备要求"), tint: Tj.Palette.leaf) {
|
||||||
bullet("AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据。")
|
bullet(String(appLoc: "系统:iOS 17 或更新版本。"))
|
||||||
bullet("原图与数据库采用系统级文件加密,随设备锁屏受到保护。")
|
bullet(String(appLoc: "本地 AI 功能(拍照识别、解读、问答)需要约 8GB 内存,") +
|
||||||
bullet("支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份。")
|
String(appLoc: "推荐 iPhone 15 Pro / Pro Max 及之后发布的机型(含 iPhone 16 系列)。"))
|
||||||
bullet("可选开启 Face ID 启动锁,进一步保护隐私。")
|
bullet(String(appLoc: "在内存较小的旧机型上,App 仍可用于手动记录、归档与查看,") +
|
||||||
|
String(appLoc: "但本地 AI 相关功能可能无法运行。"))
|
||||||
}
|
}
|
||||||
|
|
||||||
section(icon: "exclamationmark.triangle", title: "使用注意", tint: Tj.Palette.amber) {
|
section(icon: "lock.shield", title: String(appLoc: "隐私保护")) {
|
||||||
bullet("本地 AI 模型体积较大(约 3GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;" +
|
bullet(String(appLoc: "AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据。"))
|
||||||
"模型未就绪时 App 仍可使用,AI 功能会提示前往下载。")
|
bullet(String(appLoc: "原图与数据库采用系统级文件加密,随设备锁屏受到保护。"))
|
||||||
bullet("AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对," +
|
bullet(String(appLoc: "支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份。"))
|
||||||
"并以原始报告 / 化验单为准。")
|
bullet(String(appLoc: "可选开启 Face ID 启动锁,进一步保护隐私。"))
|
||||||
bullet("AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。")
|
|
||||||
bullet("数据保存在本设备:卸载 App 或删除数据后可能无法恢复,重要资料请自行留存原件。")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
section(icon: "hand.raised", title: "免责声明", tint: Tj.Palette.brick) {
|
section(icon: "exclamationmark.triangle", title: String(appLoc: "使用注意"), tint: Tj.Palette.amber) {
|
||||||
bullet("康康是一款健康信息记录与参考工具,并非医疗器械,不提供医疗诊断、用药或剂量建议、急诊判断等医疗服务。")
|
bullet(String(appLoc: "本地 AI 模型体积较大(约 4GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;") +
|
||||||
bullet("App 内所有 AI 生成的解读、趋势分析与问答内容仅供信息参考," +
|
String(appLoc: "模型未就绪时 App 仍可使用,AI 功能会提示前往下载。"))
|
||||||
"不构成医疗建议,也不能替代执业医师、药师或其他专业人员的面诊、检查与意见。")
|
bullet(String(appLoc: "AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对,") +
|
||||||
bullet("任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。")
|
String(appLoc: "并以原始报告 / 化验单为准。"))
|
||||||
bullet("如出现身体不适或紧急情况,请及时就医或拨打当地急救电话,请勿依赖本 App 进行判断。")
|
bullet(String(appLoc: "AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。"))
|
||||||
bullet("在适用法律允许的范围内,因使用本 App 或依赖其中内容所产生的后果,由使用者自行承担。")
|
bullet(String(appLoc: "数据保存在本设备:卸载 App 或删除数据后可能无法恢复,重要资料请自行留存原件。"))
|
||||||
|
}
|
||||||
|
|
||||||
|
section(icon: "hand.raised", title: String(appLoc: "免责声明"), tint: Tj.Palette.brick) {
|
||||||
|
bullet(String(appLoc: "康康是一款健康信息记录与参考工具,并非医疗器械,不提供医疗诊断、用药或剂量建议、急诊判断等医疗服务。"))
|
||||||
|
bullet(String(appLoc: "App 内所有 AI 生成的解读、趋势分析与问答内容仅供信息参考,") +
|
||||||
|
String(appLoc: "不构成医疗建议,也不能替代执业医师、药师或其他专业人员的面诊、检查与意见。"))
|
||||||
|
bullet(String(appLoc: "任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。"))
|
||||||
|
bullet(String(appLoc: "如出现身体不适或紧急情况,请及时就医或拨打当地急救电话,请勿依赖本 App 进行判断。"))
|
||||||
|
bullet(String(appLoc: "在适用法律允许的范围内,因使用本 App 或依赖其中内容所产生的后果,由使用者自行承担。"))
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("康康 · 本地优先的健康档案 · \(versionText)")
|
Text("康康 · 本地优先的健康档案 · \(versionText)")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
Text("本 App 仅供健康信息记录与参考,不能替代专业医疗意见。")
|
||||||
|
.font(.tjScaled( 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
Spacer(minLength: 32)
|
Spacer(minLength: 32)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
@@ -83,7 +98,7 @@ struct AboutView: View {
|
|||||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
.fill(Tj.Palette.sand2)
|
.fill(Tj.Palette.sand2)
|
||||||
Image(systemName: "heart.text.square.fill")
|
Image(systemName: "heart.text.square.fill")
|
||||||
.font(.system(size: 34))
|
.font(.tjScaled( 34))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
}
|
}
|
||||||
.frame(width: 72, height: 72)
|
.frame(width: 72, height: 72)
|
||||||
@@ -92,8 +107,8 @@ struct AboutView: View {
|
|||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
|
||||||
Text("本地优先的个人健康影像档案")
|
Text("本地优先的个人健康随记")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
|
||||||
Text(versionText)
|
Text(versionText)
|
||||||
@@ -118,10 +133,10 @@ struct AboutView: View {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(tint)
|
.foregroundStyle(tint)
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
}
|
}
|
||||||
content()
|
content()
|
||||||
@@ -133,7 +148,7 @@ struct AboutView: View {
|
|||||||
|
|
||||||
@ViewBuilder private func paragraph(_ text: String) -> some View {
|
@ViewBuilder private func paragraph(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 14))
|
.font(.tjScaled( 14))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.lineSpacing(5)
|
.lineSpacing(5)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -146,7 +161,7 @@ struct AboutView: View {
|
|||||||
.frame(width: 5, height: 5)
|
.frame(width: 5, height: 5)
|
||||||
.padding(.top, 7)
|
.padding(.top, 7)
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 14))
|
.font(.tjScaled( 14))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.lineSpacing(5)
|
.lineSpacing(5)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|||||||