Compare commits
58 Commits
770dd6bedf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77697e1600 | ||
|
|
30f97b3535 | ||
|
|
3798efa48d | ||
|
|
bff7cfd4b6 | ||
|
|
32e7c25ed7 | ||
|
|
d72a1fec17 | ||
|
|
db7cc1bba7 | ||
|
|
adb589af16 | ||
|
|
da6223e051 | ||
|
|
40155de709 | ||
|
|
7ad41c5f09 | ||
|
|
dad9d43486 | ||
|
|
d2c77d5c51 | ||
|
|
910ca99f21 | ||
|
|
062c027c77 | ||
|
|
6ccbe4ac55 | ||
|
|
fe80e112af | ||
|
|
5f8f492f0e | ||
|
|
599d39af35 | ||
|
|
1b01923c8e | ||
|
|
39edc25dc1 | ||
|
|
37b47b2076 | ||
|
|
e2fb631b96 | ||
|
|
0f38bf585b | ||
|
|
3dcb792131 | ||
|
|
9a6d21100b | ||
|
|
7ede38ae06 | ||
|
|
22cf4bcefe | ||
|
|
bb08243aa9 | ||
|
|
b80fae35c9 | ||
|
|
e3ad24ac0e | ||
|
|
b63b26bce5 | ||
|
|
b1b8d0a8c7 | ||
|
|
2e728dcd24 | ||
|
|
46b69cf8e1 | ||
|
|
e4a68a1bdd | ||
|
|
53da442424 | ||
|
|
44ed01acf4 | ||
|
|
9419e8158f | ||
|
|
57536e5319 | ||
|
|
a3e758cf83 | ||
|
|
acfdaa1f4f | ||
|
|
9fbd31458c | ||
|
|
a02679a623 | ||
|
|
f5f78e36a6 | ||
|
|
1ee512dce1 | ||
|
|
ad1b045e12 | ||
|
|
ef0fbeac97 | ||
|
|
193e478425 | ||
|
|
771b28e7ef | ||
|
|
e7cdb45472 | ||
|
|
4dcd951821 | ||
|
|
d40cb7d1e0 | ||
|
|
ad6fb660f0 | ||
|
|
0739ccea2b | ||
|
|
d704a9eb78 | ||
|
|
2b6c4b9726 | ||
|
|
c050865db5 |
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
/build/
|
# 大模型素材:本地下载用于上传到 OpenList,不入库(~3GB)
|
||||||
/Models/
|
/Models/
|
||||||
|
/build/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
90
CLAUDE.md
@@ -1,4 +1,4 @@
|
|||||||
# 康记 / 体己 —— 工程前提
|
# 康康 —— 工程前提
|
||||||
|
|
||||||
> 这是一个 6 周决赛 demo 项目。今天是 2026-05-25,处于 W1末/W2初。
|
> 这是一个 6 周决赛 demo 项目。今天是 2026-05-25,处于 W1末/W2初。
|
||||||
> 任何 IDE/Claude 会话开始干活前,先读这份文件。
|
> 任何 IDE/Claude 会话开始干活前,先读这份文件。
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
## 1. 产品定位
|
## 1. 产品定位
|
||||||
|
|
||||||
- **名字**:康记(对内代号 体己 / Tiji)
|
- **名字**:康康(对内代号 Kangkang)
|
||||||
- **形态**:iOS 原生 App,SwiftUI + SwiftData
|
- **形态**:iOS 原生 App,SwiftUI + SwiftData
|
||||||
- **核心卖点**:**100% 本地推理**的个人健康影像档案 + 大白话解读 + 本地 RAG 问答
|
- **核心卖点**:**100% 本地推理**的个人健康影像档案 + 大白话解读 + 本地 RAG 问答
|
||||||
- **目标用户**:不愿把体检/化验报告交给云端的普通人
|
- **目标用户**:不愿把体检/化验报告交给云端的普通人
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
| 持久化 | SwiftData | 见 §5 数据模型 |
|
| 持久化 | SwiftData | 见 §5 数据模型 |
|
||||||
| 图表 | Swift Charts | iOS 16+ 原生 |
|
| 图表 | Swift Charts | iOS 16+ 原生 |
|
||||||
| **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama |
|
| **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama |
|
||||||
| LLM | Qwen3-1.7B (MLX 4bit 量化) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 |
|
| LLM | Qwen3-1.7B 4bit (HF: `mlx-community/Qwen3-1.7B-4bit`) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 |
|
||||||
| VL | Qwen2.5-VL-3B (MLX 4bit 量化) | ~2.0GB,负责拍照→结构化指标 |
|
| VL | Qwen2.5-VL-3B-Instruct 4bit (HF: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`) | ~2.0GB,负责拍照→结构化指标 |
|
||||||
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
|
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
|
||||||
| Face ID | LocalAuthentication | |
|
| Face ID | LocalAuthentication | |
|
||||||
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
|
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
|
||||||
@@ -84,7 +84,7 @@ VL prompt 必须:
|
|||||||
## 4. 模型分发
|
## 4. 模型分发
|
||||||
|
|
||||||
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
||||||
- 总体积 ~3GB,WiFi 提示必须有
|
- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有
|
||||||
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
||||||
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
||||||
|
|
||||||
@@ -92,37 +92,27 @@ VL prompt 必须:
|
|||||||
|
|
||||||
## 5. 数据模型(SwiftData)
|
## 5. 数据模型(SwiftData)
|
||||||
|
|
||||||
现有 3 个 `@Model`,要新增 2 个:
|
**当前 schema(2026-05-26)**:7 个 @Model。
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
// 已有(在 Models/Models.swift)
|
@Model class Indicator {
|
||||||
@Model class Indicator { name, value, unit, range, statusRaw, note, capturedAt }
|
name, value, unit, range, statusRaw, note, capturedAt,
|
||||||
@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt }
|
report: Report?, asset: Asset?,
|
||||||
@Model class DiaryEntry { content, createdAt }
|
pinned: Bool, // 长期监测自动 true,Trends 默认展示
|
||||||
|
seriesKey: String? // "bp.systolic" / "glucose.fasting" / ... 长期指标分组 key
|
||||||
// 待加字段
|
|
||||||
// Indicator + report: Report? 反向关系
|
|
||||||
// Indicator + asset: Asset? 关联原图
|
|
||||||
// Indicator + pinned: Bool C2 "关联到趋势" 后置 true,Trends 默认展示 pinned 指标
|
|
||||||
// Report + indicators: [Indicator] @Relationship cascade
|
|
||||||
// Report + assets: [Asset] @Relationship cascade
|
|
||||||
// DiaryEntry + tags: [String] VL/LLM 抽取的标签
|
|
||||||
|
|
||||||
// 待加 @Model
|
|
||||||
@Model class Asset {
|
|
||||||
var relativePath: String // 相对 Vault/ 的路径
|
|
||||||
var mimeType: String
|
|
||||||
var bytes: Int
|
|
||||||
var createdAt: Date
|
|
||||||
}
|
}
|
||||||
|
@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 ChatTurn {
|
@Model class UserProfile { // 全 App 单例(UserProfileStore.loadOrCreate)
|
||||||
var question: String
|
birthYear?, biologicalSexRaw, heightCM?, bloodTypeRaw,
|
||||||
var answer: String
|
allergies, chronicConditions, familyHistory, currentMedications,
|
||||||
var referencedIndicatorIDs: [String]
|
updatedAt
|
||||||
var referencedReportIDs: [String]
|
|
||||||
var createdAt: Date
|
|
||||||
var decodeRate: Double // 该轮问答推理速度,Me 页性能展示
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -149,18 +139,21 @@ VL prompt 必须:
|
|||||||
## 7. 信息架构
|
## 7. 信息架构
|
||||||
|
|
||||||
```
|
```
|
||||||
TabBar: [首页] [+ 记录] [趋势] [我的]
|
TabBar: [主页] [记录] [+ 新建] [趋势] [我的]
|
||||||
│ │ │ │
|
│ │ │ │ │
|
||||||
│ │ │ └─ 模型管理 / Face ID / 关于
|
│ │ │ │ └─ 个人资料 / 模型管理 / Face ID / 关于
|
||||||
│ │ └─ 折线图 + AI 一句话解读
|
│ │ │ └─ 折线图 + AI 一句话解读
|
||||||
│ └─ Modal: 选择 拍一张 / 写日记 / 问问看
|
│ │ └─ Sheet: 拍一张 / 指标记录 / 报告归档 / 写日记 / 症状
|
||||||
└─ 问候 + 今日摘要 + 时间线 + 影像档案入口
|
│ └─ ArchiveListView(时间线 + 分类 chip + 年/月分组)
|
||||||
|
└─ 问候 + 今日摘要 + 进行中症状 + 最近时间线
|
||||||
```
|
```
|
||||||
|
|
||||||
- **3 Tab 不变**,中间 + 号是 Sheet
|
- TabBar **5 槽**:左 2 个内容 Tab + 中间 + 号 + 右 2 个 Tab
|
||||||
|
- "+ 新建" 是 sheet 不是 Tab
|
||||||
- AI 问答以 Modal Sheet 形式出现,**不占 Tab**
|
- AI 问答以 Modal Sheet 形式出现,**不占 Tab**
|
||||||
- "问问看"入口除了在 RecordSheet 里,首页摘要卡片下方也有一个常驻入口
|
- 「指标记录」sheet 顶部 LazyVGrid 是 8 个 MonitorMetric 长期监测预设(进趋势),
|
||||||
- 历史时间线在首页下半部分,不单独开 Tab
|
下方 horizontal scroll 是化验项快捷预设(不进趋势),不选预设走自由输入
|
||||||
|
- 「我的 · 个人资料」是 NavigationLink push 的 Form 编辑页
|
||||||
|
|
||||||
### 7.1 档案库 C1 / C2 导航(看的一半)
|
### 7.1 档案库 C1 / C2 导航(看的一半)
|
||||||
|
|
||||||
@@ -205,8 +198,8 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
|||||||
## 8. 现有代码状态(2026-05-25)
|
## 8. 现有代码状态(2026-05-25)
|
||||||
|
|
||||||
```
|
```
|
||||||
体己/
|
康康/
|
||||||
├── App/TijiApp.swift ✅ SwiftData container 已建
|
├── App/KangkangApp.swift ✅ SwiftData container 已建
|
||||||
├── RootView.swift ✅ 3 Tab + RecordSheet 已建
|
├── RootView.swift ✅ 3 Tab + RecordSheet 已建
|
||||||
├── Models/Models.swift ✅ Indicator / Report / DiaryEntry,缺 Asset / ChatTurn
|
├── Models/Models.swift ✅ Indicator / Report / DiaryEntry,缺 Asset / ChatTurn
|
||||||
├── DesignSystem/ ✅ Tokens + Components,沿用
|
├── DesignSystem/ ✅ Tokens + Components,沿用
|
||||||
@@ -219,9 +212,10 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
|||||||
└── Me/ ❌ 只有 placeholder
|
└── Me/ ❌ 只有 placeholder
|
||||||
|
|
||||||
待建:
|
待建:
|
||||||
├── AI/ ❌ AIRuntime, LLMSession, VLSession, Prompts/
|
├── AI/ ⚠️ AIRuntime + LLMSession + ModelStore + TokenChunk ✅;VLSession + Prompts/ ❌
|
||||||
|
├── Debug/DebugAIRunner.swift ✅ DEBUG-only AI 自检入口
|
||||||
├── Services/ ❌ CaptureService, AskService, TrendService, ReportCompareService
|
├── Services/ ❌ CaptureService, AskService, TrendService, ReportCompareService
|
||||||
├── Persistence/FileVault.swift ❌ 原图加密目录管理
|
├── Persistence/FileVault.swift ✅ 原图加密目录管理
|
||||||
├── Security/AppLock.swift ❌ Face ID 启动锁
|
├── Security/AppLock.swift ❌ Face ID 启动锁
|
||||||
├── Features/Ask/ ❌ AskSheet (RAG 问答 UI)
|
├── Features/Ask/ ❌ AskSheet (RAG 问答 UI)
|
||||||
├── Features/Archive/
|
├── Features/Archive/
|
||||||
@@ -255,7 +249,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
|
6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2)
|
||||||
7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
|
7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
|
||||||
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
|
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
|
||||||
|
|
||||||
@@ -265,8 +259,8 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
|||||||
|
|
||||||
| 周次 | 必交付 |
|
| 周次 | 必交付 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| W1 末 / **W2 当前** | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 |
|
| W1 末 / W2 当前 | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 |
|
||||||
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果) |
|
| 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) |
|
||||||
| W4-W5 | 趋势(Swift Charts + AI 解读)、**C2 ReportDetailView**(三 Tab + 重新解读) |
|
| W4-W5 | 趋势(Swift Charts + AI 解读)、**C2 ReportDetailView**(三 Tab + 重新解读) |
|
||||||
|
|||||||
41
docs/design/AppIcon-source.svg
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<filter id="wordShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#0f3f33" flood-opacity="0.42"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="1024" height="1024" fill="#8ED9E4"/>
|
||||||
|
<circle cx="748" cy="286" r="124" fill="#FFF1A8"/>
|
||||||
|
<circle cx="748" cy="286" r="76" fill="#FFFFFF"/>
|
||||||
|
|
||||||
|
<path d="M0 426C210 350 404 377 592 500C731 592 875 608 1024 506V1024H0V426Z" fill="#2C7E79"/>
|
||||||
|
<path d="M0 612C226 533 436 536 624 631C774 710 903 690 1024 580V1024H0V612Z" fill="#1F6761"/>
|
||||||
|
<path d="M0 780C232 678 436 641 634 693C799 743 924 711 1024 594V1024H0V780Z" fill="#53A247"/>
|
||||||
|
<path d="M0 888C232 807 447 780 656 825C812 863 931 825 1024 722V1024H0V888Z" fill="#82CC52"/>
|
||||||
|
<path d="M318 1024C506 861 725 727 1024 604V1024H318Z" fill="#B2D95E"/>
|
||||||
|
|
||||||
|
<path
|
||||||
|
d="M188 560H268L324 416L428 704L500 560H594"
|
||||||
|
fill="none"
|
||||||
|
stroke="#F4FFFC"
|
||||||
|
stroke-width="36"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
opacity="0.96"/>
|
||||||
|
|
||||||
|
<text
|
||||||
|
x="512"
|
||||||
|
y="846"
|
||||||
|
fill="#FFFFFF"
|
||||||
|
stroke="#145D46"
|
||||||
|
stroke-width="5"
|
||||||
|
stroke-opacity="0.68"
|
||||||
|
paint-order="stroke fill"
|
||||||
|
font-family="Hiragino Sans GB, Songti SC, Helvetica Neue, Arial, sans-serif"
|
||||||
|
font-size="136"
|
||||||
|
font-weight="600"
|
||||||
|
text-anchor="middle"
|
||||||
|
letter-spacing="8"
|
||||||
|
filter="url(#wordShadow)">康康</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
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.
|
||||||
113
docs/superpowers/notes/2026-05-25-mlx-api-corrections.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# MLX-Swift-Examples API 核对(2026-05-25)
|
||||||
|
|
||||||
|
研究产出来源:`https://github.com/ml-explore/mlx-swift-examples` tag `2.29.1`,commit `9bff95ca5f0b9e8c021acc4d71a2bbe4a7441631`。
|
||||||
|
|
||||||
|
W2 plan Task 6 的 LLMSession 草稿与真实 API 有 4 处偏差,**Task 6 必须用本文修正版,不要回头读 plan 里的草稿**。
|
||||||
|
|
||||||
|
## 关键修正
|
||||||
|
|
||||||
|
| 项 | 草稿 | 真实 API |
|
||||||
|
|---|---|---|
|
||||||
|
| `ModelConfiguration(directory:)` | ✓ | ✓ 一致 |
|
||||||
|
| `LLMModelFactory.shared.loadContainer(configuration:)` | ✓ | ✓ 一致(`hub` / `progressHandler` 有默认值) |
|
||||||
|
| `container.perform { context in ... }` | 未类型化 | context 是 `ModelContext` struct(具体类型);`processor: any UserInputProcessor` |
|
||||||
|
| `MLXLMCommon.generate(...)` 调用语义 | `try MLXLMCommon.generate(...)` 后内部 `for await` | **同上,只需 `try`(无 `await`)**;**返回 `AsyncStream<Generation>`(非 throwing)** |
|
||||||
|
| `Generation` 枚举 case | 只列了 `.chunk(String)` 和 `.info(...)` | **还有 `.toolCall(ToolCall)`,switch 必须穷举** |
|
||||||
|
| `GenerateParameters` | 只传 `temperature / topP`,`maxTokens` 在草稿用 `produced >= maxTokens break` 控制 | **`maxTokens` 必须传 GenerateParameters**;`temperature` / `topP` 是 `Float` 不是 `Double` |
|
||||||
|
| 取消 | 草稿没处理 | **必须** `continuation.onTermination = { _ in task.cancel() }` |
|
||||||
|
| `UserInput` 构造 | `LMInput.init(prompt:)` | `UserInput(prompt: prompt)` → `context.processor.prepare(input: userInput)` → `LMInput` |
|
||||||
|
|
||||||
|
## 修正版 LLMSession.swift(Task 6 直接抄)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import Foundation
|
||||||
|
import MLX
|
||||||
|
import MLXLLM
|
||||||
|
import MLXLMCommon
|
||||||
|
|
||||||
|
actor LLMSession {
|
||||||
|
let container: ModelContainer
|
||||||
|
|
||||||
|
init(container: ModelContainer) {
|
||||||
|
self.container = container
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(folderURL: URL) async throws -> LLMSession {
|
||||||
|
let configuration = ModelConfiguration(directory: folderURL)
|
||||||
|
let container = try await LLMModelFactory.shared.loadContainer(
|
||||||
|
configuration: configuration
|
||||||
|
)
|
||||||
|
return LLMSession(container: container)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式生成。返回的 AsyncThrowingStream 取消时,内部 Task 也会取消。
|
||||||
|
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||||
|
AsyncThrowingStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
do {
|
||||||
|
let parameters = GenerateParameters(
|
||||||
|
maxTokens: maxTokens,
|
||||||
|
temperature: Float(0.6),
|
||||||
|
topP: Float(0.9)
|
||||||
|
)
|
||||||
|
|
||||||
|
try await container.perform { (context: ModelContext) in
|
||||||
|
let userInput = UserInput(prompt: prompt)
|
||||||
|
let lmInput = try await context.processor.prepare(input: userInput)
|
||||||
|
|
||||||
|
let start = Date()
|
||||||
|
var produced = 0
|
||||||
|
|
||||||
|
for await event in try MLXLMCommon.generate(
|
||||||
|
input: lmInput,
|
||||||
|
parameters: parameters,
|
||||||
|
context: context
|
||||||
|
) {
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
|
||||||
|
switch event {
|
||||||
|
case .chunk(let text):
|
||||||
|
produced += 1
|
||||||
|
let elapsed = Date().timeIntervalSince(start)
|
||||||
|
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
|
||||||
|
continuation.yield(TokenChunk(text: text, decodeRate: rate))
|
||||||
|
|
||||||
|
case .info:
|
||||||
|
// 生成完成统计,是流的最后一个事件
|
||||||
|
break
|
||||||
|
|
||||||
|
case .toolCall:
|
||||||
|
// 纯文本生成不会触发,switch 穷举
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MLX.GPU.synchronize()
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
} catch {
|
||||||
|
continuation.finish(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continuation.onTermination = { _ in task.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与 AIRuntime 的对接
|
||||||
|
|
||||||
|
`AIRuntime.swift`(W2-T5 提交的 `4dcd951` + `e7cdb45`)已经预设:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let session = try await LLMSession.load(folderURL: ModelStore.shared.localURL(for: .llm))
|
||||||
|
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
||||||
|
```
|
||||||
|
|
||||||
|
签名匹配,Task 6 不改 AIRuntime。
|
||||||
|
|
||||||
|
## 真实模型 HF 仓库名
|
||||||
|
|
||||||
|
- LLM: `mlx-community/Qwen3-1.7B-4bit`(沙盒目录:`Qwen3-1.7B-4bit`)
|
||||||
|
- VL: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`(沙盒目录:`Qwen2.5-VL-3B-Instruct-4bit`)
|
||||||
|
|
||||||
|
注:plan 文档 Task 6 里写的是带 "MLX-" 中缀的旧名,**已弃用**。ModelKind rawValue 已在 commit `771b28e` 修正。
|
||||||
@@ -18,23 +18,23 @@
|
|||||||
|
|
||||||
| 路径 | 职责 |
|
| 路径 | 职责 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `体己/AI/AIRuntime.swift` | actor 单例,推理串行化,暴露 prepare / generate / lastDecodeRate |
|
| `康康/AI/AIRuntime.swift` | actor 单例,推理串行化,暴露 prepare / generate / lastDecodeRate |
|
||||||
| `体己/AI/ModelStore.swift` | 模型路径管理 + bundle 旁路 |
|
| `康康/AI/ModelStore.swift` | 模型路径管理 + bundle 旁路 |
|
||||||
| `体己/AI/LLMSession.swift` | Qwen3-1.7B 加载 + 流式生成 |
|
| `康康/AI/LLMSession.swift` | Qwen3-1.7B 加载 + 流式生成 |
|
||||||
| `体己/AI/TokenChunk.swift` | 流式数据结构 |
|
| `康康/AI/TokenChunk.swift` | 流式数据结构 |
|
||||||
| `体己/Persistence/FileVault.swift` | `Application Support/Vault/` 加密目录读写 |
|
| `康康/Persistence/FileVault.swift` | `Application Support/Vault/` 加密目录读写 |
|
||||||
| `体己/Debug/DebugAIRunner.swift` | DEBUG-only 测试入口,挂在 MeView 末尾 |
|
| `康康/Debug/DebugAIRunner.swift` | DEBUG-only 测试入口,挂在 MeView 末尾 |
|
||||||
| `体己Tests/FileVaultTests.swift` | FileVault 单元测试 |
|
| `康康Tests/FileVaultTests.swift` | FileVault 单元测试 |
|
||||||
| `体己Tests/ModelStoreTests.swift` | ModelStore 单元测试 |
|
| `康康Tests/ModelStoreTests.swift` | ModelStore 单元测试 |
|
||||||
|
|
||||||
### 修改
|
### 修改
|
||||||
|
|
||||||
| 路径 | 改什么 |
|
| 路径 | 改什么 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `体己/Models/Models.swift` | 加 Asset / ChatTurn,Indicator 加 report/asset/pinned,Report 加 indicators/assets 关系,DiaryEntry 加 tags |
|
| `康康/Models/Models.swift` | 加 Asset / ChatTurn,Indicator 加 report/asset/pinned,Report 加 indicators/assets 关系,DiaryEntry 加 tags |
|
||||||
| `体己/App/TijiApp.swift` | Schema 加入两个新 @Model |
|
| `康康/App/KangkangApp.swift` | Schema 加入两个新 @Model |
|
||||||
| `体己/Features/Me/MeView.swift` | DEBUG 块挂 DebugAIRunner |
|
| `康康/Features/Me/MeView.swift` | DEBUG 块挂 DebugAIRunner |
|
||||||
| `体己.xcodeproj` | SPM 加入 mlx-swift 与 mlx-swift-examples |
|
| `康康.xcodeproj` | SPM 加入 mlx-swift 与 mlx-swift-examples |
|
||||||
|
|
||||||
### 不动(W2 不碰)
|
### 不动(W2 不碰)
|
||||||
|
|
||||||
@@ -45,15 +45,15 @@
|
|||||||
## Task 1:Xcode 项目加入 MLX Swift SPM 依赖
|
## Task 1:Xcode 项目加入 MLX Swift SPM 依赖
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `体己.xcodeproj/project.pbxproj`(通过 Xcode UI 修改,不要手编)
|
- Modify: `康康.xcodeproj/project.pbxproj`(通过 Xcode UI 修改,不要手编)
|
||||||
|
|
||||||
- [ ] **Step 1:打开 Xcode 项目**
|
- [x] **Step 1:打开 Xcode 项目**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
open /Users/xuhuayong/apps/体己/体己.xcodeproj
|
open /Users/xuhuayong/apps/康康/康康.xcodeproj
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2:加入 MLX Swift 依赖**
|
- [x] **Step 2:加入 MLX Swift 依赖**
|
||||||
|
|
||||||
在 Xcode → File → Add Package Dependencies → 输入 URL:
|
在 Xcode → File → Add Package Dependencies → 输入 URL:
|
||||||
|
|
||||||
@@ -61,14 +61,14 @@ open /Users/xuhuayong/apps/体己/体己.xcodeproj
|
|||||||
https://github.com/ml-explore/mlx-swift
|
https://github.com/ml-explore/mlx-swift
|
||||||
```
|
```
|
||||||
|
|
||||||
选 "Up to Next Major" → 添加,勾选这些 product 加到 **体己** target:
|
选 "Up to Next Major" → 添加,勾选这些 product 加到 **康康** target:
|
||||||
- `MLX`
|
- `MLX`
|
||||||
- `MLXFast`
|
- `MLXFast`
|
||||||
- `MLXNN`
|
- `MLXNN`
|
||||||
- `MLXOptimizers`
|
- `MLXOptimizers`
|
||||||
- `MLXRandom`
|
- `MLXRandom`
|
||||||
|
|
||||||
- [ ] **Step 3:加入 mlx-swift-examples(含 LLM 工具)**
|
- [x] **Step 3:加入 mlx-swift-examples(含 LLM 工具)**
|
||||||
|
|
||||||
继续 Add Package Dependencies,URL:
|
继续 Add Package Dependencies,URL:
|
||||||
|
|
||||||
@@ -76,25 +76,25 @@ https://github.com/ml-explore/mlx-swift
|
|||||||
https://github.com/ml-explore/mlx-swift-examples
|
https://github.com/ml-explore/mlx-swift-examples
|
||||||
```
|
```
|
||||||
|
|
||||||
勾选 `MLXLLM` 和 `MLXLMCommon` 加到 **体己** target。
|
勾选 `MLXLLM` 和 `MLXLMCommon` 加到 **康康** target。
|
||||||
|
|
||||||
- [ ] **Step 4:确认 Build Settings**
|
- [x] **Step 4:确认 Build Settings**
|
||||||
|
|
||||||
Xcode → 体己 target → Build Settings → 搜 "Swift Language Version" → 确认 Swift 5(MLX 不支持 Swift 6 严格并发)。
|
Xcode → 康康 target → Build Settings → 搜 "Swift Language Version" → 确认 Swift 5(MLX 不支持 Swift 6 严格并发)。
|
||||||
|
|
||||||
体己 target → General → Minimum Deployments → iOS 17.0(MLX 要求)。
|
康康 target → General → Minimum Deployments → iOS 17.0(MLX 要求)。
|
||||||
|
|
||||||
- [ ] **Step 5:Build 验证**
|
- [x] **Step 5:Build 验证**
|
||||||
|
|
||||||
Xcode 顶部选模拟器(任何一个 iPhone 15+),按 ⌘B。
|
Xcode 顶部选模拟器(任何一个 iPhone 15+),按 ⌘B。
|
||||||
|
|
||||||
Expected:Build Succeeded,无依赖错误。
|
Expected:Build Succeeded,无依赖错误。
|
||||||
|
|
||||||
- [ ] **Step 6:提交**
|
- [x] **Step 6:提交**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/xuhuayong/apps/体己
|
cd /Users/xuhuayong/apps/康康
|
||||||
git add 体己.xcodeproj
|
git add 康康.xcodeproj
|
||||||
git commit -m "build: add MLX Swift SPM dependencies"
|
git commit -m "build: add MLX Swift SPM dependencies"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -103,11 +103,11 @@ git commit -m "build: add MLX Swift SPM dependencies"
|
|||||||
## Task 2:扩展 Models.swift —— Asset 与 ChatTurn
|
## Task 2:扩展 Models.swift —— Asset 与 ChatTurn
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `体己/Models/Models.swift`(全文重写)
|
- Modify: `康康/Models/Models.swift`(全文重写)
|
||||||
|
|
||||||
- [ ] **Step 1:把 Models.swift 替换为新内容**
|
- [x] **Step 1:把 Models.swift 替换为新内容**
|
||||||
|
|
||||||
打开 `体己/Models/Models.swift`,**整文件替换**为:
|
打开 `康康/Models/Models.swift`,**整文件替换**为:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -268,9 +268,9 @@ final class ChatTurn {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2:更新 TijiApp.swift Schema**
|
- [x] **Step 2:更新 KangkangApp.swift Schema**
|
||||||
|
|
||||||
打开 `体己/App/TijiApp.swift`,替换 Schema 数组:
|
打开 `康康/App/KangkangApp.swift`,替换 Schema 数组:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
let schema = Schema([
|
let schema = Schema([
|
||||||
@@ -282,7 +282,7 @@ let schema = Schema([
|
|||||||
])
|
])
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3:删模拟器沙盒(破坏性迁移)**
|
- [x] **Step 3:删模拟器沙盒(破坏性迁移)**
|
||||||
|
|
||||||
在 Mac 上:
|
在 Mac 上:
|
||||||
|
|
||||||
@@ -293,16 +293,16 @@ xcrun simctl erase all
|
|||||||
|
|
||||||
(也可以在 Simulator → Device → Erase All Content and Settings)
|
(也可以在 Simulator → Device → Erase All Content and Settings)
|
||||||
|
|
||||||
- [ ] **Step 4:Build & Run 验证**
|
- [x] **Step 4:Build & Run 验证**
|
||||||
|
|
||||||
Xcode ⌘R 运行到模拟器,App 启动不崩 = Schema OK。
|
Xcode ⌘R 运行到模拟器,App 启动不崩 = Schema OK。
|
||||||
|
|
||||||
Expected:App 启动到 RootView,无 fatalError。
|
Expected:App 启动到 RootView,无 fatalError。
|
||||||
|
|
||||||
- [ ] **Step 5:提交**
|
- [x] **Step 5:提交**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add 体己/Models/Models.swift 体己/App/TijiApp.swift
|
git add 康康/Models/Models.swift 康康/App/KangkangApp.swift
|
||||||
git commit -m "feat(models): add Asset/ChatTurn, indicator-report relationship, pinned flag"
|
git commit -m "feat(models): add Asset/ChatTurn, indicator-report relationship, pinned flag"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -311,17 +311,17 @@ git commit -m "feat(models): add Asset/ChatTurn, indicator-report relationship,
|
|||||||
## Task 3:FileVault —— 加密目录读写(TDD)
|
## Task 3:FileVault —— 加密目录读写(TDD)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `体己/Persistence/FileVault.swift`
|
- Create: `康康/Persistence/FileVault.swift`
|
||||||
- Test: `体己Tests/FileVaultTests.swift`
|
- Test: `康康Tests/FileVaultTests.swift`
|
||||||
|
|
||||||
- [ ] **Step 1:写失败的测试**
|
- [x] **Step 1:写失败的测试**
|
||||||
|
|
||||||
创建 `体己Tests/FileVaultTests.swift`:
|
创建 `康康Tests/FileVaultTests.swift`:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
import Testing
|
import Testing
|
||||||
import UIKit
|
import UIKit
|
||||||
@testable import 体己
|
@testable import 康康
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct FileVaultTests {
|
struct FileVaultTests {
|
||||||
@@ -369,15 +369,15 @@ struct FileVaultTests {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2:运行测试,确认 fail**
|
- [x] **Step 2:运行测试,确认 fail**
|
||||||
|
|
||||||
Xcode ⌘U 跑测试(在模拟器上跑)。
|
Xcode ⌘U 跑测试(在模拟器上跑)。
|
||||||
|
|
||||||
Expected:`FileVaultTests` 编译错误 "Cannot find 'FileVault' in scope"。
|
Expected:`FileVaultTests` 编译错误 "Cannot find 'FileVault' in scope"。
|
||||||
|
|
||||||
- [ ] **Step 3:写最小 FileVault 实现**
|
- [x] **Step 3:写最小 FileVault 实现**
|
||||||
|
|
||||||
创建 `体己/Persistence/FileVault.swift`:
|
创建 `康康/Persistence/FileVault.swift`:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -454,22 +454,22 @@ final class FileVault {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4:把 FileVault.swift 加入 体己 target**
|
- [x] **Step 4:把 FileVault.swift 加入 康康 target**
|
||||||
|
|
||||||
Xcode 右键 `体己/` 目录 → New Group "Persistence" → 把 FileVault.swift 拖进去,确认 Target Membership 勾选 "体己"。
|
Xcode 右键 `康康/` 目录 → New Group "Persistence" → 把 FileVault.swift 拖进去,确认 Target Membership 勾选 "康康"。
|
||||||
|
|
||||||
把 FileVaultTests.swift 拖进 体己Tests target,确认 Target Membership 勾选 "体己Tests"。
|
把 FileVaultTests.swift 拖进 康康Tests target,确认 Target Membership 勾选 "康康Tests"。
|
||||||
|
|
||||||
- [ ] **Step 5:跑测试,确认全 pass**
|
- [x] **Step 5:跑测试,确认全 pass**
|
||||||
|
|
||||||
Xcode ⌘U。
|
Xcode ⌘U。
|
||||||
|
|
||||||
Expected:`writeAndReadJPEGRoundtrip` / `removeMakesFileGone` / `wipeRemovesAllFiles` 全绿。
|
Expected:`writeAndReadJPEGRoundtrip` / `removeMakesFileGone` / `wipeRemovesAllFiles` 全绿。
|
||||||
|
|
||||||
- [ ] **Step 6:提交**
|
- [x] **Step 6:提交**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add 体己/Persistence/FileVault.swift 体己Tests/FileVaultTests.swift 体己.xcodeproj
|
git add 康康/Persistence/FileVault.swift 康康Tests/FileVaultTests.swift 康康.xcodeproj
|
||||||
git commit -m "feat(persistence): add FileVault with complete file protection"
|
git commit -m "feat(persistence): add FileVault with complete file protection"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -478,17 +478,17 @@ git commit -m "feat(persistence): add FileVault with complete file protection"
|
|||||||
## Task 4:ModelStore —— 模型路径与 bundle 旁路(TDD)
|
## Task 4:ModelStore —— 模型路径与 bundle 旁路(TDD)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `体己/AI/ModelStore.swift`
|
- Create: `康康/AI/ModelStore.swift`
|
||||||
- Test: `体己Tests/ModelStoreTests.swift`
|
- Test: `康康Tests/ModelStoreTests.swift`
|
||||||
|
|
||||||
- [ ] **Step 1:写失败的测试**
|
- [x] **Step 1:写失败的测试**
|
||||||
|
|
||||||
创建 `体己Tests/ModelStoreTests.swift`:
|
创建 `康康Tests/ModelStoreTests.swift`:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
import Testing
|
import Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import 体己
|
@testable import 康康
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ModelStoreTests {
|
struct ModelStoreTests {
|
||||||
@@ -531,13 +531,13 @@ struct ModelStoreTests {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2:运行测试,确认 fail**
|
- [x] **Step 2:运行测试,确认 fail**
|
||||||
|
|
||||||
⌘U → expect `Cannot find 'ModelStore'`.
|
⌘U → expect `Cannot find 'ModelStore'`.
|
||||||
|
|
||||||
- [ ] **Step 3:写 ModelStore 实现**
|
- [x] **Step 3:写 ModelStore 实现**
|
||||||
|
|
||||||
创建 `体己/AI/ModelStore.swift`:
|
创建 `康康/AI/ModelStore.swift`:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -619,21 +619,21 @@ final class ModelStore {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4:Xcode 中把文件加入 target**
|
- [x] **Step 4:Xcode 中把文件加入 target**
|
||||||
|
|
||||||
右键 `体己/` → New Group "AI" → 拖入 ModelStore.swift,勾 "体己" target。
|
右键 `康康/` → New Group "AI" → 拖入 ModelStore.swift,勾 "康康" target。
|
||||||
ModelStoreTests.swift 拖入 体己Tests target。
|
ModelStoreTests.swift 拖入 康康Tests target。
|
||||||
|
|
||||||
- [ ] **Step 5:跑测试,全绿**
|
- [x] **Step 5:跑测试,全绿**
|
||||||
|
|
||||||
⌘U。
|
⌘U。
|
||||||
|
|
||||||
Expected:3 个测试全 pass。
|
Expected:3 个测试全 pass。
|
||||||
|
|
||||||
- [ ] **Step 6:提交**
|
- [x] **Step 6:提交**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add 体己/AI/ModelStore.swift 体己Tests/ModelStoreTests.swift 体己.xcodeproj
|
git add 康康/AI/ModelStore.swift 康康Tests/ModelStoreTests.swift 康康.xcodeproj
|
||||||
git commit -m "feat(ai): add ModelStore with path management and bundle seed"
|
git commit -m "feat(ai): add ModelStore with path management and bundle seed"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -642,12 +642,12 @@ git commit -m "feat(ai): add ModelStore with path management and bundle seed"
|
|||||||
## Task 5:TokenChunk + AIRuntime actor 骨架
|
## Task 5:TokenChunk + AIRuntime actor 骨架
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `体己/AI/TokenChunk.swift`
|
- Create: `康康/AI/TokenChunk.swift`
|
||||||
- Create: `体己/AI/AIRuntime.swift`
|
- Create: `康康/AI/AIRuntime.swift`
|
||||||
|
|
||||||
本任务**不接 MLX**,只搭骨架。Task 6 才接真模型。
|
本任务**不接 MLX**,只搭骨架。Task 6 才接真模型。
|
||||||
|
|
||||||
- [ ] **Step 1:创建 TokenChunk.swift**
|
- [x] **Step 1:创建 TokenChunk.swift**
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -658,7 +658,7 @@ struct TokenChunk: Sendable {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2:创建 AIRuntime.swift 骨架**
|
- [x] **Step 2:创建 AIRuntime.swift 骨架**
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -754,19 +754,19 @@ actor AIRuntime {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3:确认 Build 失败原因合理**
|
- [x] **Step 3:确认 Build 失败原因合理**
|
||||||
|
|
||||||
⌘B → expect "Cannot find 'LLMSession' in scope"(Task 6 才会建)。
|
⌘B → expect "Cannot find 'LLMSession' in scope"(Task 6 才会建)。
|
||||||
|
|
||||||
这是预期。我们要让 Task 6 写完后 AIRuntime 直接能工作。
|
这是预期。我们要让 Task 6 写完后 AIRuntime 直接能工作。
|
||||||
|
|
||||||
- [ ] **Step 4:把文件加入 target**
|
- [x] **Step 4:把文件加入 target**
|
||||||
|
|
||||||
把 TokenChunk.swift 和 AIRuntime.swift 拖进 AI group,勾 "体己" target。
|
把 TokenChunk.swift 和 AIRuntime.swift 拖进 AI group,勾 "康康" target。
|
||||||
|
|
||||||
(此时 Build 还是失败,正常)
|
(此时 Build 还是失败,正常)
|
||||||
|
|
||||||
- [ ] **Step 5:暂不提交**
|
- [x] **Step 5:暂不提交**
|
||||||
|
|
||||||
等 Task 6 完成、Build 通过后一起提交。
|
等 Task 6 完成、Build 通过后一起提交。
|
||||||
|
|
||||||
@@ -775,7 +775,7 @@ actor AIRuntime {
|
|||||||
## Task 6:LLMSession —— 接 MLX 跑 Qwen3-1.7B
|
## Task 6:LLMSession —— 接 MLX 跑 Qwen3-1.7B
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `体己/AI/LLMSession.swift`
|
- Create: `康康/AI/LLMSession.swift`
|
||||||
|
|
||||||
**预先准备(开发者手动一次)**:
|
**预先准备(开发者手动一次)**:
|
||||||
|
|
||||||
@@ -785,7 +785,7 @@ actor AIRuntime {
|
|||||||
|
|
||||||
具体路径在 App 启动时打印,见 Step 5。
|
具体路径在 App 启动时打印,见 Step 5。
|
||||||
|
|
||||||
- [ ] **Step 1:在终端下载模型(脚本一次性)**
|
- [x] **Step 1:在终端下载模型(脚本一次性)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/tiji-models && cd ~/tiji-models
|
mkdir -p ~/tiji-models && cd ~/tiji-models
|
||||||
@@ -796,9 +796,9 @@ huggingface-cli download mlx-community/Qwen3-1.7B-MLX-4bit \
|
|||||||
|
|
||||||
Expected:目录里有 `config.json` / `model.safetensors` / `tokenizer.json` 等。
|
Expected:目录里有 `config.json` / `model.safetensors` / `tokenizer.json` 等。
|
||||||
|
|
||||||
- [ ] **Step 2:写 LLMSession 实现**
|
- [x] **Step 2:写 LLMSession 实现**
|
||||||
|
|
||||||
创建 `体己/AI/LLMSession.swift`:
|
创建 `康康/AI/LLMSession.swift`:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -866,11 +866,11 @@ actor LLMSession {
|
|||||||
|
|
||||||
> **注**:`MLXLMCommon` 的具体 API 版本可能在 GenerateParameters/stream 处略有差异。如果 Step 4 编译报错,查看 mlx-swift-examples 仓库 `Libraries/MLXLLM` 的最新示例,以仓库示例为准小幅调整。
|
> **注**:`MLXLMCommon` 的具体 API 版本可能在 GenerateParameters/stream 处略有差异。如果 Step 4 编译报错,查看 mlx-swift-examples 仓库 `Libraries/MLXLLM` 的最新示例,以仓库示例为准小幅调整。
|
||||||
|
|
||||||
- [ ] **Step 3:把 LLMSession.swift 加入 体己 target**
|
- [x] **Step 3:把 LLMSession.swift 加入 康康 target**
|
||||||
|
|
||||||
拖入 AI group,确认 Target Membership。
|
拖入 AI group,确认 Target Membership。
|
||||||
|
|
||||||
- [ ] **Step 4:Build,期望成功**
|
- [x] **Step 4:Build,期望成功**
|
||||||
|
|
||||||
⌘B。
|
⌘B。
|
||||||
|
|
||||||
@@ -878,9 +878,9 @@ Expected:Build Succeeded。
|
|||||||
|
|
||||||
若 MLX API 签名不匹配,参考 https://github.com/ml-explore/mlx-swift-examples 中 `Libraries/MLXLLM` 的最新 LLM 示例修正。
|
若 MLX API 签名不匹配,参考 https://github.com/ml-explore/mlx-swift-examples 中 `Libraries/MLXLLM` 的最新 LLM 示例修正。
|
||||||
|
|
||||||
- [ ] **Step 5:在 TijiApp 启动时打印沙盒路径(临时调试)**
|
- [x] **Step 5:在 KangkangApp 启动时打印沙盒路径(临时调试)**
|
||||||
|
|
||||||
打开 `体己/App/TijiApp.swift`,在 `WindowGroup { RootView() }` 内加一个 `.onAppear`:
|
打开 `康康/App/KangkangApp.swift`,在 `WindowGroup { RootView() }` 内加一个 `.onAppear`:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -901,7 +901,7 @@ Expected:Build Succeeded。
|
|||||||
📁 App Support: /Users/.../data/Containers/Data/Application/<UUID>/Library/Application Support
|
📁 App Support: /Users/.../data/Containers/Data/Application/<UUID>/Library/Application Support
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 6:把模型拷到沙盒**
|
- [x] **Step 6:把模型拷到沙盒**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
APP_SUPPORT="<上面控制台打印的路径>"
|
APP_SUPPORT="<上面控制台打印的路径>"
|
||||||
@@ -909,10 +909,10 @@ mkdir -p "$APP_SUPPORT/Models"
|
|||||||
cp -R ~/tiji-models/Qwen3-1.7B-MLX-4bit "$APP_SUPPORT/Models/"
|
cp -R ~/tiji-models/Qwen3-1.7B-MLX-4bit "$APP_SUPPORT/Models/"
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 7:提交(本任务 + Task 5 一起)**
|
- [x] **Step 7:提交(本任务 + Task 5 一起)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add 体己/AI/ 体己/App/TijiApp.swift 体己.xcodeproj
|
git add 康康/AI/ 康康/App/KangkangApp.swift 康康.xcodeproj
|
||||||
git commit -m "feat(ai): add AIRuntime actor and LLMSession with MLX Qwen3-1.7B"
|
git commit -m "feat(ai): add AIRuntime actor and LLMSession with MLX Qwen3-1.7B"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -921,12 +921,12 @@ git commit -m "feat(ai): add AIRuntime actor and LLMSession with MLX Qwen3-1.7B"
|
|||||||
## Task 7:DebugAIRunner —— DEBUG 测试入口
|
## Task 7:DebugAIRunner —— DEBUG 测试入口
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `体己/Debug/DebugAIRunner.swift`
|
- Create: `康康/Debug/DebugAIRunner.swift`
|
||||||
- Modify: `体己/Features/Me/MeView.swift`
|
- Modify: `康康/Features/Me/MeView.swift`
|
||||||
|
|
||||||
- [ ] **Step 1:创建 DebugAIRunner**
|
- [x] **Step 1:创建 DebugAIRunner**
|
||||||
|
|
||||||
`体己/Debug/DebugAIRunner.swift`:
|
`康康/Debug/DebugAIRunner.swift`:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -998,9 +998,9 @@ struct DebugAIRunner: View {
|
|||||||
#endif
|
#endif
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2:在 MeView 末尾挂上(仅 DEBUG)**
|
- [x] **Step 2:在 MeView 末尾挂上(仅 DEBUG)**
|
||||||
|
|
||||||
打开 `体己/Features/Me/MeView.swift`,把现有内容整体替换为:
|
打开 `康康/Features/Me/MeView.swift`,把现有内容整体替换为:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -1025,18 +1025,18 @@ struct MeView: View {
|
|||||||
#Preview { MeView() }
|
#Preview { MeView() }
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3:在 Xcode 中加入文件**
|
- [x] **Step 3:在 Xcode 中加入文件**
|
||||||
|
|
||||||
右键 `体己/` → New Group "Debug" → 拖入 DebugAIRunner.swift,勾 "体己" target。
|
右键 `康康/` → New Group "Debug" → 拖入 DebugAIRunner.swift,勾 "康康" target。
|
||||||
|
|
||||||
- [ ] **Step 4:Build,确认 OK**
|
- [x] **Step 4:Build,确认 OK**
|
||||||
|
|
||||||
⌘B → Expected: Build Succeeded。
|
⌘B → Expected: Build Succeeded。
|
||||||
|
|
||||||
- [ ] **Step 5:提交**
|
- [x] **Step 5:提交**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add 体己/Debug/ 体己/Features/Me/MeView.swift 体己.xcodeproj
|
git add 康康/Debug/ 康康/Features/Me/MeView.swift 康康.xcodeproj
|
||||||
git commit -m "chore(debug): add AI self-test runner in MeView (DEBUG only)"
|
git commit -m "chore(debug): add AI self-test runner in MeView (DEBUG only)"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1092,15 +1092,15 @@ git commit --allow-empty -m "milestone: W2 LLM 自检通过 (simulator)"
|
|||||||
## Task 9:加一组 schema 重建烟测(防回归)
|
## Task 9:加一组 schema 重建烟测(防回归)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `体己Tests/ModelsSchemaTests.swift`
|
- Create: `康康Tests/ModelsSchemaTests.swift`
|
||||||
|
|
||||||
- [ ] **Step 1:写 schema 烟测**
|
- [x] **Step 1:写 schema 烟测**
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
import Testing
|
import Testing
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import 体己
|
@testable import 康康
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ModelsSchemaTests {
|
struct ModelsSchemaTests {
|
||||||
@@ -1179,7 +1179,7 @@ struct ModelsSchemaTests {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2:加入 体己Tests target,跑测试**
|
- [x] **Step 2:加入 康康Tests target,跑测试**
|
||||||
|
|
||||||
⌘U。
|
⌘U。
|
||||||
|
|
||||||
@@ -1187,10 +1187,10 @@ Expected:3 个测试全 pass。
|
|||||||
|
|
||||||
若 cascade 删除测试失败 → 检查 `Indicator.report` 反向关系是否声明正确(参考 Task 2)。
|
若 cascade 删除测试失败 → 检查 `Indicator.report` 反向关系是否声明正确(参考 Task 2)。
|
||||||
|
|
||||||
- [ ] **Step 3:提交**
|
- [x] **Step 3:提交**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add 体己Tests/ModelsSchemaTests.swift 体己.xcodeproj
|
git add 康康Tests/ModelsSchemaTests.swift 康康.xcodeproj
|
||||||
git commit -m "test(models): add schema smoke tests for relationships and cascade"
|
git commit -m "test(models): add schema smoke tests for relationships and cascade"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
42
docs/superpowers/retros/2026-05-31-w2.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# W2 Retro · 2026-05-31
|
||||||
|
|
||||||
|
> 范围:2026-05-19(W2 起)→ 2026-05-25(W2 中段写,W3 周一前回看修订)。本次 retro 在 W2 中段写,主要是周末批量收尾的留痕。
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| 风险/里程碑 | 状态 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 · MLX 跑通 | ⚠️ 部分通过 | LLMSession.load 通过 Swift Testing 烟测,真实 tok/s 待用户手动 DebugAIRunner 验证 |
|
||||||
|
| R4 · Schema 迁移 | ✅ 通过 | 5 + 1(Symptom)个 @Model,3 + 2 个关系烟测全绿 |
|
||||||
|
| 本周里程碑 · AI 基座骨架 | ✅ | AIRuntime / LLMSession / ModelStore / FileVault 全部交付,build 干净 0 warning |
|
||||||
|
|
||||||
|
## 速度基线
|
||||||
|
- 模拟器(iPhone 17 Sim, Apple Silicon Mac):**TBD**(W3 周一前由 xuhuayong 在 macOS Designed for iPad 内点 DebugAIRunner 填入)
|
||||||
|
- 真机 iPhone 15+:**待 W3 验证**(本周未连真机,模型只 sideload 到 macOS sandbox)
|
||||||
|
|
||||||
|
> 验收门槛:模拟器 < 5 tok/s 触发 R1 红线(换 llama.cpp,W2 plan revert)。当前烟测路径无法测速,需 manual。
|
||||||
|
|
||||||
|
## 计划外完成
|
||||||
|
- **Symptom 模块**:新增 @Model + Start/End sheets + OngoingSymptomsCard。这是 CLAUDE.md §10 红线 #6 "新功能必须问'清单里有吗'" 的例外,由产品负责人决定加入。
|
||||||
|
- **Timeline 统一时间线**:TimelineEntry + TimelineRow + DateSection + TimelineGrouping,被 HomeView 和 ArchiveListView 共享。
|
||||||
|
- **ArchiveListView 提前打底**(原计划 W4):接 @Query 拉 Indicator/Report/Diary/Symptom,filter chips + 年/月分组 + 空态。
|
||||||
|
- **AppIcon**:Light/Dark/Tinted 三套 9 sizes + SVG 源。
|
||||||
|
- **Swift 6 并发清扫**:`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` 下,把 ModelStore / FileVault / ModelKind 显式标 nonisolated,LLMSession 用 task-scoped Device.withDefaultDevice 替代 deprecated API。
|
||||||
|
|
||||||
|
## 计划内缺口
|
||||||
|
- **Task 8 Step 1-2 自检与速度基线**:延后到用户 manual 验证。
|
||||||
|
- **Task 8 Step 3 真机连测**:延后到 W3。
|
||||||
|
- **Task 10 Step 2 §8 状态更新**:已在本 retro commit 内一起完成。
|
||||||
|
|
||||||
|
## 学到的
|
||||||
|
1. **`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` 会把跨边界类型/方法都默认推到 MainActor**,跟 actor (如 AIRuntime) 互操作时必须显式 `nonisolated` 整条调用链。`@unchecked Sendable` 不自动解锁实例方法的 isolation。
|
||||||
|
2. **iOS Simulator app sandbox 阻止读 Mac 用户目录**,集成测试无法直接验证真实推理;Mac Designed for iPad 又卡 code signing。W3 把 LLM 接口拆 SPM target 后才能写 host-fs 集成测试。
|
||||||
|
3. **`Device.withDefaultDevice` 是 TaskLocal,跨 actor 传递正常**,但跨 Task(如 AsyncStream 的 detached Task)需要在 inner Task 内重新 `withDefaultDevice`。
|
||||||
|
4. **MLX Swift API 比 mlx-swift-examples 文档稳定**,真正卡的是 Swift 6 并发系统,不是 MLX 本身。
|
||||||
|
|
||||||
|
## 下周(W3)前置准备
|
||||||
|
- [ ] 用户在 macOS App 内点 DebugAIRunner,把实际 tok/s 填进本 retro 的"速度基线"段
|
||||||
|
- [ ] 准备 5–10 张真实化验单照片(W4 VL 回归测用),放进 ~/tiji-models/test-reports/
|
||||||
|
- [ ] 准备 20 条危险问句(W3 末医疗话术安全测试)
|
||||||
|
- [ ] 决定是否把 LLM 接口拆 SPM target(便于真实推理集成测试)
|
||||||
|
- [ ] W3 plan 周一动笔,把 Symptom + Timeline 写进 spec
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# 康记 / 体己 —— 功能设计 Spec(v1.0)
|
# 康康 —— 功能设计 Spec(v1.0)
|
||||||
|
|
||||||
**日期**:2026-05-25
|
**日期**:2026-05-25
|
||||||
**状态**:Draft, 已与产品方对齐 §1-§6
|
**状态**:Draft, 已与产品方对齐 §1-§6
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## 0. 概要
|
## 0. 概要
|
||||||
|
|
||||||
康记是一个 iOS 原生健康影像档案 App,**100% 端侧 AI 推理**,基于 SwiftUI + SwiftData + MLX Swift,目标 6 周交付决赛 demo。本 spec 把原始功能清单收敛为 **方案 B**:核心 5 模块 + Live Activity + 分享摘要,其余 P2/P3 全部 deferred。
|
康康是一个 iOS 原生健康影像档案 App,**100% 端侧 AI 推理**,基于 SwiftUI + SwiftData + MLX Swift,目标 6 周交付决赛 demo。本 spec 把原始功能清单收敛为 **方案 B**:核心 5 模块 + Live Activity + 分享摘要,其余 P2/P3 全部 deferred。
|
||||||
|
|
||||||
**5 大核心模块**
|
**5 大核心模块**
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ Persistence
|
|||||||
### 2.1 `AIRuntime` 接口
|
### 2.1 `AIRuntime` 接口
|
||||||
|
|
||||||
```
|
```
|
||||||
体己/AI/
|
康康/AI/
|
||||||
├── AIRuntime.swift // actor 单例,推理串行化
|
├── AIRuntime.swift // actor 单例,推理串行化
|
||||||
├── ModelStore.swift // 模型路径管理 + 下载 + bundle 旁路
|
├── ModelStore.swift // 模型路径管理 + 下载 + bundle 旁路
|
||||||
├── LLMSession.swift // Qwen3-1.7B 文本生成,流式
|
├── LLMSession.swift // Qwen3-1.7B 文本生成,流式
|
||||||
@@ -112,7 +112,7 @@ struct TokenChunk {
|
|||||||
|
|
||||||
| 项 | 决策 |
|
| 项 | 决策 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 模型来源 | HuggingFace MLX 社区版 Qwen3-1.7B-MLX-4bit + Qwen2.5-VL-3B-MLX-4bit |
|
| 模型来源 | HuggingFace `mlx-community/Qwen3-1.7B-4bit` + `mlx-community/Qwen2.5-VL-3B-Instruct-4bit` |
|
||||||
| 体积 | LLM ~1.0GB + VL ~2.0GB ≈ 3GB |
|
| 体积 | LLM ~1.0GB + VL ~2.0GB ≈ 3GB |
|
||||||
| 存储 | `Application Support/Models/`,`URLSession.downloadTask` + 断点续传 |
|
| 存储 | `Application Support/Models/`,`URLSession.downloadTask` + 断点续传 |
|
||||||
| 首启动 | 启动屏 → 隐私承诺 → "下载模型"页(进度 + WiFi 提示) → 主界面 |
|
| 首启动 | 启动屏 → 隐私承诺 → "下载模型"页(进度 + WiFi 提示) → 主界面 |
|
||||||
@@ -376,7 +376,7 @@ User → UI(B2Scan) → CaptureService → AIRuntime → Persistence
|
|||||||
### 4.3 服务层文件
|
### 4.3 服务层文件
|
||||||
|
|
||||||
```
|
```
|
||||||
体己/AI/ [7.5d]
|
康康/AI/ [7.5d]
|
||||||
├── AIRuntime.swift 2d
|
├── AIRuntime.swift 2d
|
||||||
├── ModelStore.swift 1d
|
├── ModelStore.swift 1d
|
||||||
├── LLMSession.swift 1d
|
├── LLMSession.swift 1d
|
||||||
@@ -387,17 +387,17 @@ User → UI(B2Scan) → CaptureService → AIRuntime → Persistence
|
|||||||
├── KeywordExtraction.swift
|
├── KeywordExtraction.swift
|
||||||
└── TrendNarrative.swift
|
└── TrendNarrative.swift
|
||||||
|
|
||||||
体己/Services/ [4.5d]
|
康康/Services/ [4.5d]
|
||||||
├── CaptureService.swift 1.5d
|
├── CaptureService.swift 1.5d
|
||||||
├── AskService.swift 1.5d
|
├── AskService.swift 1.5d
|
||||||
├── TrendService.swift 1d
|
├── TrendService.swift 1d
|
||||||
└── ReportCompareService.swift 0.5d
|
└── ReportCompareService.swift 0.5d
|
||||||
|
|
||||||
体己/Persistence/ [1d]
|
康康/Persistence/ [1d]
|
||||||
├── FileVault.swift 0.5d
|
├── FileVault.swift 0.5d
|
||||||
└── PermanentDelete.swift 0.5d
|
└── PermanentDelete.swift 0.5d
|
||||||
|
|
||||||
体己/Security/ [0.5d]
|
康康/Security/ [0.5d]
|
||||||
└── AppLock.swift 0.5d
|
└── AppLock.swift 0.5d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
122
docs/superpowers/specs/2026-05-26-hide-monitor-preset-design.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Hide Monitor Preset · 设计 v1
|
||||||
|
|
||||||
|
> 「记录指标」sheet 长期监测预设(`MonitorMetric`)支持隐藏
|
||||||
|
>
|
||||||
|
> 日期:2026-05-26 · 状态:approved by user(2026-05-26 对话)
|
||||||
|
> 关联:[CLAUDE.md](../../../CLAUDE.md) §7,[Monitor+Profile spec](./2026-05-26-monitor-and-profile-design.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
`IndicatorQuickSheet`「长期监测(进趋势)」分组由 `MonitorMetric.allCases` 渲染,目前 6 个硬编码 case(血压/空腹血糖/餐后血糖/体温/心率/血氧)无法隐藏,与下方 `CustomMonitorMetric`(可长按编辑/删除)体验不一致。
|
||||||
|
|
||||||
|
用户场景:不测血氧、不测血压的人想清理 grid;但**不能误删历史数据**——已经测过的折线在 Trends 里还要看。
|
||||||
|
|
||||||
|
## 2. 目标
|
||||||
|
|
||||||
|
- 长按 `MonitorMetric` tile → contextMenu 出"隐藏"
|
||||||
|
- 已隐藏的 tile 从 grid 过滤掉,但已有 `Indicator` 记录、Trends 折线、`MetricReminder` 全不动
|
||||||
|
- 提供可逆恢复入口
|
||||||
|
|
||||||
|
## 3. 非目标(YAGNI)
|
||||||
|
|
||||||
|
- ❌ 化验项快捷预设(labPresets)同款功能 — 本次不动
|
||||||
|
- ❌ 「我的」里集中管理页 — grid 上就近恢复即可
|
||||||
|
- ❌ 批量隐藏 / 拖拽排序
|
||||||
|
- ❌ 二次确认弹窗 — 隐藏可逆,不需要
|
||||||
|
- ❌ 隐藏时联动关掉对应 `MetricReminder` — 用户没说,保守不动
|
||||||
|
|
||||||
|
## 4. 数据模型
|
||||||
|
|
||||||
|
`UserProfile` 增加一个字段:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
var hiddenPresetMetrics: [String] = [] // 存 MonitorMetric.rawValue
|
||||||
|
```
|
||||||
|
|
||||||
|
- 类型沿用 `[String]`,跟 `allergies` / `chronicConditions` 一致,SwiftData 自动 transformable
|
||||||
|
- init 默认 `[]`,无 migration 风险
|
||||||
|
- 写入用 `UserProfile.updatedAt = .now`
|
||||||
|
|
||||||
|
为什么不另开 `@Model HiddenPresetMetric`:8 个 case 的隐藏标记只是 UI 偏好,放 Profile 单例最自然,避免新 entity + 关联查询。
|
||||||
|
|
||||||
|
## 5. UI 行为
|
||||||
|
|
||||||
|
### 5.1 隐藏入口
|
||||||
|
|
||||||
|
`IndicatorQuickSheet.monitorTile(_:)` 加 `.contextMenu`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
hideMonitor(m)
|
||||||
|
} label: {
|
||||||
|
Label("隐藏", systemImage: "eye.slash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`hideMonitor` 把 `m.rawValue` 加入 `profile.hiddenPresetMetrics`,save,grid 因 `@Query` 重渲染。被隐藏的 tile 若当前选中,要 `clearMonitor()` 复位。
|
||||||
|
|
||||||
|
### 5.2 grid 过滤
|
||||||
|
|
||||||
|
```swift
|
||||||
|
ForEach(MonitorMetric.allCases.filter { !hiddenSet.contains($0.rawValue) }) { m in
|
||||||
|
monitorTile(m)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`hiddenSet` = `Set(profile?.hiddenPresetMetrics ?? [])`,computed property。
|
||||||
|
|
||||||
|
### 5.3 恢复入口
|
||||||
|
|
||||||
|
`monitorGridSection` 顶部 section label 一行:
|
||||||
|
|
||||||
|
```
|
||||||
|
长期监测(进趋势) 已隐藏 3 ›
|
||||||
|
```
|
||||||
|
|
||||||
|
- chip 仅当 `hiddenSet.nonEmpty` 显示
|
||||||
|
- 点 chip → `.sheet` 弹一个轻量列表(`.medium` detent)
|
||||||
|
- 列表项:每个被隐藏的 `MonitorMetric` 显示 icon + displayName + 右侧"显示"按钮
|
||||||
|
- 点"显示" → `profile.hiddenPresetMetrics.removeAll { $0 == m.rawValue }` + save
|
||||||
|
- 列表空了自动 dismiss
|
||||||
|
|
||||||
|
### 5.4 边界
|
||||||
|
|
||||||
|
- 全部 6 个都隐藏:section 还在(label + chip + addCustomTile),不消失
|
||||||
|
- 隐藏不影响:Trends 折线、`Indicator` 列表查询、`MetricReminder` 调度
|
||||||
|
- `UserProfileStore.loadOrCreate` 已保证 profile 存在,无 nil 分支
|
||||||
|
- `@Query private var profiles: [UserProfile]` 已在 sheet 里,直接取 `profiles.first`
|
||||||
|
|
||||||
|
## 6. 文件改动清单
|
||||||
|
|
||||||
|
1. `Models/UserProfile.swift` — 加 `hiddenPresetMetrics: [String]` 字段 + init 默认值
|
||||||
|
2. `Features/Indicator/IndicatorQuickSheet.swift`
|
||||||
|
- `monitorGridSection`: 过滤 + 顶部 chip
|
||||||
|
- `monitorTile`: 加 contextMenu
|
||||||
|
- 新增 `hideMonitor(_:)` / `unhideMonitor(_:)` / `hiddenSet`
|
||||||
|
- 新增 `HiddenMonitorRestoreSheet` 子 View(同文件内,私有)
|
||||||
|
|
||||||
|
不动:`MonitorMetric.swift`、`CustomMetricEditor.swift`、Trends、`ReminderService`、`MeView`。
|
||||||
|
|
||||||
|
## 7. 测试 / 验证手段
|
||||||
|
|
||||||
|
无单测目标(全 UI 行为)。手测点:
|
||||||
|
|
||||||
|
- [ ] 长按血压 tile → 出现"隐藏",点了 grid 里消失
|
||||||
|
- [ ] 顶部 chip "已隐藏 1" 出现,数字正确
|
||||||
|
- [ ] 点 chip → 弹列表,有 1 行血压,点"显示"恢复
|
||||||
|
- [ ] 全部 6 个隐藏 → grid 只剩 addCustomTile + 自定义指标,不崩
|
||||||
|
- [ ] 隐藏期间去 Trends,血压折线仍在
|
||||||
|
- [ ] 隐藏前若血压已选中,隐藏后选中态清空、字段清空
|
||||||
|
- [ ] 重启 App,隐藏状态持久
|
||||||
|
|
||||||
|
## 8. 红线核查(CLAUDE.md §10)
|
||||||
|
|
||||||
|
- ✅ 不引入云
|
||||||
|
- ✅ 不动 AIRuntime / Service 边界
|
||||||
|
- ✅ 不动 SwiftData 既有 `Indicator` schema
|
||||||
|
- ✅ Tab / RecordSheet 骨架不动
|
||||||
|
- ✅ 不是清单外功能,是对 §7 grid 的小改良
|
||||||
434
docs/superpowers/specs/2026-05-26-monitor-and-profile-design.md
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
# Monitor + Profile · 设计 v1
|
||||||
|
|
||||||
|
> 长期格式化指标录入(`.indicator` 入口预设 + 自由)+ 个人资料(年龄、性别、健康背景、用药)
|
||||||
|
>
|
||||||
|
> 日期:2026-05-26 · 状态:approved by user,进入实施
|
||||||
|
> 关联:[CLAUDE.md](../../../CLAUDE.md) §5 §7 §10;[W2 retro](../retros/2026-05-31-w2.md) 计划外完成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
### 1.1 当前缺口
|
||||||
|
|
||||||
|
康康现有的 4 个记录 kind(`quick` 拍照、`archive` 归档、`diary` 文字、`symptom` 持续症状)都是**事件型**——一次性记录,不假设后续会重复同一指标。但血压/血糖/体重这类**长期监测**类需求:
|
||||||
|
|
||||||
|
- 用户每天/每周测,数值规律地重复
|
||||||
|
- 需要趋势(W4-W5 计划的 Trends 页)
|
||||||
|
- 不需要拍照(已是格式化数字)
|
||||||
|
- 参考范围依赖个人 demographic(老人血压标准放宽)
|
||||||
|
|
||||||
|
同时,App 启动以来一直没有用户基础信息持久化的位置。LLM 给出趋势解读时缺乏 demographic context("LDL 偏高"对 35 岁健康男和 70 岁糖尿病患者风险完全不同)。
|
||||||
|
|
||||||
|
### 1.2 目标
|
||||||
|
|
||||||
|
- **一个统一的"手动录入指标"入口**:用户已加 `.indicator` case,本设计把 7 个预设(血压/血糖/体重/...)和「自由输入」合并进这个 sheet
|
||||||
|
- **个人资料卡**:在「我的」加一张资料卡,push 进 Form 编辑页,4 项核心 + 健康背景 + 用药
|
||||||
|
- **联动**:参考范围按 Profile 个性化(目前规则只覆盖"老人血压"一例,后续可扩)
|
||||||
|
|
||||||
|
### 1.3 非目标(YAGNI)
|
||||||
|
|
||||||
|
- ❌ Trends 页升级(本次只打通数据通路,留给 W4-W5)
|
||||||
|
- ❌ 提醒/通知功能(到点测量推送)
|
||||||
|
- ❌ HealthKit 导入
|
||||||
|
- ❌ 多 Profile / 给家人记
|
||||||
|
- ❌ AppLock / Face ID(W5 末统一实现)
|
||||||
|
- ❌ 单位切换(kg/lb,mmol/L vs mg/dL)
|
||||||
|
- ❌ 紧急联系人
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 数据模型
|
||||||
|
|
||||||
|
### 2.1 Indicator 扩字段
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@Model final class Indicator {
|
||||||
|
// 现有字段不动:name/value/unit/range/statusRaw/note/capturedAt/report/asset/pinned
|
||||||
|
|
||||||
|
var seriesKey: String? // 新增。预设录入填 "bp.systolic" / "glucose.fasting" / ...
|
||||||
|
// 自由输入和 VL/Report 来源的 Indicator 都为 nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么用 String 而非 enum**:`seriesKey` 跨设备/版本要稳定,enum 改名会破坏老数据;String 用命名空间约定(`bp.*` / `glucose.*`)即可。
|
||||||
|
|
||||||
|
**为什么不新建 @Model**:复用 Indicator 让 Trends/Timeline/ReportCompareService 一次写完受益,避免分裂查询路径。
|
||||||
|
|
||||||
|
### 2.2 UserProfile @Model
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@Model final class UserProfile {
|
||||||
|
// —— 核心 4 项 ——
|
||||||
|
var birthYear: Int? // 1990 等。隐私考虑只存"年",不存月日
|
||||||
|
var biologicalSexRaw: String // "" / "male" / "female"
|
||||||
|
var heightCM: Int?
|
||||||
|
var bloodTypeRaw: String // "" / "A" / "B" / "AB" / "O"
|
||||||
|
|
||||||
|
// —— 健康背景 ——
|
||||||
|
var allergies: [String] // 自由文本数组
|
||||||
|
var chronicConditions: [String] // 预设 + 自定义混合
|
||||||
|
var familyHistory: [String] // 自由文本数组
|
||||||
|
|
||||||
|
// —— 当前用药 ——
|
||||||
|
var currentMedications: [String]
|
||||||
|
|
||||||
|
var updatedAt: Date
|
||||||
|
|
||||||
|
init(birthYear: Int? = nil, /* ... */) { /* ... */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UserProfile {
|
||||||
|
enum Sex: String { case male, female, undisclosed = "" }
|
||||||
|
var sex: Sex { Sex(rawValue: biologicalSexRaw) ?? .undisclosed }
|
||||||
|
|
||||||
|
/// 当前年龄(无 birthYear 时 nil)
|
||||||
|
var age: Int? {
|
||||||
|
guard let y = birthYear else { return nil }
|
||||||
|
return Calendar.current.component(.year, from: .now) - y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 单例策略
|
||||||
|
|
||||||
|
UserProfile 全 App 单一实例,通过 helper 保证:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum UserProfileStore {
|
||||||
|
@MainActor
|
||||||
|
static func loadOrCreate(in ctx: ModelContext) -> UserProfile {
|
||||||
|
let descriptor = FetchDescriptor<UserProfile>()
|
||||||
|
if let existing = try? ctx.fetch(descriptor).first { return existing }
|
||||||
|
let new = UserProfile()
|
||||||
|
ctx.insert(new)
|
||||||
|
try? ctx.save()
|
||||||
|
return new
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
任何 View 用 `@Query` 拉,空了再调 loadOrCreate。MeView 启动时调一次,确保后续 @Query 必拿到。
|
||||||
|
|
||||||
|
### 2.4 Schema 注册
|
||||||
|
|
||||||
|
`KangkangApp.swift` 的 schema 加入 `UserProfile.self`。Indicator 加字段是 additive change,SwiftData 自动迁移(给老 row 的 seriesKey 填 nil)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. MonitorMetric Catalog
|
||||||
|
|
||||||
|
`Features/Monitor/MonitorMetric.swift`,8 个预设(血压算 1 个 case,内部展开 2 条 Indicator):
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum MonitorMetric: String, CaseIterable, Identifiable {
|
||||||
|
case bloodPressure // bp.systolic + bp.diastolic
|
||||||
|
case fastingGlucose // glucose.fasting
|
||||||
|
case postprandialGlucose // glucose.postprandial
|
||||||
|
case weight // weight
|
||||||
|
case temperature // temperature
|
||||||
|
case heartRate // heart_rate
|
||||||
|
case spo2 // spo2
|
||||||
|
case height // height(录入后回写 UserProfile.heightCM)
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String { /* "血压" / "空腹血糖" / ... */ }
|
||||||
|
var icon: String { /* SF Symbol */ }
|
||||||
|
var fields: [Field] // 1 或 2 个字段定义
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MonitorMetric {
|
||||||
|
struct Field {
|
||||||
|
let seriesKey: String // 如 "bp.systolic"
|
||||||
|
let label: String // 如 "收缩压"
|
||||||
|
let unit: String // 如 "mmHg"
|
||||||
|
let placeholder: String // 如 "120"
|
||||||
|
let baseRange: ClosedRange<Double>? // nil 表示不算 status(体重/身高)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回该 metric 在给定 profile 下的参考范围(可能跟 baseRange 不同)
|
||||||
|
func effectiveRange(for field: Field, profile: UserProfile?) -> ClosedRange<Double>? {
|
||||||
|
// 目前唯一规则:bp 在 age >= 65 时上限放宽到 150 / 90
|
||||||
|
if let age = profile?.age, age >= 65,
|
||||||
|
field.seriesKey == "bp.systolic" {
|
||||||
|
return 90...150
|
||||||
|
}
|
||||||
|
if let age = profile?.age, age >= 65,
|
||||||
|
field.seriesKey == "bp.diastolic" {
|
||||||
|
return 60...90 // 暂不调
|
||||||
|
}
|
||||||
|
return field.baseRange
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据值算 status(value 在范围内 → normal,上 → high,下 → low,无范围 → normal)
|
||||||
|
static func status(value: Double, in range: ClosedRange<Double>?) -> IndicatorStatus {
|
||||||
|
guard let r = range else { return .normal }
|
||||||
|
if value > r.upperBound { return .high }
|
||||||
|
if value < r.lowerBound { return .low }
|
||||||
|
return .normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1 Profile-aware 规则
|
||||||
|
|
||||||
|
本次仅实现 1 条规则(老人收缩压上限 140→150),目的是**展示联动机制**,不追求医学完备。未来扩规则只改 `effectiveRange` 函数,不动调用方。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UI
|
||||||
|
|
||||||
|
### 4.1 IndicatorRecordSheet(替代之前提的 MonitorRecordSheet)
|
||||||
|
|
||||||
|
`Features/Indicator/IndicatorRecordSheet.swift`,被 RootView 在 `.indicator` case 弹出。
|
||||||
|
|
||||||
|
**布局**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[拖动条]
|
||||||
|
"记录指标 · 本地处理"
|
||||||
|
|
||||||
|
[2 列 grid]
|
||||||
|
┌─────────┐ ┌─────────┐
|
||||||
|
│ 血压 │ │ 空腹血糖│
|
||||||
|
│ 收/舒 │ │ 3.9-6.1 │
|
||||||
|
└─────────┘ └─────────┘
|
||||||
|
┌─────────┐ ┌─────────┐
|
||||||
|
│ 体重 │ │ 体温 │
|
||||||
|
└─────────┘ └─────────┘
|
||||||
|
... (共 7 预设)
|
||||||
|
┌─────────┐ ┌─────────┐
|
||||||
|
│ 心率 │ │ + 自由 │
|
||||||
|
└─────────┘ └─────────┘
|
||||||
|
|
||||||
|
—— 选中 metric 后,grid 下方展开 ——
|
||||||
|
|
||||||
|
【血压】参考范围:90-140 / 60-90 mmHg(成人通用)
|
||||||
|
[收缩压 _____ mmHg]
|
||||||
|
[舒张压 _____ mmHg]
|
||||||
|
status chip 实时显示
|
||||||
|
|
||||||
|
[保存按钮]
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键交互**:
|
||||||
|
- 进入 sheet 时无选中,grid 全展示
|
||||||
|
- 点预设 → 高亮卡片 + 下方展开输入区
|
||||||
|
- 切换 metric → 数值清空(避免血压数值串到血糖)
|
||||||
|
- 选「+ 自由输入」→ 展开 4 个字段:名称 / 数值 / 单位 / 参考范围(string)
|
||||||
|
- 保存:
|
||||||
|
- 血压 → 2 条 Indicator(同 capturedAt + 各自 seriesKey)
|
||||||
|
- 单字段预设 → 1 条 Indicator(seriesKey 填)
|
||||||
|
- 身高预设 → 1 条 Indicator + 回写 UserProfile.heightCM
|
||||||
|
- 自由输入 → 1 条 Indicator(seriesKey 为 nil,name 用户输入)
|
||||||
|
|
||||||
|
**Profile-aware 提示**:
|
||||||
|
- 若 `effectiveRange` 跟 `baseRange` 不同,参考范围一行末尾小字:"按你的年龄(67)调整"
|
||||||
|
- 若 `effectiveRange` 与 baseRange 相同 / 无 Profile,正常显示
|
||||||
|
|
||||||
|
### 4.2 MeView 改造
|
||||||
|
|
||||||
|
```
|
||||||
|
[ScrollView]
|
||||||
|
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 个人资料 更多 →│
|
||||||
|
│ 38岁 · 男 · 175cm · A型 │
|
||||||
|
│ (未设置时:"点这里完善你的资料") │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
↓ tap push
|
||||||
|
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 模型管理 未配置 → │ (W6 stub)
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ Face ID 启动锁 关闭 → │ (W5 stub)
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 关于 → │ (链接到隐私承诺 placeholder)
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
DebugAIRunner
|
||||||
|
#endif
|
||||||
|
```
|
||||||
|
|
||||||
|
stub 卡片本次只放占位 + 文案,push 进去是空页或 placeholder。
|
||||||
|
|
||||||
|
### 4.3 ProfileEditView
|
||||||
|
|
||||||
|
`Features/Profile/ProfileEditView.swift`,Form 风格:
|
||||||
|
|
||||||
|
```
|
||||||
|
导航标题:个人资料
|
||||||
|
|
||||||
|
—— 基本 ——
|
||||||
|
出生年份 [picker 1900-2026]
|
||||||
|
性别 [男 / 女 / 不愿透露 segmented]
|
||||||
|
身高 [TextField + cm]
|
||||||
|
血型 [A / B / AB / O / 不知道 picker]
|
||||||
|
|
||||||
|
—— 健康背景 ——
|
||||||
|
过敏史 [chips + add field]
|
||||||
|
慢病 [8 预设 chips 多选 + 自定义 add]
|
||||||
|
家族史 [chips + add field]
|
||||||
|
|
||||||
|
—— 当前用药 ——
|
||||||
|
[列表 + add row + 行内 swipe-to-delete]
|
||||||
|
|
||||||
|
(保存即时,无显式 Save 按钮——边改边写)
|
||||||
|
```
|
||||||
|
|
||||||
|
慢病 8 预设:`高血压 / 糖尿病 / 冠心病 / 高血脂 / 甲状腺疾病 / 哮喘 / 慢性肾病 / 抑郁/焦虑`
|
||||||
|
|
||||||
|
### 4.4 Timeline 行内合并(顺手)
|
||||||
|
|
||||||
|
`Features/Timeline/TimelineEntry.swift`,`from(indicator:)` 增加配对逻辑:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
static func from(indicators: [Indicator]) -> [TimelineEntry] {
|
||||||
|
// 旧版只 map,新版需要查找 bp.systolic 配对 bp.diastolic
|
||||||
|
// 算法:同 capturedAt(到秒)+ bp.* prefix → 合并;其他单条直接 map
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ArchiveListView 和 HomeView 的 `mapped` 表达式从 `indicators.map(...)` 改为 `TimelineEntry.from(indicators:)`(批处理)。
|
||||||
|
|
||||||
|
合并后的 TimelineEntry:
|
||||||
|
- title: "血压"
|
||||||
|
- subtitle: "120 / 80 mmHg"
|
||||||
|
- trailing: 异常时显示"偏高"或"正常"
|
||||||
|
|
||||||
|
非 bp.* 的 series 不合并,逐条显示("空腹血糖 5.4 mmol/L" / "体重 68 kg")。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 联动:Profile ↔ Monitor
|
||||||
|
|
||||||
|
### 5.1 调用路径
|
||||||
|
|
||||||
|
```
|
||||||
|
IndicatorRecordSheet
|
||||||
|
↓ @Query UserProfile (单例)
|
||||||
|
MonitorMetric.effectiveRange(for: field, profile: profile)
|
||||||
|
↓
|
||||||
|
- 显示个性化参考范围
|
||||||
|
- 保存时 MonitorMetric.status(value:, in: effectiveRange) 算 statusRaw
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 未来扩展点
|
||||||
|
|
||||||
|
`effectiveRange` 是唯一规则入口,扩规则只动这个函数。规则示例(本次不实现):
|
||||||
|
- 性别 → 血红蛋白、肌酐参考范围不同
|
||||||
|
- 慢病 → 糖尿病患者血糖目标更严
|
||||||
|
- 年龄分段 → 儿童体温、心率范围
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 测试
|
||||||
|
|
||||||
|
### 6.1 新建 `康康Tests/UserProfileTests.swift`
|
||||||
|
|
||||||
|
- `freshProfileHasNilDemographics()` — 新建 profile,字段都 nil/空数组
|
||||||
|
- `ageComputedFromBirthYear()` — 1985 → 41 岁(2026 当前年)
|
||||||
|
- `sexParsesEnumFromRaw()` — male/female/空 → 三种 enum
|
||||||
|
- `loadOrCreateReturnsExistingSingleton()` — 第二次 call 不创建新 row
|
||||||
|
- `arrayFieldsRoundtripThroughSwiftData()` — chronicConditions 存读
|
||||||
|
|
||||||
|
### 6.2 新建 `康康Tests/MonitorMetricTests.swift`
|
||||||
|
|
||||||
|
- `allMetricsHaveAtLeastOneField()`
|
||||||
|
- `bpHasTwoFields()`
|
||||||
|
- `statusHighWhenAboveUpper()` / `statusLowWhenBelowLower()` / `statusNormalWhenInside()` / `statusNormalWhenRangeNil()`
|
||||||
|
- `bpUpperBoundShiftsForElderly()` — age 67 时 bp.systolic 上限 = 150
|
||||||
|
- `bpUpperBoundUnchangedWhenNoProfile()` — profile 为 nil 时上限 = 140
|
||||||
|
- `nonBPSeriesUnaffectedByProfile()` — 血糖范围不随年龄变
|
||||||
|
|
||||||
|
### 6.3 扩 `康康Tests/ModelsSchemaTests.swift`
|
||||||
|
|
||||||
|
- `userProfileSchemaPersistsAcrossSave()`
|
||||||
|
- `indicatorSeriesKeyRoundtrip()`
|
||||||
|
- `cascadeStillWorksWithSeriesKey()` — Report 删除时,关联 Indicator(无论 seriesKey)都删
|
||||||
|
|
||||||
|
### 6.4 扩 `康康Tests/TimelineGroupingTests.swift`
|
||||||
|
|
||||||
|
- `bpSystolicAndDiastolicMergeIntoSingleEntry()`
|
||||||
|
- `nonBPSeriesStayAsSeparateEntries()`
|
||||||
|
- `bpAtDifferentTimesDoNotMerge()` — capturedAt 差 > 5 秒不合并
|
||||||
|
|
||||||
|
预期总测试数:11(profile 5)+ 7(metric)+ 3(schema)+ 3(timeline)= 18 个新测试。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 不变项与守恒检查
|
||||||
|
|
||||||
|
- ✅ §10.1 不引入云服务 — 完全本地
|
||||||
|
- ✅ §10.2 不自实现密码学 — SwiftData store 已有 file protection
|
||||||
|
- ✅ §10.3 UI 不直接调 AIRuntime — 本设计不涉及 AI
|
||||||
|
- ✅ §10.4 AIRuntime actor — 不涉及
|
||||||
|
- ✅ §10.5 VL/LLM prompt — 不涉及
|
||||||
|
- ⚠️ §10.6 新功能必须问"清单里有吗" — Monitor 和 Profile 都是清单外,**已跟用户确认加入**
|
||||||
|
- ✅ §10.7 不重构现有骨架 — 不动 RootView / RecordSheet 骨架(只补 case 处理),不动 DesignSystem
|
||||||
|
- ✅ §10.8 C2 ≠ B3 — 不涉及
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 文件清单
|
||||||
|
|
||||||
|
### 新建(6)
|
||||||
|
|
||||||
|
| 路径 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `康康/Models/UserProfile.swift` | UserProfile @Model + Sex enum + age computed + loadOrCreate helper |
|
||||||
|
| `康康/Features/Monitor/MonitorMetric.swift` | 8 metric catalog + effectiveRange + status 算法 |
|
||||||
|
| `康康/Features/Indicator/IndicatorRecordSheet.swift` | 预设 grid + 自由输入合一的录入 sheet |
|
||||||
|
| `康康/Features/Profile/ProfileEditView.swift` | Form 编辑页 |
|
||||||
|
| `康康Tests/UserProfileTests.swift` | 5 测试 |
|
||||||
|
| `康康Tests/MonitorMetricTests.swift` | 7 测试 |
|
||||||
|
|
||||||
|
### 修改(7)
|
||||||
|
|
||||||
|
| 路径 | 改什么 |
|
||||||
|
|---|---|
|
||||||
|
| `康康/Models/Models.swift` | Indicator 加 `seriesKey: String?`,初始化器加默认值 nil |
|
||||||
|
| `康康/App/KangkangApp.swift` | schema 加 `UserProfile.self` |
|
||||||
|
| `康康/Features/Me/MeView.swift` | 加 ProfileCard + 3 个 stub 卡片 |
|
||||||
|
| `康康/RootView.swift` | `.indicator` case 接 IndicatorRecordSheet 弹出 |
|
||||||
|
| `康康/Features/Timeline/TimelineEntry.swift` | 加 `from(indicators:)` 批处理 + bp 配对 |
|
||||||
|
| `康康Tests/ModelsSchemaTests.swift` | 3 个新测试 |
|
||||||
|
| `康康Tests/TimelineGroupingTests.swift` | 3 个新测试 |
|
||||||
|
|
||||||
|
### 文档(2)
|
||||||
|
|
||||||
|
| 路径 | 改什么 |
|
||||||
|
|---|---|
|
||||||
|
| `CLAUDE.md` | §5 加 UserProfile @Model + Indicator seriesKey;§7 IA 加 Profile 入口;§11 时间表加备注;§10.6 例外清单加 Monitor + Profile |
|
||||||
|
| `docs/superpowers/specs/2026-05-26-monitor-and-profile-design.md` | 本文件 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 验收
|
||||||
|
|
||||||
|
- [ ] App build & test 全绿,0 警告
|
||||||
|
- [ ] DEBUG 启动 → 我的 → 个人资料 → 填年龄 + 性别 + 身高 + 血型,push back 显示在 ProfileCard
|
||||||
|
- [ ] DEBUG 启动 → + 号 → 指标记录 → 选血压 → 输 145/85 → 保存 → 在首页时间线看到合并的"血压 145/85"行
|
||||||
|
- [ ] 把 UserProfile birthYear 改成 1955(70 岁) → 再次进血压录入 → 顶部小字显示"按你的年龄(70)调整",参考范围 90-150 / 60-90
|
||||||
|
- [ ] 录入身高 175 → 个人资料卡片自动显示 175cm
|
||||||
|
- [ ] 18 个新测试全绿
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 估时
|
||||||
|
|
||||||
|
- 数据层(UserProfile + Indicator.seriesKey + schema 注册):20 分钟
|
||||||
|
- MonitorMetric catalog + effectiveRange:20 分钟
|
||||||
|
- IndicatorRecordSheet UI:25 分钟
|
||||||
|
- ProfileEditView + MeView 改造:25 分钟
|
||||||
|
- Timeline 合并:15 分钟
|
||||||
|
- 18 测试:30 分钟
|
||||||
|
- CLAUDE.md + 提交整理:15 分钟
|
||||||
|
|
||||||
|
**总计 ~150 分钟**(2.5 小时)。
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
# 导出身体档案 — 设计文档
|
||||||
|
|
||||||
|
**日期**:2026-05-27 (W2)
|
||||||
|
**作者**:link2026 + Claude
|
||||||
|
**关联卖点**:#1 影像档案系统、#2 100% 本地、#3 本地 RAG 长期记忆、#4 隐私三件套、#6 Live Activity tok/s
|
||||||
|
**优先级**:P0(打通 RAG 链路 + demo 主要演示场景)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 一句话定位
|
||||||
|
|
||||||
|
在「记录」Tab 顶部增加「导出身体档案」入口,用户输入自然语言主诉(如「我感冒 3 天,把最近一个月给医生看」),完全本地的两段式 RAG 把 SwiftData 里相关的指标 / 报告 / 症状 / 日记 / 个人资料检索并生成给医生看的 Markdown 摘要,可复制、分享、查看历史。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 用户故事
|
||||||
|
|
||||||
|
> 周日晚上,我感冒第 3 天还没好。明早要去社区医院,医生只有 5 分钟问诊,我想把过去一个月的体温记录、上次体检的关键异常项、在服的降压药、家族过敏史一次性整理出来给医生。我不想把这些数据上传到任何云。
|
||||||
|
|
||||||
|
成功标准:
|
||||||
|
|
||||||
|
- 输入 prompt → 30 秒内出现首字 → 90 秒内完整生成
|
||||||
|
- 输出 Markdown 包含主诉 / 患者背景 / 近期症状 / 关键指标 / 在服药与过敏 / 患者疑问
|
||||||
|
- 一键复制到微信发给医生,或直接 AirDrop / 邮件分享
|
||||||
|
- 重启 App 后能看到历史导出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 范围
|
||||||
|
|
||||||
|
**做**:
|
||||||
|
|
||||||
|
- 记录 Tab 右上角 toolbar「导出」按钮
|
||||||
|
- ArchiveListView 顶部「我的导出」横向卡区(有历史时显示,前 3 条 + 查看全部)
|
||||||
|
- 全屏 sheet:prompt 输入 / Phase 指示 / 流式 Markdown / 完成后复制+分享+重新生成
|
||||||
|
- 历史列表页 + 详情页
|
||||||
|
- 两段式 RAG 链路:Qwen3-1.7B 抽意图 → SwiftData 结构化检索 → Qwen3-1.7B 生成 Markdown
|
||||||
|
- 新 `HealthExport` @Model + Schema 注册
|
||||||
|
- 引用回链(referencedXxxIDs,W3 再做点击跳转)
|
||||||
|
|
||||||
|
**不做**:
|
||||||
|
|
||||||
|
- embedding / 向量检索
|
||||||
|
- 跨设备同步、云端备份
|
||||||
|
- PDF 导出(W6 余力再说)
|
||||||
|
- 给医生的诊断建议 / 用药建议(红线 §10.1)
|
||||||
|
- 自动定期导出(此版无 schedule)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ UI ─────────────────────────────────────────────────────┐
|
||||||
|
│ ArchiveListView │
|
||||||
|
│ ├─ .toolbar trailing: "导出" 图标按钮 │
|
||||||
|
│ └─ 顶部横向卡区 HealthExportRecentStrip(有历史时显示)│
|
||||||
|
│ │ │
|
||||||
|
│ └─→ HealthExportSheet (full-screen cover) │
|
||||||
|
│ ├─ prompt TextEditor │
|
||||||
|
│ ├─ Phase 状态条 │
|
||||||
|
│ ├─ Markdown 流式渲染 │
|
||||||
|
│ └─ Actions: 复制 / 分享 / 重新生成 │
|
||||||
|
│ │
|
||||||
|
│ HealthExportListView (NavigationLink "查看全部") │
|
||||||
|
│ └─ 全部历史(@Query DESC)→ HealthExportDetailView │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
↑ Event 流
|
||||||
|
┌─ Service ────────────────────────────────────────────────┐
|
||||||
|
│ HealthExportService (struct, DI ModelContext + Runtime) │
|
||||||
|
│ func export(prompt:) -> AsyncThrowingStream<Event> │
|
||||||
|
│ Event = .phaseChanged(Phase) | .token(TokenChunk) │
|
||||||
|
│ | .completed(HealthExport) | .failed(Error) │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
↑ 串行排队
|
||||||
|
┌─ AI 层 (已存在) ──────────────────────────────────────────┐
|
||||||
|
│ AIRuntime(actor 单例)→ LLMSession 串行两次调用 │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
↑ 检索
|
||||||
|
┌─ Persistence (SwiftData) ────────────────────────────────┐
|
||||||
|
│ Indicator / Report / Symptom / DiaryEntry / │
|
||||||
|
│ UserProfile / HealthExport(新增) │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**红线对齐**(CLAUDE.md §10):
|
||||||
|
|
||||||
|
- UI 不直接调 AIRuntime,只与 HealthExportService 通讯 ✅
|
||||||
|
- AIRuntime 仍是 actor 单例,两段调用在它的队列内串行,与 CaptureService / 未来的 AskService 互不抢占 GPU ✅
|
||||||
|
- 两个 prompt 都带 few-shot + 失败回退 ✅
|
||||||
|
- 不引入云服务、不自实现密码学、不重构现有 Tab/RecordSheet ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 数据模型
|
||||||
|
|
||||||
|
新增 `Models/HealthExport.swift`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model final class HealthExport {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var prompt: String = "" // 用户原始输入
|
||||||
|
var content: String = "" // 生成的 Markdown 全文
|
||||||
|
var createdAt: Date = .now
|
||||||
|
|
||||||
|
// 引用回链(对齐 §3.3)
|
||||||
|
var referencedIndicatorIDs: [UUID] = []
|
||||||
|
var referencedReportIDs: [UUID] = []
|
||||||
|
var referencedSymptomIDs: [UUID] = []
|
||||||
|
var referencedDiaryIDs: [UUID] = []
|
||||||
|
|
||||||
|
// 意图抽取快照(供"重新生成"复用,不再调一次 LLM)
|
||||||
|
var inferredTimeFromDate: Date?
|
||||||
|
var inferredTimeToDate: Date?
|
||||||
|
var inferredIntent: String?
|
||||||
|
|
||||||
|
// demo 卖点凭证
|
||||||
|
var modelTag: String = "Qwen3-1.7B-4bit"
|
||||||
|
var decodeRate: Double = 0 // 末次 tok/s
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schema 注册**:`App/KangkangApp.swift` 的 `ModelContainer(for:)` 加入 `HealthExport.self`(增表是 SwiftData 兼容变更,无需手写迁移)。
|
||||||
|
|
||||||
|
**为什么 `referenced*IDs` 用 `[UUID]` 而不是 SwiftData 关系**:
|
||||||
|
导出是历史快照,源 Indicator / Report 可能后续被用户永久删除(§10.4);弱关联避免 cascade 影响历史导出本身。点击跳转时,源记录若已不存在,UI 显示「记录已删除」灰态。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 状态机 + 数据流
|
||||||
|
|
||||||
|
`HealthExportService.export(prompt:)` 是 `AsyncThrowingStream<Event, Error>`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum Phase: String {
|
||||||
|
case extractingIntent // 理解意图
|
||||||
|
case retrieving // 检索数据
|
||||||
|
case generating // 撰写报告
|
||||||
|
case completed
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Event {
|
||||||
|
case phaseChanged(Phase)
|
||||||
|
case token(TokenChunk)
|
||||||
|
case completed(HealthExport)
|
||||||
|
case failed(Error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**流程**:
|
||||||
|
|
||||||
|
```
|
||||||
|
.idle
|
||||||
|
│ user tap 生成
|
||||||
|
▼
|
||||||
|
phaseChanged(.extractingIntent)
|
||||||
|
│ LLMSession.generate(prompt: INTENT_PROMPT, maxTokens: 120)
|
||||||
|
│ 失败 → 回退到默认 {time_range_days: 30, keywords: [], symptom_keywords: []}
|
||||||
|
▼
|
||||||
|
phaseChanged(.retrieving)
|
||||||
|
│ 同步 SwiftData 查询:
|
||||||
|
│ - Indicator where capturedAt ∈ [from, to], 可选按 keyword 过滤 name/seriesKey
|
||||||
|
│ - Report where reportDate ∈ [from, to]
|
||||||
|
│ - Symptom where startedAt <= to AND (endedAt == nil OR endedAt >= from)
|
||||||
|
│ - DiaryEntry where createdAt ∈ [from, to] AND content contains any symptom_keyword
|
||||||
|
│ (privacy 过滤:无主诉相关关键词的日记不入 prompt;
|
||||||
|
│ 若 symptom_keywords 为空,则一律不包含日记 —— 安全默认)
|
||||||
|
│ - UserProfile 单例,无条件包含
|
||||||
|
▼
|
||||||
|
phaseChanged(.generating)
|
||||||
|
│ 拼 GENERATION_PROMPT(把上一步结果序列化为简短结构)
|
||||||
|
│ LLMSession.generate(prompt:, maxTokens: 1024)
|
||||||
|
│ for token in stream: yield .token(chunk)
|
||||||
|
▼
|
||||||
|
phaseChanged(.completed)
|
||||||
|
│ build HealthExport(prompt, content, referencedIDs, inferred*, decodeRate)
|
||||||
|
│ modelContext.insert + try modelContext.save()
|
||||||
|
▼
|
||||||
|
.completed(healthExport)
|
||||||
|
```
|
||||||
|
|
||||||
|
**取消语义**:UI 关闭 sheet → stream 被取消 → 中间态不入库。
|
||||||
|
|
||||||
|
**与 AIRuntime 互斥**:HealthExportService 在 `AIRuntime` 的 actor 函数里调度两次 LLM 调用;若此时 CaptureService 正在跑 VL,自然在 actor 队列里等待。Phase indicator 在 UI 上显示「排队中」(可选,W3 polish)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Prompt 设计
|
||||||
|
|
||||||
|
两个 prompt 都放在 `AI/Prompts/HealthExportPrompts.swift`,带 2 个 few-shot。
|
||||||
|
|
||||||
|
### 7.1 意图抽取(Qwen3-1.7B,~120 token 输出)
|
||||||
|
|
||||||
|
```text
|
||||||
|
你是健康数据助手。读用户的请求,只输出严格 JSON,不要任何解释或 Markdown。
|
||||||
|
|
||||||
|
字段:
|
||||||
|
{
|
||||||
|
"time_range_days": int, // 时间窗,默认 30
|
||||||
|
"keywords": [string], // 指标关键词(中文,如"血压"/"血糖"/"体温")
|
||||||
|
"symptom_keywords": [string], // 症状关键词
|
||||||
|
"intent": string // 简短意图标签
|
||||||
|
}
|
||||||
|
|
||||||
|
示例 1:
|
||||||
|
User: 我感冒3天了,要把最近一个月的健康情况给医生看
|
||||||
|
Output: {"time_range_days":30,"keywords":["体温","血压","脉搏"],"symptom_keywords":["感冒","咳嗽","咽喉痛","发烧"],"intent":"cold_consult"}
|
||||||
|
|
||||||
|
示例 2:
|
||||||
|
User: 我最近血糖好像不稳,把上次体检前后的化验单整理一下
|
||||||
|
Output: {"time_range_days":90,"keywords":["血糖","糖化血红蛋白","胰岛素"],"symptom_keywords":[],"intent":"glucose_review"}
|
||||||
|
|
||||||
|
User: {{USER_PROMPT}}
|
||||||
|
Output:
|
||||||
|
```
|
||||||
|
|
||||||
|
**解析容错**:
|
||||||
|
- 非 JSON → 抓 `{…}` 之间的子串再试一次
|
||||||
|
- 仍失败 → 用默认 `{30, [], []}`,继续流程,不报错给用户
|
||||||
|
|
||||||
|
### 7.2 报告生成(Qwen3-1.7B,maxTokens 1024)
|
||||||
|
|
||||||
|
```text
|
||||||
|
你正在帮患者撰写一份给社区医生看的就诊摘要。
|
||||||
|
要求:
|
||||||
|
- 输出 Markdown,严格按下方结构
|
||||||
|
- 只用「数据」中提供的信息,数据缺失就写"无记录"
|
||||||
|
- 不要给诊断意见、不要给用药建议、不要写"建议就医"
|
||||||
|
- 引用具体数值时保留单位和参考范围
|
||||||
|
- 全文中文,简洁,医生 30 秒能扫完
|
||||||
|
|
||||||
|
结构:
|
||||||
|
# 就诊摘要 — {{INTENT_LABEL_CN}}
|
||||||
|
## 主诉
|
||||||
|
## 患者背景
|
||||||
|
## 近期症状(按时间倒序)
|
||||||
|
## 关键指标(异常项优先)
|
||||||
|
## 在服药与过敏
|
||||||
|
## 患者疑问
|
||||||
|
|
||||||
|
数据:
|
||||||
|
{{SERIALIZED_DATA_JSON}}
|
||||||
|
|
||||||
|
患者原话:{{USER_PROMPT}}
|
||||||
|
|
||||||
|
现在生成:
|
||||||
|
```
|
||||||
|
|
||||||
|
**SERIALIZED_DATA_JSON 结构**(给 LLM 看的精简结构):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profile": {
|
||||||
|
"age": 38, "sex": "男", "height_cm": 172,
|
||||||
|
"allergies": ["青霉素"],
|
||||||
|
"chronic": ["高血压(2 年)"],
|
||||||
|
"family_history": ["父亲冠心病"],
|
||||||
|
"current_meds": ["缬沙坦 80mg qd"]
|
||||||
|
},
|
||||||
|
"symptoms": [
|
||||||
|
{"name": "感冒", "started": "2026-05-24", "severity": 2,
|
||||||
|
"ongoing": true, "note": "鼻塞、低烧"}
|
||||||
|
],
|
||||||
|
"indicators": [
|
||||||
|
{"name": "收缩压", "value": 142, "unit": "mmHg", "range": "<140",
|
||||||
|
"status": "high", "date": "2026-05-26"}
|
||||||
|
],
|
||||||
|
"reports": [
|
||||||
|
{"title": "年度体检", "type": "physical", "date": "2026-04-12",
|
||||||
|
"institution": "瑞金医院"}
|
||||||
|
],
|
||||||
|
"diaries": [
|
||||||
|
{"date": "2026-05-25", "excerpt": "夜里两点醒了一次,头痛 7/10"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. UI 详细设计
|
||||||
|
|
||||||
|
### 8.1 ArchiveListView 改动
|
||||||
|
|
||||||
|
- toolbar trailing 加按钮:`Image(systemName: "doc.text.below.ecg") "导出"`
|
||||||
|
- 在 `List` 顶部插入 `HealthExportRecentStrip()`(若 `@Query HealthExport` 非空)
|
||||||
|
- 横向卡区,3 条最近导出 + 末尾「查看全部 →」卡,点击进入 `HealthExportListView`
|
||||||
|
|
||||||
|
### 8.2 HealthExportSheet (full-screen cover)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ ✕ 导出身体档案 本地·永不上传 │ Header
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ 例:我感冒3天了,把最近一个月给医生看 │ Hint
|
||||||
|
│ ┌──────────────────────────────────────────┐ │
|
||||||
|
│ │ (多行 TextEditor,~6 行) │ │
|
||||||
|
│ └──────────────────────────────────────────┘ │
|
||||||
|
│ [ 生成报告 ] │ TjPrimaryButton
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ ●─○─○ 理解意图 │ Phase pill,
|
||||||
|
│ │ 生成时显示
|
||||||
|
│ 本地推理 · Qwen3 · 24.3 tok/s │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ # 就诊摘要 — 感冒就诊 │
|
||||||
|
│ ## 主诉 │ Markdown 流式
|
||||||
|
│ 患者男,38 岁…… │ 渲染(原生
|
||||||
|
│ …(打字机效果)… │ Text(LocalizedStringKey))
|
||||||
|
│ │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ [ 复制 ] [ 分享 ] [ 重新生成 ] │ 完成后才显示
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 「分享」用系统 `ShareLink(item: content)`,导出纯文本
|
||||||
|
- 「重新生成」复用同一 `prompt` + `inferred*` 字段,跳过意图抽取,直接走 retrieving + generating
|
||||||
|
- 持久化时机:`.completed` 事件触发时由 Service 立即 `insert + save`;sheet 关闭只是 dismiss 视图,不再写库
|
||||||
|
- 生成中按 ✕ → 取消 stream → 不入库;已生成完成后按 ✕ → 仅 dismiss(数据已在库中)
|
||||||
|
|
||||||
|
### 8.3 HealthExportListView
|
||||||
|
|
||||||
|
简单的 `List` + `@Query(sort: \.createdAt, order: .reverse)`,每条显示:
|
||||||
|
- 标题:`HealthExport.prompt` 截断到 60 字
|
||||||
|
- 副标题:`relativeDate(createdAt)` + `tok/s` 标签
|
||||||
|
- 滑动删除
|
||||||
|
|
||||||
|
### 8.4 HealthExportDetailView
|
||||||
|
|
||||||
|
- 只读 Markdown(复用 sheet 的渲染组件)
|
||||||
|
- 顶部信息条:生成时间 / 模型 tag / tok/s
|
||||||
|
- toolbar:复制 / 分享 / 删除
|
||||||
|
- W3 再补:`referenced*IDs` 转 Pill,点击跳源记录(此 spec 不阻塞)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 错误处理
|
||||||
|
|
||||||
|
| 情况 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 模型未就绪 | toolbar 按钮置灰 + 副标题「模型未就绪,前往下载」(对齐 §4) |
|
||||||
|
| 意图抽取 JSON 解析失败 | 默认 `{30 days, [], []}` 兜底,流程继续,不报错给用户 |
|
||||||
|
| SwiftData 查询为空 | 数据段填 `"无记录"`,LLM 仍生成结构化"无明显异常"摘要 |
|
||||||
|
| 生成 stream 中途取消 | Service 抛 `CancellationError`,UI 显示「已取消」,不入库 |
|
||||||
|
| 生成超时 (>120s) | `Task.withTimeout` 超时取消,UI 同取消逻辑 |
|
||||||
|
| LLM 抛错(显存等) | UI 显示「生成失败:{msg}」+ 重试按钮 |
|
||||||
|
| `modelContext.save` 失败 | 仅日志,UI 仍展示文本,提示「保存失败,请重试」 |
|
||||||
|
|
||||||
|
**安全:** 全程不调用任何网络;`HealthExport` 持久化继承 §6 的 `.completeFileProtection`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 测试策略
|
||||||
|
|
||||||
|
**单元(`HealthExportServiceTests`)**:
|
||||||
|
|
||||||
|
- mock `AIRuntime` 协议(新增 `protocol AIRuntimeProtocol`,actor 单例符合该协议)
|
||||||
|
- 给定固定 SwiftData in-memory + 已知 Indicator/Symptom → 验证 referencedIDs 正确
|
||||||
|
- 意图抽取返回非 JSON → 验证回退到默认 30 天
|
||||||
|
- 验证 Phase 转换顺序:`.extractingIntent → .retrieving → .generating → .completed`
|
||||||
|
- 取消语义:在 `.generating` 阶段取消 → 不入库
|
||||||
|
|
||||||
|
**Preview**:
|
||||||
|
|
||||||
|
- `HealthExportSheet` 用 mock service 吐预设 Markdown(打字机视效在 Preview 即可看到)
|
||||||
|
- `HealthExportListView` 用 3 条 fake `HealthExport`
|
||||||
|
|
||||||
|
**真机验收**(W3 末):
|
||||||
|
|
||||||
|
- 在 16 inch M3 Max 模拟器上跑通(simulator 走 CPU,慢但能跑通流程)
|
||||||
|
- 真机 iPhone 15 Pro:首字 ≤ 10s,完整生成 ≤ 60s,tok/s ≥ 20
|
||||||
|
- 关 WiFi + 飞行模式仍能正常生成(隐私三件套 demo 关键)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 与现有/未来代码的关系
|
||||||
|
|
||||||
|
- **复用**:`AIRuntime` / `LLMSession` / `TokenChunk` / `Tj.*` Design System
|
||||||
|
- **铺路**:`HealthExportService` 的两段式 RAG 工程模式直接复用给 W3 的 `AskService`(只需替换 generation prompt + 输出形态)
|
||||||
|
- **不冲突**:`CaptureService` 在 AIRuntime 队列里和本服务串行;两者不会同时占 GPU
|
||||||
|
- **不影响**:`ArchiveListView` / `RecordSheet` / 现有 7 个 @Model 都不需要重构
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 取舍记录
|
||||||
|
|
||||||
|
| 决策 | 选择 | 拒绝的方案 | 理由 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 入口位置 | 「记录」Tab toolbar | RecordSheet 加一项 | 语义:RecordSheet 是「写入」,导出是「读出」 |
|
||||||
|
| 数据范围 | Indicator+Report+Symptom+Profile+Diary | 仅 Indicator+Report | 「感冒 3 天」需要 Symptom;医生需要 Profile;Diary 由 LLM 关键词过滤后入 prompt,降低隐私风险 |
|
||||||
|
| 历史位置 | ArchiveListView 顶部横向卡区 + 查看全部 | 「我的」Tab 加历史入口 | 路径更短;符合「记录 Tab=身体档案」语义 |
|
||||||
|
| Pipeline | 严格两段式 RAG | 单段 LLM / 模板化 | 准确性 + 复用给 AskService + demo 卖点 #3 |
|
||||||
|
| Markdown 渲染 | SwiftUI 原生 `Text(LocalizedStringKey)` | 第三方 Markdown 库 | YAGNI;W6 polish 时再评估 |
|
||||||
|
| referenced 关联 | `[UUID]` 弱关联 | SwiftData 关系 | 历史快照 vs 源记录可被永久删除 |
|
||||||
|
| Live Activity | 此版只在 Service 暴露 decodeRate,UI 显示数字 | 此版直接接 ActivityKit | W5 真机阶段统一接,与 AskService 共用一套 Activity |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 排期估算(放在 W2 末 ~ W3 初)
|
||||||
|
|
||||||
|
| 步骤 | 工作量 |
|
||||||
|
|---|---|
|
||||||
|
| HealthExport @Model + Schema 注册 | 0.5h |
|
||||||
|
| HealthExportPrompts(两个 prompt + few-shot 调试) | 2h |
|
||||||
|
| HealthExportService(状态机 + 两段调用 + 检索) | 4h |
|
||||||
|
| HealthExportSheet(输入 + Phase + 流式渲染 + 三按钮) | 3h |
|
||||||
|
| ArchiveListView toolbar + RecentStrip | 1.5h |
|
||||||
|
| HealthExportListView + DetailView | 1.5h |
|
||||||
|
| 单元测试 + 真机验收 | 2h |
|
||||||
|
| **合计** | **~14h ≈ 2 个工作日** |
|
||||||
|
|
||||||
|
也是 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% 杜绝小模型臆造;后续可加生成后数值校验。
|
||||||
180
docs/superpowers/specs/2026-05-29-model-download-design.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# 模型自动下载功能设计(2026-05-29)
|
||||||
|
|
||||||
|
> 让用户在「我的 · 模型管理」页一键从自建 HTTPS 服务下载两个 MLX 模型,支持断点续传、
|
||||||
|
> 进度展示和现场重装的旁路导入兜底。对应 CLAUDE.md §4「模型分发」与 W6「首启动下载流程」的核心部分。
|
||||||
|
|
||||||
|
## 1. 背景与现状
|
||||||
|
|
||||||
|
- 模型加载链路已通:`LLMSession`/`VLSession` 用 `ModelConfiguration(directory:)` 从沙盒
|
||||||
|
`Application Support/Models/<repo>/` 读取,`AIRuntime.prepare()/prepareVL()` 在
|
||||||
|
`ModelStore.isReady()` 为假时抛 `notReady`。
|
||||||
|
- **缺口**:没有任何下载实现。`ModelStore` 只有 `isReady()` 判定 + `seedFromBundle()` 占位;
|
||||||
|
唯一能装模型的路径是 DEBUG-only 的 `DebugAIRunner` 手动 `fileImporter`(且只导 LLM,漏 VL)。
|
||||||
|
- `MeView` 已预留「模型管理」卡片(`detail="未配置"`,icon `cpu`),尚未连接任何界面。
|
||||||
|
- `HealthExportService` 的未就绪文案已写「请先到『我的 · 模型管理』下载」,落点早有预期。
|
||||||
|
- 无 Onboarding / 首启动流程。
|
||||||
|
|
||||||
|
## 2. 服务器素材(已就绪并验证)
|
||||||
|
|
||||||
|
- 自建 Caddy 静态文件服务,文件根 `/srv/models/`。
|
||||||
|
- base URL:**`https://file.myv0.com/`**(用户自建反代,标准 HTTPS)。
|
||||||
|
- 备选:`http://101.132.124.52:5244/`(纯 IP,需 App 端 ATS 例外;域名挂了时用)。
|
||||||
|
- 已验证:`config.json` 返回 200;两个 `model.safetensors` 均支持 Range(`206` + `Accept-Ranges: bytes`),
|
||||||
|
反代返回的总大小与本机精确一致(LLM 968080210、VL 3073720461),未截断大文件。
|
||||||
|
- 服务器 24 个真实文件,字节数与本机逐一匹配(LLM 984015687、VL 3089713215)。
|
||||||
|
|
||||||
|
## 3. 范围
|
||||||
|
|
||||||
|
**做**:模型管理页(分模型卡片)、HTTPS 断点续传下载、大小校验、蜂窝网络提示、
|
||||||
|
旁路文件导入(LLM + VL)、MeView 接入、AI 入口未就绪「前往下载」引导。
|
||||||
|
|
||||||
|
**不做(YAGNI)**:首启动 Onboarding、启动自动后台下载、哈希校验(大小校验够)、
|
||||||
|
Live Activity 下载进度(Live Activity 是推理时的 tok/s,单独功能)、并行多文件下载。
|
||||||
|
|
||||||
|
## 4. 架构(方案 A:独立 Service + ModelStore 保持纯存储)
|
||||||
|
|
||||||
|
```
|
||||||
|
ModelManagementView (UI)
|
||||||
|
→ ModelDownloadService (@MainActor @Observable,下载编排 + 进度状态)
|
||||||
|
→ ModelStore (文件路径 / 就绪判定 / 旁路导入)
|
||||||
|
→ URLSession (HTTPS 分块下载)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 符合 §3.1 模块边界:UI 不直接碰 `URLSession`,只观察 Service 发布的状态。
|
||||||
|
- `ModelDownloadService` 与现有 `CaptureService`/`AskService` 并列。
|
||||||
|
- `ModelStore` 继续只管「模型在哪 / 是否就绪 / 旁路拷入」,不引入网络职责。
|
||||||
|
|
||||||
|
### 4.1 下载状态模型
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum DownloadPhase: Equatable {
|
||||||
|
case idle // 待下载
|
||||||
|
case downloading // 下载中
|
||||||
|
case verifying // 校验中
|
||||||
|
case ready // 已就绪
|
||||||
|
case failed(String) // 失败 · 可重试
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DownloadState: Equatable {
|
||||||
|
var phase: DownloadPhase
|
||||||
|
var receivedBytes: Int
|
||||||
|
var totalBytes: Int
|
||||||
|
var bytesPerSecond: Double
|
||||||
|
var fraction: Double { totalBytes > 0 ? Double(receivedBytes) / Double(totalBytes) : 0 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ModelDownloadService` 持有 `var states: [ModelKind: DownloadState]`,`@MainActor` 更新,UI 观察。
|
||||||
|
|
||||||
|
## 5. 数据:硬编码 manifest
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct ModelFile { let path: String; let bytes: Int } // path 相对模型目录
|
||||||
|
|
||||||
|
enum ModelManifest {
|
||||||
|
static let baseURL = URL(string: "https://file.myv0.com/")!
|
||||||
|
static func files(for kind: ModelKind) -> [ModelFile]
|
||||||
|
static func totalBytes(for kind: ModelKind) -> Int // files.reduce
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 只列**加载必需**的功能文件,排除纯文档 `README.md` / `.gitattributes`(省下载)。
|
||||||
|
- 文件 URL = `baseURL / kind.rawValue / file.path`。
|
||||||
|
- `bytes` 用于总进度计算与下载后**逐文件大小校验**。
|
||||||
|
- 精确清单见附录 A。
|
||||||
|
|
||||||
|
## 6. 下载流程(断点续传,应对 3GB 单文件)
|
||||||
|
|
||||||
|
逐文件**串行**下载,单文件级续传用 **HTTP Range + 追加写**(比 `URLSession.resumeData` 更可控,
|
||||||
|
app 重启也能续):
|
||||||
|
|
||||||
|
1. 目标 `Models/<repo>/<file>` 已存在且 size 匹配 → 跳过(粗粒度续传)。
|
||||||
|
2. 否则下到 `Models/<repo>/<file>.part`:已下字节数 = `.part` 当前大小,
|
||||||
|
发 `Range: bytes=<已下>-` 请求,`URLSession` data delegate 流式 `FileHandle` 追加写。
|
||||||
|
3. 完成后校验 `.part` 大小 == manifest `bytes`,原子 `rename` 去掉 `.part` 后缀。
|
||||||
|
4. 该模型全部文件就位 → `ModelStore.isReady` 自然为真。
|
||||||
|
|
||||||
|
- 串行(一次一个文件):不抢 MLX 资源、进度计算清晰。
|
||||||
|
- 总进度 = 已完成字节 / `totalBytes(for:)`;速度用滑动窗口算 bytes/s。
|
||||||
|
- 支持「暂停」:取消当前 task,`.part` 保留,下次从断点续。
|
||||||
|
|
||||||
|
## 7. UI
|
||||||
|
|
||||||
|
### 7.1 `ModelManagementView`(分模型卡片)
|
||||||
|
|
||||||
|
- 两张卡:
|
||||||
|
- **Qwen3-1.7B · 文本解读**(约 939 MB)
|
||||||
|
- **Qwen2.5-VL-3B · 拍照识别**(约 2.9 GB)
|
||||||
|
- 每张卡显示:状态 `待下载 / 下载中 xx% · x.x MB/s / 校验中 / 已就绪 ✅ / 失败 · 重试`
|
||||||
|
+ 进度条(原生 `ProgressView` + `Tj.Palette`)+ 大小。
|
||||||
|
- 顶部总操作 `下载全部模型`(`TjPrimaryButton`);下载中切为 `暂停`。
|
||||||
|
- **蜂窝网络提示**:`NWPathMonitor` 检测到非 WiFi,开下前弹确认("约 3.9GB,建议 WiFi 下载")。
|
||||||
|
- 底部 `从文件导入`(`TjGhostButton`)→ 旁路导入。
|
||||||
|
- 复用 `.tjCard` / `TjBadge` / `TjLockChip`,不新增设计 token(§9)。
|
||||||
|
|
||||||
|
### 7.2 旁路导入(现场重装兜底)
|
||||||
|
|
||||||
|
把 `DebugAIRunner` 的 `fileImporter` 逻辑转正进 Service / `ModelStore`:
|
||||||
|
|
||||||
|
- 选文件夹 → 校验含 `config.json` → 拷入 `Models/<repo>/`。
|
||||||
|
- **补上 VL**(现在 DEBUG 只导 LLM)。
|
||||||
|
- 按所选文件夹名匹配 `ModelKind.rawValue` 自动识别是 LLM 还是 VL;不匹配时提示选择。
|
||||||
|
|
||||||
|
## 8. 接入点
|
||||||
|
|
||||||
|
- `MeView` 「模型管理」卡片 → `NavigationLink` 到 `ModelManagementView`;
|
||||||
|
`detail` 动态显示 `已就绪 / 未下载 / 下载中 xx%`。
|
||||||
|
- **AI 入口未就绪引导**(§4 要求):`DiaryQuickSheet`、`UnifiedCaptureFlow`、`HealthExport`
|
||||||
|
的「模型未就绪」错误态补 `前往下载` 按钮,跳 `ModelManagementView`。
|
||||||
|
|
||||||
|
## 9. 错误处理
|
||||||
|
|
||||||
|
- 网络中断 → 卡片转 `失败 · 重试`,保留 `.part` 供下次续传,不卡死、不删已下数据。
|
||||||
|
- 校验失败(size 不符)→ 删该文件重下。
|
||||||
|
- 旁路导入选错文件夹(无 `config.json`)→ 提示,不写入。
|
||||||
|
- base URL 不可达 → 失败态,文案提示检查网络。
|
||||||
|
|
||||||
|
## 10. 测试策略
|
||||||
|
|
||||||
|
- 单元测试(用 `URLProtocol` mock 网络,不碰真 MLX / SwiftData):
|
||||||
|
- `ModelManifest.totalBytes` 计算正确。
|
||||||
|
- 续传偏移计算:`.part` 已有 N 字节时请求 `Range: bytes=N-`。
|
||||||
|
- 大小校验:size 不符判失败。
|
||||||
|
- `DownloadState.fraction` 边界(totalBytes=0)。
|
||||||
|
- `ModelStore.isReady` 在文件齐全 / 缺失时的判定。
|
||||||
|
- UI 手动验证:模拟器跑下载流程(指向真实 base URL 或 mock)。
|
||||||
|
|
||||||
|
## 附录 A:精确文件清单(功能文件,排除 README/.gitattributes)
|
||||||
|
|
||||||
|
### Qwen3-1.7B-4bit(9 文件,984,013,244 字节,≈939 MB)
|
||||||
|
|
||||||
|
| path | bytes |
|
||||||
|
|---|---|
|
||||||
|
| config.json | 937 |
|
||||||
|
| model.safetensors | 968080210 |
|
||||||
|
| model.safetensors.index.json | 49731 |
|
||||||
|
| tokenizer.json | 11422654 |
|
||||||
|
| tokenizer_config.json | 9706 |
|
||||||
|
| vocab.json | 2776833 |
|
||||||
|
| merges.txt | 1671853 |
|
||||||
|
| special_tokens_map.json | 613 |
|
||||||
|
| added_tokens.json | 707 |
|
||||||
|
|
||||||
|
### Qwen2.5-VL-3B-Instruct-4bit(11 文件,3,089,710,883 字节,≈2.9 GB)
|
||||||
|
|
||||||
|
| path | bytes |
|
||||||
|
|---|---|
|
||||||
|
| config.json | 1659 |
|
||||||
|
| model.safetensors | 3073720461 |
|
||||||
|
| model.safetensors.index.json | 108307 |
|
||||||
|
| tokenizer.json | 11421896 |
|
||||||
|
| tokenizer_config.json | 7256 |
|
||||||
|
| vocab.json | 2776833 |
|
||||||
|
| merges.txt | 1671853 |
|
||||||
|
| special_tokens_map.json | 613 |
|
||||||
|
| added_tokens.json | 605 |
|
||||||
|
| chat_template.json | 1050 |
|
||||||
|
| preprocessor_config.json | 350 |
|
||||||
|
|
||||||
|
> 注:进度分母 = 本表功能文件 `bytes` 之和(已排除 README/.gitattributes)。
|
||||||
|
> 服务器上含 README/.gitattributes 的全量为 LLM 984,015,687 / VL 3,089,713,215 字节,仅作素材核对参照。
|
||||||
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` 这一条路径。
|
||||||
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"
|
||||||
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
|
||||||
@@ -1,620 +0,0 @@
|
|||||||
// !$*UTF8*$!
|
|
||||||
{
|
|
||||||
archiveVersion = 1;
|
|
||||||
classes = {
|
|
||||||
};
|
|
||||||
objectVersion = 77;
|
|
||||||
objects = {
|
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
|
||||||
5E463D092FC403BC0089145B /* PBXContainerItemProxy */ = {
|
|
||||||
isa = PBXContainerItemProxy;
|
|
||||||
containerPortal = 5E463CF12FC403BB0089145B /* Project object */;
|
|
||||||
proxyType = 1;
|
|
||||||
remoteGlobalIDString = 5E463CF82FC403BB0089145B;
|
|
||||||
remoteInfo = "体己";
|
|
||||||
};
|
|
||||||
5E463D132FC403BC0089145B /* PBXContainerItemProxy */ = {
|
|
||||||
isa = PBXContainerItemProxy;
|
|
||||||
containerPortal = 5E463CF12FC403BB0089145B /* Project object */;
|
|
||||||
proxyType = 1;
|
|
||||||
remoteGlobalIDString = 5E463CF82FC403BB0089145B;
|
|
||||||
remoteInfo = "体己";
|
|
||||||
};
|
|
||||||
/* End PBXContainerItemProxy section */
|
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
|
||||||
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; };
|
|
||||||
5E463D122FC403BC0089145B /* 体己UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "体己UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
/* End PBXFileReference section */
|
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
|
||||||
5E463CFB2FC403BB0089145B /* 体己 */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
path = "体己";
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
5E463D0B2FC403BC0089145B /* 体己Tests */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
path = "体己Tests";
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
5E463D152FC403BC0089145B /* 体己UITests */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
path = "体己UITests";
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
|
||||||
5E463CF62FC403BB0089145B /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
5E463D052FC403BC0089145B /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
5E463D0F2FC403BC0089145B /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXFrameworksBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
|
||||||
5E463CF02FC403BB0089145B = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
5E463CFB2FC403BB0089145B /* 体己 */,
|
|
||||||
5E463D0B2FC403BC0089145B /* 体己Tests */,
|
|
||||||
5E463D152FC403BC0089145B /* 体己UITests */,
|
|
||||||
5E463CFA2FC403BB0089145B /* Products */,
|
|
||||||
);
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
5E463CFA2FC403BB0089145B /* Products */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
5E463CF92FC403BB0089145B /* 体己.app */,
|
|
||||||
5E463D082FC403BC0089145B /* 体己Tests.xctest */,
|
|
||||||
5E463D122FC403BC0089145B /* 体己UITests.xctest */,
|
|
||||||
);
|
|
||||||
name = Products;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXGroup section */
|
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
|
||||||
5E463CF82FC403BB0089145B /* 体己 */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = 5E463D1C2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己" */;
|
|
||||||
buildPhases = (
|
|
||||||
5E463CF52FC403BB0089145B /* Sources */,
|
|
||||||
5E463CF62FC403BB0089145B /* Frameworks */,
|
|
||||||
5E463CF72FC403BB0089145B /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
);
|
|
||||||
fileSystemSynchronizedGroups = (
|
|
||||||
5E463CFB2FC403BB0089145B /* 体己 */,
|
|
||||||
);
|
|
||||||
name = "体己";
|
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = "体己";
|
|
||||||
productReference = 5E463CF92FC403BB0089145B /* 体己.app */;
|
|
||||||
productType = "com.apple.product-type.application";
|
|
||||||
};
|
|
||||||
5E463D072FC403BC0089145B /* 体己Tests */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = 5E463D1F2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己Tests" */;
|
|
||||||
buildPhases = (
|
|
||||||
5E463D042FC403BC0089145B /* Sources */,
|
|
||||||
5E463D052FC403BC0089145B /* Frameworks */,
|
|
||||||
5E463D062FC403BC0089145B /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
5E463D0A2FC403BC0089145B /* PBXTargetDependency */,
|
|
||||||
);
|
|
||||||
fileSystemSynchronizedGroups = (
|
|
||||||
5E463D0B2FC403BC0089145B /* 体己Tests */,
|
|
||||||
);
|
|
||||||
name = "体己Tests";
|
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = "体己Tests";
|
|
||||||
productReference = 5E463D082FC403BC0089145B /* 体己Tests.xctest */;
|
|
||||||
productType = "com.apple.product-type.bundle.unit-test";
|
|
||||||
};
|
|
||||||
5E463D112FC403BC0089145B /* 体己UITests */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = 5E463D222FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己UITests" */;
|
|
||||||
buildPhases = (
|
|
||||||
5E463D0E2FC403BC0089145B /* Sources */,
|
|
||||||
5E463D0F2FC403BC0089145B /* Frameworks */,
|
|
||||||
5E463D102FC403BC0089145B /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
5E463D142FC403BC0089145B /* PBXTargetDependency */,
|
|
||||||
);
|
|
||||||
fileSystemSynchronizedGroups = (
|
|
||||||
5E463D152FC403BC0089145B /* 体己UITests */,
|
|
||||||
);
|
|
||||||
name = "体己UITests";
|
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = "体己UITests";
|
|
||||||
productReference = 5E463D122FC403BC0089145B /* 体己UITests.xctest */;
|
|
||||||
productType = "com.apple.product-type.bundle.ui-testing";
|
|
||||||
};
|
|
||||||
/* End PBXNativeTarget section */
|
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
|
||||||
5E463CF12FC403BB0089145B /* Project object */ = {
|
|
||||||
isa = PBXProject;
|
|
||||||
attributes = {
|
|
||||||
BuildIndependentTargetsInParallel = 1;
|
|
||||||
LastSwiftUpdateCheck = 2600;
|
|
||||||
LastUpgradeCheck = 2600;
|
|
||||||
TargetAttributes = {
|
|
||||||
5E463CF82FC403BB0089145B = {
|
|
||||||
CreatedOnToolsVersion = 26.0.1;
|
|
||||||
};
|
|
||||||
5E463D072FC403BC0089145B = {
|
|
||||||
CreatedOnToolsVersion = 26.0.1;
|
|
||||||
TestTargetID = 5E463CF82FC403BB0089145B;
|
|
||||||
};
|
|
||||||
5E463D112FC403BC0089145B = {
|
|
||||||
CreatedOnToolsVersion = 26.0.1;
|
|
||||||
TestTargetID = 5E463CF82FC403BB0089145B;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
buildConfigurationList = 5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "体己" */;
|
|
||||||
developmentRegion = en;
|
|
||||||
hasScannedForEncodings = 0;
|
|
||||||
knownRegions = (
|
|
||||||
en,
|
|
||||||
Base,
|
|
||||||
);
|
|
||||||
mainGroup = 5E463CF02FC403BB0089145B;
|
|
||||||
minimizedProjectReferenceProxies = 1;
|
|
||||||
preferredProjectObjectVersion = 77;
|
|
||||||
productRefGroup = 5E463CFA2FC403BB0089145B /* Products */;
|
|
||||||
projectDirPath = "";
|
|
||||||
projectRoot = "";
|
|
||||||
targets = (
|
|
||||||
5E463CF82FC403BB0089145B /* 体己 */,
|
|
||||||
5E463D072FC403BC0089145B /* 体己Tests */,
|
|
||||||
5E463D112FC403BC0089145B /* 体己UITests */,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
/* End PBXProject section */
|
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
|
||||||
5E463CF72FC403BB0089145B /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
5E463D062FC403BC0089145B /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
5E463D102FC403BC0089145B /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXResourcesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
|
||||||
5E463CF52FC403BB0089145B /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
5E463D042FC403BC0089145B /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
5E463D0E2FC403BC0089145B /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXSourcesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
|
||||||
5E463D0A2FC403BC0089145B /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = 5E463CF82FC403BB0089145B /* 体己 */;
|
|
||||||
targetProxy = 5E463D092FC403BC0089145B /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
5E463D142FC403BC0089145B /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = 5E463CF82FC403BB0089145B /* 体己 */;
|
|
||||||
targetProxy = 5E463D132FC403BC0089145B /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
/* End PBXTargetDependency section */
|
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
|
||||||
5E463D1A2FC403BC0089145B /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_TESTABILITY = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_OPTIMIZATION_LEVEL = 0;
|
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
|
||||||
"DEBUG=1",
|
|
||||||
"$(inherited)",
|
|
||||||
);
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
|
||||||
MTL_FAST_MATH = YES;
|
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
5E463D1B2FC403BC0089145B /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
|
||||||
MTL_FAST_MATH = YES;
|
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
5E463D1D2FC403BC0089145B /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
|
||||||
ENABLE_APP_SANDBOX = YES;
|
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
|
||||||
ENABLE_PREVIEWS = YES;
|
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
|
|
||||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
|
|
||||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--";
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
REGISTER_APP_GROUPS = YES;
|
|
||||||
SDKROOT = auto;
|
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
5E463D1E2FC403BC0089145B /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
|
||||||
ENABLE_APP_SANDBOX = YES;
|
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
|
||||||
ENABLE_PREVIEWS = YES;
|
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
|
|
||||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
|
|
||||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--";
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
REGISTER_APP_GROUPS = YES;
|
|
||||||
SDKROOT = auto;
|
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
5E463D202FC403BC0089145B /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--Tests";
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SDKROOT = auto;
|
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/体己.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/体己";
|
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
5E463D212FC403BC0089145B /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--Tests";
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SDKROOT = auto;
|
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/体己.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/体己";
|
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
5E463D232FC403BC0089145B /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--UITests";
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SDKROOT = auto;
|
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
|
||||||
TEST_TARGET_NAME = "体己";
|
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
5E463D242FC403BC0089145B /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--UITests";
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SDKROOT = auto;
|
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
|
||||||
TEST_TARGET_NAME = "体己";
|
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
/* End XCBuildConfiguration section */
|
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
|
||||||
5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "体己" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
5E463D1A2FC403BC0089145B /* Debug */,
|
|
||||||
5E463D1B2FC403BC0089145B /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
5E463D1C2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
5E463D1D2FC403BC0089145B /* Debug */,
|
|
||||||
5E463D1E2FC403BC0089145B /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
5E463D1F2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己Tests" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
5E463D202FC403BC0089145B /* Debug */,
|
|
||||||
5E463D212FC403BC0089145B /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
5E463D222FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己UITests" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
5E463D232FC403BC0089145B /* Debug */,
|
|
||||||
5E463D242FC403BC0089145B /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
/* End XCConfigurationList section */
|
|
||||||
};
|
|
||||||
rootObject = 5E463CF12FC403BB0089145B /* Project object */;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "self:">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?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>SchemeUserState</key>
|
|
||||||
<dict>
|
|
||||||
<key>体己.xcscheme_^#shared#^_</key>
|
|
||||||
<dict>
|
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>0</integer>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "dark"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "tinted"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "16x16"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "16x16"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "32x32"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "32x32"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "128x128"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "128x128"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "256x256"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "256x256"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "512x512"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "512x512"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
//
|
|
||||||
// ContentView.swift
|
|
||||||
// 体己
|
|
||||||
//
|
|
||||||
// Created by Tim on 2026/5/25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
struct ContentView: View {
|
|
||||||
@Environment(\.modelContext) private var modelContext
|
|
||||||
@Query private var items: [Item]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationSplitView {
|
|
||||||
List {
|
|
||||||
ForEach(items) { item in
|
|
||||||
NavigationLink {
|
|
||||||
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
|
|
||||||
} label: {
|
|
||||||
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDelete(perform: deleteItems)
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
|
||||||
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
|
|
||||||
#endif
|
|
||||||
.toolbar {
|
|
||||||
#if os(iOS)
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
EditButton()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
ToolbarItem {
|
|
||||||
Button(action: addItem) {
|
|
||||||
Label("Add Item", systemImage: "plus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} detail: {
|
|
||||||
Text("Select an item")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addItem() {
|
|
||||||
withAnimation {
|
|
||||||
let newItem = Item(timestamp: Date())
|
|
||||||
modelContext.insert(newItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteItems(offsets: IndexSet) {
|
|
||||||
withAnimation {
|
|
||||||
for index in offsets {
|
|
||||||
modelContext.delete(items[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ContentView()
|
|
||||||
.modelContainer(for: Item.self, inMemory: true)
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
//
|
|
||||||
// Item.swift
|
|
||||||
// 体己
|
|
||||||
//
|
|
||||||
// Created by Tim on 2026/5/25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
@Model
|
|
||||||
final class Item {
|
|
||||||
var timestamp: Date
|
|
||||||
|
|
||||||
init(timestamp: Date) {
|
|
||||||
self.timestamp = timestamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
//
|
|
||||||
// __App.swift
|
|
||||||
// 体己
|
|
||||||
//
|
|
||||||
// Created by Tim on 2026/5/25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct __App: App {
|
|
||||||
var sharedModelContainer: ModelContainer = {
|
|
||||||
let schema = Schema([
|
|
||||||
Item.self,
|
|
||||||
])
|
|
||||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
|
||||||
|
|
||||||
do {
|
|
||||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
|
||||||
} catch {
|
|
||||||
fatalError("Could not create ModelContainer: \(error)")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var body: some Scene {
|
|
||||||
WindowGroup {
|
|
||||||
ContentView()
|
|
||||||
}
|
|
||||||
.modelContainer(sharedModelContainer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "6b8265ebd61c6fdfca835dd1f90f17439ca9abc5c11a8b7b5db8790be0349e4d",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "gzipswift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/1024jp/GzipSwift",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "731037f6cc2be2ec01562f6597c1d0aa3fe6fd05",
|
||||||
|
"version" : "6.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "mlx-swift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/ml-explore/mlx-swift",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "072b684acaae80b6a463abab3a103732f33774bf",
|
||||||
|
"version" : "0.29.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "mlx-swift-examples",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/ml-explore/mlx-swift-examples",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "9bff95ca5f0b9e8c021acc4d71a2bbe4a7441631",
|
||||||
|
"version" : "2.29.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-collections",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-collections.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a",
|
||||||
|
"version" : "1.5.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-jinja",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/huggingface/swift-jinja.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "0b67ecb79139f6addef8699eff3622808aa6c7dc",
|
||||||
|
"version" : "2.3.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-numerics",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-numerics",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
|
||||||
|
"version" : "1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-transformers",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/huggingface/swift-transformers",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a2e184dddb4757bc943e77fbe99ac6786c53f0b2",
|
||||||
|
"version" : "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
102
康康.xcodeproj/xcshareddata/xcschemes/康康.xcscheme
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2600"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "5E463CF82FC403BB0089145B"
|
||||||
|
BuildableName = "康康.app"
|
||||||
|
BlueprintName = "康康"
|
||||||
|
ReferencedContainer = "container:康康.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "5E463D072FC403BC0089145B"
|
||||||
|
BuildableName = "康康Tests.xctest"
|
||||||
|
BlueprintName = "康康Tests"
|
||||||
|
ReferencedContainer = "container:康康.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "5E463D112FC403BC0089145B"
|
||||||
|
BuildableName = "康康UITests.xctest"
|
||||||
|
BlueprintName = "康康UITests"
|
||||||
|
ReferencedContainer = "container:康康.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "5E463CF82FC403BB0089145B"
|
||||||
|
BuildableName = "康康.app"
|
||||||
|
BlueprintName = "康康"
|
||||||
|
ReferencedContainer = "container:康康.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "5E463CF82FC403BB0089145B"
|
||||||
|
BuildableName = "康康.app"
|
||||||
|
BlueprintName = "康康"
|
||||||
|
ReferencedContainer = "container:康康.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?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>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>康康.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
<dict>
|
||||||
|
<key>5E463CF82FC403BB0089145B</key>
|
||||||
|
<dict>
|
||||||
|
<key>primary</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>5E463D072FC403BC0089145B</key>
|
||||||
|
<dict>
|
||||||
|
<key>primary</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>5E463D112FC403BC0089145B</key>
|
||||||
|
<dict>
|
||||||
|
<key>primary</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
243
康康/AI/AIRuntime.swift
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import Foundation
|
||||||
|
import MLX
|
||||||
|
|
||||||
|
enum AIRuntimeError: Error, LocalizedError {
|
||||||
|
case notReady
|
||||||
|
case modelLoadFailed(String)
|
||||||
|
case inferenceFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notReady: return String(appLoc: "AI 模型尚未准备好")
|
||||||
|
case .modelLoadFailed(let m): return String(appLoc: "模型加载失败:\(m)")
|
||||||
|
case .inferenceFailed(let m): return String(appLoc: "推理失败:\(m)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor AIRuntime {
|
||||||
|
static let shared = AIRuntime()
|
||||||
|
|
||||||
|
enum Status: Sendable, Equatable {
|
||||||
|
case notReady
|
||||||
|
case loading
|
||||||
|
case ready
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var status: Status = .notReady
|
||||||
|
private(set) var vlStatus: Status = .notReady
|
||||||
|
private(set) var lastDecodeRate: Double = 0
|
||||||
|
|
||||||
|
private var llmSession: LLMSession?
|
||||||
|
private var vlSession: VLSession?
|
||||||
|
|
||||||
|
// 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 var gateBusy = false
|
||||||
|
private var gateWaiters: [CheckedContinuation<Void, Never>] = []
|
||||||
|
|
||||||
|
private func acquireGate() async {
|
||||||
|
if !gateBusy {
|
||||||
|
gateBusy = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
||||||
|
gateWaiters.append(cont)
|
||||||
|
}
|
||||||
|
// 被 releaseGate 唤醒时即已持有闸门(gateBusy 保持 true)。
|
||||||
|
}
|
||||||
|
|
||||||
|
private func releaseGate() {
|
||||||
|
if gateWaiters.isEmpty {
|
||||||
|
gateBusy = false
|
||||||
|
} else {
|
||||||
|
// 把闸门直接交给队首等待者,gateBusy 维持 true,不留空窗。
|
||||||
|
let next = gateWaiters.removeFirst()
|
||||||
|
next.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.GPU.set(cacheLimit: 256 * 1024 * 1024)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载模型。首次调用会真正加载,后续幂等。
|
||||||
|
func prepare() async throws {
|
||||||
|
// 已有其他调用方在加载时,轮询等其结束再判定结果。
|
||||||
|
// 不能像旧实现那样裸 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 模型未就绪")
|
||||||
|
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
|
||||||
|
do {
|
||||||
|
let session = try await LLMSession.load(
|
||||||
|
folderURL: ModelStore.shared.localURL(for: .llm)
|
||||||
|
)
|
||||||
|
self.llmSession = session
|
||||||
|
status = .ready
|
||||||
|
} catch {
|
||||||
|
status = .error("\(error)")
|
||||||
|
throw AIRuntimeError.modelLoadFailed("\(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式生成。调用前应先 await prepare()。
|
||||||
|
/// 注意:返回流是同步创建的,但跨 actor 调用 LLMSession 需要 await。
|
||||||
|
func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||||
|
// 在 actor 隔离上下文中捕获快照,Task 内不再访问 self.status / self.llmSession
|
||||||
|
let snapshotStatus = status
|
||||||
|
let snapshotSession = llmSession
|
||||||
|
|
||||||
|
return AsyncThrowingStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
guard snapshotStatus == .ready, let session = snapshotSession else {
|
||||||
|
continuation.finish(throwing: AIRuntimeError.notReady)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 进闸门:保证本次 LLM 解码与任何 VL 解码 / 模型加载串行,绝不并发占显存。
|
||||||
|
await self.acquireGate()
|
||||||
|
do {
|
||||||
|
// session.generate 跨 actor 边界,需要 await
|
||||||
|
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
||||||
|
for try await chunk in stream {
|
||||||
|
// 消费者(UI)提前关闭/取消时,下面的 checkCancellation 让本 Task 尽快退出,
|
||||||
|
// 连带丢弃 session 流并触发其 onTermination,停止底层 MLX 解码,不空耗 GPU。
|
||||||
|
try Task.checkCancellation()
|
||||||
|
// Task 闭包在 generate() 内启动,继承 AIRuntime 的 actor 隔离;
|
||||||
|
// 调用同 actor 的 recordRate 不需要 await
|
||||||
|
self.recordRate(chunk.decodeRate)
|
||||||
|
continuation.yield(chunk)
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
} catch {
|
||||||
|
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||||
|
}
|
||||||
|
// 正常结束 / 异常 / 取消(checkCancellation 抛出后被上面 catch 吞掉)都会走到这,
|
||||||
|
// 闸门一定释放,不会死锁后续推理。
|
||||||
|
self.releaseGate()
|
||||||
|
}
|
||||||
|
// 消费者取消/流终止时取消内部 Task(与 LLMSession / HealthExportService 一致)。
|
||||||
|
continuation.onTermination = { _ in task.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordRate(_ rate: Double) {
|
||||||
|
if rate > 0 { lastDecodeRate = rate }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - VL
|
||||||
|
|
||||||
|
/// 加载 VL 模型。幂等,首调真正 load。
|
||||||
|
func prepareVL() async throws {
|
||||||
|
while vlStatus == .loading {
|
||||||
|
try await Task.sleep(nanoseconds: 80_000_000)
|
||||||
|
}
|
||||||
|
if vlStatus == .ready { return }
|
||||||
|
|
||||||
|
// 同 prepare():用 isComplete 排除半下载(避免在残缺权重上崩溃),与下载服务判据一致。
|
||||||
|
guard ModelStore.shared.isComplete(for: .vl) else {
|
||||||
|
vlStatus = .error("VL 模型未就绪")
|
||||||
|
throw AIRuntimeError.notReady
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进闸门:等所有在跑的推理(可能是 LLM 文本流)结束,再卸 LLM + 载 VL。
|
||||||
|
// —— 这正是「异常项快拍识别时 App 自动退出」的主因防护。
|
||||||
|
await acquireGate()
|
||||||
|
defer { releaseGate() }
|
||||||
|
if vlStatus == .ready { return }
|
||||||
|
|
||||||
|
// OOM 闸门(§3.1):加载 VL(~3GB)前先卸 LLM(~1GB),否则两者常驻叠加冲过内存上限被 jetsam 杀。
|
||||||
|
unloadLLM()
|
||||||
|
|
||||||
|
vlStatus = .loading
|
||||||
|
do {
|
||||||
|
let session = try await VLSession.load(
|
||||||
|
folderURL: ModelStore.shared.localURL(for: .vl)
|
||||||
|
)
|
||||||
|
self.vlSession = session
|
||||||
|
vlStatus = .ready
|
||||||
|
} catch {
|
||||||
|
vlStatus = .error("\(error)")
|
||||||
|
throw AIRuntimeError.modelLoadFailed("\(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 卸载(OOM 闸门)
|
||||||
|
|
||||||
|
/// 卸载 LLM,释放 ModelContainer 引用并清 MLX 显存缓存。幂等。
|
||||||
|
/// 注:只在持有推理闸门时调用(prepareVL 内),此刻不会有 LLM 流在解码,卸载即时生效。
|
||||||
|
private func unloadLLM() {
|
||||||
|
guard llmSession != nil else { return }
|
||||||
|
llmSession = nil
|
||||||
|
status = .notReady
|
||||||
|
MLX.GPU.clearCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卸载 VL,释放 ModelContainer 引用并清 MLX 显存缓存。幂等。
|
||||||
|
private func unloadVL() {
|
||||||
|
guard vlSession != nil else { return }
|
||||||
|
vlSession = nil
|
||||||
|
vlStatus = .notReady
|
||||||
|
MLX.GPU.clearCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图像 → JSON 字符串(由 VLPrompts.reportExtraction 引导)。
|
||||||
|
/// 调用方负责解析 + 失败回退(§3.2)。
|
||||||
|
/// 推理闸门保证本调用与 LLM.generate() 的解码串行,不会同时占显存 OOM。
|
||||||
|
func analyzeReport(imageURLs: [URL],
|
||||||
|
prompt: String,
|
||||||
|
maxTokens: Int = 512) async throws -> String {
|
||||||
|
guard vlStatus == .ready, let session = vlSession else {
|
||||||
|
throw AIRuntimeError.notReady
|
||||||
|
}
|
||||||
|
await acquireGate()
|
||||||
|
defer { releaseGate() }
|
||||||
|
do {
|
||||||
|
return try await session.analyze(
|
||||||
|
imageURLs: imageURLs,
|
||||||
|
prompt: prompt,
|
||||||
|
maxTokens: maxTokens
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw AIRuntimeError.inferenceFailed("\(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
158
康康/AI/FileDownloader.swift
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum DownloadError: Error, LocalizedError {
|
||||||
|
case badStatus(Int)
|
||||||
|
case sizeMismatch(expected: Int, got: Int)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .badStatus(let code):
|
||||||
|
return String(appLoc: "下载失败(HTTP \(code))")
|
||||||
|
case .sizeMismatch(let expected, let got):
|
||||||
|
return String(appLoc: "文件大小校验失败(预期 \(expected),实际 \(got))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载单个文件,支持 HTTP Range 断点续传 + 完成后大小校验。
|
||||||
|
/// 用 `URLSessionDataDelegate` 把响应体分块写入 `.part`,完成后原子改名为成品。
|
||||||
|
///
|
||||||
|
/// 注意:文件大小一律用 `FileManager.attributesOfItem` 读取,**不用**
|
||||||
|
/// `URL.resourceValues(.fileSizeKey)` —— 后者会把结果缓存在 URL 实例上,
|
||||||
|
/// 续传时先读 offset 再读 finalSize 会拿到下载前的陈旧大小,导致误判校验失败。
|
||||||
|
///
|
||||||
|
/// 一个实例一次处理一个文件(串行)。共享状态用锁保证可见性。
|
||||||
|
final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendable {
|
||||||
|
private let configuration: URLSessionConfiguration
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var handle: FileHandle?
|
||||||
|
private var written: Int = 0
|
||||||
|
private var onProgress: ((Int) -> Void)?
|
||||||
|
private var responseError: Error?
|
||||||
|
private var continuation: CheckedContinuation<Void, Error>?
|
||||||
|
|
||||||
|
init(configuration: URLSessionConfiguration = .default) {
|
||||||
|
self.configuration = configuration
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 不走 URL 资源值缓存的文件大小读取。
|
||||||
|
static func fileSize(at url: URL) -> Int {
|
||||||
|
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||||
|
let size = attrs[.size] as? Int else { return 0 }
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 `url` 下载到 `destination`。若存在 `destination.part` 则发 Range 请求续传;
|
||||||
|
/// 完成后校验总大小 == `expectedBytes`,通过则原子改名为 `destination`。
|
||||||
|
nonisolated func download(
|
||||||
|
from url: URL,
|
||||||
|
to destination: URL,
|
||||||
|
expectedBytes: Int,
|
||||||
|
onProgress: (@Sendable (Int) -> Void)? = nil
|
||||||
|
) async throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let part = destination.appendingPathExtension("part")
|
||||||
|
|
||||||
|
// 成品已存在且大小正确 → 跳过
|
||||||
|
if Self.fileSize(at: destination) == expectedBytes,
|
||||||
|
fm.fileExists(atPath: destination.path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try fm.createDirectory(
|
||||||
|
at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
if fm.fileExists(atPath: part.path) {
|
||||||
|
offset = Self.fileSize(at: part)
|
||||||
|
} else {
|
||||||
|
fm.createFile(atPath: part.path, contents: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileHandle = try FileHandle(forWritingTo: part)
|
||||||
|
try fileHandle.seekToEnd()
|
||||||
|
|
||||||
|
lock.withLock {
|
||||||
|
self.handle = fileHandle
|
||||||
|
self.written = offset
|
||||||
|
self.onProgress = onProgress
|
||||||
|
self.responseError = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
if offset > 0 {
|
||||||
|
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
||||||
|
defer { session.finishTasksAndInvalidate() }
|
||||||
|
|
||||||
|
// 句柄在 didCompleteWithError 内关闭(同一 delegate 队列,串行于 didReceive)。
|
||||||
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||||
|
lock.lock()
|
||||||
|
self.continuation = cont
|
||||||
|
lock.unlock()
|
||||||
|
session.dataTask(with: request).resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalSize = Self.fileSize(at: part)
|
||||||
|
guard finalSize == expectedBytes else {
|
||||||
|
try? fm.removeItem(at: part)
|
||||||
|
throw DownloadError.sizeMismatch(expected: expectedBytes, got: finalSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fm.fileExists(atPath: destination.path) {
|
||||||
|
try fm.removeItem(at: destination)
|
||||||
|
}
|
||||||
|
try fm.moveItem(at: part, to: destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - URLSessionDataDelegate (全部在串行 delegate 队列执行)
|
||||||
|
|
||||||
|
nonisolated func urlSession(
|
||||||
|
_ session: URLSession, dataTask: URLSessionDataTask,
|
||||||
|
didReceive response: URLResponse,
|
||||||
|
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
||||||
|
) {
|
||||||
|
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
|
||||||
|
lock.lock(); responseError = DownloadError.badStatus(http.statusCode); lock.unlock()
|
||||||
|
completionHandler(.cancel)
|
||||||
|
} else {
|
||||||
|
completionHandler(.allow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func urlSession(
|
||||||
|
_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data
|
||||||
|
) {
|
||||||
|
lock.lock()
|
||||||
|
try? handle?.write(contentsOf: data)
|
||||||
|
written += data.count
|
||||||
|
let progress = written
|
||||||
|
let callback = onProgress
|
||||||
|
lock.unlock()
|
||||||
|
callback?(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func urlSession(
|
||||||
|
_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?
|
||||||
|
) {
|
||||||
|
lock.lock()
|
||||||
|
try? handle?.close()
|
||||||
|
handle = nil
|
||||||
|
let cont = continuation
|
||||||
|
continuation = nil
|
||||||
|
let respErr = responseError
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
if let respErr {
|
||||||
|
cont?.resume(throwing: respErr)
|
||||||
|
} else if let error {
|
||||||
|
cont?.resume(throwing: error)
|
||||||
|
} else {
|
||||||
|
cont?.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
康康/AI/LLMSession.swift
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import Foundation
|
||||||
|
import MLX
|
||||||
|
import MLXLLM
|
||||||
|
import MLXLMCommon
|
||||||
|
|
||||||
|
/// 封装 MLX 语言模型的流式生成,actor 保证单线程访问。
|
||||||
|
/// 基于 mlx-swift-examples 2.29.1(commit 9bff95ca)的 API。
|
||||||
|
actor LLMSession {
|
||||||
|
let container: ModelContainer
|
||||||
|
|
||||||
|
init(container: ModelContainer) {
|
||||||
|
self.container = container
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在 simulator 把默认设备强切为 CPU(MLX 的 Metal backend 在部分 Sim 路径会 abort)。
|
||||||
|
/// 真机走 body 默认设备(GPU/ANE)。
|
||||||
|
/// 用 task-scoped `withDefaultDevice`,TaskLocal 会传递到 child Task / actor 调用。
|
||||||
|
private static func withDeviceOverride<R>(
|
||||||
|
_ body: () async throws -> R
|
||||||
|
) async rethrows -> R {
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
return try await Device.withDefaultDevice(.cpu, body)
|
||||||
|
#else
|
||||||
|
return try await body()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从本地目录加载模型(包含 config.json + weights + tokenizer)。
|
||||||
|
static func load(folderURL: URL) async throws -> LLMSession {
|
||||||
|
let configuration = ModelConfiguration(directory: folderURL)
|
||||||
|
let container = try await withDeviceOverride {
|
||||||
|
try await LLMModelFactory.shared.loadContainer(
|
||||||
|
configuration: configuration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return LLMSession(container: container)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式生成。返回的 AsyncThrowingStream 被取消时,内部 Task 也会取消。
|
||||||
|
/// - Parameters:
|
||||||
|
/// - prompt: 原始 prompt 文本(经 processor 转 LMInput)
|
||||||
|
/// - maxTokens: 最大 token 数,由 GenerateParameters 控制
|
||||||
|
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||||
|
AsyncThrowingStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
do {
|
||||||
|
try await Self.withDeviceOverride {
|
||||||
|
let parameters = GenerateParameters(
|
||||||
|
maxTokens: maxTokens,
|
||||||
|
temperature: Float(0.6),
|
||||||
|
topP: Float(0.9)
|
||||||
|
)
|
||||||
|
|
||||||
|
try await container.perform { (context: ModelContext) in
|
||||||
|
let userInput = UserInput(prompt: prompt)
|
||||||
|
let lmInput = try await context.processor.prepare(input: userInput)
|
||||||
|
|
||||||
|
let start = Date()
|
||||||
|
var produced = 0
|
||||||
|
|
||||||
|
for await event in try MLXLMCommon.generate(
|
||||||
|
input: lmInput,
|
||||||
|
parameters: parameters,
|
||||||
|
context: context
|
||||||
|
) {
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
|
||||||
|
switch event {
|
||||||
|
case .chunk(let text):
|
||||||
|
produced += 1
|
||||||
|
let elapsed = Date().timeIntervalSince(start)
|
||||||
|
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
|
||||||
|
continuation.yield(TokenChunk(text: text, decodeRate: rate))
|
||||||
|
|
||||||
|
case .info:
|
||||||
|
// 生成完成统计,是流的最后一个事件
|
||||||
|
break
|
||||||
|
|
||||||
|
case .toolCall:
|
||||||
|
// 纯文本生成不会触发,switch 穷举
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 注:研究笔记里曾建议尾部 MLX.GPU.synchronize() 以确保
|
||||||
|
// GPU 操作全部完成。但 AsyncStream 已经 yield 真实解码后的
|
||||||
|
// 文字,GPU 是否完全空闲不影响数据正确性。去掉此调用同时省
|
||||||
|
// 一份 transitive import MLX 的依赖,简化 SPM 链接。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
} catch {
|
||||||
|
continuation.finish(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continuation.onTermination = { _ in task.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
康康/AI/ModelManifest.swift
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 模型文件清单中的一项:相对模型目录的路径 + 预期字节数(用于总进度计算与下载后大小校验)。
|
||||||
|
struct ModelFile: Equatable, Sendable {
|
||||||
|
let path: String
|
||||||
|
let bytes: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 硬编码的模型文件清单与下载源。
|
||||||
|
/// 只列加载必需的功能文件,排除 README.md / .gitattributes(省下载)。
|
||||||
|
/// 字节数与服务器素材逐一核对一致,见
|
||||||
|
/// docs/superpowers/specs/2026-05-29-model-download-design.md 附录 A。
|
||||||
|
nonisolated enum ModelManifest {
|
||||||
|
/// 自建 Caddy 静态服务(用户自建 HTTPS 反代)。
|
||||||
|
/// 备选纯 IP(需 App 端 ATS 例外): http://101.132.124.52:5244/
|
||||||
|
static let baseURL = URL(string: "https://file.myv0.com/")!
|
||||||
|
|
||||||
|
static func files(for kind: ModelKind) -> [ModelFile] {
|
||||||
|
switch kind {
|
||||||
|
case .llm:
|
||||||
|
return [
|
||||||
|
ModelFile(path: "config.json", bytes: 937),
|
||||||
|
ModelFile(path: "model.safetensors", bytes: 968_080_210),
|
||||||
|
ModelFile(path: "model.safetensors.index.json", bytes: 49_731),
|
||||||
|
ModelFile(path: "tokenizer.json", bytes: 11_422_654),
|
||||||
|
ModelFile(path: "tokenizer_config.json", bytes: 9_706),
|
||||||
|
ModelFile(path: "vocab.json", bytes: 2_776_833),
|
||||||
|
ModelFile(path: "merges.txt", bytes: 1_671_853),
|
||||||
|
ModelFile(path: "special_tokens_map.json", bytes: 613),
|
||||||
|
ModelFile(path: "added_tokens.json", bytes: 707),
|
||||||
|
]
|
||||||
|
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_config.json", bytes: 5_445),
|
||||||
|
ModelFile(path: "vocab.json", bytes: 2_776_833),
|
||||||
|
ModelFile(path: "merges.txt", bytes: 1_671_853),
|
||||||
|
ModelFile(path: "special_tokens_map.json", bytes: 613),
|
||||||
|
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),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func totalBytes(for kind: ModelKind) -> Int {
|
||||||
|
files(for: kind).reduce(0) { $0 + $1.bytes }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个文件的下载 URL = baseURL / <仓库名> / <相对路径>。
|
||||||
|
static func fileURL(for kind: ModelKind, file: ModelFile) -> URL {
|
||||||
|
baseURL
|
||||||
|
.appendingPathComponent(kind.rawValue, isDirectory: true)
|
||||||
|
.appendingPathComponent(file.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
138
康康/AI/ModelStore.swift
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
nonisolated enum ModelKind: String, CaseIterable {
|
||||||
|
/// 与 HuggingFace mlx-community 仓库名一一对应,也是沙盒 Models/ 下的子目录名。
|
||||||
|
case llm = "Qwen3-1.7B-4bit"
|
||||||
|
case vl = "Qwen3-VL-4B-Instruct-4bit"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .llm: return "Qwen3-1.7B"
|
||||||
|
case .vl: return "Qwen3-VL-4B"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HuggingFace 仓库 ID(org/name),用于下载
|
||||||
|
var huggingFaceRepo: String { "mlx-community/\(rawValue)" }
|
||||||
|
|
||||||
|
/// 用于判定该模型是否已就绪的最小标志文件
|
||||||
|
var sentinelFilename: String { "config.json" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `@unchecked Sendable`:rootURL 是 let,方法只读 filesystem(线程安全),
|
||||||
|
/// 可被任意 actor / Task 跨边界访问。
|
||||||
|
/// 实例方法显式 `nonisolated`:项目开了 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`,
|
||||||
|
/// 默认会把方法推到 MainActor,导致从 `AIRuntime` 等 actor 调用报错。
|
||||||
|
final class ModelStore: @unchecked Sendable {
|
||||||
|
nonisolated static let shared: ModelStore = {
|
||||||
|
do {
|
||||||
|
let appSupport = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let root = appSupport.appendingPathComponent("Models", isDirectory: true)
|
||||||
|
return try ModelStore(rootURL: root)
|
||||||
|
} catch {
|
||||||
|
fatalError("ModelStore.shared init failed: \(error)")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
let rootURL: URL
|
||||||
|
|
||||||
|
init(rootURL: URL) throws {
|
||||||
|
self.rootURL = rootURL
|
||||||
|
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func localURL(for kind: ModelKind) -> URL {
|
||||||
|
rootURL.appendingPathComponent(kind.rawValue, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func isReady(_ kind: ModelKind) -> Bool {
|
||||||
|
let sentinel = localURL(for: kind).appendingPathComponent(kind.sentinelFilename)
|
||||||
|
return FileManager.default.fileExists(atPath: sentinel.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func totalBytes(for kind: ModelKind) -> Int {
|
||||||
|
let folder = localURL(for: kind)
|
||||||
|
guard let enumerator = FileManager.default.enumerator(
|
||||||
|
at: folder,
|
||||||
|
includingPropertiesForKeys: [.fileSizeKey]
|
||||||
|
) else { return 0 }
|
||||||
|
var sum = 0
|
||||||
|
for case let url as URL in enumerator {
|
||||||
|
if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||||
|
sum += size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Demo 现场旁路:从 Bundle 拷贝预装模型(W6 才真正使用,本周占位)
|
||||||
|
nonisolated func seedFromBundle(_ kind: ModelKind) throws {
|
||||||
|
guard let bundleURL = Bundle.main.url(forResource: kind.rawValue, withExtension: nil) else {
|
||||||
|
#if DEBUG
|
||||||
|
assertionFailure("Bundle 缺少 \(kind.rawValue),检查资源是否加入 target")
|
||||||
|
#endif
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let target = localURL(for: kind)
|
||||||
|
if FileManager.default.fileExists(atPath: target.path) {
|
||||||
|
try FileManager.default.removeItem(at: target)
|
||||||
|
}
|
||||||
|
try FileManager.default.copyItem(at: bundleURL, to: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 下载 / 导入支撑
|
||||||
|
|
||||||
|
/// 模型目录下某个相对路径文件的本地 URL。
|
||||||
|
nonisolated func fileURL(for kind: ModelKind, relativePath: String) -> URL {
|
||||||
|
localURL(for: kind).appendingPathComponent(relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地该文件当前字节数,不存在返回 0(用于断点续传偏移与跳过判断)。
|
||||||
|
nonisolated func localBytes(for kind: ModelKind, relativePath: String) -> Int {
|
||||||
|
let url = fileURL(for: kind, relativePath: relativePath)
|
||||||
|
guard let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize else { return 0 }
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按清单校验模型是否完整:每个文件都存在且大小等于预期。
|
||||||
|
/// `files` 默认取 `ModelManifest`;测试可注入小清单。
|
||||||
|
nonisolated func isComplete(for kind: ModelKind, files: [ModelFile]? = nil) -> Bool {
|
||||||
|
let manifest = files ?? ModelManifest.files(for: kind)
|
||||||
|
guard !manifest.isEmpty else { return false }
|
||||||
|
for file in manifest where localBytes(for: kind, relativePath: file.path) != file.bytes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旁路导入:把一个含 config.json 的模型文件夹整体拷入沙盒(现场重装兜底)。
|
||||||
|
nonisolated func importModel(_ kind: ModelKind, from sourceFolder: URL) throws {
|
||||||
|
let configPath = sourceFolder.appendingPathComponent(kind.sentinelFilename).path
|
||||||
|
guard FileManager.default.fileExists(atPath: configPath) else {
|
||||||
|
throw ModelStoreError.missingConfig
|
||||||
|
}
|
||||||
|
let target = localURL(for: kind)
|
||||||
|
if FileManager.default.fileExists(atPath: target.path) {
|
||||||
|
try FileManager.default.removeItem(at: target)
|
||||||
|
}
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try FileManager.default.copyItem(at: sourceFolder, to: target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ModelStoreError: Error, LocalizedError {
|
||||||
|
case missingConfig
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missingConfig:
|
||||||
|
return String(appLoc: "所选文件夹缺少 config.json,不是有效的模型目录")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
康康/AI/Prompts/DiaryAssistPrompts.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
117
康康/AI/Prompts/HealthExportPrompts.swift
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
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
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
135
康康/AI/Prompts/VLPrompts.swift
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// VL 模型(Qwen3-VL)用于体检 / 化验单识别的 prompt 模板。
|
||||||
|
/// 输出契约:严格 JSON,无任何解释文字、markdown 围栏或前后缀。
|
||||||
|
/// 解析失败 → CaptureService 回退到手动录入(§3.2 失败回退红线)。
|
||||||
|
enum VLPrompts {
|
||||||
|
|
||||||
|
/// 输出 JSON 的字段定义(写进 prompt 里教模型):
|
||||||
|
/// ```
|
||||||
|
/// {
|
||||||
|
/// "title": "春季年度体检", // 报告抬头,无则 "拍摄识别"
|
||||||
|
/// "type": "checkup|lab|imaging|prescription|other",
|
||||||
|
/// "report_date": "YYYY-MM-DD", // 报告日期(无则今天)
|
||||||
|
/// "institution": "XX 医院", // 可空字符串
|
||||||
|
/// "page_count": 1,
|
||||||
|
/// "summary": "整体趋势短句", // 可空字符串
|
||||||
|
/// "indicators": [
|
||||||
|
/// {
|
||||||
|
/// "name": "低密度脂蛋白",
|
||||||
|
/// "value": "3.84",
|
||||||
|
/// "unit": "mmol/L",
|
||||||
|
/// "range": "< 3.40",
|
||||||
|
/// "status": "high|low|normal"
|
||||||
|
/// }
|
||||||
|
/// ]
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
/// `kind` 字段省略 —— UI 由 indicators 数量决定走 A2(单项)或 B3(多项)。
|
||||||
|
|
||||||
|
/// VL 模型不知"今天"是哪天,且 few-shot 示例里写死了日期,
|
||||||
|
/// 必须把当天日期显式注入 prompt,模型在无报告日期时才会用对正确的回退值。
|
||||||
|
static func reportExtraction(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 reportExtractionTemplate.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let reportExtractionTemplate: String = #"""
|
||||||
|
你是一个医学体检报告识别助手。请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||||
|
|
||||||
|
今天的日期是 {{TODAY}}。
|
||||||
|
|
||||||
|
JSON schema(严格):
|
||||||
|
{
|
||||||
|
"title": string,
|
||||||
|
"type": "checkup" | "lab" | "imaging" | "prescription" | "other",
|
||||||
|
"report_date": "YYYY-MM-DD",
|
||||||
|
"institution": string,
|
||||||
|
"page_count": number,
|
||||||
|
"summary": string,
|
||||||
|
"indicators": [
|
||||||
|
{
|
||||||
|
"name": string,
|
||||||
|
"value": string,
|
||||||
|
"unit": string,
|
||||||
|
"range": string,
|
||||||
|
"status": "high" | "low" | "normal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"。
|
||||||
|
- range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象。
|
||||||
|
- 无法识别的字段填空字符串(institution / summary)。
|
||||||
|
- report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。
|
||||||
|
- 不要发明指标。看不清的整行跳过。
|
||||||
|
- 化验单一般 type = "lab",体检套餐 = "checkup"。
|
||||||
|
|
||||||
|
示例 1(化验单 · 单项):
|
||||||
|
输入: 一张化验单照片,只能看清「低密度脂蛋白 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"}]}
|
||||||
|
|
||||||
|
示例 2(体检 · 多项):
|
||||||
|
输入: 一份春季体检,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"}]}
|
||||||
|
|
||||||
|
现在请识别图片并输出 JSON:
|
||||||
|
"""#
|
||||||
|
|
||||||
|
// 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 = #"""
|
||||||
|
你是一个医学化验单识别助手。下面给你的是一张化验单/体检报告的**局部照片**,通常只框住了一两行指标。
|
||||||
|
请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||||
|
|
||||||
|
今天的日期是 {{TODAY}}。
|
||||||
|
|
||||||
|
JSON schema(严格):
|
||||||
|
{
|
||||||
|
"indicators": [
|
||||||
|
{
|
||||||
|
"name": string,
|
||||||
|
"value": string,
|
||||||
|
"unit": string,
|
||||||
|
"range": string,
|
||||||
|
"status": "high" | "low" | "normal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 只识别框内清楚可读的指标行,通常 1-3 行;看不清的整行跳过,绝不发明指标。
|
||||||
|
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"。
|
||||||
|
- range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象。
|
||||||
|
- 识别不出单位/范围就填空字符串,不要编造。
|
||||||
|
- 不要输出 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"}]}
|
||||||
|
|
||||||
|
现在请识别这张局部照片并输出 JSON:
|
||||||
|
"""#
|
||||||
|
}
|
||||||
6
康康/AI/TokenChunk.swift
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TokenChunk: Sendable {
|
||||||
|
let text: String
|
||||||
|
let decodeRate: Double // tokens / second, 估算值
|
||||||
|
}
|
||||||
72
康康/AI/VLSession.swift
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
import MLX
|
||||||
|
import MLXVLM
|
||||||
|
import MLXLMCommon
|
||||||
|
|
||||||
|
/// 封装 MLX VL 模型(Qwen3-VL)的图像 → 文本推理。
|
||||||
|
/// 与 LLMSession 同款 actor 隔离,串行化由上游 AIRuntime 统一保证。
|
||||||
|
actor VLSession {
|
||||||
|
let container: ModelContainer
|
||||||
|
|
||||||
|
init(container: ModelContainer) {
|
||||||
|
self.container = container
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func withDeviceOverride<R>(
|
||||||
|
_ body: () async throws -> R
|
||||||
|
) async rethrows -> R {
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
return try await Device.withDefaultDevice(.cpu, body)
|
||||||
|
#else
|
||||||
|
return try await body()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从本地目录加载 VL 模型(包含 config.json + weights + tokenizer + processor)。
|
||||||
|
static func load(folderURL: URL) async throws -> VLSession {
|
||||||
|
let configuration = ModelConfiguration(directory: folderURL)
|
||||||
|
let container = try await withDeviceOverride {
|
||||||
|
try await VLMModelFactory.shared.loadContainer(
|
||||||
|
configuration: configuration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return VLSession(container: container)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次性生成(等收完所有 token 再返回完整字符串)。
|
||||||
|
/// VL 用于结构化 JSON 抽取,不需要流式 — 也避免半成品 JSON 抖动 UI。
|
||||||
|
/// - Parameters:
|
||||||
|
/// - imageURLs: 本地 file:// URL,从 FileVault 拿
|
||||||
|
/// - prompt: 文本指令(VLPrompts.reportExtraction)
|
||||||
|
/// - maxTokens: 默认 512(JSON 体量 ≈ 200-400)
|
||||||
|
func analyze(imageURLs: [URL],
|
||||||
|
prompt: String,
|
||||||
|
maxTokens: Int = 512) async throws -> String {
|
||||||
|
try await Self.withDeviceOverride {
|
||||||
|
try await container.perform { (context: ModelContext) in
|
||||||
|
let images = imageURLs.map { UserInput.Image.url($0) }
|
||||||
|
let userInput = UserInput(prompt: prompt, images: images)
|
||||||
|
let lmInput = try await context.processor.prepare(input: userInput)
|
||||||
|
|
||||||
|
let parameters = GenerateParameters(
|
||||||
|
maxTokens: maxTokens,
|
||||||
|
temperature: Float(0.2), // JSON 要稳,温度低
|
||||||
|
topP: Float(0.9)
|
||||||
|
)
|
||||||
|
|
||||||
|
var collected = ""
|
||||||
|
for await event in try MLXLMCommon.generate(
|
||||||
|
input: lmInput,
|
||||||
|
parameters: parameters,
|
||||||
|
context: context
|
||||||
|
) {
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
if case .chunk(let text) = event {
|
||||||
|
collected.append(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return collected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
康康/App/KangkangApp.swift
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct KangkangApp: App {
|
||||||
|
@State private var lang = LanguageManager.shared
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// 启动即给 MLX 显存缓存设上限,配合 entitlement + LLM/VL 互斥卸载防 jetsam OOM。
|
||||||
|
AIRuntime.configureMLXMemory()
|
||||||
|
}
|
||||||
|
|
||||||
|
var sharedModelContainer: ModelContainer = {
|
||||||
|
let schema = Schema([
|
||||||
|
Indicator.self,
|
||||||
|
Report.self,
|
||||||
|
DiaryEntry.self,
|
||||||
|
Asset.self,
|
||||||
|
ChatTurn.self,
|
||||||
|
Symptom.self,
|
||||||
|
UserProfile.self,
|
||||||
|
MetricReminder.self,
|
||||||
|
CustomMonitorMetric.self,
|
||||||
|
HealthExport.self,
|
||||||
|
CustomReminder.self,
|
||||||
|
])
|
||||||
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||||
|
// 建库后给 store 文件补 .completeUnlessOpen 保护(§6),两条创建路径共用。
|
||||||
|
func makeContainer() throws -> ModelContainer {
|
||||||
|
let container = try ModelContainer(for: schema, configurations: [config])
|
||||||
|
KangkangApp.protectStore(at: config.url)
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try makeContainer()
|
||||||
|
} catch {
|
||||||
|
// 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 {
|
||||||
|
WindowGroup {
|
||||||
|
AppLockContainer {
|
||||||
|
RootView()
|
||||||
|
.environment(\.locale, lang.locale)
|
||||||
|
.id(lang.current) // 语言切换 → 整树重建,即时生效
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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 |
98
康康/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "app-icon-kangkang-1024.png",
|
||||||
|
"idiom": "universal",
|
||||||
|
"platform": "ios",
|
||||||
|
"size": "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances": [
|
||||||
|
{
|
||||||
|
"appearance": "luminosity",
|
||||||
|
"value": "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename": "app-icon-kangkang-dark-1024.png",
|
||||||
|
"idiom": "universal",
|
||||||
|
"platform": "ios",
|
||||||
|
"size": "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances": [
|
||||||
|
{
|
||||||
|
"appearance": "luminosity",
|
||||||
|
"value": "tinted"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename": "app-icon-kangkang-tinted-1024.png",
|
||||||
|
"idiom": "universal",
|
||||||
|
"platform": "ios",
|
||||||
|
"size": "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "app-icon-kangkang-16.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "app-icon-kangkang-32.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "app-icon-kangkang-32.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "app-icon-kangkang-64.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "app-icon-kangkang-128.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "app-icon-kangkang-256.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "app-icon-kangkang-256.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "app-icon-kangkang-512.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "app-icon-kangkang-512.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "app-icon-kangkang-1024.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-1024.png
Normal file
|
After Width: | Height: | Size: 511 KiB |
BIN
康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-128.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-16.png
Normal file
|
After Width: | Height: | Size: 990 B |
BIN
康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-256.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-512.png
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-64.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 68 KiB |
146
康康/DesignSystem/Components.swift
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TjLockChip: View {
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
|
Text("本地加密")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.tracking(0.5)
|
||||||
|
}
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Capsule().fill(Tj.Palette.ink))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TjBadgeStyle {
|
||||||
|
case brick, amber, leaf, ink, neutral
|
||||||
|
|
||||||
|
var bg: Color {
|
||||||
|
switch self {
|
||||||
|
case .brick: return Tj.Palette.brickSoft
|
||||||
|
case .amber: return Color(red: 0.957, green: 0.890, blue: 0.749)
|
||||||
|
case .leaf: return Tj.Palette.leafSoft
|
||||||
|
case .ink: return Tj.Palette.ink
|
||||||
|
case .neutral: return Tj.Palette.sand2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var fg: Color {
|
||||||
|
switch self {
|
||||||
|
case .brick: return Tj.Palette.brick
|
||||||
|
case .amber: return Tj.Palette.amber
|
||||||
|
case .leaf: return Tj.Palette.leaf
|
||||||
|
case .ink: return Tj.Palette.paper
|
||||||
|
case .neutral: return Tj.Palette.text2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TjBadge: View {
|
||||||
|
let text: String
|
||||||
|
var style: TjBadgeStyle = .neutral
|
||||||
|
var body: some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.tracking(0.3)
|
||||||
|
.foregroundStyle(style.fg)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Capsule().fill(style.bg))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TjPlaceholder: View {
|
||||||
|
let label: String
|
||||||
|
var dark: Bool = false
|
||||||
|
var radius: CGFloat = Tj.Radius.sm
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||||
|
.fill(dark ? Color(red: 0.110, green: 0.122, blue: 0.110) : Tj.Palette.sand2)
|
||||||
|
DiagonalStripes(spacing: 7, color: dark ? Color.white.opacity(0.04) : Color.black.opacity(0.05))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.tracking(0.5)
|
||||||
|
.foregroundStyle(dark ? Color.white.opacity(0.5) : Tj.Palette.text3)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DiagonalStripes: View {
|
||||||
|
let spacing: CGFloat
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Canvas { ctx, size in
|
||||||
|
let step = spacing
|
||||||
|
let count = Int((size.width + size.height) / step) + 4
|
||||||
|
for i in -2..<count {
|
||||||
|
let x = CGFloat(i) * step
|
||||||
|
var path = Path()
|
||||||
|
path.move(to: CGPoint(x: x, y: 0))
|
||||||
|
path.addLine(to: CGPoint(x: x + size.height, y: size.height))
|
||||||
|
ctx.stroke(path, with: .color(color), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TjPrimaryButton: ButtonStyle {
|
||||||
|
var height: CGFloat = 48
|
||||||
|
var fontSize: CGFloat = 15
|
||||||
|
var horizontalPadding: CGFloat = 22
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.font(.system(size: fontSize, weight: .semibold))
|
||||||
|
.tracking(1)
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.padding(.horizontal, horizontalPadding)
|
||||||
|
.frame(height: height)
|
||||||
|
.background(Capsule().fill(Tj.Palette.ink))
|
||||||
|
.opacity(configuration.isPressed ? 0.85 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TjGhostButton: ButtonStyle {
|
||||||
|
var height: CGFloat = 48
|
||||||
|
var fontSize: CGFloat = 15
|
||||||
|
var horizontalPadding: CGFloat = 22
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.font(.system(size: fontSize, weight: .semibold))
|
||||||
|
.tracking(1)
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
.padding(.horizontal, horizontalPadding)
|
||||||
|
.frame(height: height)
|
||||||
|
.background(
|
||||||
|
Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.opacity(configuration.isPressed ? 0.7 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TjDashedDivider: View {
|
||||||
|
var body: some View {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Tj.Palette.line)
|
||||||
|
.frame(height: 1)
|
||||||
|
.mask(
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ForEach(0..<200, id: \.self) { _ in
|
||||||
|
Rectangle().frame(width: 4, height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
康康/DesignSystem/Tokens.swift
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum Tj {
|
||||||
|
enum Palette {
|
||||||
|
static let ink = Color(red: 0.165, green: 0.153, blue: 0.137)
|
||||||
|
static let ink2 = Color(red: 0.286, green: 0.275, blue: 0.251)
|
||||||
|
static let inkSoft = Color(red: 0.459, green: 0.447, blue: 0.424)
|
||||||
|
static let sand = Color(red: 0.976, green: 0.969, blue: 0.949)
|
||||||
|
static let sand2 = Color(red: 0.929, green: 0.918, blue: 0.886)
|
||||||
|
static let sand3 = Color(red: 0.878, green: 0.859, blue: 0.816)
|
||||||
|
static let paper = Color(red: 0.992, green: 0.988, blue: 0.973)
|
||||||
|
static let line = Color(red: 0.875, green: 0.863, blue: 0.831)
|
||||||
|
static let lineSoft = Color(red: 0.925, green: 0.918, blue: 0.890)
|
||||||
|
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)
|
||||||
|
static let brickSoft = Color(red: 0.976, green: 0.863, blue: 0.824)
|
||||||
|
static let amber = Color(red: 0.871, green: 0.627, blue: 0.314)
|
||||||
|
static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518)
|
||||||
|
static let leafSoft = Color(red: 0.867, green: 0.910, blue: 0.941)
|
||||||
|
static let darkBg = Color(red: 0.051, green: 0.063, blue: 0.059)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Radius {
|
||||||
|
static let xs: CGFloat = 8
|
||||||
|
static let sm: CGFloat = 14
|
||||||
|
static let md: CGFloat = 20
|
||||||
|
static let lg: CGFloat = 28
|
||||||
|
static let xl: CGFloat = 36
|
||||||
|
static let pill: CGFloat = 999
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Shadow {
|
||||||
|
static func card() -> some View {
|
||||||
|
Color.clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Font {
|
||||||
|
static func tjTitle(_ size: CGFloat = 30) -> Font { .system(size: size, weight: .bold, design: .default) }
|
||||||
|
static func tjH2(_ size: CGFloat = 18) -> Font { .system(size: size, weight: .bold, design: .default) }
|
||||||
|
static func tjMono(_ size: CGFloat = 11) -> Font { .system(size: size, weight: .regular, design: .monospaced) }
|
||||||
|
static func tjSerifBody(_ size: CGFloat = 17) -> Font { .system(size: size, weight: .regular, design: .default) }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func tjCard(bordered: Bool = false, radius: CGFloat = Tj.Radius.md) -> some View {
|
||||||
|
self
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: bordered ? 1 : 0)
|
||||||
|
)
|
||||||
|
.shadow(color: bordered ? .clear : Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.05),
|
||||||
|
radius: 2, x: 0, y: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
321
康康/Features/Archive/ArchiveListView.swift
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct ArchiveListView: 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]
|
||||||
|
|
||||||
|
@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]
|
||||||
|
|
||||||
|
/// 记录页内的 push 目的地。用单个 `navigationDestination(item:)` 驱动——
|
||||||
|
/// 多个 `navigationDestination(isPresented:)` 并存时 SwiftUI 行为未定义(会误触发)。
|
||||||
|
private enum Route: Hashable { case exports, reminders }
|
||||||
|
|
||||||
|
@State private var filter: TimelineKind? = nil
|
||||||
|
@State private var endingSymptom: Symptom?
|
||||||
|
@State private var selectedEntry: TimelineEntry?
|
||||||
|
@State private var showExportSheet = false
|
||||||
|
@State private var route: Route?
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private var allEntries: [TimelineEntry] {
|
||||||
|
let mapped =
|
||||||
|
TimelineEntry.from(indicators: indicators) +
|
||||||
|
reports.map(TimelineEntry.from(report:)) +
|
||||||
|
diaries.map(TimelineEntry.from(diary:)) +
|
||||||
|
symptoms.map(TimelineEntry.from(symptom:))
|
||||||
|
let filtered = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
|
||||||
|
return filtered.sorted { $0.date > $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var grouped: [(section: DateSection, items: [TimelineEntry])] {
|
||||||
|
TimelineGrouping.group(allEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalCount: Int { allEntries.count }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
content
|
||||||
|
.navigationDestination(item: $route) { route in
|
||||||
|
switch route {
|
||||||
|
case .exports: HealthExportListView()
|
||||||
|
case .reminders: RemindersListView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var content: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
header
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
|
if reminderTotal > 0 {
|
||||||
|
reminderBoard
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChips
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
|
if allEntries.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) {
|
||||||
|
ForEach(grouped, id: \.section) { group in
|
||||||
|
Section {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(group.items) { entry in
|
||||||
|
rowView(for: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
} header: {
|
||||||
|
sectionHeader(group.section, count: group.items.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.sheet(item: $endingSymptom) { sym in
|
||||||
|
SymptomEndSheet(symptom: sym)
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedEntry) { entry in
|
||||||
|
if let d = detail(for: entry) {
|
||||||
|
TimelineEntryDetailView(detail: d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $showExportSheet) {
|
||||||
|
HealthExportSheet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func rowView(for entry: TimelineEntry) -> some View {
|
||||||
|
if entry.kind == .symptom, entry.isOngoing,
|
||||||
|
let sym = symptoms.first(where: { "symptom-\($0.persistentModelID)" == entry.id }) {
|
||||||
|
// 进行中症状:点 → 标记结束 sheet(沿用原交互)
|
||||||
|
Button {
|
||||||
|
endingSymptom = sym
|
||||||
|
} label: {
|
||||||
|
TimelineRow(entry: entry)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else {
|
||||||
|
// 其余条目(报告/指标/日记/已结束症状):点 → 只读详情
|
||||||
|
Button {
|
||||||
|
if detail(for: entry) != nil { selectedEntry = entry }
|
||||||
|
} label: {
|
||||||
|
TimelineRow(entry: entry)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把时间线条目反查回源记录。逻辑统一收敛到 `TimelineDetail.resolve`(主页/档案库共用)。
|
||||||
|
private func detail(for entry: TimelineEntry) -> TimelineDetail? {
|
||||||
|
TimelineDetail.resolve(for: entry,
|
||||||
|
indicators: indicators, reports: reports,
|
||||||
|
diaries: diaries, symptoms: symptoms)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .lastTextBaseline) {
|
||||||
|
Text("记录")
|
||||||
|
.font(.tjTitle(26))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount) 条"))
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
showExportSheet = true
|
||||||
|
} label: {
|
||||||
|
Label("生成新导出", systemImage: "doc.text.below.ecg")
|
||||||
|
}
|
||||||
|
if !exports.isEmpty {
|
||||||
|
Button {
|
||||||
|
route = .exports
|
||||||
|
} label: {
|
||||||
|
Label("我的导出 · \(exports.count) 份", systemImage: "clock.arrow.circlepath")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "doc.text.below.ecg")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
Text("导出身体档案")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(Capsule().fill(Tj.Palette.ink))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(.system(size: 16))
|
||||||
|
.foregroundStyle(reminderEnabledCount > 0 ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(reminderCountLabel)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
if !reminderTitlePreview.isEmpty {
|
||||||
|
Text(reminderTitleLine)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filterChips: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
chip(label: String(appLoc: "全部"), selected: filter == nil) { filter = nil }
|
||||||
|
ForEach(TimelineKind.allCases) { kind in
|
||||||
|
chip(label: kind.label, selected: filter == kind) {
|
||||||
|
filter = filter == kind ? nil : kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
||||||
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(section.label)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.tracking(0.5)
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Rectangle()
|
||||||
|
.fill(Tj.Palette.lineSoft)
|
||||||
|
.frame(height: 1)
|
||||||
|
Text("\(count)")
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Tj.Palette.sand)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
Spacer()
|
||||||
|
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
|
||||||
|
.frame(width: 240, height: 140)
|
||||||
|
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ArchiveListView()
|
||||||
|
.modelContainer(for: [
|
||||||
|
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self,
|
||||||
|
HealthExport.self, ChatTurn.self, UserProfile.self,
|
||||||
|
MetricReminder.self, CustomMonitorMetric.self
|
||||||
|
], inMemory: true)
|
||||||
|
}
|
||||||
190
康康/Features/Archive/HealthExportDetailView.swift
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.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(.system(size: 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(.system(size: 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(.system(size: 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(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var promptBlock: some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "quote.opening")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(export.prompt)
|
||||||
|
.font(.system(size: 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: export.content) {
|
||||||
|
Label("分享", systemImage: "square.and.arrow.up")
|
||||||
|
.font(.system(size: 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(.system(size: 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 = 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(.system(size: 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(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(Self.relativeDate(export.createdAt))
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
if export.decodeRate > 0 {
|
||||||
|
Text(String(format: "%.1f tok/s", export.decodeRate))
|
||||||
|
.font(.system(size: 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)
|
||||||
|
}
|
||||||
555
康康/Features/Archive/HealthExportSheet.swift
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 「导出身体档案」全屏 sheet。
|
||||||
|
/// 状态机:idle → running(extractingIntent → retrieving → generating)→ completed / failed
|
||||||
|
struct HealthExportSheet: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
/// 可选:从历史「重新生成」时传入(暂时未启用,W3 接)。
|
||||||
|
let initialPrompt: String
|
||||||
|
|
||||||
|
@State private var prompt: 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
|
||||||
|
@FocusState private var promptFocused: Bool
|
||||||
|
|
||||||
|
init(initialPrompt: String = "") {
|
||||||
|
self.initialPrompt = initialPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isRunning: Bool { phase != nil && !completed && error == nil }
|
||||||
|
private var isInputMode: Bool { phase == nil && !completed && error == nil }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
if isInputMode {
|
||||||
|
inputSection
|
||||||
|
} else {
|
||||||
|
promptEcho
|
||||||
|
if isRunning { phaseIndicator }
|
||||||
|
if !content.isEmpty {
|
||||||
|
MarkdownView(text: 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if completed { actionRow }
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.onAppear {
|
||||||
|
if prompt.isEmpty { prompt = initialPrompt }
|
||||||
|
if isInputMode { promptFocused = true }
|
||||||
|
}
|
||||||
|
.onDisappear { task?.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
Button { close() } label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 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(.system(size: 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: - Input section (idle)
|
||||||
|
|
||||||
|
private var inputSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("说说你想给医生看什么")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("例:我感冒3天了,把最近一个月的健康情况给医生看")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text("例:最近血糖好像不稳,把过去三个月的化验单整理一下")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
if prompt.isEmpty {
|
||||||
|
Text("在这里输入主诉……")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
TextEditor(text: $prompt)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.frame(minHeight: 130)
|
||||||
|
.focused($promptFocused)
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("本地 RAG · Qwen3 1.7B · 不上传任何数据")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
Button { start() } label: {
|
||||||
|
Text("生成报告")
|
||||||
|
}
|
||||||
|
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
|
||||||
|
.disabled(prompt.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
.opacity(prompt.trimmingCharacters(in: .whitespaces).isEmpty ? 0.5 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prompt echo (after start)
|
||||||
|
|
||||||
|
private var promptEcho: some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "quote.opening")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(prompt)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.lineLimit(3)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.sand2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 phase == .generating && rate > 0 {
|
||||||
|
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
|
} else {
|
||||||
|
Text(phase?.label ?? "")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(.system(size: 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(.system(size: 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(.system(size: 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: content) {
|
||||||
|
Label("分享", systemImage: "square.and.arrow.up")
|
||||||
|
.font(.system(size: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func start() {
|
||||||
|
let p = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !p.isEmpty else { return }
|
||||||
|
promptFocused = false
|
||||||
|
content = ""
|
||||||
|
rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示
|
||||||
|
error = nil
|
||||||
|
completed = false
|
||||||
|
phase = .extractingIntent
|
||||||
|
|
||||||
|
let stream = HealthExportService.shared.export(prompt: p, in: ctx)
|
||||||
|
task = Task { @MainActor in
|
||||||
|
do {
|
||||||
|
for try await event in stream {
|
||||||
|
switch event {
|
||||||
|
case .phaseChanged(let ph):
|
||||||
|
phase = ph
|
||||||
|
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
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reset() {
|
||||||
|
task?.cancel()
|
||||||
|
task = nil
|
||||||
|
phase = nil
|
||||||
|
content = ""
|
||||||
|
rate = 0
|
||||||
|
error = nil
|
||||||
|
completed = false
|
||||||
|
promptFocused = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copy() {
|
||||||
|
UIPasteboard.general.string = content
|
||||||
|
copiedFlash = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
|
||||||
|
copiedFlash = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func close() {
|
||||||
|
task?.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(.system(size: 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(.system(size: 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(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
Text(inline(abnormalText))
|
||||||
|
.font(.system(size: 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(.system(size: 14))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.leading, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .body(let s):
|
||||||
|
Text(inline(s))
|
||||||
|
.font(.system(size: 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)
|
||||||
|
}
|
||||||
264
康康/Features/Capture/CaptureReviewForm.swift
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// VL 解析后的可编辑表单。
|
||||||
|
/// 用户可改 title / type / reportDate / institution / summary / 各 indicator;
|
||||||
|
/// 也可删除识别错的 indicator,或手加一行。
|
||||||
|
/// 「保存」回调写 SwiftData + 关联已写入 Vault 的 assets。
|
||||||
|
struct CaptureReviewForm: View {
|
||||||
|
@State var parsed: ParsedReport
|
||||||
|
let assets: [FileVault.SavedAsset]
|
||||||
|
let warning: String?
|
||||||
|
let onSave: (ParsedReport) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
/// 「重新识别」回调。assets 为空(写图失败)时传 nil,banner 上不显示该按钮。
|
||||||
|
var onReanalyze: (() -> Void)? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
if let warning {
|
||||||
|
warningBanner(warning)
|
||||||
|
}
|
||||||
|
if !assets.isEmpty {
|
||||||
|
pageThumbnails
|
||||||
|
}
|
||||||
|
metaSection
|
||||||
|
indicatorSection
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 顶部 warning
|
||||||
|
|
||||||
|
private func warningBanner(_ text: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
if let onReanalyze {
|
||||||
|
Button {
|
||||||
|
onReanalyze()
|
||||||
|
} label: {
|
||||||
|
Label("重新识别", systemImage: "arrow.clockwise")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.brickSoft.opacity(0.5))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 缩略图
|
||||||
|
|
||||||
|
private var pageThumbnails: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
|
||||||
|
if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) {
|
||||||
|
Image(uiImage: img)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 84, height: 110)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - meta(title / type / date / institution / summary)
|
||||||
|
|
||||||
|
private var metaSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
sectionLabel(String(appLoc: "基本信息"))
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
labeledField(String(appLoc: "标题")) {
|
||||||
|
TextField("如:春季年度体检", text: $parsed.title)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
}
|
||||||
|
labeledField(String(appLoc: "类型")) {
|
||||||
|
Picker("", selection: $parsed.typeRaw) {
|
||||||
|
ForEach(ReportType.allCases, id: \.rawValue) { t in
|
||||||
|
Text(t.label).tag(t.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
labeledField(String(appLoc: "报告日期")) {
|
||||||
|
DatePicker("", selection: $parsed.reportDate,
|
||||||
|
in: ...Date.now,
|
||||||
|
displayedComponents: .date)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.labelsHidden()
|
||||||
|
.environment(\.locale, Locale.current)
|
||||||
|
}
|
||||||
|
labeledField(String(appLoc: "机构(可选)")) {
|
||||||
|
TextField("如:协和医院", text: $parsed.institution)
|
||||||
|
}
|
||||||
|
labeledField(String(appLoc: "摘要(可选)")) {
|
||||||
|
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
|
||||||
|
.lineLimit(1...3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(fieldBg)
|
||||||
|
.overlay(fieldBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func labeledField<C: View>(_ label: String, @ViewBuilder content: () -> C) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - indicators
|
||||||
|
|
||||||
|
private var indicatorSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
sectionLabel(String(appLoc: "指标(\(parsed.indicators.count) 项)"))
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
parsed.indicators.append(
|
||||||
|
.init(name: "", value: "", unit: "", range: "", status: .normal)
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Label("加一项", systemImage: "plus.circle")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
if parsed.indicators.isEmpty {
|
||||||
|
Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach($parsed.indicators) { $indicator in
|
||||||
|
indicatorRow($indicator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func indicatorRow(_ binding: Binding<ParsedReport.ParsedIndicator>) -> some View {
|
||||||
|
let id = binding.wrappedValue.id
|
||||||
|
return VStack(spacing: 8) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("指标名", text: binding.name)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
Button(role: .destructive) {
|
||||||
|
parsed.indicators.removeAll { $0.id == id }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("数值", text: binding.value)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||||
|
.frame(maxWidth: 90)
|
||||||
|
TextField("单位", text: binding.unit)
|
||||||
|
.frame(maxWidth: 80)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
TextField("参考", text: binding.range)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
Picker("", selection: binding.status) {
|
||||||
|
Text("正常").tag(IndicatorStatus.normal)
|
||||||
|
Text("偏高 ↑").tag(IndicatorStatus.high)
|
||||||
|
Text("偏低 ↓").tag(IndicatorStatus.low)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(statusColor(binding.status.wrappedValue).opacity(0.4),
|
||||||
|
lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusColor(_ s: IndicatorStatus) -> Color {
|
||||||
|
switch s {
|
||||||
|
case .normal: return Tj.Palette.leaf
|
||||||
|
case .high: return Tj.Palette.brick
|
||||||
|
case .low: return Tj.Palette.amber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - actions
|
||||||
|
|
||||||
|
private var actions: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
onSave(parsed)
|
||||||
|
} label: {
|
||||||
|
Text("保存到记录")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(TjPrimaryButton())
|
||||||
|
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Text("取消(图片不保留)")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - helpers
|
||||||
|
|
||||||
|
private func sectionLabel(_ t: String) -> some View {
|
||||||
|
Text(t)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.tracking(0.3)
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fieldBg: some View {
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fieldBorder: some View {
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
康康/Features/Capture/DocumentScanner.swift
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VisionKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
#if canImport(VisionKit) && os(iOS)
|
||||||
|
|
||||||
|
/// VisionKit 文档扫描器的 SwiftUI 包装。
|
||||||
|
/// - 真机:全屏多页文档扫描,自动透视校正
|
||||||
|
/// - 模拟器:`VNDocumentCameraViewController.isSupported == false`,
|
||||||
|
/// 父 View 不要 present 这个,改走 PhotosPicker 回退(见 PhotoPickerSheet)
|
||||||
|
struct DocumentScannerView: UIViewControllerRepresentable {
|
||||||
|
let onFinish: ([UIImage]) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
|
||||||
|
let vc = VNDocumentCameraViewController()
|
||||||
|
vc.delegate = context.coordinator
|
||||||
|
return vc
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: VNDocumentCameraViewController,
|
||||||
|
context: Context) {}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(onFinish: onFinish, onCancel: onCancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
|
||||||
|
let onFinish: ([UIImage]) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
init(onFinish: @escaping ([UIImage]) -> Void,
|
||||||
|
onCancel: @escaping () -> Void) {
|
||||||
|
self.onFinish = onFinish
|
||||||
|
self.onCancel = onCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentCameraViewController(
|
||||||
|
_ controller: VNDocumentCameraViewController,
|
||||||
|
didFinishWith scan: VNDocumentCameraScan
|
||||||
|
) {
|
||||||
|
var images: [UIImage] = []
|
||||||
|
for i in 0..<scan.pageCount {
|
||||||
|
images.append(scan.imageOfPage(at: i))
|
||||||
|
}
|
||||||
|
onFinish(images)
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentCameraViewControllerDidCancel(
|
||||||
|
_ controller: VNDocumentCameraViewController
|
||||||
|
) {
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentCameraViewController(
|
||||||
|
_ controller: VNDocumentCameraViewController,
|
||||||
|
didFailWithError error: Error
|
||||||
|
) {
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var isSupported: Bool {
|
||||||
|
VNDocumentCameraViewController.isSupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
68
康康/Features/Capture/PhotoPickerSheet.swift
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
|
/// VisionKit 在模拟器不可用,demo / 验证场景走 PhotosPicker 回退选已有照片。
|
||||||
|
/// 真机正式录入走 DocumentScannerView。
|
||||||
|
struct PhotoPickerSheet: View {
|
||||||
|
let onFinish: ([UIImage]) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
@State private var selection: [PhotosPickerItem] = []
|
||||||
|
@State private var loading = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "photo.on.rectangle.angled")
|
||||||
|
.font(.system(size: 56))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text("模拟器没有摄像头,从相册选一张化验单/体检报告")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
PhotosPicker(selection: $selection,
|
||||||
|
maxSelectionCount: 5,
|
||||||
|
matching: .images) {
|
||||||
|
Text("从相册选 ≤5 张")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Tj.Palette.ink)
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("取消", action: onCancel)
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
|
||||||
|
if loading {
|
||||||
|
ProgressView().tint(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(28)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.onChange(of: selection) { _, newValue in
|
||||||
|
guard !newValue.isEmpty else { return }
|
||||||
|
loadImages(from: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadImages(from items: [PhotosPickerItem]) {
|
||||||
|
loading = true
|
||||||
|
Task {
|
||||||
|
var images: [UIImage] = []
|
||||||
|
for item in items {
|
||||||
|
if let data = try? await item.loadTransferable(type: Data.self),
|
||||||
|
let img = UIImage(data: data) {
|
||||||
|
images.append(img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
loading = false
|
||||||
|
if images.isEmpty { onCancel() }
|
||||||
|
else { onFinish(images) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
415
康康/Features/Capture/UnifiedCaptureFlow.swift
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// 拍报告 → VL 识别 → 编辑 → 保存(图 + 结构化文本)
|
||||||
|
/// 一条统一流程,替代原 A1-A3 / B1-B5 两套 mockup。
|
||||||
|
///
|
||||||
|
/// 状态机:
|
||||||
|
/// ```
|
||||||
|
/// idle → captured(images) → analyzing → editing(parsed, assets)
|
||||||
|
/// ↓ 失败
|
||||||
|
/// editing(empty, assets)
|
||||||
|
/// editing → saved → dismiss
|
||||||
|
/// ```
|
||||||
|
struct UnifiedCaptureFlow: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
@AppStorage("hasSeenCaptureTip") private var hasSeenCaptureTip: Bool = false
|
||||||
|
@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 {
|
||||||
|
case idle
|
||||||
|
case analyzing(images: [UIImage], assets: [FileVault.SavedAsset]?)
|
||||||
|
case editing(parsed: ParsedReport,
|
||||||
|
assets: [FileVault.SavedAsset],
|
||||||
|
warning: String?)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
content
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("取消") { cancelAll() }
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(phaseTitle)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if !hasSeenCaptureTip { showTip = true }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showTip) {
|
||||||
|
CaptureTipSheet(onDismiss: {
|
||||||
|
hasSeenCaptureTip = true
|
||||||
|
showTip = false
|
||||||
|
})
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var phaseTitle: String {
|
||||||
|
switch phase {
|
||||||
|
case .idle: return String(appLoc: "拍摄报告")
|
||||||
|
case .analyzing: return String(appLoc: "本地识别中…")
|
||||||
|
case .editing: return String(appLoc: "核对识别结果")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
switch phase {
|
||||||
|
case .idle:
|
||||||
|
captureEntry
|
||||||
|
case .analyzing(let images, _):
|
||||||
|
AnalyzingView(
|
||||||
|
images: images,
|
||||||
|
timeoutSeconds: analyzeTimeoutSeconds,
|
||||||
|
onCancel: {
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
analyzeTask = nil
|
||||||
|
phase = .idle
|
||||||
|
}
|
||||||
|
)
|
||||||
|
case .editing(let parsed, let assets, let warning):
|
||||||
|
CaptureReviewForm(
|
||||||
|
parsed: parsed,
|
||||||
|
assets: assets,
|
||||||
|
warning: warning,
|
||||||
|
onSave: { final in saveAll(parsed: final, assets: assets) },
|
||||||
|
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: - 入口:相机 / 相册
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var captureEntry: some View {
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
PhotoPickerSheet(
|
||||||
|
onFinish: { startAnalyze(images: $0) },
|
||||||
|
onCancel: onClose
|
||||||
|
)
|
||||||
|
#else
|
||||||
|
if DocumentScannerView.isSupported {
|
||||||
|
DocumentScannerView(
|
||||||
|
onFinish: { startAnalyze(images: $0) },
|
||||||
|
onCancel: onClose
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
} else {
|
||||||
|
PhotoPickerSheet(
|
||||||
|
onFinish: { startAnalyze(images: $0) },
|
||||||
|
onCancel: onClose
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 启动识别
|
||||||
|
|
||||||
|
private func startAnalyze(images: [UIImage]) {
|
||||||
|
guard !images.isEmpty else { onClose(); return }
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
phase = .analyzing(images: images, assets: nil)
|
||||||
|
let timeout = analyzeTimeoutSeconds
|
||||||
|
analyzeTask = Task {
|
||||||
|
// Step 1: 先把图写进 Vault。
|
||||||
|
// 在 UI 这一层写,而不是塞进 CaptureService.analyze —— 这样取消/失败回退时,
|
||||||
|
// assets 已经在 phase 里,cancelAll 能清理孤儿,editingFallback 也不必再补写。
|
||||||
|
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
|
||||||
|
// 极端情况:用户在写图过程中按了「取消」,View 已 dismiss、cancelAll 看到的
|
||||||
|
// phase 还是 .analyzing(_, nil),清不到这批刚写完的图 — 这里手动收尾。
|
||||||
|
if Task.isCancelled {
|
||||||
|
for a in assets { try? FileVault.shared.remove(relativePath: a.relativePath) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !assets.isEmpty else {
|
||||||
|
await MainActor.run {
|
||||||
|
phase = .editing(
|
||||||
|
parsed: .empty(),
|
||||||
|
assets: [],
|
||||||
|
warning: String(appLoc: "图片保存失败,手动录入并保留文本")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 把 assets 暴露给 phase,使工具栏「取消」也能找到孤儿清理。
|
||||||
|
await MainActor.run {
|
||||||
|
if case .analyzing(let imgs, _) = phase {
|
||||||
|
phase = .analyzing(images: imgs, assets: assets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: VL 推理(timeout 哨兵到点 cancel 父任务,VLSession 在下一个 token break)。
|
||||||
|
let watchdog = Task {
|
||||||
|
try? await Task.sleep(for: .seconds(timeout))
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
}
|
||||||
|
defer { watchdog.cancel() }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
|
||||||
|
if Task.isCancelled {
|
||||||
|
await editingFallback(assets: assets,
|
||||||
|
msg: String(appLoc: "识别超时(>\(timeout)s),先手动录入"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
phase = .editing(
|
||||||
|
parsed: parsed,
|
||||||
|
assets: assets,
|
||||||
|
warning: parsed.isEmpty ? String(appLoc: "识别没有读出指标,请手动补充") : nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch let CaptureError.parseFailed(msg) {
|
||||||
|
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
|
||||||
|
} catch let CaptureError.inferenceFailed(msg) {
|
||||||
|
await editingFallback(assets: assets,
|
||||||
|
msg: Task.isCancelled
|
||||||
|
? String(appLoc: "识别超时(>\(timeout)s),先手动录入")
|
||||||
|
: String(appLoc: "推理失败:\(msg)"))
|
||||||
|
} catch CaptureError.modelNotReady {
|
||||||
|
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪,先手动录入"))
|
||||||
|
} catch {
|
||||||
|
await editingFallback(assets: assets,
|
||||||
|
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「重新识别」:复用已存 assets,不再写图,只重跑 VL。
|
||||||
|
private func reanalyze(assets: [FileVault.SavedAsset]) {
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
// 这里没有原始 UIImage,AnalyzingView 显示首张缩略图即可
|
||||||
|
let thumbnails: [UIImage] = assets.compactMap {
|
||||||
|
try? FileVault.shared.loadImage(relativePath: $0.relativePath)
|
||||||
|
}
|
||||||
|
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() }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
|
||||||
|
if Task.isCancelled {
|
||||||
|
await editingFallback(assets: assets,
|
||||||
|
msg: String(appLoc: "识别超时(>\(timeout)s),保留旧编辑"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
phase = .editing(
|
||||||
|
parsed: parsed,
|
||||||
|
assets: assets,
|
||||||
|
warning: parsed.isEmpty ? String(appLoc: "重新识别没有读出新指标") : nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch CaptureError.modelNotReady {
|
||||||
|
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪"))
|
||||||
|
} catch let CaptureError.parseFailed(msg) {
|
||||||
|
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
|
||||||
|
} catch let CaptureError.inferenceFailed(msg) {
|
||||||
|
await editingFallback(assets: assets,
|
||||||
|
msg: Task.isCancelled
|
||||||
|
? String(appLoc: "识别超时(>\(timeout)s)")
|
||||||
|
: String(appLoc: "推理失败:\(msg)"))
|
||||||
|
} catch {
|
||||||
|
await editingFallback(assets: assets,
|
||||||
|
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// reanalyze 失败时回到 editing,保留 assets 但清空 parsed。
|
||||||
|
private func editingFallback(assets: [FileVault.SavedAsset], msg: String) async {
|
||||||
|
await MainActor.run {
|
||||||
|
phase = .editing(parsed: .empty(), assets: assets, warning: msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 持久化
|
||||||
|
|
||||||
|
private func saveAll(parsed final: ParsedReport,
|
||||||
|
assets: [FileVault.SavedAsset]) {
|
||||||
|
let report = Report(
|
||||||
|
title: final.title.isEmpty ? String(appLoc: "拍摄识别") : final.title,
|
||||||
|
type: ReportType(rawValue: final.typeRaw) ?? .other,
|
||||||
|
reportDate: final.reportDate,
|
||||||
|
institution: final.institution.isEmpty ? nil : final.institution,
|
||||||
|
summary: final.summary.isEmpty ? nil : final.summary,
|
||||||
|
pageCount: final.pageCount
|
||||||
|
)
|
||||||
|
ctx.insert(report)
|
||||||
|
|
||||||
|
// 关联 Asset
|
||||||
|
for a in assets {
|
||||||
|
let asset = Asset(relativePath: a.relativePath, bytes: a.bytes)
|
||||||
|
ctx.insert(asset)
|
||||||
|
report.assets.append(asset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关联 Indicator
|
||||||
|
for ind in final.indicators {
|
||||||
|
let i = Indicator(
|
||||||
|
name: ind.name,
|
||||||
|
value: ind.value,
|
||||||
|
unit: ind.unit,
|
||||||
|
range: ind.range,
|
||||||
|
status: ind.status,
|
||||||
|
capturedAt: final.reportDate,
|
||||||
|
report: report
|
||||||
|
)
|
||||||
|
ctx.insert(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
try? ctx.save()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 分析中视图
|
||||||
|
|
||||||
|
private struct AnalyzingView: View {
|
||||||
|
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 {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer()
|
||||||
|
if let first = images.first {
|
||||||
|
Image(uiImage: first)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxHeight: 240)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.overlay(
|
||||||
|
ProgressView().tint(Tj.Palette.ink).scaleEffect(1.4)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text("本地识别中")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("\(images.count) 页 · 100% 本地推理 · 已用 \(elapsed)s")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
if elapsed >= timeoutSeconds - 5 {
|
||||||
|
Text("快超时了,>\(timeoutSeconds)s 会自动转为手动录入")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("取消识别 · 改为手动录入", action: onCancel)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.padding(.top, 4)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.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(.system(size: 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
573
康康/Features/Diary/DiaryQuickSheet.swift
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 「健康记录」录入 sheet。
|
||||||
|
/// 主体仍是 DiaryEntry @Model;UI/文案改为面向健康记录,并加 AI 辅助区:
|
||||||
|
/// 让 Qwen3 从医生问诊角度提 3-4 个追问,用户可一键将「补充模板」追加到输入框。
|
||||||
|
/// 支持多轮——每轮把已问过的 q 传给 LLM 要求别重复;已采纳的 row 灰色 + ✓ 标记。
|
||||||
|
struct DiaryQuickSheet: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var content: String = ""
|
||||||
|
@State private var createdAt: Date = .now
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
|
||||||
|
private var hasContent: Bool {
|
||||||
|
!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 }
|
||||||
|
private var canSubmit: Bool { hasContent }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Capsule()
|
||||||
|
.fill(Tj.Palette.line)
|
||||||
|
.frame(width: 40, height: 4)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("健康记录")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text("本机保存")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel(String(appLoc: "内容"))
|
||||||
|
TextField("今天身体怎么样?吃了什么药、有什么感觉?",
|
||||||
|
text: $content, axis: .vertical)
|
||||||
|
.lineLimit(3...8)
|
||||||
|
.focused($contentFocused)
|
||||||
|
.onChange(of: content) { _, _ in exhaustedNote = false }
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assistSection
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel(String(appLoc: "时间"))
|
||||||
|
DatePicker("", selection: $createdAt, in: ...Date.now)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
// 底部锚点,新一轮 question 进来后自动滚到这里
|
||||||
|
Color.clear.frame(height: 1).id("assist-bottom")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
}
|
||||||
|
.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) {
|
||||||
|
Button("取消") { dismiss() }
|
||||||
|
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||||
|
Button("保存") { submit() }
|
||||||
|
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||||
|
.disabled(!canSubmit)
|
||||||
|
.opacity(canSubmit ? 1 : 0.4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
Tj.Palette.sand
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
)
|
||||||
|
.presentationDetents([.medium, .large], selection: $detent)
|
||||||
|
.presentationDragIndicator(.hidden)
|
||||||
|
.presentationBackground(Tj.Palette.sand)
|
||||||
|
.presentationCornerRadius(Tj.Radius.xl)
|
||||||
|
.onDisappear { suggestTask?.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AI 辅助区
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var assistSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
// section header
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
|
||||||
|
Spacer()
|
||||||
|
if hasQuestions {
|
||||||
|
Text("\(questions.count) 个建议")
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
if lastRate > 0 {
|
||||||
|
Text(String(format: "%.1f tok/s", lastRate))
|
||||||
|
.font(.system(size: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if exhaustedNote {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "checkmark.seal.fill")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
|
Text("已覆盖主要问诊维度;补充原文后可再追问")
|
||||||
|
.font(.system(size: 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,
|
||||||
|
action: requestSuggestions
|
||||||
|
)
|
||||||
|
|
||||||
|
case .loading:
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
Text("AI 思考中… 本地推理,通常 5-10 秒")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer()
|
||||||
|
Button("取消") { cancelSuggestions() }
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 11)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
|
||||||
|
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(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Button { requestSuggestions() } label: {
|
||||||
|
Text("重试")
|
||||||
|
.font(.system(size: 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))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assistPrimaryButton(icon: String,
|
||||||
|
label: String,
|
||||||
|
enabled: Bool,
|
||||||
|
action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 11)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(
|
||||||
|
enabled ? Tj.Palette.ink : Tj.Palette.line,
|
||||||
|
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// 纯描边背景、内部透明:补 contentShape 让整框可点(否则只有图标+文字本体能点)。
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 给定整张 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(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
Text(round == 1
|
||||||
|
? String(appLoc: "第 1 轮 · \(count) 条")
|
||||||
|
: String(appLoc: "第 \(round) 轮 · 基于你刚才更新的文本 · \(count) 条"))
|
||||||
|
.font(.system(size: 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(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick)
|
||||||
|
Text(question.q)
|
||||||
|
.font(.system(size: 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(.system(size: 10, weight: .bold))
|
||||||
|
Text("已采纳")
|
||||||
|
.font(.system(size: 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(.system(size: 12))
|
||||||
|
Text("采纳")
|
||||||
|
.font(.system(size: 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(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(question.fill)
|
||||||
|
.font(.system(size: 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 {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.tracking(0.3)
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 触发一轮 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() {
|
||||||
|
guard canSubmit else { return }
|
||||||
|
let entry = DiaryEntry(
|
||||||
|
content: content.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
createdAt: createdAt
|
||||||
|
)
|
||||||
|
ctx.insert(entry)
|
||||||
|
try? ctx.save()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
DiaryQuickSheet()
|
||||||
|
}
|
||||||
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(.system(size: 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(.system(size: 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(.system(size: 12, weight: .semibold))
|
||||||
|
Text("加入记录")
|
||||||
|
.font(.system(size: 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(.system(size: 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(.system(size: 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(.system(size: 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 } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
189
康康/Features/Home/HomeView.swift
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct HomeView: View {
|
||||||
|
var onTapArchive: () -> 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]
|
||||||
|
|
||||||
|
/// 点「最近记录」某行 → 打开只读详情 sheet(与档案库 C1 同款交互)。
|
||||||
|
@State private var selectedEntry: TimelineEntry?
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private var recentEntries: [TimelineEntry] {
|
||||||
|
let all =
|
||||||
|
TimelineEntry.from(indicators: indicators) +
|
||||||
|
reports.map(TimelineEntry.from(report:)) +
|
||||||
|
diaries.map(TimelineEntry.from(diary:)) +
|
||||||
|
symptoms.map(TimelineEntry.from(symptom:))
|
||||||
|
return all.sorted { $0.date > $1.date }.prefix(6).map { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recentGrouped: [(section: DateSection, items: [TimelineEntry])] {
|
||||||
|
TimelineGrouping.group(recentEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
greeting
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
TodayRemindersCard()
|
||||||
|
|
||||||
|
OngoingSymptomsCard()
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
recentSection
|
||||||
|
.padding(.bottom, 22)
|
||||||
|
|
||||||
|
archiveSection
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var greeting: some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(todayLine)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.tracking(1)
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(greetingWord)
|
||||||
|
.font(.tjTitle())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
TjLockChip()
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var todayLine: String {
|
||||||
|
let now = Date()
|
||||||
|
let day = now.formatted(.dateTime.month().day())
|
||||||
|
let weekday = now.formatted(.dateTime.weekday(.abbreviated))
|
||||||
|
return "\(day) · \(weekday)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var greetingWord: String {
|
||||||
|
switch Calendar.current.component(.hour, from: Date()) {
|
||||||
|
case 5..<12: return String(appLoc: "早安")
|
||||||
|
case 12..<18: return String(appLoc: "下午好")
|
||||||
|
default: return String(appLoc: "晚上好")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recentSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .lastTextBaseline) {
|
||||||
|
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
Button(action: onTapArchive) {
|
||||||
|
Text("全部 ›")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recentEntries.isEmpty {
|
||||||
|
emptyRecent
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
ForEach(recentGrouped, id: \.section) { group in
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(group.section.label)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.tracking(0.5)
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(group.items) { entry in
|
||||||
|
Button {
|
||||||
|
if TimelineDetail.resolve(
|
||||||
|
for: entry,
|
||||||
|
indicators: indicators, reports: reports,
|
||||||
|
diaries: diaries, symptoms: symptoms
|
||||||
|
) != nil {
|
||||||
|
selectedEntry = entry
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
TimelineRow(entry: entry)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyRecent: some View {
|
||||||
|
HStack {
|
||||||
|
Text("还没有任何记录,点底部 + 号开始第一条")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.tjCard(bordered: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var archiveSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||||
|
|
||||||
|
Button(action: onTapArchive) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("我的报告档案")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("\(reports.count) 份 · \(indicators.count) 项指标 · 端侧加密")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.tjCard(bordered: true)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
HomeView()
|
||||||
|
}
|
||||||
59
康康/Features/Home/RecentItemRow.swift
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum RecentItemStatus {
|
||||||
|
case high, archive, diary
|
||||||
|
|
||||||
|
var dotColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .high: return Tj.Palette.brick
|
||||||
|
case .archive: return Tj.Palette.ink2
|
||||||
|
case .diary: return Tj.Palette.leaf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var valueColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .high: return Tj.Palette.brick
|
||||||
|
default: return Tj.Palette.text2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecentItemRow: View {
|
||||||
|
let date: String
|
||||||
|
let type: String
|
||||||
|
let name: String
|
||||||
|
let value: String?
|
||||||
|
let status: RecentItemStatus
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||||
|
.fill(status.dotColor)
|
||||||
|
.frame(width: 6, height: 40)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("\(date) · \(type)")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.tracking(0.3)
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(name)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
if let value {
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(status.valueColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.tjCard(bordered: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
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(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
Button { showingCenter = true } label: {
|
||||||
|
Text("全部 ›")
|
||||||
|
.font(.system(size: 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(.system(size: 14, weight: .semibold).monospacedDigit())
|
||||||
|
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.ink)
|
||||||
|
.frame(width: 46, alignment: .leading)
|
||||||
|
Image(systemName: "bell.fill")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.amber)
|
||||||
|
Text(item.title)
|
||||||
|
.font(.system(size: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
329
康康/Features/Indicator/CustomMetricEditor.swift
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
let customMetricIconChoices: [String] = [
|
||||||
|
"circle.fill",
|
||||||
|
"drop.fill",
|
||||||
|
"flame.fill",
|
||||||
|
"bolt.fill",
|
||||||
|
"leaf.fill",
|
||||||
|
"pills.fill",
|
||||||
|
"gauge.high",
|
||||||
|
"moon.fill",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 名称冲突判定结果。`detectNameConflict` 返回此值用于 UI 警告。
|
||||||
|
enum CustomMetricNameConflict: Equatable {
|
||||||
|
case none
|
||||||
|
case builtin(String) // 撞到 MonitorMetric.displayName
|
||||||
|
case existingCustom(String) // 撞到其他 CustomMonitorMetric.name
|
||||||
|
|
||||||
|
var warningText: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return ""
|
||||||
|
case .builtin(let n): return String(appLoc: "「\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块")
|
||||||
|
case .existingCustom(let n):return String(appLoc: "已经有一个叫「\(n)」的自定义指标")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 纯函数:给定 candidate name + 现有 customs + 编辑时排除的 seriesKey,返回冲突类型。
|
||||||
|
/// 抽离方便单测,不依赖 SwiftData 上下文。
|
||||||
|
func detectNameConflict(
|
||||||
|
candidate: String,
|
||||||
|
customs: [CustomMonitorMetric],
|
||||||
|
excludingSeriesKey: String? = nil
|
||||||
|
) -> CustomMetricNameConflict {
|
||||||
|
let trimmed = candidate.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty else { return .none }
|
||||||
|
|
||||||
|
if MonitorMetric.allCases.contains(where: { $0.displayName == trimmed }) {
|
||||||
|
return .builtin(trimmed)
|
||||||
|
}
|
||||||
|
for c in customs where c.seriesKey != excludingSeriesKey && c.name == trimmed {
|
||||||
|
return .existingCustom(trimmed)
|
||||||
|
}
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自定义长期监测指标的 create / edit / delete sheet。
|
||||||
|
struct CustomMetricEditor: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
/// nil = 新建;非 nil = 编辑现有
|
||||||
|
let existing: CustomMonitorMetric?
|
||||||
|
/// 保存或删除后回调,parent 可借此 setSelectedCustom(metric? ) 触发后续 UI
|
||||||
|
var onSaved: (CustomMonitorMetric?) -> Void
|
||||||
|
|
||||||
|
@Query private var allCustoms: [CustomMonitorMetric]
|
||||||
|
|
||||||
|
@State private var name: String = ""
|
||||||
|
@State private var unit: String = ""
|
||||||
|
@State private var lower: String = ""
|
||||||
|
@State private var upper: String = ""
|
||||||
|
@State private var icon: String = "circle.fill"
|
||||||
|
@State private var hydrated = false
|
||||||
|
|
||||||
|
private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
|
||||||
|
private var trimmedUnit: String { unit.trimmingCharacters(in: .whitespaces) }
|
||||||
|
private var canSubmit: Bool { !trimmedName.isEmpty }
|
||||||
|
|
||||||
|
private var nameConflict: CustomMetricNameConflict {
|
||||||
|
detectNameConflict(
|
||||||
|
candidate: name,
|
||||||
|
customs: allCustoms,
|
||||||
|
excludingSeriesKey: existing?.seriesKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Capsule()
|
||||||
|
.fill(Tj.Palette.line)
|
||||||
|
.frame(width: 40, height: 4)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
|
header
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
nameSection
|
||||||
|
unitSection
|
||||||
|
rangeRow
|
||||||
|
iconSection
|
||||||
|
if existing != nil {
|
||||||
|
deleteButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
Tj.Palette.sand
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
)
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.hidden)
|
||||||
|
.presentationBackground(Tj.Palette.sand)
|
||||||
|
.presentationCornerRadius(Tj.Radius.xl)
|
||||||
|
.onAppear { hydrate() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack {
|
||||||
|
Text(existing == nil ? "新建自定义指标" : "编辑「\(existing!.name)」")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
if existing == nil {
|
||||||
|
Text("保存后会出现在录入选项里")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nameSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel(String(appLoc: "名称"))
|
||||||
|
TextField("例如:腰围 / 步数 / 睡眠时长", text: $name)
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||||
|
.background(fieldBg)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(
|
||||||
|
nameConflict == .none ? Tj.Palette.line : Tj.Palette.amber,
|
||||||
|
lineWidth: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if nameConflict != .none {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
|
Text(nameConflict.warningText)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var unitSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel(String(appLoc: "单位(可选)"))
|
||||||
|
TextField("例如:cm / 步 / 小时", text: $unit)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||||
|
.background(fieldBg).overlay(fieldBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rangeRow: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
sectionLabel(String(appLoc: "参考范围(可选)"))
|
||||||
|
Spacer()
|
||||||
|
Text("用于自动判定 正常/偏高/偏低")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
rangeField(label: String(appLoc: "下限"), value: $lower, placeholder: "70")
|
||||||
|
Text("—").foregroundStyle(Tj.Palette.text3)
|
||||||
|
rangeField(label: String(appLoc: "上限"), value: $upper, placeholder: "90")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rangeField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
TextField(placeholder, text: value)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||||
|
.background(fieldBg).overlay(fieldBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel(String(appLoc: "图标"))
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4),
|
||||||
|
spacing: 8) {
|
||||||
|
ForEach(customMetricIconChoices, id: \.self) { sf in
|
||||||
|
Button {
|
||||||
|
icon = sf
|
||||||
|
} label: {
|
||||||
|
Image(systemName: sf)
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundStyle(icon == sf ? Tj.Palette.paper : Tj.Palette.ink)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 44)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.fill(icon == sf ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: icon == sf ? 0 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deleteButton: some View {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
if let m = existing {
|
||||||
|
ReminderService.cancel(metricId: m.seriesKey)
|
||||||
|
ctx.delete(m)
|
||||||
|
try? ctx.save()
|
||||||
|
onSaved(nil)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
Text("删除这项自定义指标")
|
||||||
|
}
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.brickSoft.opacity(0.5))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("取消") { dismiss() }
|
||||||
|
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||||
|
Button(existing == nil ? "新建" : "保存") { submit() }
|
||||||
|
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||||
|
.disabled(!canSubmit)
|
||||||
|
.opacity(canSubmit ? 1 : 0.4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(
|
||||||
|
Tj.Palette.sand
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - helpers
|
||||||
|
|
||||||
|
private var fieldBg: some View {
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
}
|
||||||
|
private var fieldBorder: some View {
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
}
|
||||||
|
private func sectionLabel(_ t: String) -> some View {
|
||||||
|
Text(t).font(.system(size: 12, weight: .semibold)).tracking(0.3)
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hydrate() {
|
||||||
|
guard !hydrated, let m = existing else { hydrated = true; return }
|
||||||
|
name = m.name; unit = m.unit; icon = m.icon
|
||||||
|
lower = m.lowerBound.map { fmt($0) } ?? ""
|
||||||
|
upper = m.upperBound.map { fmt($0) } ?? ""
|
||||||
|
hydrated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() {
|
||||||
|
guard canSubmit else { return }
|
||||||
|
let lo = Double(lower.trimmingCharacters(in: .whitespaces))
|
||||||
|
let hi = Double(upper.trimmingCharacters(in: .whitespaces))
|
||||||
|
if let m = existing {
|
||||||
|
m.name = trimmedName
|
||||||
|
m.unit = trimmedUnit
|
||||||
|
m.lowerBound = lo
|
||||||
|
m.upperBound = hi
|
||||||
|
m.icon = icon
|
||||||
|
try? ctx.save()
|
||||||
|
onSaved(m)
|
||||||
|
} else {
|
||||||
|
let m = CustomMonitorMetric(
|
||||||
|
name: trimmedName,
|
||||||
|
unit: trimmedUnit,
|
||||||
|
lowerBound: lo,
|
||||||
|
upperBound: hi,
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
ctx.insert(m)
|
||||||
|
try? ctx.save()
|
||||||
|
onSaved(m)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fmt(_ v: Double) -> String {
|
||||||
|
v.truncatingRemainder(dividingBy: 1) == 0
|
||||||
|
? String(format: "%.0f", v)
|
||||||
|
: String(format: "%.1f", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
1188
康康/Features/Indicator/IndicatorQuickSheet.swift
Normal file
176
康康/Features/Me/AboutView.swift
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 「我的 · 关于」——本软件基本介绍、使用注意与免责声明。
|
||||||
|
/// 纯静态阅读页,不调任何 Service / AIRuntime,复用现有 DesignSystem token。
|
||||||
|
/// 文案按 App Store 上架合规口径撰写:避免绝对化用语、精确区分本地/联网行为、强化医疗免责。
|
||||||
|
struct AboutView: View {
|
||||||
|
/// 真实读取 Bundle 版本号,避免硬编码与实际发版脱节。
|
||||||
|
private var versionText: String {
|
||||||
|
let info = Bundle.main.infoDictionary
|
||||||
|
let short = info?["CFBundleShortVersionString"] as? String ?? "0.1"
|
||||||
|
let build = info?["CFBundleVersion"] as? String
|
||||||
|
if let build, !build.isEmpty, build != short {
|
||||||
|
return "v\(short) (\(build))"
|
||||||
|
}
|
||||||
|
return "v\(short)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
header
|
||||||
|
|
||||||
|
section(icon: "sparkles", title: String(appLoc: "这是什么")) {
|
||||||
|
paragraph(
|
||||||
|
String(appLoc: "康康是一款以本地优先为设计原则的个人健康影像档案工具。") +
|
||||||
|
String(appLoc: "你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;") +
|
||||||
|
String(appLoc: "设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
section(icon: "checklist", title: String(appLoc: "主要功能")) {
|
||||||
|
bullet(String(appLoc: "拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档"))
|
||||||
|
bullet(String(appLoc: "通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明"))
|
||||||
|
bullet(String(appLoc: "长期趋势:关注的指标可生成折线图和简要解读"))
|
||||||
|
bullet(String(appLoc: "本地问答:基于你自己的档案问答,引用可点击回链到原记录"))
|
||||||
|
bullet(String(appLoc: "隐私优先:健康数据不上传、无需注册账号"))
|
||||||
|
}
|
||||||
|
|
||||||
|
section(icon: "iphone", title: String(appLoc: "设备要求"), tint: Tj.Palette.leaf) {
|
||||||
|
bullet(String(appLoc: "系统:iOS 17 或更新版本。"))
|
||||||
|
bullet(String(appLoc: "本地 AI 功能(拍照识别、解读、问答)需要约 8GB 内存,") +
|
||||||
|
String(appLoc: "推荐 iPhone 15 Pro / Pro Max 及之后发布的机型(含 iPhone 16 系列)。"))
|
||||||
|
bullet(String(appLoc: "在内存较小的旧机型上,App 仍可用于手动记录、归档与查看,") +
|
||||||
|
String(appLoc: "但本地 AI 相关功能可能无法运行。"))
|
||||||
|
}
|
||||||
|
|
||||||
|
section(icon: "lock.shield", title: String(appLoc: "隐私保护")) {
|
||||||
|
bullet(String(appLoc: "AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据。"))
|
||||||
|
bullet(String(appLoc: "原图与数据库采用系统级文件加密,随设备锁屏受到保护。"))
|
||||||
|
bullet(String(appLoc: "支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份。"))
|
||||||
|
bullet(String(appLoc: "可选开启 Face ID 启动锁,进一步保护隐私。"))
|
||||||
|
}
|
||||||
|
|
||||||
|
section(icon: "exclamationmark.triangle", title: String(appLoc: "使用注意"), tint: Tj.Palette.amber) {
|
||||||
|
bullet(String(appLoc: "本地 AI 模型体积较大(约 4GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;") +
|
||||||
|
String(appLoc: "模型未就绪时 App 仍可使用,AI 功能会提示前往下载。"))
|
||||||
|
bullet(String(appLoc: "AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对,") +
|
||||||
|
String(appLoc: "并以原始报告 / 化验单为准。"))
|
||||||
|
bullet(String(appLoc: "AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。"))
|
||||||
|
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)")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
Text("本 App 仅供健康信息记录与参考,不能替代专业医疗意见。")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Spacer(minLength: 32)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand)
|
||||||
|
.navigationTitle("关于")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
@ViewBuilder private var header: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(Tj.Palette.sand2)
|
||||||
|
Image(systemName: "heart.text.square.fill")
|
||||||
|
.font(.system(size: 34))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
}
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
|
||||||
|
Text("康康")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
|
||||||
|
Text("本地优先的个人健康影像档案")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
|
||||||
|
Text(versionText)
|
||||||
|
.font(.tjMono())
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 24)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Section builders
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func section<Content: View>(
|
||||||
|
icon: String,
|
||||||
|
title: String,
|
||||||
|
tint: Color = Tj.Palette.text2,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(16)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func paragraph(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.lineSpacing(5)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func bullet(_ text: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(Tj.Palette.text3)
|
||||||
|
.frame(width: 5, height: 5)
|
||||||
|
.padding(.top, 7)
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.lineSpacing(5)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
AboutView()
|
||||||
|
}
|
||||||
|
}
|
||||||
153
康康/Features/Me/CustomMetricsListView.swift
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 「我的 · 自定义指标」管理页。
|
||||||
|
/// 从 MeView 进入;集中查看 / 新建 / 编辑 / 删除自定义长期监测指标。
|
||||||
|
struct CustomMetricsListView: View {
|
||||||
|
@Query(sort: \CustomMonitorMetric.createdAt, order: .reverse)
|
||||||
|
private var metrics: [CustomMonitorMetric]
|
||||||
|
|
||||||
|
@Query private var indicators: [Indicator]
|
||||||
|
|
||||||
|
@State private var editingTarget: CustomMetricEditTarget?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
hintBanner
|
||||||
|
if metrics.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
ForEach(metrics) { m in
|
||||||
|
Button {
|
||||||
|
editingTarget = CustomMetricEditTarget(metric: m)
|
||||||
|
} label: {
|
||||||
|
row(m)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationTitle("自定义指标")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
editingTarget = CustomMetricEditTarget(metric: nil)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $editingTarget) { target in
|
||||||
|
CustomMetricEditor(existing: target.metric) { _ in }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - subviews
|
||||||
|
|
||||||
|
private var hintBanner: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "info.circle.fill")
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text("自定义指标会出现在「+ 指标记录 → 长期监测」的 grid 里,可设提醒、进趋势")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.sand2.opacity(0.5))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
Spacer(minLength: 40)
|
||||||
|
TjPlaceholder(label: String(appLoc: "还没有自定义指标"))
|
||||||
|
.frame(width: 220, height: 130)
|
||||||
|
Text("右上角 + 新建一个")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ m: CustomMonitorMetric) -> some View {
|
||||||
|
let count = usageCount(for: m)
|
||||||
|
return HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Tj.Palette.leafSoft)
|
||||||
|
Image(systemName: m.icon)
|
||||||
|
.font(.system(size: 17, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(m.name)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if !m.unit.isEmpty {
|
||||||
|
Text(m.unit)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
if !m.rangeText.isEmpty {
|
||||||
|
Text("·")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(m.rangeText)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(count == 0 ? String(appLoc: "未使用") : String(appLoc: "用 \(count) 次"))
|
||||||
|
.font(.system(size: 11, weight: count > 0 ? .semibold : .regular))
|
||||||
|
.foregroundStyle(count > 0 ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func usageCount(for m: CustomMonitorMetric) -> Int {
|
||||||
|
indicators.filter { $0.seriesKey == m.seriesKey }.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
CustomMetricsListView()
|
||||||
|
}
|
||||||
|
.modelContainer(for: [
|
||||||
|
CustomMonitorMetric.self, Indicator.self,
|
||||||
|
UserProfile.self, MetricReminder.self,
|
||||||
|
], inMemory: true)
|
||||||
|
}
|
||||||
300
康康/Features/Me/CustomReminderEditSheet.swift
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 自由周期提醒的创建 / 编辑表单。
|
||||||
|
/// `reminder == nil` 为新建;否则为编辑(多一个删除按钮)。
|
||||||
|
/// 本地 @State 暂存,保存时才写 SwiftData + 调度通知;取消即丢弃。
|
||||||
|
struct CustomReminderEditSheet: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
/// nil = 新建模式。
|
||||||
|
let reminder: CustomReminder?
|
||||||
|
|
||||||
|
@State private var title = ""
|
||||||
|
@State private var note = ""
|
||||||
|
@State private var pickedTime: Date = .now
|
||||||
|
@State private var frequency: CustomReminder.Frequency = .daily
|
||||||
|
@State private var weekdays: Set<Int> = Set(1...7)
|
||||||
|
@State private var dayOfMonth = 1
|
||||||
|
@State private var month = 1
|
||||||
|
@State private var hydrated = false
|
||||||
|
@State private var showAuthDeniedAlert = false
|
||||||
|
|
||||||
|
/// 常用时间快捷预设(时, 分):早 / 午 / 傍晚 / 睡前。
|
||||||
|
private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)]
|
||||||
|
|
||||||
|
init(reminder: CustomReminder? = nil) {
|
||||||
|
self.reminder = reminder
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isEditing: Bool { reminder != nil }
|
||||||
|
private var trimmedTitle: String {
|
||||||
|
title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
private var canSave: Bool {
|
||||||
|
guard !trimmedTitle.isEmpty else { return false }
|
||||||
|
if frequency == .weekly { return !weekdays.isEmpty }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField(String(appLoc: "做点什么?例:跑步5公里 / 吃2片护肝片"),
|
||||||
|
text: $title, axis: .vertical)
|
||||||
|
.lineLimit(1...3)
|
||||||
|
TextField(String(appLoc: "备注(可选)"), text: $note, axis: .vertical)
|
||||||
|
.lineLimit(1...3)
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker(String(appLoc: "重复"), selection: $frequency) {
|
||||||
|
Text(String(appLoc: "每日")).tag(CustomReminder.Frequency.daily)
|
||||||
|
Text(String(appLoc: "每周")).tag(CustomReminder.Frequency.weekly)
|
||||||
|
Text(String(appLoc: "每月")).tag(CustomReminder.Frequency.monthly)
|
||||||
|
Text(String(appLoc: "每年")).tag(CustomReminder.Frequency.yearly)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
|
||||||
|
frequencyDetail
|
||||||
|
} header: {
|
||||||
|
Text("重复")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
timePresetRow
|
||||||
|
DatePicker(String(appLoc: "时间"), selection: $pickedTime,
|
||||||
|
displayedComponents: .hourAndMinute)
|
||||||
|
} header: {
|
||||||
|
Text("时间")
|
||||||
|
}
|
||||||
|
|
||||||
|
if isEditing {
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) { deleteReminder() } label: {
|
||||||
|
Label(String(appLoc: "删除提醒"), systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationTitle(isEditing ? String(appLoc: "编辑提醒") : String(appLoc: "新建提醒"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button(String(appLoc: "取消")) { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(String(appLoc: "保存")) { save() }
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.disabled(!canSave)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear(perform: hydrate)
|
||||||
|
.onChange(of: month) { _, newMonth in
|
||||||
|
// 切月份后,把超出该月最大天数的「日」收回(避免「2月31日」这种永不触发的组合)。
|
||||||
|
let maxD = Self.daysInMonth(newMonth)
|
||||||
|
if dayOfMonth > maxD { dayOfMonth = maxD }
|
||||||
|
}
|
||||||
|
.alert(String(appLoc: "通知未开启"), isPresented: $showAuthDeniedAlert) {
|
||||||
|
Button(String(appLoc: "好")) { dismiss() }
|
||||||
|
} message: {
|
||||||
|
Text("提醒已保存,但系统通知权限未开启,到点不会弹出。请在「设置 · 通知 · 康康」中允许。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 频率子控件
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var frequencyDetail: some View {
|
||||||
|
switch frequency {
|
||||||
|
case .daily:
|
||||||
|
EmptyView()
|
||||||
|
case .weekly:
|
||||||
|
weekdayRow
|
||||||
|
case .monthly:
|
||||||
|
Picker(String(appLoc: "日期"), selection: $dayOfMonth) {
|
||||||
|
ForEach(1...31, id: \.self) { d in
|
||||||
|
Text(String(appLoc: "\(d)日")).tag(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dayOfMonth >= 29 { skipHint }
|
||||||
|
case .yearly:
|
||||||
|
Picker(String(appLoc: "月份"), selection: $month) {
|
||||||
|
ForEach(1...12, id: \.self) { mo in
|
||||||
|
Text(String(appLoc: "\(mo)月")).tag(mo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Picker(String(appLoc: "日期"), selection: $dayOfMonth) {
|
||||||
|
ForEach(1...Self.daysInMonth(month), id: \.self) { d in
|
||||||
|
Text(String(appLoc: "\(d)日")).tag(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if month == 2 && dayOfMonth == 29 { skipHint } // 仅闰年的 2/29
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var skipHint: some View {
|
||||||
|
Text(String(appLoc: "部分月份无此日,该月将跳过"))
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 某月最大天数(2 月取 29,允许设闰年 2/29)。
|
||||||
|
private static func daysInMonth(_ month: Int) -> Int {
|
||||||
|
switch month {
|
||||||
|
case 2: return 29
|
||||||
|
case 4, 6, 9, 11: return 30
|
||||||
|
default: return 31
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 时间快捷预设
|
||||||
|
|
||||||
|
private var timePresetRow: some View {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let curH = cal.component(.hour, from: pickedTime)
|
||||||
|
let curM = cal.component(.minute, from: pickedTime)
|
||||||
|
return HStack(spacing: 8) {
|
||||||
|
ForEach(Array(timePresets.enumerated()), id: \.offset) { _, preset in
|
||||||
|
let on = curH == preset.h && curM == preset.m
|
||||||
|
Button {
|
||||||
|
pickedTime = cal.date(bySettingHour: preset.h, minute: preset.m,
|
||||||
|
second: 0, of: pickedTime) ?? pickedTime
|
||||||
|
} label: {
|
||||||
|
Text(String(format: "%d:%02d", preset.h, preset.m))
|
||||||
|
.font(.system(size: 13, weight: on ? .semibold : .regular))
|
||||||
|
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 30)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 周几选择(与 RemindersListView 同款)
|
||||||
|
|
||||||
|
private var weekdayRow: some View {
|
||||||
|
let names = [
|
||||||
|
String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"),
|
||||||
|
String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"),
|
||||||
|
String(appLoc: "日"),
|
||||||
|
]
|
||||||
|
let values = [2, 3, 4, 5, 6, 7, 1]
|
||||||
|
return HStack(spacing: 6) {
|
||||||
|
ForEach(Array(values.enumerated()), id: \.offset) { idx, w in
|
||||||
|
let on = weekdays.contains(w)
|
||||||
|
Button {
|
||||||
|
if on { weekdays.remove(w) } else { weekdays.insert(w) }
|
||||||
|
} label: {
|
||||||
|
Text(names[idx])
|
||||||
|
.font(.system(size: 13, weight: on ? .semibold : .regular))
|
||||||
|
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 30)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 数据
|
||||||
|
|
||||||
|
private func hydrate() {
|
||||||
|
guard !hydrated else { return }
|
||||||
|
hydrated = true
|
||||||
|
if let r = reminder {
|
||||||
|
title = r.title
|
||||||
|
note = r.note
|
||||||
|
frequency = r.frequency
|
||||||
|
weekdays = Set(r.weekdays)
|
||||||
|
dayOfMonth = r.dayOfMonth
|
||||||
|
month = r.month
|
||||||
|
pickedTime = Calendar.current.date(
|
||||||
|
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
|
||||||
|
) ?? .now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
guard canSave else { return }
|
||||||
|
let cal = Calendar.current
|
||||||
|
let hour = cal.component(.hour, from: pickedTime)
|
||||||
|
let minute = cal.component(.minute, from: pickedTime)
|
||||||
|
let sortedDays = weekdays.sorted()
|
||||||
|
|
||||||
|
let target: CustomReminder
|
||||||
|
if let r = reminder {
|
||||||
|
r.title = trimmedTitle
|
||||||
|
r.note = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
r.hour = hour
|
||||||
|
r.minute = minute
|
||||||
|
r.weekdays = sortedDays
|
||||||
|
r.frequency = frequency
|
||||||
|
r.dayOfMonth = dayOfMonth
|
||||||
|
r.month = month
|
||||||
|
r.updatedAt = .now
|
||||||
|
target = r
|
||||||
|
} else {
|
||||||
|
let new = CustomReminder(
|
||||||
|
title: trimmedTitle,
|
||||||
|
note: note.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
hour: hour,
|
||||||
|
minute: minute,
|
||||||
|
weekdays: sortedDays,
|
||||||
|
frequency: frequency,
|
||||||
|
dayOfMonth: dayOfMonth,
|
||||||
|
month: month
|
||||||
|
)
|
||||||
|
ctx.insert(new)
|
||||||
|
target = new
|
||||||
|
}
|
||||||
|
try? ctx.save()
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
let state = await ReminderService.requestAuthorization()
|
||||||
|
await ReminderService.sync(target)
|
||||||
|
if state == .denied {
|
||||||
|
showAuthDeniedAlert = true
|
||||||
|
} else {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteReminder() {
|
||||||
|
guard let r = reminder else { return }
|
||||||
|
ReminderService.cancel(customId: r.id)
|
||||||
|
ctx.delete(r)
|
||||||
|
try? ctx.save()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("新建") {
|
||||||
|
CustomReminderEditSheet()
|
||||||
|
.modelContainer(for: [CustomReminder.self], inMemory: true)
|
||||||
|
}
|
||||||
79
康康/Features/Me/LanguageSettingsView.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 「我的 · 语言」选择页。选中即时生效(整个 App 重建为所选语言,无需重启)。
|
||||||
|
struct LanguageSettingsView: View {
|
||||||
|
@State private var lang = LanguageManager.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(AppLanguage.allCases) { option in
|
||||||
|
row(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("切换后整个 App 立即生效,无需重启。")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationTitle("语言")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ option: AppLanguage) -> some View {
|
||||||
|
let selected = lang.current == option
|
||||||
|
return Button {
|
||||||
|
// 切换会触发根视图 .id 重建 → 当前导航栈回到「我的」根,整树换语言。
|
||||||
|
lang.set(option)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(selected ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||||
|
icon(option, selected: selected)
|
||||||
|
}
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
|
Text(option.displayName)
|
||||||
|
.font(.system(size: 15, weight: selected ? .semibold : .regular))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if selected {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 每个语言的个性化图标:本族语代表字(中/A/あ/가),跟随系统用地球符号。
|
||||||
|
@ViewBuilder
|
||||||
|
private func icon(_ option: AppLanguage, selected: Bool) -> some View {
|
||||||
|
let fg = selected ? Tj.Palette.ink : Tj.Palette.text2
|
||||||
|
switch option.pickerIcon {
|
||||||
|
case .symbol(let name):
|
||||||
|
Image(systemName: name)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(fg)
|
||||||
|
case .glyph(let g):
|
||||||
|
Text(verbatim: g)
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundStyle(fg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack { LanguageSettingsView() }
|
||||||
|
}
|
||||||
257
康康/Features/Me/MeView.swift
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct MeView: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Query private var profiles: [UserProfile]
|
||||||
|
@Query private var customMetrics: [CustomMonitorMetric]
|
||||||
|
|
||||||
|
@State private var downloadService = ModelDownloadService.shared
|
||||||
|
@State private var appLock = AppLock.shared
|
||||||
|
@State private var lang = LanguageManager.shared
|
||||||
|
// key 必须与 AppLock.enabledKey 一致。
|
||||||
|
@AppStorage("faceIDLockEnabled") private var lockEnabled = false
|
||||||
|
|
||||||
|
private var profile: UserProfile? { profiles.first }
|
||||||
|
|
||||||
|
/// 真实读取 Bundle 版本号,与「关于」页保持一致。
|
||||||
|
private var appVersionText: String {
|
||||||
|
let short = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1"
|
||||||
|
return "v\(short)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("我的")
|
||||||
|
.font(.tjTitle())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
|
||||||
|
profileCard
|
||||||
|
customMetricsCard
|
||||||
|
modelManagementCard
|
||||||
|
languageCard
|
||||||
|
faceIDCard
|
||||||
|
NavigationLink {
|
||||||
|
AboutView()
|
||||||
|
} label: {
|
||||||
|
settingsCard(title: String(appLoc: "关于"),
|
||||||
|
detail: appVersionText,
|
||||||
|
icon: "info.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
// 标题改用内容文字渲染(与 主页/记录/趋势 一致),不走 .navigationTitle:
|
||||||
|
// 大标题导航栏在真机状态栏区域会露出系统白底,破坏全 App 的沙色背景。
|
||||||
|
.onAppear {
|
||||||
|
if profiles.isEmpty {
|
||||||
|
_ = UserProfileStore.loadOrCreate(in: ctx)
|
||||||
|
}
|
||||||
|
downloadService.refreshStates()
|
||||||
|
appLock.refreshAvailability()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cards
|
||||||
|
|
||||||
|
private var profileCard: some View {
|
||||||
|
NavigationLink {
|
||||||
|
ProfileEditView()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Tj.Palette.amber.opacity(0.25))
|
||||||
|
Image(systemName: "person.crop.circle.fill")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("个人资料")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(profileLine)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customMetricsCard: some View {
|
||||||
|
NavigationLink {
|
||||||
|
CustomMetricsListView()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(customMetrics.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft)
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(customMetrics.isEmpty ? Tj.Palette.text2 : Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("自定义指标")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(customMetricsLine)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customMetricsLine: String {
|
||||||
|
if customMetrics.isEmpty { return String(appLoc: "添加你自己的长期监测项") }
|
||||||
|
return String(appLoc: "\(customMetrics.count) 项")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var modelManagementCard: some View {
|
||||||
|
NavigationLink {
|
||||||
|
ModelManagementView()
|
||||||
|
} label: {
|
||||||
|
settingsCard(title: String(appLoc: "模型管理"), detail: modelDetail, icon: "cpu")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var modelDetail: String {
|
||||||
|
let states = downloadService.states
|
||||||
|
if ModelKind.allCases.allSatisfy({ states[$0]?.phase == .ready }) { return String(appLoc: "已就绪") }
|
||||||
|
if downloadService.isAnyDownloading { return String(appLoc: "下载中…") }
|
||||||
|
let readyCount = ModelKind.allCases.filter { states[$0]?.phase == .ready }.count
|
||||||
|
return readyCount == 0 ? String(appLoc: "未下载") : String(appLoc: "\(readyCount)/\(ModelKind.allCases.count) 就绪")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var languageCard: some View {
|
||||||
|
NavigationLink {
|
||||||
|
LanguageSettingsView()
|
||||||
|
} label: {
|
||||||
|
settingsCard(title: String(appLoc: "语言"),
|
||||||
|
detail: lang.current.displayName,
|
||||||
|
icon: "character.bubble")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Face ID 启动锁(可交互 Toggle 卡)
|
||||||
|
|
||||||
|
private var faceIDCard: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(lockEnabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||||
|
Image(systemName: "faceid")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(lockEnabled ? Tj.Palette.ink : Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Face ID 启动锁")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(faceIDLine)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Toggle("", isOn: faceIDBinding)
|
||||||
|
.labelsHidden()
|
||||||
|
.disabled(!appLock.biometryAvailable)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var faceIDLine: String {
|
||||||
|
if !appLock.biometryAvailable { return String(appLoc: "本设备未设置 Face ID 或密码") }
|
||||||
|
return lockEnabled ? String(appLoc: "已开启 · \(appLock.biometryLabel)") : String(appLoc: "关闭")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开 → 先认证一次,成功才置 enabled(失败则开关弹回);关闭 → 直接关。
|
||||||
|
private var faceIDBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { lockEnabled },
|
||||||
|
set: { newValue in
|
||||||
|
if newValue {
|
||||||
|
Task { await appLock.enableWithAuth() }
|
||||||
|
} else {
|
||||||
|
appLock.disable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func settingsCard(title: String, detail: String, icon: String) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Tj.Palette.sand2)
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
Text(detail)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var profileLine: String {
|
||||||
|
guard let p = profile, p.hasAnyBasics else {
|
||||||
|
return String(appLoc: "点这里完善你的资料")
|
||||||
|
}
|
||||||
|
return p.summaryLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
MeView()
|
||||||
|
.modelContainer(for: [
|
||||||
|
UserProfile.self, Indicator.self, Report.self, DiaryEntry.self,
|
||||||
|
Asset.self, ChatTurn.self, Symptom.self, MetricReminder.self,
|
||||||
|
CustomMonitorMetric.self,
|
||||||
|
], inMemory: true)
|
||||||
|
}
|
||||||
240
康康/Features/Me/ModelManagementView.swift
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Network
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// 「我的 · 模型管理」页:分模型卡片显示下载状态/进度,支持下载全部/暂停 + 旁路文件导入。
|
||||||
|
/// 只观察 ModelDownloadService 的状态,不直接碰 URLSession(§3.1)。
|
||||||
|
struct ModelManagementView: View {
|
||||||
|
@State private var service = ModelDownloadService.shared
|
||||||
|
@State private var isCellular = false
|
||||||
|
@State private var showCellularConfirm = false
|
||||||
|
@State private var showImporter = false
|
||||||
|
@State private var importError: String?
|
||||||
|
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
private let monitorQueue = DispatchQueue(label: "kk.netmonitor")
|
||||||
|
|
||||||
|
private var allReady: Bool {
|
||||||
|
ModelKind.allCases.allSatisfy { service.states[$0]?.phase == .ready }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
ForEach(ModelKind.allCases, id: \.self) { kind in
|
||||||
|
modelCard(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
actionButtons
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
if service.states[.llm]?.phase == .ready {
|
||||||
|
NavigationLink {
|
||||||
|
ModelSelfTestView()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "play.circle")
|
||||||
|
Text("运行推理自检")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(TjGhostButton())
|
||||||
|
}
|
||||||
|
|
||||||
|
if let importError {
|
||||||
|
Text(importError)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
footer
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationTitle("模型管理")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear {
|
||||||
|
service.refreshStates()
|
||||||
|
monitor.pathUpdateHandler = { path in
|
||||||
|
let cellular = path.status == .satisfied && path.usesInterfaceType(.cellular)
|
||||||
|
Task { @MainActor in isCellular = cellular }
|
||||||
|
}
|
||||||
|
monitor.start(queue: monitorQueue)
|
||||||
|
}
|
||||||
|
.onDisappear { monitor.cancel() }
|
||||||
|
.fileImporter(isPresented: $showImporter,
|
||||||
|
allowedContentTypes: [.folder]) { handleImport($0) }
|
||||||
|
.alert("使用蜂窝网络下载?", isPresented: $showCellularConfirm) {
|
||||||
|
Button("取消", role: .cancel) {}
|
||||||
|
Button("继续下载") { service.downloadAll() }
|
||||||
|
} message: {
|
||||||
|
Text("模型约 \(formatBytes(totalAllBytes)),建议在 Wi-Fi 下下载。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 模型卡片
|
||||||
|
|
||||||
|
private func modelCard(_ kind: ModelKind) -> some View {
|
||||||
|
let state = service.states[kind]
|
||||||
|
?? DownloadState(phase: .idle, receivedBytes: 0,
|
||||||
|
totalBytes: ModelManifest.totalBytes(for: kind), bytesPerSecond: 0)
|
||||||
|
return VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(kind.displayName)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(subtitle(kind))
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
statusBadge(state.phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.phase == .downloading {
|
||||||
|
ProgressView(value: min(max(state.fraction, 0), 1))
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
|
HStack {
|
||||||
|
Text("\(Int(state.fraction * 100))%")
|
||||||
|
Spacer()
|
||||||
|
Text(speedText(state))
|
||||||
|
}
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
Text(formatBytes(ModelManifest.totalBytes(for: kind)))
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
if case .failed(let message) = state.phase {
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.tjCard()
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
if case .failed = state.phase { service.download(kind) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusBadge(_ phase: DownloadPhase) -> some View {
|
||||||
|
switch phase {
|
||||||
|
case .idle: return TjBadge(text: String(appLoc: "待下载"), style: .neutral)
|
||||||
|
case .downloading: return TjBadge(text: String(appLoc: "下载中"), style: .amber)
|
||||||
|
case .verifying: return TjBadge(text: String(appLoc: "校验中"), style: .amber)
|
||||||
|
case .ready: return TjBadge(text: String(appLoc: "已就绪"), style: .leaf)
|
||||||
|
case .failed: return TjBadge(text: String(appLoc: "失败 · 重试"), style: .brick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 动作按钮
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var actionButtons: some View {
|
||||||
|
if service.isAnyDownloading {
|
||||||
|
Button {
|
||||||
|
for kind in ModelKind.allCases { service.cancel(kind) }
|
||||||
|
} label: {
|
||||||
|
Text("暂停下载").frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(TjGhostButton())
|
||||||
|
} else if allReady {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "checkmark.seal.fill")
|
||||||
|
Text("两个模型都已就绪")
|
||||||
|
}
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
if isCellular { showCellularConfirm = true } else { service.downloadAll() }
|
||||||
|
} label: {
|
||||||
|
Text("下载全部模型 · \(formatBytes(totalAllBytes))")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(TjPrimaryButton())
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
importError = nil
|
||||||
|
showImporter = true
|
||||||
|
} label: {
|
||||||
|
Text("从文件导入(离线)").frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(TjGhostButton())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
TjLockChip()
|
||||||
|
Text("100% 本地推理 · 模型仅需下载一次")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 旁路导入
|
||||||
|
|
||||||
|
private func handleImport(_ result: Result<URL, Error>) {
|
||||||
|
do {
|
||||||
|
let folder = try result.get()
|
||||||
|
let scoped = folder.startAccessingSecurityScopedResource()
|
||||||
|
defer { if scoped { folder.stopAccessingSecurityScopedResource() } }
|
||||||
|
|
||||||
|
let name = folder.lastPathComponent
|
||||||
|
guard let kind = ModelKind.allCases.first(where: { $0.rawValue == name }) else {
|
||||||
|
let names = ModelKind.allCases.map(\.rawValue).joined(separator: " 或 ")
|
||||||
|
importError = String(appLoc: "请选择名为 \(names) 的文件夹")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try service.importModel(kind, from: folder)
|
||||||
|
importError = nil
|
||||||
|
} catch {
|
||||||
|
importError = String(appLoc: "导入失败:\(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 辅助
|
||||||
|
|
||||||
|
private var totalAllBytes: Int {
|
||||||
|
ModelKind.allCases.reduce(0) { $0 + ModelManifest.totalBytes(for: $1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subtitle(_ kind: ModelKind) -> String {
|
||||||
|
switch kind {
|
||||||
|
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答")
|
||||||
|
case .vl: return String(appLoc: "拍照识别报告 → 结构化指标")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatBytes(_ bytes: Int) -> String {
|
||||||
|
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func speedText(_ state: DownloadState) -> String {
|
||||||
|
guard state.bytesPerSecond > 0 else { return "—" }
|
||||||
|
return formatBytes(Int(state.bytesPerSecond)) + "/s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
ModelManagementView()
|
||||||
|
}
|
||||||
|
}
|
||||||
119
康康/Features/Me/ModelSelfTestView.swift
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 模型推理自检:加载 LLM 跑一段固定 prompt,流式显示输出 + tok/s。
|
||||||
|
/// 模型就绪后从「我的 · 模型管理」进入,用于现场快速验证本地推理是否正常。
|
||||||
|
struct ModelSelfTestView: View {
|
||||||
|
@State private var output = ""
|
||||||
|
@State private var phase: Phase = .idle
|
||||||
|
@State private var rate: Double = 0
|
||||||
|
|
||||||
|
private enum Phase: Equatable {
|
||||||
|
case idle, loading, running, done, failed(String)
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .idle: return String(appLoc: "未开始")
|
||||||
|
case .loading: return String(appLoc: "加载模型…")
|
||||||
|
case .running: return String(appLoc: "推理中…")
|
||||||
|
case .done: return String(appLoc: "完成 ✓")
|
||||||
|
case .failed(let m): return String(appLoc: "失败:\(m)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let prompt = "用中文一句话介绍肝功能里 ALT 这个指标。"
|
||||||
|
|
||||||
|
private var isBusy: Bool { phase == .loading || phase == .running }
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
switch phase {
|
||||||
|
case .failed: return Tj.Palette.brick
|
||||||
|
case .done: return Tj.Palette.leaf
|
||||||
|
default: return Tj.Palette.text2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("测试 PROMPT")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.tracking(0.5)
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(prompt)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.tjCard()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(phase.label)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
if rate > 0 {
|
||||||
|
Text(String(format: "%.1f tok/s", rate))
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await run() }
|
||||||
|
} label: {
|
||||||
|
Text(isBusy ? "运行中…" : "运行推理自检").frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(TjPrimaryButton())
|
||||||
|
.disabled(isBusy)
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
Text(output.isEmpty ? "(暂无输出)" : output)
|
||||||
|
.font(.system(.footnote, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 280)
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationTitle("推理自检")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func run() async {
|
||||||
|
output = ""
|
||||||
|
rate = 0
|
||||||
|
phase = .loading
|
||||||
|
do {
|
||||||
|
try await AIRuntime.shared.prepare()
|
||||||
|
phase = .running
|
||||||
|
for try await chunk in await AIRuntime.shared.generate(prompt: prompt, maxTokens: 200) {
|
||||||
|
output += chunk.text
|
||||||
|
rate = chunk.decodeRate
|
||||||
|
}
|
||||||
|
phase = .done
|
||||||
|
} catch {
|
||||||
|
phase = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack { ModelSelfTestView() }
|
||||||
|
}
|
||||||
338
康康/Features/Me/RemindersListView.swift
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct RemindersListView: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Query(sort: \CustomReminder.updatedAt, order: .reverse)
|
||||||
|
private var customReminders: [CustomReminder]
|
||||||
|
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
|
||||||
|
private var reminders: [MetricReminder]
|
||||||
|
|
||||||
|
/// 以 sheet 形态呈现(从「新建」入口进入)时补一个「完成」按钮关闭;
|
||||||
|
/// push 形态有系统返回,默认 false。
|
||||||
|
var presentedAsSheet = false
|
||||||
|
|
||||||
|
@State private var editingId: String?
|
||||||
|
@State private var creatingNew = false
|
||||||
|
@State private var editingCustom: CustomReminder?
|
||||||
|
|
||||||
|
private var isEmpty: Bool { customReminders.isEmpty && reminders.isEmpty }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
header
|
||||||
|
createButton
|
||||||
|
|
||||||
|
if isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
ForEach(customReminders) { r in
|
||||||
|
CustomReminderRow(
|
||||||
|
reminder: r,
|
||||||
|
onTapEdit: { editingCustom = r },
|
||||||
|
onToggle: { Task { await syncCustom(r) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reminders.isEmpty {
|
||||||
|
sectionLabel(String(appLoc: "指标记录提醒"))
|
||||||
|
ForEach(reminders) { r in
|
||||||
|
ReminderRow(
|
||||||
|
reminder: r,
|
||||||
|
isEditing: editingId == r.metricId,
|
||||||
|
onTapEdit: { toggleEdit(r.metricId) },
|
||||||
|
onChange: { Task { await sync(r) } },
|
||||||
|
onDelete: { delete(r) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationTitle("提醒")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
if presentedAsSheet {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(String(appLoc: "完成")) { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $creatingNew) {
|
||||||
|
CustomReminderEditSheet()
|
||||||
|
}
|
||||||
|
.sheet(item: $editingCustom) { r in
|
||||||
|
CustomReminderEditSheet(reminder: r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
Text("新建提醒,或在记录指标时开启")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var createButton: some View {
|
||||||
|
Button { creatingNew = true } label: {
|
||||||
|
Label(String(appLoc: "新建提醒"), systemImage: "plus")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(TjPrimaryButton(height: 46, fontSize: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionLabel(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Spacer(minLength: 40)
|
||||||
|
TjPlaceholder(label: String(appLoc: "还没有提醒,点上方新建"))
|
||||||
|
.frame(width: 240, height: 140)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 自由提醒
|
||||||
|
|
||||||
|
private func syncCustom(_ r: CustomReminder) async {
|
||||||
|
r.updatedAt = .now
|
||||||
|
try? ctx.save()
|
||||||
|
await ReminderService.sync(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 指标提醒(沿用原逻辑)
|
||||||
|
|
||||||
|
private func toggleEdit(_ id: String) {
|
||||||
|
editingId = (editingId == id) ? nil : id
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sync(_ r: MetricReminder) async {
|
||||||
|
r.updatedAt = .now
|
||||||
|
try? ctx.save()
|
||||||
|
await ReminderService.sync(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func delete(_ r: MetricReminder) {
|
||||||
|
ReminderService.cancel(metricId: r.metricId)
|
||||||
|
ctx.delete(r)
|
||||||
|
try? ctx.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自由提醒行:点空白区进编辑 sheet;行上 Toggle 控开关。
|
||||||
|
private struct CustomReminderRow: View {
|
||||||
|
@Bindable var reminder: CustomReminder
|
||||||
|
let onTapEdit: () -> Void
|
||||||
|
let onToggle: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: onTapEdit) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||||
|
Image(systemName: "bell.fill")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(reminder.title)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Toggle("", isOn: $reminder.enabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
|
.onChange(of: reminder.enabled) { _, _ in onToggle() }
|
||||||
|
|
||||||
|
// 与指标提醒行的 28×28 展开按钮等宽,保证两类行的 Toggle 纵向对齐。
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReminderRow: View {
|
||||||
|
@Bindable var reminder: MetricReminder
|
||||||
|
let isEditing: Bool
|
||||||
|
let onTapEdit: () -> Void
|
||||||
|
let onChange: () -> Void
|
||||||
|
let onDelete: () -> Void
|
||||||
|
|
||||||
|
@State private var pickedTime: Date = .now
|
||||||
|
@State private var hydrated = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
headerRow
|
||||||
|
if isEditing {
|
||||||
|
editingPanel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headerRow: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||||
|
Image(systemName: "bell.fill")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(reminder.displayName)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Toggle("", isOn: $reminder.enabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
|
.onChange(of: reminder.enabled) { _, _ in onChange() }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onTapEdit()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: isEditing ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var editingPanel: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("时间").font(.system(size: 13)).foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer()
|
||||||
|
DatePicker("", selection: $pickedTime, displayedComponents: .hourAndMinute)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.labelsHidden()
|
||||||
|
.onChange(of: pickedTime) { _, new in
|
||||||
|
let cal = Calendar.current
|
||||||
|
reminder.hour = cal.component(.hour, from: new)
|
||||||
|
reminder.minute = cal.component(.minute, from: new)
|
||||||
|
onChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
weekdayRow
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
onDelete()
|
||||||
|
} label: {
|
||||||
|
Label("删除提醒", systemImage: "trash")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if !hydrated {
|
||||||
|
pickedTime = Calendar.current.date(
|
||||||
|
bySettingHour: reminder.hour, minute: reminder.minute, second: 0, of: .now
|
||||||
|
) ?? .now
|
||||||
|
hydrated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekdayRow: some View {
|
||||||
|
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]
|
||||||
|
return HStack(spacing: 6) {
|
||||||
|
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
|
||||||
|
Button {
|
||||||
|
var s = Set(reminder.weekdays)
|
||||||
|
if s.contains(w) { s.remove(w) } else { s.insert(w) }
|
||||||
|
reminder.weekdays = s.sorted()
|
||||||
|
onChange()
|
||||||
|
} label: {
|
||||||
|
Text(names[idx])
|
||||||
|
.font(.system(size: 13,
|
||||||
|
weight: reminder.weekdays.contains(w) ? .semibold : .regular))
|
||||||
|
.foregroundStyle(reminder.weekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 30)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.fill(reminder.weekdays.contains(w) ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line,
|
||||||
|
lineWidth: reminder.weekdays.contains(w) ? 0 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
RemindersListView()
|
||||||
|
}
|
||||||
|
.modelContainer(for: [MetricReminder.self, CustomReminder.self], inMemory: true)
|
||||||
|
}
|
||||||
146
康康/Features/Monitor/MonitorMetric.swift
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 长期监测指标预设目录。`IndicatorRecordSheet` 顶部 grid 由 `MonitorMetric.allCases` 渲染。
|
||||||
|
///
|
||||||
|
/// 录入时按 metric 展开 1 或 2 个 Field;血压拆 2 条 Indicator(同 capturedAt + 各自 seriesKey),
|
||||||
|
/// 其他预设产 1 条。`effectiveRange(for:profile:)` 用 Profile 调整参考范围(目前只 1 条规则:
|
||||||
|
/// 老人收缩压上限 140→150)。
|
||||||
|
enum MonitorMetric: String, CaseIterable, Identifiable {
|
||||||
|
case bloodPressure // bp.systolic + bp.diastolic
|
||||||
|
case fastingGlucose // glucose.fasting
|
||||||
|
case postprandialGlucose // glucose.postprandial
|
||||||
|
case temperature // temperature
|
||||||
|
case heartRate // heart_rate
|
||||||
|
case spo2 // spo2
|
||||||
|
|
||||||
|
// 注:身高 / 体重不在这里——它们是 UserProfile 的字段(单值,不存历史)。
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .bloodPressure: return String(appLoc: "血压")
|
||||||
|
case .fastingGlucose: return String(appLoc: "空腹血糖")
|
||||||
|
case .postprandialGlucose: return String(appLoc: "餐后血糖")
|
||||||
|
case .temperature: return String(appLoc: "体温")
|
||||||
|
case .heartRate: return String(appLoc: "心率")
|
||||||
|
case .spo2: return String(appLoc: "血氧")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SF Symbol。grid 卡片图标。
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .bloodPressure: return "heart.fill"
|
||||||
|
case .fastingGlucose: return "drop.fill"
|
||||||
|
case .postprandialGlucose: return "drop.circle.fill"
|
||||||
|
case .temperature: return "thermometer.medium"
|
||||||
|
case .heartRate: return "waveform.path.ecg"
|
||||||
|
case .spo2: return "lungs.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fields: [Field] {
|
||||||
|
switch self {
|
||||||
|
case .bloodPressure:
|
||||||
|
return [
|
||||||
|
Field(seriesKey: "bp.systolic",
|
||||||
|
label: String(appLoc: "收缩压"),
|
||||||
|
unit: "mmHg",
|
||||||
|
placeholder: "120",
|
||||||
|
baseRange: 90...140),
|
||||||
|
Field(seriesKey: "bp.diastolic",
|
||||||
|
label: String(appLoc: "舒张压"),
|
||||||
|
unit: "mmHg",
|
||||||
|
placeholder: "80",
|
||||||
|
baseRange: 60...90),
|
||||||
|
]
|
||||||
|
case .fastingGlucose:
|
||||||
|
return [Field(seriesKey: "glucose.fasting",
|
||||||
|
label: String(appLoc: "空腹血糖"),
|
||||||
|
unit: "mmol/L",
|
||||||
|
placeholder: "5.0",
|
||||||
|
baseRange: 3.9...6.1)]
|
||||||
|
case .postprandialGlucose:
|
||||||
|
return [Field(seriesKey: "glucose.postprandial",
|
||||||
|
label: String(appLoc: "餐后 2h"),
|
||||||
|
unit: "mmol/L",
|
||||||
|
placeholder: "6.5",
|
||||||
|
baseRange: 0...7.8)]
|
||||||
|
case .temperature:
|
||||||
|
return [Field(seriesKey: "temperature",
|
||||||
|
label: String(appLoc: "体温"),
|
||||||
|
unit: "°C",
|
||||||
|
placeholder: "36.5",
|
||||||
|
baseRange: 36.0...37.2)]
|
||||||
|
case .heartRate:
|
||||||
|
return [Field(seriesKey: "heart_rate",
|
||||||
|
label: String(appLoc: "心率"),
|
||||||
|
unit: "bpm",
|
||||||
|
placeholder: "72",
|
||||||
|
baseRange: 60...100)]
|
||||||
|
case .spo2:
|
||||||
|
return [Field(seriesKey: "spo2",
|
||||||
|
label: String(appLoc: "血氧"),
|
||||||
|
unit: "%",
|
||||||
|
placeholder: "98",
|
||||||
|
baseRange: 95...100)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MonitorMetric {
|
||||||
|
struct Field: Identifiable, Hashable {
|
||||||
|
let seriesKey: String
|
||||||
|
let label: String
|
||||||
|
let unit: String
|
||||||
|
let placeholder: String
|
||||||
|
let baseRange: ClosedRange<Double>?
|
||||||
|
|
||||||
|
var id: String { seriesKey }
|
||||||
|
|
||||||
|
/// 给 IndicatorRecordSheet 显示在数值旁的「90-140 mmHg」字样。
|
||||||
|
func rangeText(_ range: ClosedRange<Double>?) -> String {
|
||||||
|
guard let r = range else { return String(appLoc: "无参考范围") }
|
||||||
|
let lower = format(r.lowerBound)
|
||||||
|
let upper = format(r.upperBound)
|
||||||
|
// 餐后血糖 baseRange 是 0...7.8,显示成「<7.8」
|
||||||
|
if r.lowerBound == 0 { return "<\(upper) \(unit)" }
|
||||||
|
return "\(lower)–\(upper) \(unit)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func format(_ v: Double) -> String {
|
||||||
|
v.truncatingRemainder(dividingBy: 1) == 0
|
||||||
|
? String(format: "%.0f", v)
|
||||||
|
: String(format: "%.1f", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 给定 field 在 profile 下的有效参考范围。
|
||||||
|
/// 目前只 1 条规则:age ≥ 65 时 bp.systolic 上限 140 → 150。
|
||||||
|
/// profile 为 nil(未设资料)时返回 baseRange。
|
||||||
|
func effectiveRange(for field: Field, profile: UserProfile?) -> ClosedRange<Double>? {
|
||||||
|
if let age = profile?.age, age >= 65, field.seriesKey == "bp.systolic" {
|
||||||
|
return 90...150
|
||||||
|
}
|
||||||
|
return field.baseRange
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 算出 effectiveRange 后,判定 value 的 status。
|
||||||
|
/// value 高于上限 → high;低于下限 → low;在内 → normal;无范围 → normal。
|
||||||
|
static func status(value: Double, in range: ClosedRange<Double>?) -> IndicatorStatus {
|
||||||
|
guard let r = range else { return .normal }
|
||||||
|
if value > r.upperBound { return .high }
|
||||||
|
if value < r.lowerBound { return .low }
|
||||||
|
return .normal
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 给 IndicatorRecordSheet 「按你的年龄(67)调整」提示用:
|
||||||
|
/// 当 effectiveRange ≠ baseRange 时返回 true。
|
||||||
|
func isRangePersonalized(for field: Field, profile: UserProfile?) -> Bool {
|
||||||
|
guard let p = profile else { return false }
|
||||||
|
let base = field.baseRange
|
||||||
|
let eff = effectiveRange(for: field, profile: p)
|
||||||
|
return base != eff
|
||||||
|
}
|
||||||
|
}
|
||||||
389
康康/Features/Profile/ProfileEditView.swift
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 「我的 · 个人资料」编辑页。Form 风格,即改即存(无显式 Save 按钮)。
|
||||||
|
/// UserProfile 是 SwiftData 单例:进入时通过 UserProfileStore.loadOrCreate 拿到。
|
||||||
|
struct ProfileEditView: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Query private var profiles: [UserProfile]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let p = profiles.first {
|
||||||
|
ProfileEditForm(profile: p)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.onAppear { _ = UserProfileStore.loadOrCreate(in: ctx) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实际表单。
|
||||||
|
///
|
||||||
|
/// 性能要点(为什么拆成一堆小 Row 子视图):
|
||||||
|
/// SwiftData `@Model` 走 Observation,谁读了某个属性谁才会因它变化而失效。
|
||||||
|
/// 早期这页把所有字段塞进一个 `body`,任何一次按键(包括「添加过敏/用药」输入框,
|
||||||
|
/// 它们的 `@State` 当时也挂在父视图上)都会重算整个 `body`,顺带把年份选择器里
|
||||||
|
/// 126 个 `Text(year)` 全部重建一遍 → 输入卡顿。
|
||||||
|
///
|
||||||
|
/// 现在的写法:
|
||||||
|
/// - `ProfileEditForm.body` 不读任何 `profile.*`、不持有随打字变化的 `@State`,
|
||||||
|
/// 所以编辑过程中它整体不再重算,只是组合一批子视图。
|
||||||
|
/// - 每个 Row / Section 子视图只读自己那一个字段,Observation 把失效范围收到单行。
|
||||||
|
/// - 各「添加条目」输入框的 `@State` 下沉进各自的 Section 子视图,敲字只重算那一节。
|
||||||
|
/// - 年份用「点开展开 .wheel 滚轮」,折叠时不构建 126 项,展开时由原生
|
||||||
|
/// UIPickerView 虚拟化承载,秒开。
|
||||||
|
private struct ProfileEditForm: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Bindable var profile: UserProfile
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
BirthYearRow(profile: profile)
|
||||||
|
SexRow(profile: profile)
|
||||||
|
HeightRow(profile: profile)
|
||||||
|
WeightRow(profile: profile)
|
||||||
|
BloodTypeRow(profile: profile)
|
||||||
|
} header: {
|
||||||
|
Text("基本")
|
||||||
|
} footer: {
|
||||||
|
BMIFooter(profile: profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
ChronicSection(profile: profile)
|
||||||
|
|
||||||
|
StringListSection(title: String(appLoc: "过敏史"), placeholder: String(appLoc: "如:青霉素"),
|
||||||
|
items: $profile.allergies)
|
||||||
|
StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"),
|
||||||
|
items: $profile.familyHistory)
|
||||||
|
StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"),
|
||||||
|
items: $profile.currentMedications)
|
||||||
|
}
|
||||||
|
.navigationTitle("个人资料")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.onDisappear {
|
||||||
|
profile.updatedAt = .now
|
||||||
|
try? ctx.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 基本:逐行子视图(各自只读一个字段,失效互不牵连)
|
||||||
|
|
||||||
|
/// 出生年份:点击行展开 `.wheel` 滚轮,折叠时只是一行文字 —— 不构建 126 项列表。
|
||||||
|
private struct BirthYearRow: View {
|
||||||
|
@Bindable var profile: UserProfile
|
||||||
|
@State private var expanded = false
|
||||||
|
|
||||||
|
private var currentYear: Int {
|
||||||
|
Calendar.current.component(.year, from: .now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 年份倒序数组。本行仅在 birthYear / expanded 变化时重算,与其他字段编辑解耦;
|
||||||
|
/// 且 `years` 只在滚轮展开(body 实际读它)时才被遍历构建。
|
||||||
|
private var years: [Int] {
|
||||||
|
Array((1900...currentYear).reversed())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedLabel: String {
|
||||||
|
if let y = profile.birthYear {
|
||||||
|
let age = currentYear - y
|
||||||
|
return age >= 0 ? "\(y)(\(age)\(String(appLoc: "岁")))" : String(y)
|
||||||
|
}
|
||||||
|
return String(appLoc: "未设置")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var yearBinding: Binding<Int> {
|
||||||
|
Binding(
|
||||||
|
get: { profile.birthYear ?? 0 },
|
||||||
|
set: { profile.birthYear = $0 == 0 ? nil : $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("出生年份").foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
Text(selectedLabel)
|
||||||
|
.foregroundStyle(profile.birthYear == nil ? Tj.Palette.text3 : Tj.Palette.text2)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.rotationEffect(.degrees(expanded ? 90 : 0))
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if expanded {
|
||||||
|
Picker("出生年份", selection: yearBinding) {
|
||||||
|
Text("未设置").tag(0)
|
||||||
|
ForEach(years, id: \.self) { year in
|
||||||
|
Text(String(year)).tag(year)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.wheel)
|
||||||
|
.frame(maxHeight: 140)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SexRow: View {
|
||||||
|
@Bindable var profile: UserProfile
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Picker("性别", selection: Binding(
|
||||||
|
get: { profile.sex },
|
||||||
|
set: { profile.sex = $0 }
|
||||||
|
)) {
|
||||||
|
ForEach(UserProfile.Sex.allCases, id: \.self) { s in
|
||||||
|
Text(s.label).tag(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 身高:数值输入逻辑不变,只把整行变成可点聚焦区 —— 原先只有右侧 80pt 的输入框
|
||||||
|
/// 本体能点中,标签与中间空白点了不聚焦,所以显得「不灵敏」。
|
||||||
|
private struct HeightRow: View {
|
||||||
|
@Bindable var profile: UserProfile
|
||||||
|
@FocusState private var focused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text("身高")
|
||||||
|
Spacer()
|
||||||
|
TextField("cm", value: $profile.heightCM, format: .number)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(width: 80)
|
||||||
|
.focused($focused)
|
||||||
|
Text("cm").foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { focused = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WeightRow: View {
|
||||||
|
@Bindable var profile: UserProfile
|
||||||
|
@FocusState private var focused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text("体重")
|
||||||
|
Spacer()
|
||||||
|
TextField("kg", value: $profile.weightKG, format: .number.precision(.fractionLength(0...1)))
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(width: 80)
|
||||||
|
.focused($focused)
|
||||||
|
Text("kg").foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { focused = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BloodTypeRow: View {
|
||||||
|
@Bindable var profile: UserProfile
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Picker("血型", selection: $profile.bloodTypeRaw) {
|
||||||
|
Text("不知道").tag("")
|
||||||
|
Text("A 型").tag("A")
|
||||||
|
Text("B 型").tag("B")
|
||||||
|
Text("AB 型").tag("AB")
|
||||||
|
Text("O 型").tag("O")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BMI 页脚:只读 heightCM + weightKG,只有这两项变化时才重算。
|
||||||
|
private struct BMIFooter: View {
|
||||||
|
@Bindable var profile: UserProfile
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let bmi = profile.bmi {
|
||||||
|
Text("BMI: \(String(format: "%.1f", bmi)) \(label(bmi))")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func label(_ bmi: Double) -> String {
|
||||||
|
switch bmi {
|
||||||
|
case ..<18.5: return String(appLoc: "(偏瘦)")
|
||||||
|
case ..<24: return String(appLoc: "(正常)")
|
||||||
|
case ..<28: return String(appLoc: "(超重)")
|
||||||
|
default: return String(appLoc: "(肥胖)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 慢病
|
||||||
|
|
||||||
|
private struct ChronicSection: View {
|
||||||
|
@Bindable var profile: UserProfile
|
||||||
|
@State private var newCustomCondition = ""
|
||||||
|
|
||||||
|
/// 计算属性形式:每次按当前语言解析,语言切换即时更新(不可用 static/let 缓存)。
|
||||||
|
private var presets: [String] {
|
||||||
|
[String(appLoc: "高血压"), String(appLoc: "糖尿病"), String(appLoc: "冠心病"), String(appLoc: "高血脂"),
|
||||||
|
String(appLoc: "甲状腺疾病"), String(appLoc: "哮喘"), String(appLoc: "慢性肾病"), String(appLoc: "抑郁/焦虑")]
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
FlexibleChipGrid {
|
||||||
|
ForEach(presets, id: \.self) { name in
|
||||||
|
chip(label: name,
|
||||||
|
selected: profile.chronicConditions.contains(name)) {
|
||||||
|
toggle(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(profile.chronicConditions.filter { !presets.contains($0) },
|
||||||
|
id: \.self) { name in
|
||||||
|
chip(label: name, selected: true) {
|
||||||
|
profile.chronicConditions.removeAll { $0 == name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
TextField("自定义慢病", text: $newCustomCondition)
|
||||||
|
Button("加") {
|
||||||
|
let trimmed = newCustomCondition.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty,
|
||||||
|
!profile.chronicConditions.contains(trimmed) else { return }
|
||||||
|
profile.chronicConditions.append(trimmed)
|
||||||
|
newCustomCondition = ""
|
||||||
|
}
|
||||||
|
.disabled(newCustomCondition.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("慢病(影响参考范围与 AI 解读)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggle(_ name: String) {
|
||||||
|
if profile.chronicConditions.contains(name) {
|
||||||
|
profile.chronicConditions.removeAll { $0 == name }
|
||||||
|
} else {
|
||||||
|
profile.chronicConditions.append(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
||||||
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper))
|
||||||
|
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 过敏 / 家族史 / 用药(每节自带 @State,敲字只重算本节)
|
||||||
|
|
||||||
|
private struct StringListSection: View {
|
||||||
|
let title: String
|
||||||
|
let placeholder: String
|
||||||
|
@Binding var items: [String]
|
||||||
|
@State private var newInput = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section(title) {
|
||||||
|
ForEach(items, id: \.self) { item in
|
||||||
|
HStack {
|
||||||
|
Text(item)
|
||||||
|
Spacer()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
items.removeAll { $0 == item }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle")
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
TextField(placeholder, text: $newInput)
|
||||||
|
Button("加") {
|
||||||
|
let trimmed = newInput.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty, !items.contains(trimmed) else { return }
|
||||||
|
items.append(trimmed)
|
||||||
|
newInput = ""
|
||||||
|
}
|
||||||
|
.disabled(newInput.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 流式 chip 布局(SwiftUI 无原生 Wrap,用 Layout 协议自实现)
|
||||||
|
|
||||||
|
struct FlexibleChipGrid<Content: View>: View {
|
||||||
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
FlowLayout { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 6
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||||
|
let maxWidth = proposal.width ?? .infinity
|
||||||
|
var rows: [CGFloat] = [0]
|
||||||
|
var rowMaxHeight: [CGFloat] = [0]
|
||||||
|
var x: CGFloat = 0
|
||||||
|
for s in subviews {
|
||||||
|
let size = s.sizeThatFits(.unspecified)
|
||||||
|
if x + size.width > maxWidth, x > 0 {
|
||||||
|
rows.append(0); rowMaxHeight.append(0)
|
||||||
|
x = 0
|
||||||
|
}
|
||||||
|
rows[rows.count - 1] = max(rows[rows.count - 1], x + size.width)
|
||||||
|
rowMaxHeight[rowMaxHeight.count - 1] = max(rowMaxHeight.last ?? 0, size.height)
|
||||||
|
x += size.width + spacing
|
||||||
|
}
|
||||||
|
let totalHeight = rowMaxHeight.reduce(0, +) + spacing * CGFloat(max(0, rows.count - 1))
|
||||||
|
let totalWidth = rows.max() ?? 0
|
||||||
|
return CGSize(width: totalWidth, height: totalHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||||
|
var x: CGFloat = bounds.minX
|
||||||
|
var y: CGFloat = bounds.minY
|
||||||
|
var rowHeight: CGFloat = 0
|
||||||
|
for s in subviews {
|
||||||
|
let size = s.sizeThatFits(.unspecified)
|
||||||
|
if x + size.width > bounds.maxX, x > bounds.minX {
|
||||||
|
x = bounds.minX
|
||||||
|
y += rowHeight + spacing
|
||||||
|
rowHeight = 0
|
||||||
|
}
|
||||||
|
s.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||||
|
x += size.width + spacing
|
||||||
|
rowHeight = max(rowHeight, size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
ProfileEditView()
|
||||||
|
}
|
||||||
|
.modelContainer(for: [UserProfile.self], inMemory: true)
|
||||||
|
}
|
||||||
254
康康/Features/Quick/QuickRegionCaptureFlow.swift
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// 异常项快拍 · 统一流程。
|
||||||
|
/// 局部小框拍摄 → VL 识别(只抽 indicators)→ 确认 → 存成独立 Indicator(不建 Report、不留图)。
|
||||||
|
///
|
||||||
|
/// 状态机:
|
||||||
|
/// ```
|
||||||
|
/// idle(相机/相册) → analyzing(croppedImage) → confirm(items)
|
||||||
|
/// ↓ 失败/超时
|
||||||
|
/// confirm(空 + warning)
|
||||||
|
/// confirm → save → dismiss · confirm → 重拍 → idle
|
||||||
|
/// ```
|
||||||
|
struct QuickRegionCaptureFlow: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
@State private var phase: Phase = .idle
|
||||||
|
@State private var analyzeTask: Task<Void, Never>? = nil
|
||||||
|
|
||||||
|
/// VL 单次推理超时(防卡死);超时后 cancel 子任务,UI 转手动录入。
|
||||||
|
private let analyzeTimeoutSeconds: Int = 30
|
||||||
|
|
||||||
|
enum Phase {
|
||||||
|
case idle
|
||||||
|
case analyzing(image: UIImage)
|
||||||
|
case confirm(image: UIImage?, items: [QuickRegionItem], warning: String?)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
switch phase {
|
||||||
|
case .idle:
|
||||||
|
captureEntry
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
case .analyzing(let image):
|
||||||
|
NavigationStack {
|
||||||
|
AnalyzingRegionView(
|
||||||
|
image: image,
|
||||||
|
timeoutSeconds: analyzeTimeoutSeconds,
|
||||||
|
onCancel: {
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
analyzeTask = nil
|
||||||
|
// 取消识别 → 直接进确认页手动补充(图仍在内存,可重拍)
|
||||||
|
phase = .confirm(image: image, items: [],
|
||||||
|
warning: String(appLoc: "已取消识别,手动补充或重拍"))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.navigationTitle(String(appLoc: "本地识别中…"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("取消") { cancelAll() }
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .confirm(let image, let items, let warning):
|
||||||
|
NavigationStack {
|
||||||
|
QuickRegionConfirmView(
|
||||||
|
image: image,
|
||||||
|
items: items,
|
||||||
|
warning: warning,
|
||||||
|
onSave: { finalItems, capturedAt in save(items: finalItems, capturedAt: capturedAt) },
|
||||||
|
onCancel: cancelAll,
|
||||||
|
onRetake: { phase = .idle }
|
||||||
|
)
|
||||||
|
.navigationTitle(String(appLoc: "核对异常项"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("取消") { cancelAll() }
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 入口:相机(真机)/ 相册(模拟器)
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var captureEntry: some View {
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
PhotoPickerSheet(
|
||||||
|
onFinish: { imgs in if let first = imgs.first { startAnalyze(image: first) } },
|
||||||
|
onCancel: onClose
|
||||||
|
)
|
||||||
|
#else
|
||||||
|
RegionCameraView(
|
||||||
|
onCapture: { startAnalyze(image: $0) },
|
||||||
|
onCancel: onClose
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 识别
|
||||||
|
|
||||||
|
private func startAnalyze(image: UIImage) {
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
phase = .analyzing(image: image)
|
||||||
|
let timeout = analyzeTimeoutSeconds
|
||||||
|
// 本类型默认 MainActor 隔离,Task{} 继承之,故内部 phase 写入都在主线程,直接赋值即可。
|
||||||
|
analyzeTask = Task {
|
||||||
|
guard let data = image.jpegData(compressionQuality: 0.9) else {
|
||||||
|
phase = .confirm(image: image, items: [],
|
||||||
|
warning: String(appLoc: "图片编码失败,手动补充或重拍"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let watchdog = Task {
|
||||||
|
try? await Task.sleep(for: .seconds(timeout))
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
}
|
||||||
|
defer { watchdog.cancel() }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let parsed = try await CaptureService.shared.recognizeRegion(imageData: data)
|
||||||
|
if Task.isCancelled {
|
||||||
|
phase = .confirm(image: image, items: [],
|
||||||
|
warning: String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let items = Self.buildItems(from: parsed)
|
||||||
|
phase = .confirm(
|
||||||
|
image: image,
|
||||||
|
items: items,
|
||||||
|
warning: items.isEmpty ? String(appLoc: "没读出指标,手动补充或重拍") : nil
|
||||||
|
)
|
||||||
|
} catch CaptureError.modelNotReady {
|
||||||
|
phase = .confirm(image: image, items: [],
|
||||||
|
warning: String(appLoc: "VL 模型未就绪,手动补充"))
|
||||||
|
} catch let CaptureError.parseFailed(msg) {
|
||||||
|
phase = .confirm(image: image, items: [],
|
||||||
|
warning: String(appLoc: "VL 输出无法解析:\(msg)"))
|
||||||
|
} catch let CaptureError.inferenceFailed(msg) {
|
||||||
|
phase = .confirm(image: image, items: [],
|
||||||
|
warning: Task.isCancelled
|
||||||
|
? String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍")
|
||||||
|
: String(appLoc: "推理失败:\(msg)"))
|
||||||
|
} catch {
|
||||||
|
phase = .confirm(image: image, items: [],
|
||||||
|
warning: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VL 结果 → 可编辑行,异常项(high/low)置顶、默认勾选。
|
||||||
|
private static func buildItems(from parsed: [ParsedReport.ParsedIndicator]) -> [QuickRegionItem] {
|
||||||
|
let mapped = parsed.map {
|
||||||
|
QuickRegionItem(name: $0.name, value: $0.value, unit: $0.unit,
|
||||||
|
range: $0.range, status: $0.status, include: true)
|
||||||
|
}
|
||||||
|
// 异常优先(stable):high/low 在前,normal 在后
|
||||||
|
return mapped.enumerated().sorted { a, b in
|
||||||
|
let aAbn = a.element.status != .normal
|
||||||
|
let bAbn = b.element.status != .normal
|
||||||
|
if aAbn != bAbn { return aAbn && !bAbn }
|
||||||
|
return a.offset < b.offset
|
||||||
|
}.map { $0.element }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 取消 / 保存
|
||||||
|
|
||||||
|
private func cancelAll() {
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
analyzeTask = nil
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 勾选项各存一条独立 Indicator(与「记录指标」自由输入一致):无 Report、无 Asset、无 seriesKey。
|
||||||
|
private func save(items: [QuickRegionItem], capturedAt: Date) {
|
||||||
|
let selected = items.filter {
|
||||||
|
$0.include
|
||||||
|
&& !$0.name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& !$0.value.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
for item in selected {
|
||||||
|
let indicator = Indicator(
|
||||||
|
name: item.name.trimmingCharacters(in: .whitespaces),
|
||||||
|
value: item.value.trimmingCharacters(in: .whitespaces),
|
||||||
|
unit: item.unit.trimmingCharacters(in: .whitespaces),
|
||||||
|
range: item.range.trimmingCharacters(in: .whitespaces),
|
||||||
|
status: item.status,
|
||||||
|
capturedAt: capturedAt
|
||||||
|
)
|
||||||
|
ctx.insert(indicator)
|
||||||
|
}
|
||||||
|
try? ctx.save()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 识别中视图
|
||||||
|
|
||||||
|
private struct AnalyzingRegionView: View {
|
||||||
|
let image: 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 {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer()
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxHeight: 200)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.overlay(ProgressView().tint(Tj.Palette.ink).scaleEffect(1.3))
|
||||||
|
)
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text("识别框内指标")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("100% 本地推理 · 已用 \(elapsed)s")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
if elapsed >= timeoutSeconds - 5 {
|
||||||
|
Text("快超时了,>\(timeoutSeconds)s 会自动转手动录入")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("取消识别 · 改为手动录入", action: onCancel)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.padding(.top, 4)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Tj.Palette.sand)
|
||||||
|
.onReceive(tick) { _ in elapsed += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
305
康康/Features/Quick/QuickRegionConfirmView.swift
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 异常项快拍 · 确认页。VL 识别结果逐项可编辑 + 勾选纳入,确认后只存数值(不留图)。
|
||||||
|
/// 与「记录指标」自由输入落库一致 —— 每个勾选项 = 一条独立 Indicator。
|
||||||
|
struct QuickRegionConfirmView: View {
|
||||||
|
let image: UIImage?
|
||||||
|
let warning: String?
|
||||||
|
let onSave: ([QuickRegionItem], Date) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
let onRetake: () -> Void
|
||||||
|
|
||||||
|
@State private var items: [QuickRegionItem]
|
||||||
|
@State private var capturedAt: Date
|
||||||
|
|
||||||
|
init(image: UIImage?,
|
||||||
|
items: [QuickRegionItem],
|
||||||
|
warning: String?,
|
||||||
|
capturedAt: Date = .now,
|
||||||
|
onSave: @escaping ([QuickRegionItem], Date) -> Void,
|
||||||
|
onCancel: @escaping () -> Void,
|
||||||
|
onRetake: @escaping () -> Void) {
|
||||||
|
self.image = image
|
||||||
|
self.warning = warning
|
||||||
|
self.onSave = onSave
|
||||||
|
self.onCancel = onCancel
|
||||||
|
self.onRetake = onRetake
|
||||||
|
_items = State(initialValue: items)
|
||||||
|
_capturedAt = State(initialValue: capturedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedCount: Int {
|
||||||
|
items.filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& !$0.value.trimmingCharacters(in: .whitespaces).isEmpty }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
if let warning { warningBanner(warning) }
|
||||||
|
if let image { thumbnailCard(image) }
|
||||||
|
timeCard
|
||||||
|
itemsCard
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) { bottomBar }
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 区块
|
||||||
|
|
||||||
|
private func warningBanner(_ text: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.amber.opacity(0.12))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func thumbnailCard(_ image: UIImage) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("拍到的局部")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer()
|
||||||
|
Text("仅核对用 · 不保存照片")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(maxHeight: 180)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
Button {
|
||||||
|
onRetake()
|
||||||
|
} label: {
|
||||||
|
Label("重拍", systemImage: "camera.rotate")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("测量时间")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
DatePicker("", selection: $capturedAt, in: ...Date.now)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var itemsCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack {
|
||||||
|
Text("识别到的指标 (\(items.count))")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
items.append(QuickRegionItem(name: "", value: "", unit: "", range: "",
|
||||||
|
status: .high, include: true))
|
||||||
|
} label: {
|
||||||
|
Label("加一项", systemImage: "plus.circle.fill")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if items.isEmpty {
|
||||||
|
Text("没有识别到指标,点「加一项」手动补充,或返回重拍")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
} else {
|
||||||
|
ForEach($items) { $item in
|
||||||
|
itemRow($item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func itemRow(_ item: Binding<QuickRegionItem>) -> some View {
|
||||||
|
let abnormal = item.wrappedValue.status != .normal
|
||||||
|
return VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
item.wrappedValue.include.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: item.wrappedValue.include ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundStyle(item.wrappedValue.include ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
TextField(String(appLoc: "指标名"), text: item.name)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
|
||||||
|
if abnormal {
|
||||||
|
Text(statusLabel(item.wrappedValue.status))
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(statusColor(item.wrappedValue.status))
|
||||||
|
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||||
|
.background(Capsule().fill(statusColor(item.wrappedValue.status).opacity(0.16)))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if let idx = items.firstIndex(where: { $0.id == item.wrappedValue.id }) {
|
||||||
|
items.remove(at: idx)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
fieldCol(String(appLoc: "数值"), item.value, width: 80, mono: true)
|
||||||
|
fieldCol(String(appLoc: "单位"), item.unit, width: 80)
|
||||||
|
fieldCol(String(appLoc: "范围"), item.range)
|
||||||
|
}
|
||||||
|
statusPicker(item)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.opacity(item.wrappedValue.include ? 1 : 0.5)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(abnormal ? statusColor(item.wrappedValue.status).opacity(0.6) : Tj.Palette.line,
|
||||||
|
lineWidth: abnormal ? 1.5 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fieldCol(_ label: String, _ text: Binding<String>, width: CGFloat? = nil,
|
||||||
|
mono: Bool = false) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
TextField("", text: text)
|
||||||
|
.font(.system(size: 14, weight: mono ? .semibold : .regular,
|
||||||
|
design: mono ? .monospaced : .default))
|
||||||
|
.keyboardType(mono ? .decimalPad : .default)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.sand)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.frame(width: width)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: width == nil ? .infinity : nil, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusPicker(_ item: Binding<QuickRegionItem>) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(IndicatorStatus.allCases, id: \.self) { st in
|
||||||
|
let selected = item.wrappedValue.status == st
|
||||||
|
Button {
|
||||||
|
item.wrappedValue.status = st
|
||||||
|
} label: {
|
||||||
|
Text(statusLabel(st))
|
||||||
|
.font(.system(size: 12, weight: selected ? .semibold : .regular))
|
||||||
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text2)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Capsule().fill(selected ? statusColor(st) : Tj.Palette.paper))
|
||||||
|
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusLabel(_ s: IndicatorStatus) -> String {
|
||||||
|
switch s {
|
||||||
|
case .normal: return String(appLoc: "正常")
|
||||||
|
case .high: return String(appLoc: "偏高 ↑")
|
||||||
|
case .low: return String(appLoc: "偏低 ↓")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusColor(_ s: IndicatorStatus) -> Color {
|
||||||
|
switch s {
|
||||||
|
case .normal: return Tj.Palette.leaf
|
||||||
|
case .high: return Tj.Palette.brick
|
||||||
|
case .low: return Tj.Palette.amber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bottomBar: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Text("取消")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(TjGhostButton())
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onSave(items, capturedAt)
|
||||||
|
} label: {
|
||||||
|
Text(selectedCount > 0 ? "\(String(appLoc: "保存到记录"))(\(selectedCount))"
|
||||||
|
: String(appLoc: "保存到记录"))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(TjPrimaryButton())
|
||||||
|
.disabled(selectedCount == 0)
|
||||||
|
.opacity(selectedCount == 0 ? 0.4 : 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(
|
||||||
|
Tj.Palette.sand
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确认页可编辑行模型。`include` 控制是否落库(异常项默认勾选,正常项也默认勾选但可取消)。
|
||||||
|
struct QuickRegionItem: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var name: String
|
||||||
|
var value: String
|
||||||
|
var unit: String
|
||||||
|
var range: String
|
||||||
|
var status: IndicatorStatus
|
||||||
|
var include: Bool
|
||||||
|
}
|
||||||
372
康康/Features/Quick/RegionCameraView.swift
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// 异常项快拍 · 局部相机。
|
||||||
|
/// 实时预览 + 居中小框 + 快门 → **裁剪到小框区域**的 UIImage 回调。
|
||||||
|
/// 只在真机可用(模拟器无相机,QuickRegionCaptureFlow 退化到 PhotoPicker)。
|
||||||
|
///
|
||||||
|
/// 裁剪原理:先把拍到的照片 bake 成 `.up`(竖屏),再用纯几何 aspect-fill 反算把屏上小框
|
||||||
|
/// (view 点坐标)映射到照片像素 rect(见 `RegionImageCropper`)。
|
||||||
|
/// 不用 `metadataOutputRectConverted` —— 它返回传感器横向坐标,套到竖屏照片会轴对调裁出竖条。
|
||||||
|
struct RegionCameraView: View {
|
||||||
|
let onCapture: (UIImage) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
@StateObject private var controller = RegionCameraController()
|
||||||
|
@State private var authState: AuthState = .checking
|
||||||
|
@State private var isCapturing = false
|
||||||
|
@State private var flash = false
|
||||||
|
|
||||||
|
enum AuthState { case checking, authorized, denied }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
|
||||||
|
switch authState {
|
||||||
|
case .checking:
|
||||||
|
ProgressView().tint(.white)
|
||||||
|
case .denied:
|
||||||
|
deniedView
|
||||||
|
case .authorized:
|
||||||
|
cameraStack
|
||||||
|
}
|
||||||
|
|
||||||
|
if flash {
|
||||||
|
Color.white.ignoresSafeArea().transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await resolveAuth() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 相机 + 小框 + 控件
|
||||||
|
|
||||||
|
private var cameraStack: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let box = RegionFraming.box(in: proxy.size)
|
||||||
|
ZStack {
|
||||||
|
RegionCameraPreview(controller: controller)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// 框外压暗(even-odd 挖空),只突出小框内
|
||||||
|
Canvas { ctx, size in
|
||||||
|
var path = Path(CGRect(origin: .zero, size: size))
|
||||||
|
path.addPath(Path(roundedRect: box, cornerRadius: Tj.Radius.md))
|
||||||
|
ctx.fill(path, with: .color(.black.opacity(0.5)), style: FillStyle(eoFill: true))
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
|
||||||
|
// 小框边
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Color.white.opacity(0.95),
|
||||||
|
style: StrokeStyle(lineWidth: 2, dash: [8, 6]))
|
||||||
|
.frame(width: box.width, height: box.height)
|
||||||
|
.position(x: box.midX, y: box.midY)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
|
||||||
|
// 提示
|
||||||
|
Text("把异常项放进框里 · 对准一两行")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Capsule().fill(.black.opacity(0.4)))
|
||||||
|
.position(x: box.midX, y: box.minY - 22)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
|
||||||
|
controlsOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var controlsOverlay: some View {
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
onCancel()
|
||||||
|
} label: {
|
||||||
|
Text("取消")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Capsule().fill(.black.opacity(0.35)))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
shutterButton
|
||||||
|
.padding(.bottom, 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shutterButton: some View {
|
||||||
|
Button {
|
||||||
|
capture()
|
||||||
|
} label: {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(.white).frame(width: 72, height: 72)
|
||||||
|
Circle().strokeBorder(.white.opacity(0.6), lineWidth: 3).frame(width: 84, height: 84)
|
||||||
|
if isCapturing {
|
||||||
|
ProgressView().tint(.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isCapturing)
|
||||||
|
.accessibilityLabel("拍摄异常项")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deniedView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "camera.fill")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
Text("相机权限未开启")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 36)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("取消") { onCancel() }
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 18).padding(.vertical, 10)
|
||||||
|
.background(Capsule().strokeBorder(.white.opacity(0.5), lineWidth: 1))
|
||||||
|
Button("去设置") {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
.padding(.horizontal, 18).padding(.vertical, 10)
|
||||||
|
.background(Capsule().fill(.white))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 行为
|
||||||
|
|
||||||
|
private func capture() {
|
||||||
|
guard !isCapturing else { return }
|
||||||
|
isCapturing = true
|
||||||
|
withAnimation(.easeOut(duration: 0.08)) { flash = true }
|
||||||
|
controller.capture { image in
|
||||||
|
withAnimation(.easeIn(duration: 0.15)) { flash = false }
|
||||||
|
isCapturing = false
|
||||||
|
guard let image else { return }
|
||||||
|
onCapture(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveAuth() async {
|
||||||
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||||
|
case .authorized:
|
||||||
|
authState = .authorized
|
||||||
|
case .notDetermined:
|
||||||
|
let granted = await AVCaptureDevice.requestAccess(for: .video)
|
||||||
|
authState = granted ? .authorized : .denied
|
||||||
|
default:
|
||||||
|
authState = .denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 小框几何(UIView 与 SwiftUI 覆盖层共用,保证坐标一致)
|
||||||
|
|
||||||
|
enum RegionFraming {
|
||||||
|
/// 居中、略高于中心的小框。宽 84% 屏宽,高取 160 与 28% 屏高的较小值。
|
||||||
|
static func box(in size: CGSize) -> CGRect {
|
||||||
|
guard size.width > 0, size.height > 0 else { return .zero }
|
||||||
|
let w = size.width * 0.84
|
||||||
|
let h = min(160, size.height * 0.28)
|
||||||
|
let x = (size.width - w) / 2
|
||||||
|
let y = (size.height - h) / 2 - size.height * 0.06
|
||||||
|
return CGRect(x: x, y: y, width: w, height: h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 裁剪纯函数
|
||||||
|
|
||||||
|
enum RegionImageCropper {
|
||||||
|
/// 把屏上小框(view 点坐标)按 `.resizeAspectFill` 反算到 `.up` 照片的像素裁剪 rect。
|
||||||
|
/// 前提:预览以 aspect-fill 铺满 viewSize,照片与预览同源、同为竖屏方向。
|
||||||
|
/// 纯几何、方向自洽 —— 不用 `metadataOutputRectConverted`(它返回传感器**横向**坐标,
|
||||||
|
/// 套到竖屏照片会 x/y 轴对调,把宽框裁成竖窄条,见 RegionImageCropperTests)。越界自动夹紧。
|
||||||
|
static func cropRect(photoPixelSize p: CGSize, box: CGRect, in viewSize: CGSize) -> CGRect {
|
||||||
|
guard p.width > 0, p.height > 0, viewSize.width > 0, viewSize.height > 0 else { return .zero }
|
||||||
|
// aspect-fill:取较大缩放系数让照片铺满视图,溢出部分被裁。
|
||||||
|
let scale = max(viewSize.width / p.width, viewSize.height / p.height)
|
||||||
|
let scaledW = p.width * scale
|
||||||
|
let scaledH = p.height * scale
|
||||||
|
// 缩放后照片相对视图居中,溢出维度的原点为负。
|
||||||
|
let ox = (viewSize.width - scaledW) / 2
|
||||||
|
let oy = (viewSize.height - scaledH) / 2
|
||||||
|
// 视图点 → 照片像素:先减去居中偏移,再除以缩放系数。
|
||||||
|
var x = (box.minX - ox) / scale
|
||||||
|
var y = (box.minY - oy) / scale
|
||||||
|
var w = box.width / scale
|
||||||
|
var h = box.height / scale
|
||||||
|
// 夹紧到照片范围内。
|
||||||
|
x = max(0, min(p.width, x))
|
||||||
|
y = max(0, min(p.height, y))
|
||||||
|
w = max(0, min(p.width - x, w))
|
||||||
|
h = max(0, min(p.height - y, h))
|
||||||
|
return CGRect(x: x, y: y, width: w, height: h).integral
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按屏上小框裁 `.up` 照片(`box` / `viewSize` 同为 view 点坐标);失败回退原图。
|
||||||
|
static func crop(_ image: UIImage, box: CGRect, viewSize: CGSize) -> UIImage {
|
||||||
|
guard let cg = image.cgImage else { return image }
|
||||||
|
let rect = cropRect(photoPixelSize: CGSize(width: cg.width, height: cg.height),
|
||||||
|
box: box, in: viewSize)
|
||||||
|
guard rect.width >= 1, rect.height >= 1, let cropped = cg.cropping(to: rect) else { return image }
|
||||||
|
return UIImage(cgImage: cropped, scale: image.scale, orientation: .up)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIImage {
|
||||||
|
/// 把 EXIF 方向 bake 进像素,返回 `.up` 方向图,便于按归一化 rect 直接裁 CGImage。
|
||||||
|
func normalizedUp() -> UIImage {
|
||||||
|
if imageOrientation == .up { return self }
|
||||||
|
let format = UIGraphicsImageRendererFormat.default()
|
||||||
|
format.scale = scale
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size, format: format)
|
||||||
|
return renderer.image { _ in draw(in: CGRect(origin: .zero, size: size)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVFoundation 桥接
|
||||||
|
|
||||||
|
/// SwiftUI 持有,作为快门触发的句柄(weak 指向真正的 UIView)。
|
||||||
|
final class RegionCameraController: ObservableObject {
|
||||||
|
weak var view: RegionPreviewUIView?
|
||||||
|
func capture(_ completion: @escaping (UIImage?) -> Void) {
|
||||||
|
guard let view else { completion(nil); return }
|
||||||
|
view.capture(completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RegionCameraPreview: UIViewRepresentable {
|
||||||
|
let controller: RegionCameraController
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> RegionPreviewUIView {
|
||||||
|
let v = RegionPreviewUIView()
|
||||||
|
controller.view = v
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: RegionPreviewUIView, context: Context) {}
|
||||||
|
|
||||||
|
static func dismantleUIView(_ uiView: RegionPreviewUIView, coordinator: ()) {
|
||||||
|
uiView.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实时预览 + 单张拍摄,拍完按小框裁剪。
|
||||||
|
final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
|
||||||
|
private let session = AVCaptureSession()
|
||||||
|
private let output = AVCapturePhotoOutput()
|
||||||
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
private var setupDone = false
|
||||||
|
private var captureCompletion: ((UIImage?) -> Void)?
|
||||||
|
|
||||||
|
override func didMoveToWindow() {
|
||||||
|
super.didMoveToWindow()
|
||||||
|
guard !setupDone, window != nil else { return }
|
||||||
|
setupDone = true
|
||||||
|
configure()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configure() {
|
||||||
|
session.beginConfiguration()
|
||||||
|
session.sessionPreset = .photo
|
||||||
|
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
|
||||||
|
let input = try? AVCaptureDeviceInput(device: device),
|
||||||
|
session.canAddInput(input) else {
|
||||||
|
session.commitConfiguration()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.addInput(input)
|
||||||
|
if session.canAddOutput(output) { session.addOutput(output) }
|
||||||
|
session.commitConfiguration()
|
||||||
|
|
||||||
|
let preview = AVCaptureVideoPreviewLayer(session: session)
|
||||||
|
preview.videoGravity = .resizeAspectFill
|
||||||
|
preview.frame = bounds
|
||||||
|
layer.addSublayer(preview)
|
||||||
|
self.previewLayer = preview
|
||||||
|
applyPortrait(preview.connection)
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
|
self?.session.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 锁竖屏(iOS 17+ 用 videoRotationAngle,避免 videoOrientation 弃用告警)。
|
||||||
|
private func applyPortrait(_ connection: AVCaptureConnection?) {
|
||||||
|
guard let connection else { return }
|
||||||
|
if connection.isVideoRotationAngleSupported(90) {
|
||||||
|
connection.videoRotationAngle = 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
previewLayer?.frame = bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
func capture(completion: @escaping (UIImage?) -> Void) {
|
||||||
|
guard session.isRunning else { completion(nil); return }
|
||||||
|
captureCompletion = completion
|
||||||
|
applyPortrait(output.connection(with: .video))
|
||||||
|
output.capturePhoto(with: AVCapturePhotoSettings(), delegate: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard session.isRunning else { return }
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
|
self?.session.stopRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func photoOutput(_ output: AVCapturePhotoOutput,
|
||||||
|
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||||
|
error: Error?) {
|
||||||
|
let completion = captureCompletion
|
||||||
|
captureCompletion = nil
|
||||||
|
// 代理回调在 AVFoundation 私有队列,SwiftUI 状态更新必须切回主线程。
|
||||||
|
let deliver: (UIImage?) -> Void = { result in
|
||||||
|
DispatchQueue.main.async { completion?(result) }
|
||||||
|
}
|
||||||
|
guard error == nil,
|
||||||
|
let data = photo.fileDataRepresentation(),
|
||||||
|
let image = UIImage(data: data) else {
|
||||||
|
deliver(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let upright = image.normalizedUp()
|
||||||
|
guard previewLayer != nil else {
|
||||||
|
deliver(upright)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 裁剪走纯几何映射:预览以 .resizeAspectFill 铺满 bounds,照片与预览同源同为竖屏,
|
||||||
|
// 故屏上小框可按 aspect-fill 反算到照片像素 rect。读 bounds 几何回主线程更稳。
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let viewSize = self.bounds.size
|
||||||
|
let box = RegionFraming.box(in: viewSize)
|
||||||
|
let cropped = RegionImageCropper.crop(upright, box: box, viewSize: viewSize)
|
||||||
|
completion?(cropped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
康康/Features/Record/RecordSheet.swift
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum RecordKind: String, Identifiable, CaseIterable {
|
||||||
|
case quick, indicator, archive, diary, symptom, reminder
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
/// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。
|
||||||
|
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .quick, .archive]
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .quick: return String(appLoc: "异常项快拍")
|
||||||
|
case .indicator: return String(appLoc: "记录指标")
|
||||||
|
case .archive: return String(appLoc: "体检报告归档")
|
||||||
|
case .diary: return String(appLoc: "健康日记")
|
||||||
|
case .symptom: return String(appLoc: "记录症状")
|
||||||
|
case .reminder: return String(appLoc: "开启一个提醒")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var subtitle: String {
|
||||||
|
switch self {
|
||||||
|
case .quick: return String(appLoc: "拍一张化验单,VL 自动识别")
|
||||||
|
case .indicator: return String(appLoc: "手动填一项指标(免拍照)")
|
||||||
|
case .archive: return String(appLoc: "完整保存整份报告(可多页)")
|
||||||
|
case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助")
|
||||||
|
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
|
||||||
|
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .quick: return "camera.fill"
|
||||||
|
case .indicator: return "number.square.fill"
|
||||||
|
case .archive: return "doc.fill"
|
||||||
|
case .diary: return "heart.text.square"
|
||||||
|
case .symptom: return "waveform.path.ecg"
|
||||||
|
case .reminder: return "bell.badge"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var accent: Color {
|
||||||
|
switch self {
|
||||||
|
case .quick: return Tj.Palette.brick
|
||||||
|
case .indicator: return Tj.Palette.brick
|
||||||
|
case .archive: return Tj.Palette.ink
|
||||||
|
case .diary: return Tj.Palette.leaf
|
||||||
|
case .symptom: return Tj.Palette.amber
|
||||||
|
case .reminder: return Tj.Palette.leaf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecordSheet: View {
|
||||||
|
var onPick: (RecordKind) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Capsule()
|
||||||
|
.fill(Tj.Palette.line)
|
||||||
|
.frame(width: 40, height: 4)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("记录什么?")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
Text("本地处理 · 永不上传")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
|
// ScrollView 包裹:6 个入口在小屏固定 detent 下可能溢出,滚动确保都能触达。
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(RecordKind.displayOrder) { kind in
|
||||||
|
Button {
|
||||||
|
onPick(kind)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(kind.accent)
|
||||||
|
Image(systemName: kind.icon)
|
||||||
|
.font(.system(size: 18, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(kind.title)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(kind.subtitle)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 22)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.background(
|
||||||
|
Tj.Palette.sand
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
)
|
||||||
|
.presentationDetents([.fraction(0.8)])
|
||||||
|
.presentationDragIndicator(.hidden)
|
||||||
|
.presentationBackground(Tj.Palette.sand)
|
||||||
|
.presentationCornerRadius(Tj.Radius.xl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("RecordSheet · 直接渲染") {
|
||||||
|
RecordSheet { kind in print("picked: \(kind)") }
|
||||||
|
.frame(width: 390, height: 560)
|
||||||
|
.background(Tj.Palette.sand)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("RecordSheet · sheet 模式") {
|
||||||
|
PreviewContainer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PreviewContainer: View {
|
||||||
|
@State private var show = true
|
||||||
|
var body: some View {
|
||||||
|
Text("点这里再开一次")
|
||||||
|
.onTapGesture { show = true }
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.sheet(isPresented: $show) {
|
||||||
|
RecordSheet { kind in print("picked: \(kind)"); show = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
117
康康/Features/Symptom/OngoingSymptomsCard.swift
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
struct OngoingSymptomsCard: View {
|
||||||
|
@Query(filter: #Predicate<Symptom> { $0.endedAt == nil },
|
||||||
|
sort: \Symptom.startedAt, order: .reverse)
|
||||||
|
private var ongoing: [Symptom]
|
||||||
|
|
||||||
|
@State private var ending: Symptom?
|
||||||
|
@State private var tick: Date = .now
|
||||||
|
|
||||||
|
private let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if ongoing.isEmpty {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(Tj.Palette.brick)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
Text("持续中")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("\(ongoing.count) 个")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(ongoing) { sym in
|
||||||
|
row(sym)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(timer) { now in tick = now }
|
||||||
|
.sheet(item: $ending) { sym in
|
||||||
|
SymptomEndSheet(symptom: sym)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ sym: Symptom) -> some View {
|
||||||
|
let interval = max(0, tick.timeIntervalSince(sym.startedAt))
|
||||||
|
let isLong = interval >= 3 * 24 * 3600
|
||||||
|
|
||||||
|
return HStack(spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(sym.name)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
severityDot(sym.severity)
|
||||||
|
}
|
||||||
|
Text("已持续 \(formatDuration(interval))")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(isLong ? Tj.Palette.brick : Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Button {
|
||||||
|
ending = sym
|
||||||
|
} label: {
|
||||||
|
Text("结束")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(Tj.Palette.sand2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(severityColor(sym.severity))
|
||||||
|
.frame(width: 3)
|
||||||
|
.clipShape(
|
||||||
|
UnevenRoundedRectangle(
|
||||||
|
topLeadingRadius: Tj.Radius.sm,
|
||||||
|
bottomLeadingRadius: Tj.Radius.sm,
|
||||||
|
bottomTrailingRadius: 0,
|
||||||
|
topTrailingRadius: 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04),
|
||||||
|
radius: 2, x: 0, y: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func severityDot(_ value: Int) -> some View {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
ForEach(1...5, id: \.self) { i in
|
||||||
|
Circle()
|
||||||
|
.fill(i <= value ? severityColor(value) : Tj.Palette.line)
|
||||||
|
.frame(width: 5, height: 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func severityColor(_ value: Int) -> Color {
|
||||||
|
switch value {
|
||||||
|
case 1, 2: return Tj.Palette.leaf
|
||||||
|
case 3: return Tj.Palette.amber
|
||||||
|
default: return Tj.Palette.brick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
康康/Features/Symptom/SymptomEndSheet.swift
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct SymptomEndSheet: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let symptom: Symptom
|
||||||
|
|
||||||
|
@State private var endedAt: Date = .now
|
||||||
|
|
||||||
|
private var lowerBound: Date { symptom.startedAt }
|
||||||
|
|
||||||
|
private var durationLabel: String {
|
||||||
|
let interval = max(0, endedAt.timeIntervalSince(lowerBound))
|
||||||
|
return formatDuration(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Capsule()
|
||||||
|
.fill(Tj.Palette.line)
|
||||||
|
.frame(width: 40, height: 4)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("结束症状")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.tracking(0.3)
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(symptom.name)
|
||||||
|
.font(.tjTitle(24))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("开始于")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(symptom.startedAt.formatted(date: .abbreviated, time: .shortened))
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("结束时间")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.tracking(0.3)
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
DatePicker("", selection: $endedAt, in: lowerBound...Date.now)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("本次持续")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
Text(durationLabel)
|
||||||
|
.font(.system(size: 15, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("取消") { dismiss() }
|
||||||
|
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||||
|
Button("结束并保存") { submit() }
|
||||||
|
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
Tj.Palette.sand
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
)
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
.presentationDragIndicator(.hidden)
|
||||||
|
.presentationBackground(Tj.Palette.sand)
|
||||||
|
.presentationCornerRadius(Tj.Radius.xl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() {
|
||||||
|
symptom.endedAt = max(endedAt, symptom.startedAt)
|
||||||
|
try? ctx.save()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
232
康康/Features/Symptom/SymptomStartSheet.swift
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 计算属性形式:每次取值按当前语言解析,语言切换后即时更新(不可用 static/let 缓存)。
|
||||||
|
private func symptomPresets() -> [String] {
|
||||||
|
[String(appLoc: "头痛"), String(appLoc: "咳嗽"), String(appLoc: "腹痛"), String(appLoc: "发烧"),
|
||||||
|
String(appLoc: "恶心"), String(appLoc: "失眠"), String(appLoc: "疲劳"), String(appLoc: "关节痛")]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SymptomStartSheet: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var name: String = ""
|
||||||
|
@State private var customName: String = ""
|
||||||
|
@State private var startedAt: Date = .now
|
||||||
|
@State private var severity: Double = 3
|
||||||
|
@State private var note: String = ""
|
||||||
|
|
||||||
|
private var resolvedName: String {
|
||||||
|
let trimmed = customName.trimmingCharacters(in: .whitespaces)
|
||||||
|
return trimmed.isEmpty ? name : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSubmit: Bool { !resolvedName.isEmpty }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
handle
|
||||||
|
header
|
||||||
|
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
presetSection
|
||||||
|
customSection
|
||||||
|
timeSection
|
||||||
|
severitySection
|
||||||
|
noteSection
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
Tj.Palette.sand
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.hidden)
|
||||||
|
.presentationBackground(Tj.Palette.sand)
|
||||||
|
.presentationCornerRadius(Tj.Radius.xl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var handle: some View {
|
||||||
|
Capsule()
|
||||||
|
.fill(Tj.Palette.line)
|
||||||
|
.frame(width: 40, height: 4)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack {
|
||||||
|
Text("症状开始")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
Text("结束时再来点结束")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var presetSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel(String(appLoc: "常见症状"))
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(symptomPresets(), id: \.self) { item in
|
||||||
|
chip(item, selected: name == item) {
|
||||||
|
name = item
|
||||||
|
customName = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel(String(appLoc: "或者自己写"))
|
||||||
|
TextField("例如:眼皮跳", text: $customName)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
.onChange(of: customName) { _, newValue in
|
||||||
|
if !newValue.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
name = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel(String(appLoc: "开始时间"))
|
||||||
|
DatePicker("", selection: $startedAt, in: ...Date.now)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var severitySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
sectionLabel(String(appLoc: "强度"))
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(severity)) / 5")
|
||||||
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(severityColor)
|
||||||
|
}
|
||||||
|
Slider(value: $severity, in: 1...5, step: 1)
|
||||||
|
.tint(severityColor)
|
||||||
|
HStack {
|
||||||
|
Text("轻微").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
Text("剧烈").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noteSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel(String(appLoc: "备注(可选)"))
|
||||||
|
TextField("位置、可能诱因…", text: $note, axis: .vertical)
|
||||||
|
.lineLimit(2...4)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("取消") { dismiss() }
|
||||||
|
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||||
|
Button("开始记录") { submit() }
|
||||||
|
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||||
|
.disabled(!canSubmit)
|
||||||
|
.opacity(canSubmit ? 1 : 0.4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(
|
||||||
|
Tj.Palette.sand
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var severityColor: Color {
|
||||||
|
switch Int(severity) {
|
||||||
|
case 1, 2: return Tj.Palette.leaf
|
||||||
|
case 3: return Tj.Palette.amber
|
||||||
|
default: return Tj.Palette.brick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionLabel(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.tracking(0.3)
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
||||||
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() {
|
||||||
|
guard canSubmit else { return }
|
||||||
|
let symptom = Symptom(
|
||||||
|
name: resolvedName,
|
||||||
|
startedAt: startedAt,
|
||||||
|
note: note.trimmingCharacters(in: .whitespaces).isEmpty ? nil : note,
|
||||||
|
severity: Int(severity)
|
||||||
|
)
|
||||||
|
ctx.insert(symptom)
|
||||||
|
try? ctx.save()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SymptomStartSheet()
|
||||||
|
.modelContainer(for: Symptom.self, inMemory: true)
|
||||||
|
}
|
||||||
77
康康/Features/Timeline/DateSection.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
nonisolated enum DateSection: Hashable {
|
||||||
|
case today
|
||||||
|
case yesterday
|
||||||
|
case thisWeek
|
||||||
|
case thisMonth
|
||||||
|
case year(Int)
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .today: return String(appLoc: "今天")
|
||||||
|
case .yesterday: return String(appLoc: "昨天")
|
||||||
|
case .thisWeek: return String(appLoc: "本周")
|
||||||
|
case .thisMonth: return String(appLoc: "本月")
|
||||||
|
case .year(let y): return String(appLoc: "\(y) 年")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortIndex: Int {
|
||||||
|
switch self {
|
||||||
|
case .today: return 0
|
||||||
|
case .yesterday: return 1
|
||||||
|
case .thisWeek: return 2
|
||||||
|
case .thisMonth: return 3
|
||||||
|
case .year(let y): return 10_000 - y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TimelineGrouping {
|
||||||
|
static func section(for date: Date,
|
||||||
|
now: Date = .now,
|
||||||
|
calendar: Calendar = .current) -> DateSection {
|
||||||
|
if calendar.isDate(date, inSameDayAs: now) { return .today }
|
||||||
|
if let yesterday = calendar.date(byAdding: .day, value: -1, to: now),
|
||||||
|
calendar.isDate(date, inSameDayAs: yesterday) {
|
||||||
|
return .yesterday
|
||||||
|
}
|
||||||
|
if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) {
|
||||||
|
return .thisWeek
|
||||||
|
}
|
||||||
|
if calendar.isDate(date, equalTo: now, toGranularity: .month) {
|
||||||
|
return .thisMonth
|
||||||
|
}
|
||||||
|
let year = calendar.component(.year, from: date)
|
||||||
|
return .year(year)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func group(_ entries: [TimelineEntry],
|
||||||
|
now: Date = .now,
|
||||||
|
calendar: Calendar = .current)
|
||||||
|
-> [(section: DateSection, items: [TimelineEntry])] {
|
||||||
|
var buckets: [DateSection: [TimelineEntry]] = [:]
|
||||||
|
for entry in entries {
|
||||||
|
let key = section(for: entry.date, now: now, calendar: calendar)
|
||||||
|
buckets[key, default: []].append(entry)
|
||||||
|
}
|
||||||
|
return buckets
|
||||||
|
.map { ($0.key, $0.value.sorted { $0.date > $1.date }) }
|
||||||
|
.sorted { $0.0.sortIndex < $1.0.sortIndex }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(_ interval: TimeInterval) -> String {
|
||||||
|
let totalMinutes = Int(max(0, interval) / 60)
|
||||||
|
let days = totalMinutes / (60 * 24)
|
||||||
|
let hours = (totalMinutes % (60 * 24)) / 60
|
||||||
|
let minutes = totalMinutes % 60
|
||||||
|
|
||||||
|
if days > 0 && hours > 0 { return String(appLoc: "\(days) 天 \(hours) 小时") }
|
||||||
|
if days > 0 { return String(appLoc: "\(days) 天") }
|
||||||
|
if hours > 0 && minutes > 0 { return String(appLoc: "\(hours) 小时 \(minutes) 分") }
|
||||||
|
if hours > 0 { return String(appLoc: "\(hours) 小时") }
|
||||||
|
if minutes > 0 { return String(appLoc: "\(minutes) 分钟") }
|
||||||
|
return String(appLoc: "刚刚")
|
||||||
|
}
|
||||||
200
康康/Features/Timeline/TimelineEntry.swift
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum TimelineKind: String, CaseIterable, Identifiable {
|
||||||
|
case indicator, report, symptom, diary
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .indicator: return String(appLoc: "指标")
|
||||||
|
case .report: return String(appLoc: "报告")
|
||||||
|
case .symptom: return String(appLoc: "症状")
|
||||||
|
case .diary: return String(appLoc: "日记")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .indicator: return "drop.fill"
|
||||||
|
case .report: return "doc.fill"
|
||||||
|
case .symptom: return "waveform.path.ecg"
|
||||||
|
case .diary: return "pencil"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var accent: Color {
|
||||||
|
switch self {
|
||||||
|
case .indicator: return Tj.Palette.brick
|
||||||
|
case .report: return Tj.Palette.ink2
|
||||||
|
case .symptom: return Tj.Palette.amber
|
||||||
|
case .diary: return Tj.Palette.leaf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimelineEntry: Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let kind: TimelineKind
|
||||||
|
let date: Date
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let trailing: String?
|
||||||
|
let trailingIsAlert: Bool
|
||||||
|
let isOngoing: Bool
|
||||||
|
|
||||||
|
static func from(indicator i: Indicator) -> TimelineEntry {
|
||||||
|
TimelineEntry(
|
||||||
|
id: "indicator-\(i.persistentModelID)",
|
||||||
|
kind: .indicator,
|
||||||
|
date: i.capturedAt,
|
||||||
|
title: i.name,
|
||||||
|
subtitle: typeSubtitle(for: i),
|
||||||
|
trailing: indicatorValue(i),
|
||||||
|
trailingIsAlert: i.status != .normal,
|
||||||
|
isOngoing: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批处理 Indicator 列表,把 bp.systolic + bp.diastolic 同 capturedAt 合并成
|
||||||
|
/// 一条 "血压 120/80 mmHg" timeline entry。其他 series 逐条 from(indicator:)。
|
||||||
|
/// 合并条件:capturedAt 差 ≤ 5 秒(防止跨次混淆)。
|
||||||
|
static func from(indicators: [Indicator]) -> [TimelineEntry] {
|
||||||
|
var entries: [TimelineEntry] = []
|
||||||
|
var consumed = Set<PersistentIdentifier>()
|
||||||
|
|
||||||
|
// 先找 bp.systolic,配 bp.diastolic
|
||||||
|
for sys in indicators where sys.seriesKey == "bp.systolic" {
|
||||||
|
if consumed.contains(sys.persistentModelID) { continue }
|
||||||
|
guard let dia = indicators.first(where: {
|
||||||
|
$0.seriesKey == "bp.diastolic" &&
|
||||||
|
!consumed.contains($0.persistentModelID) &&
|
||||||
|
abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5
|
||||||
|
}) else { continue }
|
||||||
|
consumed.insert(sys.persistentModelID)
|
||||||
|
consumed.insert(dia.persistentModelID)
|
||||||
|
entries.append(mergedBP(systolic: sys, diastolic: dia))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 剩下的 indicator(含未配对的 systolic/diastolic、其他 series、自由输入)
|
||||||
|
for i in indicators where !consumed.contains(i.persistentModelID) {
|
||||||
|
entries.append(from(indicator: i))
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mergedBP(systolic sys: Indicator, diastolic dia: Indicator) -> TimelineEntry {
|
||||||
|
let abnormal = sys.status != .normal || dia.status != .normal
|
||||||
|
// 方向箭头按实际 status 给:两值同向才标 ↑/↓;一高一低只标红不给方向
|
||||||
|
// (旧实现异常一律 ↑,低血压 85/55 会错误显示 ↑)。
|
||||||
|
let arrow: String
|
||||||
|
switch (sys.status, dia.status) {
|
||||||
|
case (.high, .high), (.high, .normal), (.normal, .high): arrow = " ↑"
|
||||||
|
case (.low, .low), (.low, .normal), (.normal, .low): arrow = " ↓"
|
||||||
|
default: arrow = ""
|
||||||
|
}
|
||||||
|
return TimelineEntry(
|
||||||
|
id: "bp-\(sys.persistentModelID)-\(dia.persistentModelID)",
|
||||||
|
kind: .indicator,
|
||||||
|
date: sys.capturedAt,
|
||||||
|
title: String(appLoc: "血压"),
|
||||||
|
subtitle: String(appLoc: "长期监测"),
|
||||||
|
trailing: "\(sys.value)/\(dia.value) mmHg" + arrow,
|
||||||
|
trailingIsAlert: abnormal,
|
||||||
|
isOngoing: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(report r: Report) -> TimelineEntry {
|
||||||
|
let highCount = r.indicators.filter { $0.status == .high }.count
|
||||||
|
let lowCount = r.indicators.filter { $0.status == .low }.count
|
||||||
|
return TimelineEntry(
|
||||||
|
id: "report-\(r.persistentModelID)",
|
||||||
|
kind: .report,
|
||||||
|
date: r.reportDate,
|
||||||
|
title: r.title,
|
||||||
|
subtitle: "\(r.type.label) · " + String(appLoc: "共 \(r.pageCount) 页"),
|
||||||
|
trailing: abnormalSummary(high: highCount, low: lowCount),
|
||||||
|
trailingIsAlert: highCount + lowCount > 0,
|
||||||
|
isOngoing: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 异常计数 → trailing 文案。只高→「N 项偏高」、只低→「N 项偏低」、混合→「N 项异常」、无→nil。
|
||||||
|
/// 旧实现一律写「N 项偏高」,只含偏低指标的报告会显示与事实相反的结论(demo 翻车点)。
|
||||||
|
static func abnormalSummary(high: Int, low: Int) -> String? {
|
||||||
|
switch (high, low) {
|
||||||
|
case (0, 0): return nil
|
||||||
|
case (let h, 0): return String(appLoc: "\(h) 项偏高")
|
||||||
|
case (0, let l): return String(appLoc: "\(l) 项偏低")
|
||||||
|
case (let h, let l): return String(appLoc: "\(h + l) 项异常")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(diary d: DiaryEntry) -> TimelineEntry {
|
||||||
|
TimelineEntry(
|
||||||
|
id: "diary-\(d.persistentModelID)",
|
||||||
|
kind: .diary,
|
||||||
|
date: d.createdAt,
|
||||||
|
title: d.content.firstLine(),
|
||||||
|
subtitle: String(appLoc: "文字日记"),
|
||||||
|
trailing: nil,
|
||||||
|
trailingIsAlert: false,
|
||||||
|
isOngoing: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(symptom s: Symptom) -> TimelineEntry {
|
||||||
|
let ongoing = s.isOngoing
|
||||||
|
let date = s.endedAt ?? s.startedAt
|
||||||
|
let subtitle: String
|
||||||
|
let trailing: String?
|
||||||
|
if ongoing {
|
||||||
|
subtitle = String(appLoc: "症状 · 持续中")
|
||||||
|
trailing = String(appLoc: "持续 \(formatDuration(s.duration))")
|
||||||
|
} else {
|
||||||
|
subtitle = String(appLoc: "症状 · 已结束")
|
||||||
|
trailing = String(appLoc: "持续 \(formatDuration(s.duration))")
|
||||||
|
}
|
||||||
|
return TimelineEntry(
|
||||||
|
id: "symptom-\(s.persistentModelID)",
|
||||||
|
kind: .symptom,
|
||||||
|
date: date,
|
||||||
|
title: s.name,
|
||||||
|
subtitle: subtitle,
|
||||||
|
trailing: trailing,
|
||||||
|
trailingIsAlert: ongoing,
|
||||||
|
isOngoing: ongoing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func typeSubtitle(for i: Indicator) -> String {
|
||||||
|
if let report = i.report {
|
||||||
|
return String(appLoc: "指标 · \(report.title)")
|
||||||
|
}
|
||||||
|
return String(appLoc: "异常项快拍")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func indicatorValue(_ i: Indicator) -> String {
|
||||||
|
let unit = i.unit.isEmpty ? "" : " \(i.unit)"
|
||||||
|
let arrow: String
|
||||||
|
switch i.status {
|
||||||
|
case .high: arrow = " ↑"
|
||||||
|
case .low: arrow = " ↓"
|
||||||
|
case .normal: arrow = ""
|
||||||
|
}
|
||||||
|
return "\(i.value)\(unit)\(arrow)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
func firstLine() -> String {
|
||||||
|
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let line = trimmed.split(whereSeparator: \.isNewline).first {
|
||||||
|
let s = String(line)
|
||||||
|
return s.count > 40 ? String(s.prefix(40)) + "…" : s
|
||||||
|
}
|
||||||
|
return trimmed.isEmpty ? String(appLoc: "(空日记)") : trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
389
康康/Features/Timeline/TimelineEntryDetailView.swift
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 时间线条目反查到的源记录,驱动只读详情 sheet。
|
||||||
|
/// 注:报告详情这里是 W2 轻量只读版;W4 的 C2 `ReportDetailView`(三 Tab + 对比上次)另建,
|
||||||
|
/// 届时把时间线报告行改路由到 C2 即可,本类型不与之冲突。
|
||||||
|
enum TimelineDetail {
|
||||||
|
case indicator(Indicator)
|
||||||
|
case bloodPressure(sys: Indicator, dia: Indicator?)
|
||||||
|
case report(Report)
|
||||||
|
case diary(DiaryEntry)
|
||||||
|
case symptom(Symptom)
|
||||||
|
|
||||||
|
/// 把时间线条目反查回源记录(id 形如 `<kind>-<persistentModelID>` / `bp-<sysID>-<diaID>`)。
|
||||||
|
/// 主页「最近记录」与档案库 C1 共用同一套反查,避免逻辑重复。无法定位源记录时返回 nil。
|
||||||
|
static func resolve(for entry: TimelineEntry,
|
||||||
|
indicators: [Indicator],
|
||||||
|
reports: [Report],
|
||||||
|
diaries: [DiaryEntry],
|
||||||
|
symptoms: [Symptom]) -> TimelineDetail? {
|
||||||
|
switch entry.kind {
|
||||||
|
case .report:
|
||||||
|
return reports.first { "report-\($0.persistentModelID)" == entry.id }
|
||||||
|
.map(TimelineDetail.report)
|
||||||
|
case .diary:
|
||||||
|
return diaries.first { "diary-\($0.persistentModelID)" == entry.id }
|
||||||
|
.map(TimelineDetail.diary)
|
||||||
|
case .symptom:
|
||||||
|
return symptoms.first { "symptom-\($0.persistentModelID)" == entry.id }
|
||||||
|
.map(TimelineDetail.symptom)
|
||||||
|
case .indicator:
|
||||||
|
if let i = indicators.first(where: { "indicator-\($0.persistentModelID)" == entry.id }) {
|
||||||
|
return .indicator(i)
|
||||||
|
}
|
||||||
|
// 合并血压条目:bp-<sysID>-<diaID>
|
||||||
|
if entry.id.hasPrefix("bp-"),
|
||||||
|
let sys = indicators.first(where: { entry.id.hasPrefix("bp-\($0.persistentModelID)-") }) {
|
||||||
|
// 用 id 里编码的 diaID 精确反查,不再用 ±5s 时间窗近似匹配
|
||||||
|
//(后者在同日多次量血压时会把详情配到错误的舒张读数)。
|
||||||
|
let dia = indicators.first { entry.id.hasSuffix("-\($0.persistentModelID)") }
|
||||||
|
return .bloodPressure(sys: sys, dia: dia)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 时间线条目的只读详情:展示该记录的完整字段。各类型一屏看完,不可编辑。
|
||||||
|
struct TimelineEntryDetailView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
let detail: TimelineDetail
|
||||||
|
|
||||||
|
@State private var showDeleteConfirm = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
bodyContent
|
||||||
|
deleteButton
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationBackground(Tj.Palette.sand)
|
||||||
|
.presentationCornerRadius(Tj.Radius.xl)
|
||||||
|
.alert(String(appLoc: "永久删除这条记录?"), isPresented: $showDeleteConfirm) {
|
||||||
|
Button(String(appLoc: "删除"), role: .destructive) { performDelete() }
|
||||||
|
Button(String(appLoc: "取消"), role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text("删除后无法恢复。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 删除(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6)
|
||||||
|
|
||||||
|
private var deleteButton: some View {
|
||||||
|
Button(role: .destructive) { showDeleteConfirm = true } label: {
|
||||||
|
Label(String(appLoc: "永久删除"), systemImage: "trash")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.brick.opacity(0.8))
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.brick.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
// 纯描边按钮:补 contentShape 让整框可点(否则中间透明区点不到)。
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performDelete() {
|
||||||
|
switch detail {
|
||||||
|
case .indicator(let i):
|
||||||
|
deleteIndicator(i)
|
||||||
|
case .bloodPressure(let sys, let dia):
|
||||||
|
deleteIndicator(sys)
|
||||||
|
if let dia { deleteIndicator(dia) }
|
||||||
|
case .report(let r):
|
||||||
|
// cascade 只删 Asset/Indicator 记录,Vault 里的 JPEG 要手动 unlink。
|
||||||
|
var paths = Set(r.assets.map(\.relativePath))
|
||||||
|
paths.formUnion(r.indicators.compactMap { $0.asset?.relativePath })
|
||||||
|
for p in paths { try? FileVault.shared.remove(relativePath: p) }
|
||||||
|
ctx.delete(r)
|
||||||
|
case .diary(let d):
|
||||||
|
ctx.delete(d)
|
||||||
|
case .symptom(let s):
|
||||||
|
ctx.delete(s)
|
||||||
|
}
|
||||||
|
try? ctx.save()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删一条指标:先 unlink 其原图文件 + Asset 记录(关系默认 nullify,不会自动级联),再删指标本身。
|
||||||
|
private func deleteIndicator(_ i: Indicator) {
|
||||||
|
if let asset = i.asset {
|
||||||
|
try? FileVault.shared.remove(relativePath: asset.relativePath)
|
||||||
|
ctx.delete(asset)
|
||||||
|
}
|
||||||
|
ctx.delete(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(Circle().fill(Tj.Palette.sand2))
|
||||||
|
}
|
||||||
|
Text(titleText)
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
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 titleText: String {
|
||||||
|
switch detail {
|
||||||
|
case .indicator: return String(appLoc: "指标详情")
|
||||||
|
case .bloodPressure: return String(appLoc: "血压详情")
|
||||||
|
case .report: return String(appLoc: "报告详情")
|
||||||
|
case .diary: return String(appLoc: "日记详情")
|
||||||
|
case .symptom: return String(appLoc: "症状详情")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var bodyContent: some View {
|
||||||
|
switch detail {
|
||||||
|
case .indicator(let i): indicatorBody(i)
|
||||||
|
case .bloodPressure(let s, let d): bpBody(sys: s, dia: d)
|
||||||
|
case .report(let r): reportBody(r)
|
||||||
|
case .diary(let d): diaryBody(d)
|
||||||
|
case .symptom(let s): symptomBody(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 指标
|
||||||
|
|
||||||
|
private func indicatorBody(_ i: Indicator) -> some View {
|
||||||
|
card {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
statusChip(i.status)
|
||||||
|
}
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
Text(i.value)
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||||
|
if !i.unit.isEmpty {
|
||||||
|
Text(i.unit).font(.system(size: 14)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
divider
|
||||||
|
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
|
||||||
|
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
|
||||||
|
field(String(appLoc: "来源"), i.report?.title ?? String(appLoc: "异常项快拍"))
|
||||||
|
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 血压(合并条目)
|
||||||
|
|
||||||
|
private func bpBody(sys: Indicator, dia: Indicator?) -> some View {
|
||||||
|
let combined: IndicatorStatus = sys.status != .normal
|
||||||
|
? sys.status
|
||||||
|
: (dia?.status ?? .normal)
|
||||||
|
return card {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
statusChip(combined)
|
||||||
|
}
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
Text("\(sys.value)/\(dia?.value ?? "—")")
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||||
|
Text("mmHg").font(.system(size: 14)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
divider
|
||||||
|
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
|
||||||
|
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 报告
|
||||||
|
|
||||||
|
private func reportBody(_ r: Report) -> some View {
|
||||||
|
let sorted = r.indicators.sorted {
|
||||||
|
($0.status == .normal ? 1 : 0) < ($1.status == .normal ? 1 : 0)
|
||||||
|
}
|
||||||
|
return VStack(alignment: .leading, spacing: 16) {
|
||||||
|
card {
|
||||||
|
Text(r.title).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TjBadge(text: r.type.label, style: .neutral)
|
||||||
|
Text(Self.dateText(r.reportDate))
|
||||||
|
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
if !r.assets.isEmpty {
|
||||||
|
Text(String(appLoc: "原图\(r.assets.count)张"))
|
||||||
|
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let inst = r.institution, !inst.isEmpty {
|
||||||
|
field(String(appLoc: "机构"), inst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sum = r.summary, !sum.isEmpty {
|
||||||
|
card {
|
||||||
|
Text(String(appLoc: "摘要"))
|
||||||
|
.font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||||
|
Text(sum).font(.system(size: 14)).foregroundStyle(Tj.Palette.text)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !r.indicators.isEmpty {
|
||||||
|
card {
|
||||||
|
Text(String(appLoc: "指标"))
|
||||||
|
.font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||||
|
ForEach(sorted) { ind in
|
||||||
|
HStack {
|
||||||
|
Text(ind.name).font(.system(size: 14)).foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)")
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
.foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
|
||||||
|
statusChip(ind.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let note = r.note, !note.isEmpty {
|
||||||
|
card { field(String(appLoc: "备注"), note) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 日记
|
||||||
|
|
||||||
|
private func diaryBody(_ d: DiaryEntry) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
card {
|
||||||
|
Text(Self.dateTimeText(d.createdAt))
|
||||||
|
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(d.content)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
if !d.tags.isEmpty {
|
||||||
|
field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 症状
|
||||||
|
|
||||||
|
private func symptomBody(_ s: Symptom) -> some View {
|
||||||
|
card {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(s.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
if s.isOngoing {
|
||||||
|
Text(String(appLoc: "进行中"))
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||||
|
.background(Capsule().fill(Tj.Palette.brick.opacity(0.14)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
divider
|
||||||
|
field(String(appLoc: "程度"), "\(s.severity) / 5")
|
||||||
|
field(String(appLoc: "开始"), Self.dateTimeText(s.startedAt))
|
||||||
|
field(String(appLoc: "结束"), s.endedAt.map(Self.dateTimeText) ?? String(appLoc: "进行中"))
|
||||||
|
field(String(appLoc: "持续"), formatDuration(s.duration))
|
||||||
|
if let note = s.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||||
|
if !s.tags.isEmpty {
|
||||||
|
field(String(appLoc: "标签"), s.tags.map { "#\($0)" }.joined(separator: " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 复用件
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) { content() }
|
||||||
|
.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 field(_ label: String, _ value: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var divider: some View {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusChip(_ s: IndicatorStatus) -> some View {
|
||||||
|
let text: String
|
||||||
|
let color: Color
|
||||||
|
let arrow: String
|
||||||
|
switch s {
|
||||||
|
case .high: text = String(appLoc: "偏高"); color = Tj.Palette.brick; arrow = "↑"
|
||||||
|
case .low: text = String(appLoc: "偏低"); color = Tj.Palette.brick; arrow = "↓"
|
||||||
|
case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = ""
|
||||||
|
}
|
||||||
|
return HStack(spacing: 3) {
|
||||||
|
if !arrow.isEmpty { Text(arrow).font(.system(size: 11, weight: .bold)) }
|
||||||
|
Text(text).font(.system(size: 12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Capsule().fill(color.opacity(0.14)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func dateTimeText(_ d: Date) -> String {
|
||||||
|
d.formatted(.dateTime.year().month().day().hour().minute())
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func dateText(_ d: Date) -> String {
|
||||||
|
d.formatted(.dateTime.year().month().day())
|
||||||
|
}
|
||||||
|
}
|
||||||
67
康康/Features/Timeline/TimelineRow.swift
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TimelineRow: View {
|
||||||
|
let entry: TimelineEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.fill(entry.kind.accent.opacity(0.12))
|
||||||
|
Image(systemName: entry.kind.icon)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(entry.kind.accent)
|
||||||
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
if entry.isOngoing {
|
||||||
|
Circle()
|
||||||
|
.fill(Tj.Palette.brick)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
.overlay(Circle().strokeBorder(Tj.Palette.sand, lineWidth: 1.5))
|
||||||
|
.offset(x: 3, y: -3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("\(entry.date.timelineLabel) · \(entry.subtitle)")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.tracking(0.3)
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(entry.title)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
if let trailing = entry.trailing {
|
||||||
|
Text(trailing)
|
||||||
|
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(entry.trailingIsAlert ? Tj.Palette.brick : Tj.Palette.text2)
|
||||||
|
.lineLimit(1)
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.tjCard(bordered: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
var timelineLabel: String {
|
||||||
|
let cal = Calendar.current
|
||||||
|
if cal.isDateInToday(self) {
|
||||||
|
return self.formatted(date: .omitted, time: .shortened)
|
||||||
|
}
|
||||||
|
if cal.isDateInYesterday(self) {
|
||||||
|
return String(appLoc: "昨天") + " " + self.formatted(date: .omitted, time: .shortened)
|
||||||
|
}
|
||||||
|
let now = Date.now
|
||||||
|
if cal.isDate(self, equalTo: now, toGranularity: .year) {
|
||||||
|
return self.formatted(.dateTime.month().day())
|
||||||
|
}
|
||||||
|
return self.formatted(.dateTime.year().month().day())
|
||||||
|
}
|
||||||
|
}
|
||||||