diff --git a/.gitignore b/.gitignore index 0b1f374..00603ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ -/build/ +# 大模型素材:本地下载用于上传到 OpenList,不入库(~3GB) /Models/ +/build/ .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index 50043ad..05de976 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# 康记 / 体己 —— 工程前提 +# 康康 —— 工程前提 > 这是一个 6 周决赛 demo 项目。今天是 2026-05-25,处于 W1末/W2初。 > 任何 IDE/Claude 会话开始干活前,先读这份文件。 @@ -7,7 +7,7 @@ ## 1. 产品定位 -- **名字**:康记(对内代号 体己 / Tiji) +- **名字**:康康(对内代号 Kangkang) - **形态**:iOS 原生 App,SwiftUI + SwiftData - **核心卖点**:**100% 本地推理**的个人健康影像档案 + 大白话解读 + 本地 RAG 问答 - **目标用户**:不愿把体检/化验报告交给云端的普通人 @@ -23,8 +23,8 @@ | 持久化 | SwiftData | 见 §5 数据模型 | | 图表 | Swift Charts | iOS 16+ 原生 | | **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama | -| LLM | Qwen3-1.7B (MLX 4bit 量化) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 | -| VL | Qwen2.5-VL-3B (MLX 4bit 量化) | ~2.0GB,负责拍照→结构化指标 | +| LLM | Qwen3-1.7B 4bit (HF: `mlx-community/Qwen3-1.7B-4bit`) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 | +| VL | Qwen2.5-VL-3B-Instruct 4bit (HF: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`) | ~2.0GB,负责拍照→结构化指标 | | 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 | | Face ID | LocalAuthentication | | | Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 | @@ -84,7 +84,7 @@ VL prompt 必须: ## 4. 模型分发 - 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条 -- 总体积 ~3GB,WiFi 提示必须有 +- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有 - App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载" - `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用) @@ -92,37 +92,27 @@ VL prompt 必须: ## 5. 数据模型(SwiftData) -现有 3 个 `@Model`,要新增 2 个: +**当前 schema(2026-05-26)**:7 个 @Model。 ```swift -// 已有(在 Models/Models.swift) -@Model class Indicator { name, value, unit, range, statusRaw, note, capturedAt } -@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt } -@Model class DiaryEntry { content, createdAt } - -// 待加字段 -// 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 Indicator { + name, value, unit, range, statusRaw, note, capturedAt, + report: Report?, asset: Asset?, + pinned: Bool, // 长期监测自动 true,Trends 默认展示 + seriesKey: String? // "bp.systolic" / "glucose.fasting" / ... 长期指标分组 key } +@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt, + indicators: [Indicator] cascade, + assets: [Asset] cascade } +@Model class DiaryEntry { content, createdAt, tags: [String] } +@Model class Symptom { name, startedAt, endedAt?, note?, severity 1-5, tags, createdAt } +@Model class Asset { relativePath, mimeType, bytes, createdAt } +@Model class ChatTurn { question, answer, referencedIndicatorIDs, referencedReportIDs, createdAt, decodeRate } -@Model class ChatTurn { - var question: String - var answer: String - var referencedIndicatorIDs: [String] - var referencedReportIDs: [String] - var createdAt: Date - var decodeRate: Double // 该轮问答推理速度,Me 页性能展示 +@Model class UserProfile { // 全 App 单例(UserProfileStore.loadOrCreate) + birthYear?, biologicalSexRaw, heightCM?, bloodTypeRaw, + allergies, chronicConditions, familyHistory, currentMedications, + updatedAt } ``` @@ -149,18 +139,21 @@ VL prompt 必须: ## 7. 信息架构 ``` -TabBar: [首页] [+ 记录] [趋势] [我的] - │ │ │ │ - │ │ │ └─ 模型管理 / Face ID / 关于 - │ │ └─ 折线图 + AI 一句话解读 - │ └─ Modal: 选择 拍一张 / 写日记 / 问问看 - └─ 问候 + 今日摘要 + 时间线 + 影像档案入口 +TabBar: [主页] [记录] [+ 新建] [趋势] [我的] + │ │ │ │ │ + │ │ │ │ └─ 个人资料 / 模型管理 / Face ID / 关于 + │ │ │ └─ 折线图 + AI 一句话解读 + │ │ └─ Sheet: 拍一张 / 指标记录 / 报告归档 / 写日记 / 症状 + │ └─ ArchiveListView(时间线 + 分类 chip + 年/月分组) + └─ 问候 + 今日摘要 + 进行中症状 + 最近时间线 ``` -- **3 Tab 不变**,中间 + 号是 Sheet +- TabBar **5 槽**:左 2 个内容 Tab + 中间 + 号 + 右 2 个 Tab +- "+ 新建" 是 sheet 不是 Tab - AI 问答以 Modal Sheet 形式出现,**不占 Tab** -- "问问看"入口除了在 RecordSheet 里,首页摘要卡片下方也有一个常驻入口 -- 历史时间线在首页下半部分,不单独开 Tab +- 「指标记录」sheet 顶部 LazyVGrid 是 8 个 MonitorMetric 长期监测预设(进趋势), + 下方 horizontal scroll 是化验项快捷预设(不进趋势),不选预设走自由输入 +- 「我的 · 个人资料」是 NavigationLink push 的 Form 编辑页 ### 7.1 档案库 C1 / C2 导航(看的一半) @@ -205,8 +198,8 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算 ## 8. 现有代码状态(2026-05-25) ``` -体己/ -├── App/TijiApp.swift ✅ SwiftData container 已建 +康康/ +├── App/KangkangApp.swift ✅ SwiftData container 已建 ├── RootView.swift ✅ 3 Tab + RecordSheet 已建 ├── Models/Models.swift ✅ Indicator / Report / DiaryEntry,缺 Asset / ChatTurn ├── DesignSystem/ ✅ Tokens + Components,沿用 @@ -219,9 +212,10 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算 └── 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 -├── Persistence/FileVault.swift ❌ 原图加密目录管理 +├── Persistence/FileVault.swift ✅ 原图加密目录管理 ├── Security/AppLock.swift ❌ Face ID 启动锁 ├── Features/Ask/ ❌ AskSheet (RAG 问答 UI) ├── Features/Archive/ @@ -255,7 +249,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算 3. **UI 不直接调 AIRuntime**——必须经过 Service 4. **AIRuntime 必须 actor 化**——禁止 class + lock 5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏 -6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**例外**:报告对比(16.1)已加回,见 §7.2 +6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2) 7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来 8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架 @@ -265,8 +259,8 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算 | 周次 | 必交付 | |---|---| -| W1 末 / **W2 当前** | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 | -| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果) | +| W1 末 / W2 当前 | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 | +| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) | | W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault | | W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) | | W4-W5 | 趋势(Swift Charts + AI 解读)、**C2 ReportDetailView**(三 Tab + 重新解读) | diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/design/AppIcon-source.svg b/docs/design/AppIcon-source.svg new file mode 100644 index 0000000..deb0b1e --- /dev/null +++ b/docs/design/AppIcon-source.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + 康康 + diff --git a/docs/legal/privacy-policy.md b/docs/legal/privacy-policy.md new file mode 100644 index 0000000..36c6739 --- /dev/null +++ b/docs/legal/privacy-policy.md @@ -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 或公开页面中展示。 diff --git a/docs/release/app-store-metadata.md b/docs/release/app-store-metadata.md new file mode 100644 index 0000000..37d2137 --- /dev/null +++ b/docs/release/app-store-metadata.md @@ -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. diff --git a/docs/superpowers/notes/2026-05-25-mlx-api-corrections.md b/docs/superpowers/notes/2026-05-25-mlx-api-corrections.md new file mode 100644 index 0000000..fc88d3e --- /dev/null +++ b/docs/superpowers/notes/2026-05-25-mlx-api-corrections.md @@ -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`(非 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 { + 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` 修正。 diff --git a/docs/superpowers/plans/2026-05-25-w2-ai-foundation.md b/docs/superpowers/plans/2026-05-25-w2-ai-foundation.md index ab154f9..f8bb0fa 100644 --- a/docs/superpowers/plans/2026-05-25-w2-ai-foundation.md +++ b/docs/superpowers/plans/2026-05-25-w2-ai-foundation.md @@ -18,23 +18,23 @@ | 路径 | 职责 | |---|---| -| `体己/AI/AIRuntime.swift` | actor 单例,推理串行化,暴露 prepare / generate / lastDecodeRate | -| `体己/AI/ModelStore.swift` | 模型路径管理 + bundle 旁路 | -| `体己/AI/LLMSession.swift` | Qwen3-1.7B 加载 + 流式生成 | -| `体己/AI/TokenChunk.swift` | 流式数据结构 | -| `体己/Persistence/FileVault.swift` | `Application Support/Vault/` 加密目录读写 | -| `体己/Debug/DebugAIRunner.swift` | DEBUG-only 测试入口,挂在 MeView 末尾 | -| `体己Tests/FileVaultTests.swift` | FileVault 单元测试 | -| `体己Tests/ModelStoreTests.swift` | ModelStore 单元测试 | +| `康康/AI/AIRuntime.swift` | actor 单例,推理串行化,暴露 prepare / generate / lastDecodeRate | +| `康康/AI/ModelStore.swift` | 模型路径管理 + bundle 旁路 | +| `康康/AI/LLMSession.swift` | Qwen3-1.7B 加载 + 流式生成 | +| `康康/AI/TokenChunk.swift` | 流式数据结构 | +| `康康/Persistence/FileVault.swift` | `Application Support/Vault/` 加密目录读写 | +| `康康/Debug/DebugAIRunner.swift` | DEBUG-only 测试入口,挂在 MeView 末尾 | +| `康康Tests/FileVaultTests.swift` | FileVault 单元测试 | +| `康康Tests/ModelStoreTests.swift` | ModelStore 单元测试 | ### 修改 | 路径 | 改什么 | |---|---| -| `体己/Models/Models.swift` | 加 Asset / ChatTurn,Indicator 加 report/asset/pinned,Report 加 indicators/assets 关系,DiaryEntry 加 tags | -| `体己/App/TijiApp.swift` | Schema 加入两个新 @Model | -| `体己/Features/Me/MeView.swift` | DEBUG 块挂 DebugAIRunner | -| `体己.xcodeproj` | SPM 加入 mlx-swift 与 mlx-swift-examples | +| `康康/Models/Models.swift` | 加 Asset / ChatTurn,Indicator 加 report/asset/pinned,Report 加 indicators/assets 关系,DiaryEntry 加 tags | +| `康康/App/KangkangApp.swift` | Schema 加入两个新 @Model | +| `康康/Features/Me/MeView.swift` | DEBUG 块挂 DebugAIRunner | +| `康康.xcodeproj` | SPM 加入 mlx-swift 与 mlx-swift-examples | ### 不动(W2 不碰) @@ -45,15 +45,15 @@ ## Task 1:Xcode 项目加入 MLX Swift SPM 依赖 **Files:** -- Modify: `体己.xcodeproj/project.pbxproj`(通过 Xcode UI 修改,不要手编) +- Modify: `康康.xcodeproj/project.pbxproj`(通过 Xcode UI 修改,不要手编) -- [ ] **Step 1:打开 Xcode 项目** +- [x] **Step 1:打开 Xcode 项目** ```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: @@ -61,14 +61,14 @@ open /Users/xuhuayong/apps/体己/体己.xcodeproj https://github.com/ml-explore/mlx-swift ``` -选 "Up to Next Major" → 添加,勾选这些 product 加到 **体己** target: +选 "Up to Next Major" → 添加,勾选这些 product 加到 **康康** target: - `MLX` - `MLXFast` - `MLXNN` - `MLXOptimizers` - `MLXRandom` -- [ ] **Step 3:加入 mlx-swift-examples(含 LLM 工具)** +- [x] **Step 3:加入 mlx-swift-examples(含 LLM 工具)** 继续 Add Package Dependencies,URL: @@ -76,25 +76,25 @@ https://github.com/ml-explore/mlx-swift 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。 Expected:Build Succeeded,无依赖错误。 -- [ ] **Step 6:提交** +- [x] **Step 6:提交** ```bash -cd /Users/xuhuayong/apps/体己 -git add 体己.xcodeproj +cd /Users/xuhuayong/apps/康康 +git add 康康.xcodeproj 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 **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 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 let schema = Schema([ @@ -282,7 +282,7 @@ let schema = Schema([ ]) ``` -- [ ] **Step 3:删模拟器沙盒(破坏性迁移)** +- [x] **Step 3:删模拟器沙盒(破坏性迁移)** 在 Mac 上: @@ -293,16 +293,16 @@ xcrun simctl erase all (也可以在 Simulator → Device → Erase All Content and Settings) -- [ ] **Step 4:Build & Run 验证** +- [x] **Step 4:Build & Run 验证** Xcode ⌘R 运行到模拟器,App 启动不崩 = Schema OK。 Expected:App 启动到 RootView,无 fatalError。 -- [ ] **Step 5:提交** +- [x] **Step 5:提交** ```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" ``` @@ -311,17 +311,17 @@ git commit -m "feat(models): add Asset/ChatTurn, indicator-report relationship, ## Task 3:FileVault —— 加密目录读写(TDD) **Files:** -- Create: `体己/Persistence/FileVault.swift` -- Test: `体己Tests/FileVaultTests.swift` +- Create: `康康/Persistence/FileVault.swift` +- Test: `康康Tests/FileVaultTests.swift` -- [ ] **Step 1:写失败的测试** +- [x] **Step 1:写失败的测试** -创建 `体己Tests/FileVaultTests.swift`: +创建 `康康Tests/FileVaultTests.swift`: ```swift import Testing import UIKit -@testable import 体己 +@testable import 康康 @MainActor struct FileVaultTests { @@ -369,15 +369,15 @@ struct FileVaultTests { } ``` -- [ ] **Step 2:运行测试,确认 fail** +- [x] **Step 2:运行测试,确认 fail** Xcode ⌘U 跑测试(在模拟器上跑)。 Expected:`FileVaultTests` 编译错误 "Cannot find 'FileVault' in scope"。 -- [ ] **Step 3:写最小 FileVault 实现** +- [x] **Step 3:写最小 FileVault 实现** -创建 `体己/Persistence/FileVault.swift`: +创建 `康康/Persistence/FileVault.swift`: ```swift 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。 Expected:`writeAndReadJPEGRoundtrip` / `removeMakesFileGone` / `wipeRemovesAllFiles` 全绿。 -- [ ] **Step 6:提交** +- [x] **Step 6:提交** ```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" ``` @@ -478,17 +478,17 @@ git commit -m "feat(persistence): add FileVault with complete file protection" ## Task 4:ModelStore —— 模型路径与 bundle 旁路(TDD) **Files:** -- Create: `体己/AI/ModelStore.swift` -- Test: `体己Tests/ModelStoreTests.swift` +- Create: `康康/AI/ModelStore.swift` +- Test: `康康Tests/ModelStoreTests.swift` -- [ ] **Step 1:写失败的测试** +- [x] **Step 1:写失败的测试** -创建 `体己Tests/ModelStoreTests.swift`: +创建 `康康Tests/ModelStoreTests.swift`: ```swift import Testing import Foundation -@testable import 体己 +@testable import 康康 @MainActor struct ModelStoreTests { @@ -531,13 +531,13 @@ struct ModelStoreTests { } ``` -- [ ] **Step 2:运行测试,确认 fail** +- [x] **Step 2:运行测试,确认 fail** ⌘U → expect `Cannot find 'ModelStore'`. -- [ ] **Step 3:写 ModelStore 实现** +- [x] **Step 3:写 ModelStore 实现** -创建 `体己/AI/ModelStore.swift`: +创建 `康康/AI/ModelStore.swift`: ```swift import Foundation @@ -619,21 +619,21 @@ final class ModelStore { } ``` -- [ ] **Step 4:Xcode 中把文件加入 target** +- [x] **Step 4:Xcode 中把文件加入 target** -右键 `体己/` → New Group "AI" → 拖入 ModelStore.swift,勾 "体己" target。 -ModelStoreTests.swift 拖入 体己Tests target。 +右键 `康康/` → New Group "AI" → 拖入 ModelStore.swift,勾 "康康" target。 +ModelStoreTests.swift 拖入 康康Tests target。 -- [ ] **Step 5:跑测试,全绿** +- [x] **Step 5:跑测试,全绿** ⌘U。 Expected:3 个测试全 pass。 -- [ ] **Step 6:提交** +- [x] **Step 6:提交** ```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" ``` @@ -642,12 +642,12 @@ git commit -m "feat(ai): add ModelStore with path management and bundle seed" ## Task 5:TokenChunk + AIRuntime actor 骨架 **Files:** -- Create: `体己/AI/TokenChunk.swift` -- Create: `体己/AI/AIRuntime.swift` +- Create: `康康/AI/TokenChunk.swift` +- Create: `康康/AI/AIRuntime.swift` 本任务**不接 MLX**,只搭骨架。Task 6 才接真模型。 -- [ ] **Step 1:创建 TokenChunk.swift** +- [x] **Step 1:创建 TokenChunk.swift** ```swift import Foundation @@ -658,7 +658,7 @@ struct TokenChunk: Sendable { } ``` -- [ ] **Step 2:创建 AIRuntime.swift 骨架** +- [x] **Step 2:创建 AIRuntime.swift 骨架** ```swift import Foundation @@ -754,19 +754,19 @@ actor AIRuntime { } ``` -- [ ] **Step 3:确认 Build 失败原因合理** +- [x] **Step 3:确认 Build 失败原因合理** ⌘B → expect "Cannot find 'LLMSession' in scope"(Task 6 才会建)。 这是预期。我们要让 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 还是失败,正常) -- [ ] **Step 5:暂不提交** +- [x] **Step 5:暂不提交** 等 Task 6 完成、Build 通过后一起提交。 @@ -775,7 +775,7 @@ actor AIRuntime { ## Task 6:LLMSession —— 接 MLX 跑 Qwen3-1.7B **Files:** -- Create: `体己/AI/LLMSession.swift` +- Create: `康康/AI/LLMSession.swift` **预先准备(开发者手动一次)**: @@ -785,7 +785,7 @@ actor AIRuntime { 具体路径在 App 启动时打印,见 Step 5。 -- [ ] **Step 1:在终端下载模型(脚本一次性)** +- [x] **Step 1:在终端下载模型(脚本一次性)** ```bash 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` 等。 -- [ ] **Step 2:写 LLMSession 实现** +- [x] **Step 2:写 LLMSession 实现** -创建 `体己/AI/LLMSession.swift`: +创建 `康康/AI/LLMSession.swift`: ```swift import Foundation @@ -866,11 +866,11 @@ actor LLMSession { > **注**:`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。 -- [ ] **Step 4:Build,期望成功** +- [x] **Step 4:Build,期望成功** ⌘B。 @@ -878,9 +878,9 @@ Expected:Build Succeeded。 若 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 .onAppear { @@ -901,7 +901,7 @@ Expected:Build Succeeded。 📁 App Support: /Users/.../data/Containers/Data/Application//Library/Application Support ``` -- [ ] **Step 6:把模型拷到沙盒** +- [x] **Step 6:把模型拷到沙盒** ```bash APP_SUPPORT="<上面控制台打印的路径>" @@ -909,10 +909,10 @@ mkdir -p "$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 -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" ``` @@ -921,12 +921,12 @@ git commit -m "feat(ai): add AIRuntime actor and LLMSession with MLX Qwen3-1.7B" ## Task 7:DebugAIRunner —— DEBUG 测试入口 **Files:** -- Create: `体己/Debug/DebugAIRunner.swift` -- Modify: `体己/Features/Me/MeView.swift` +- Create: `康康/Debug/DebugAIRunner.swift` +- Modify: `康康/Features/Me/MeView.swift` -- [ ] **Step 1:创建 DebugAIRunner** +- [x] **Step 1:创建 DebugAIRunner** -`体己/Debug/DebugAIRunner.swift`: +`康康/Debug/DebugAIRunner.swift`: ```swift #if DEBUG @@ -998,9 +998,9 @@ struct DebugAIRunner: View { #endif ``` -- [ ] **Step 2:在 MeView 末尾挂上(仅 DEBUG)** +- [x] **Step 2:在 MeView 末尾挂上(仅 DEBUG)** -打开 `体己/Features/Me/MeView.swift`,把现有内容整体替换为: +打开 `康康/Features/Me/MeView.swift`,把现有内容整体替换为: ```swift import SwiftUI @@ -1025,18 +1025,18 @@ struct MeView: View { #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。 -- [ ] **Step 5:提交** +- [x] **Step 5:提交** ```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)" ``` @@ -1092,15 +1092,15 @@ git commit --allow-empty -m "milestone: W2 LLM 自检通过 (simulator)" ## Task 9:加一组 schema 重建烟测(防回归) **Files:** -- Create: `体己Tests/ModelsSchemaTests.swift` +- Create: `康康Tests/ModelsSchemaTests.swift` -- [ ] **Step 1:写 schema 烟测** +- [x] **Step 1:写 schema 烟测** ```swift import Testing import SwiftData import Foundation -@testable import 体己 +@testable import 康康 @MainActor struct ModelsSchemaTests { @@ -1179,7 +1179,7 @@ struct ModelsSchemaTests { } ``` -- [ ] **Step 2:加入 体己Tests target,跑测试** +- [x] **Step 2:加入 康康Tests target,跑测试** ⌘U。 @@ -1187,10 +1187,10 @@ Expected:3 个测试全 pass。 若 cascade 删除测试失败 → 检查 `Indicator.report` 反向关系是否声明正确(参考 Task 2)。 -- [ ] **Step 3:提交** +- [x] **Step 3:提交** ```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" ``` diff --git a/docs/superpowers/retros/2026-05-31-w2.md b/docs/superpowers/retros/2026-05-31-w2.md new file mode 100644 index 0000000..be47558 --- /dev/null +++ b/docs/superpowers/retros/2026-05-31-w2.md @@ -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 diff --git a/docs/superpowers/specs/2026-05-25-kangji-features-design.md b/docs/superpowers/specs/2026-05-25-kangji-features-design.md index c2764d6..76c90a7 100644 --- a/docs/superpowers/specs/2026-05-25-kangji-features-design.md +++ b/docs/superpowers/specs/2026-05-25-kangji-features-design.md @@ -1,4 +1,4 @@ -# 康记 / 体己 —— 功能设计 Spec(v1.0) +# 康康 —— 功能设计 Spec(v1.0) **日期**:2026-05-25 **状态**:Draft, 已与产品方对齐 §1-§6 @@ -8,7 +8,7 @@ ## 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 大核心模块** @@ -77,7 +77,7 @@ Persistence ### 2.1 `AIRuntime` 接口 ``` -体己/AI/ +康康/AI/ ├── AIRuntime.swift // actor 单例,推理串行化 ├── ModelStore.swift // 模型路径管理 + 下载 + bundle 旁路 ├── 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 | | 存储 | `Application Support/Models/`,`URLSession.downloadTask` + 断点续传 | | 首启动 | 启动屏 → 隐私承诺 → "下载模型"页(进度 + WiFi 提示) → 主界面 | @@ -376,7 +376,7 @@ User → UI(B2Scan) → CaptureService → AIRuntime → Persistence ### 4.3 服务层文件 ``` -体己/AI/ [7.5d] +康康/AI/ [7.5d] ├── AIRuntime.swift 2d ├── ModelStore.swift 1d ├── LLMSession.swift 1d @@ -387,17 +387,17 @@ User → UI(B2Scan) → CaptureService → AIRuntime → Persistence ├── KeywordExtraction.swift └── TrendNarrative.swift -体己/Services/ [4.5d] +康康/Services/ [4.5d] ├── CaptureService.swift 1.5d ├── AskService.swift 1.5d ├── TrendService.swift 1d └── ReportCompareService.swift 0.5d -体己/Persistence/ [1d] +康康/Persistence/ [1d] ├── FileVault.swift 0.5d └── PermanentDelete.swift 0.5d -体己/Security/ [0.5d] +康康/Security/ [0.5d] └── AppLock.swift 0.5d ``` diff --git a/docs/superpowers/specs/2026-05-26-hide-monitor-preset-design.md b/docs/superpowers/specs/2026-05-26-hide-monitor-preset-design.md new file mode 100644 index 0000000..af80324 --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-hide-monitor-preset-design.md @@ -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 的小改良 diff --git a/docs/superpowers/specs/2026-05-26-monitor-and-profile-design.md b/docs/superpowers/specs/2026-05-26-monitor-and-profile-design.md new file mode 100644 index 0000000..9ff4721 --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-monitor-and-profile-design.md @@ -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() + 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? // nil 表示不算 status(体重/身高) + } + + /// 返回该 metric 在给定 profile 下的参考范围(可能跟 baseRange 不同) + func effectiveRange(for field: Field, profile: UserProfile?) -> ClosedRange? { + // 目前唯一规则: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?) -> 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 小时)。 diff --git a/docs/superpowers/specs/2026-05-27-export-health-profile-design.md b/docs/superpowers/specs/2026-05-27-export-health-profile-design.md new file mode 100644 index 0000000..912a383 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-export-health-profile-design.md @@ -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 = .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`: + +```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% 杜绝小模型臆造;后续可加生成后数值校验。 diff --git a/docs/superpowers/specs/2026-05-29-model-download-design.md b/docs/superpowers/specs/2026-05-29-model-download-design.md new file mode 100644 index 0000000..2ba9ed8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-model-download-design.md @@ -0,0 +1,180 @@ +# 模型自动下载功能设计(2026-05-29) + +> 让用户在「我的 · 模型管理」页一键从自建 HTTPS 服务下载两个 MLX 模型,支持断点续传、 +> 进度展示和现场重装的旁路导入兜底。对应 CLAUDE.md §4「模型分发」与 W6「首启动下载流程」的核心部分。 + +## 1. 背景与现状 + +- 模型加载链路已通:`LLMSession`/`VLSession` 用 `ModelConfiguration(directory:)` 从沙盒 + `Application Support/Models//` 读取,`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//` 已存在且 size 匹配 → 跳过(粗粒度续传)。 +2. 否则下到 `Models//.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//`。 +- **补上 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 字节,仅作素材核对参照。 diff --git a/docs/superpowers/specs/2026-05-30-custom-reminder-design.md b/docs/superpowers/specs/2026-05-30-custom-reminder-design.md new file mode 100644 index 0000000..6b641f2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-custom-reminder-design.md @@ -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` | +| 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..w`(与指标的 `kangkang.reminder..w` 不冲突)。 +- 保存时调 `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` 的正式迁移(注释已就地标注)。 diff --git a/docs/superpowers/specs/2026-05-30-faceid-app-lock-design.md b/docs/superpowers/specs/2026-05-30-faceid-app-lock-design.md new file mode 100644 index 0000000..55b58cc --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-faceid-app-lock-design.md @@ -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 │ + │ @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 可模拟。 diff --git a/docs/superpowers/specs/2026-05-31-abnormal-quick-capture-design.md b/docs/superpowers/specs/2026-05-31-abnormal-quick-capture-design.md new file mode 100644 index 0000000..3e37271 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-abnormal-quick-capture-design.md @@ -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` 这一条路径。 diff --git a/scripts/fetch-qwen3vl.sh b/scripts/fetch-qwen3vl.sh new file mode 100755 index 0000000..a89c013 --- /dev/null +++ b/scripts/fetch-qwen3vl.sh @@ -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" diff --git a/scripts/upload-qwen3vl.sh b/scripts/upload-qwen3vl.sh new file mode 100644 index 0000000..7d18a2f --- /dev/null +++ b/scripts/upload-qwen3vl.sh @@ -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/ +# → 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 diff --git a/体己.xcodeproj/project.pbxproj b/体己.xcodeproj/project.pbxproj deleted file mode 100644 index ab4c588..0000000 --- a/体己.xcodeproj/project.pbxproj +++ /dev/null @@ -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 = ""; - }; - 5E463D0B2FC403BC0089145B /* 体己Tests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = "体己Tests"; - sourceTree = ""; - }; - 5E463D152FC403BC0089145B /* 体己UITests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = "体己UITests"; - sourceTree = ""; - }; -/* 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 = ""; - }; - 5E463CFA2FC403BB0089145B /* Products */ = { - isa = PBXGroup; - children = ( - 5E463CF92FC403BB0089145B /* 体己.app */, - 5E463D082FC403BC0089145B /* 体己Tests.xctest */, - 5E463D122FC403BC0089145B /* 体己UITests.xctest */, - ); - name = Products; - sourceTree = ""; - }; -/* 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 */; -} diff --git a/体己.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/体己.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/体己.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/体己.xcodeproj/xcuserdata/xuhuayong.xcuserdatad/xcschemes/xcschememanagement.plist b/体己.xcodeproj/xcuserdata/xuhuayong.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 3e96040..0000000 --- a/体己.xcodeproj/xcuserdata/xuhuayong.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - 体己.xcscheme_^#shared#^_ - - orderHint - 0 - - - - diff --git a/体己/Assets.xcassets/AppIcon.appiconset/Contents.json b/体己/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index ffdfe15..0000000 --- a/体己/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -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 - } -} diff --git a/体己/ContentView.swift b/体己/ContentView.swift deleted file mode 100644 index 5e6fa60..0000000 --- a/体己/ContentView.swift +++ /dev/null @@ -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) -} diff --git a/体己/Item.swift b/体己/Item.swift deleted file mode 100644 index 2570f82..0000000 --- a/体己/Item.swift +++ /dev/null @@ -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 - } -} diff --git a/体己/__App.swift b/体己/__App.swift deleted file mode 100644 index 2172e37..0000000 --- a/体己/__App.swift +++ /dev/null @@ -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) - } -} diff --git a/康康.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/康康.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..6fffd8a --- /dev/null +++ b/康康.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/康康.xcodeproj/xcshareddata/xcschemes/康康.xcscheme b/康康.xcodeproj/xcshareddata/xcschemes/康康.xcscheme new file mode 100644 index 0000000..162559b --- /dev/null +++ b/康康.xcodeproj/xcshareddata/xcschemes/康康.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/康康.xcodeproj/xcuserdata/xuhuayong.xcuserdatad/xcschemes/xcschememanagement.plist b/康康.xcodeproj/xcuserdata/xuhuayong.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..d92cd0b --- /dev/null +++ b/康康.xcodeproj/xcuserdata/xuhuayong.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,32 @@ + + + + + SchemeUserState + + 康康.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + 5E463CF82FC403BB0089145B + + primary + + + 5E463D072FC403BC0089145B + + primary + + + 5E463D112FC403BC0089145B + + primary + + + + + diff --git a/康康/AI/AIRuntime.swift b/康康/AI/AIRuntime.swift new file mode 100644 index 0000000..6363a3f --- /dev/null +++ b/康康/AI/AIRuntime.swift @@ -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] = [] + + private func acquireGate() async { + if !gateBusy { + gateBusy = true + return + } + await withCheckedContinuation { (cont: CheckedContinuation) 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 { + // 在 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)") + } + } +} diff --git a/康康/AI/FileDownloader.swift b/康康/AI/FileDownloader.swift new file mode 100644 index 0000000..5c4a514 --- /dev/null +++ b/康康/AI/FileDownloader.swift @@ -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? + + 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) 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() + } + } +} diff --git a/康康/AI/LLMSession.swift b/康康/AI/LLMSession.swift new file mode 100644 index 0000000..5aebe69 --- /dev/null +++ b/康康/AI/LLMSession.swift @@ -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( + _ 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 { + 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() } + } + } +} diff --git a/康康/AI/ModelManifest.swift b/康康/AI/ModelManifest.swift new file mode 100644 index 0000000..105d525 --- /dev/null +++ b/康康/AI/ModelManifest.swift @@ -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) + } +} diff --git a/康康/AI/ModelStore.swift b/康康/AI/ModelStore.swift new file mode 100644 index 0000000..dcfa9c0 --- /dev/null +++ b/康康/AI/ModelStore.swift @@ -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,不是有效的模型目录") + } + } +} diff --git a/康康/AI/Prompts/DiaryAssistPrompts.swift b/康康/AI/Prompts/DiaryAssistPrompts.swift new file mode 100644 index 0000000..a8cfd24 --- /dev/null +++ b/康康/AI/Prompts/DiaryAssistPrompts.swift @@ -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 围栏、不要 标签。结构: + {"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 + """ + } +} diff --git a/康康/AI/Prompts/HealthExportPrompts.swift b/康康/AI/Prompts/HealthExportPrompts.swift new file mode 100644 index 0000000..6a19c74 --- /dev/null +++ b/康康/AI/Prompts/HealthExportPrompts.swift @@ -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,不要思考过程,不要 标签: + /no_think + """ + } +} diff --git a/康康/AI/Prompts/VLPrompts.swift b/康康/AI/Prompts/VLPrompts.swift new file mode 100644 index 0000000..1be6249 --- /dev/null +++ b/康康/AI/Prompts/VLPrompts.swift @@ -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: +"""# +} diff --git a/康康/AI/TokenChunk.swift b/康康/AI/TokenChunk.swift new file mode 100644 index 0000000..90e21f2 --- /dev/null +++ b/康康/AI/TokenChunk.swift @@ -0,0 +1,6 @@ +import Foundation + +struct TokenChunk: Sendable { + let text: String + let decodeRate: Double // tokens / second, 估算值 +} diff --git a/康康/AI/VLSession.swift b/康康/AI/VLSession.swift new file mode 100644 index 0000000..20666bf --- /dev/null +++ b/康康/AI/VLSession.swift @@ -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( + _ 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 + } + } + } +} diff --git a/康康/App/KangkangApp.swift b/康康/App/KangkangApp.swift new file mode 100644 index 0000000..bdde285 --- /dev/null +++ b/康康/App/KangkangApp.swift @@ -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) + } +} diff --git a/康康/App/Localization.swift b/康康/App/Localization.swift new file mode 100644 index 0000000..ba5e81e --- /dev/null +++ b/康康/App/Localization.swift @@ -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) + } +} diff --git a/体己/Assets.xcassets/AccentColor.colorset/Contents.json b/康康/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from 体己/Assets.xcassets/AccentColor.colorset/Contents.json rename to 康康/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-1024.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-1024.png new file mode 100644 index 0000000..204381b Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-1024.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-128.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-128.png new file mode 100644 index 0000000..71eb47a Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-128.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-16.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-16.png new file mode 100644 index 0000000..f0117a1 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-16.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-256.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-256.png new file mode 100644 index 0000000..735739e Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-256.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-32.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-32.png new file mode 100644 index 0000000..4fe3f43 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-32.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-512.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-512.png new file mode 100644 index 0000000..7427677 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-512.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-64.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-64.png new file mode 100644 index 0000000..2fc2975 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-64.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-dark-1024.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-dark-1024.png new file mode 100644 index 0000000..204381b Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-dark-1024.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-tinted-1024.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-tinted-1024.png new file mode 100644 index 0000000..204381b Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-tinted-1024.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/Contents.json b/康康/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..7612143 --- /dev/null +++ b/康康/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-1024.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-1024.png new file mode 100644 index 0000000..9e3dd43 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-1024.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-128.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-128.png new file mode 100644 index 0000000..2758fcc Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-128.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-16.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-16.png new file mode 100644 index 0000000..10d7493 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-16.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-256.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-256.png new file mode 100644 index 0000000..38ef5d5 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-256.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-32.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-32.png new file mode 100644 index 0000000..543fa38 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-32.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-512.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-512.png new file mode 100644 index 0000000..1330af6 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-512.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-64.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-64.png new file mode 100644 index 0000000..2ce042e Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-64.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-dark-1024.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-dark-1024.png new file mode 100644 index 0000000..b00a2a1 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-dark-1024.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-tinted-1024.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-tinted-1024.png new file mode 100644 index 0000000..e873f51 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-tinted-1024.png differ diff --git a/体己/Assets.xcassets/Contents.json b/康康/Assets.xcassets/Contents.json similarity index 100% rename from 体己/Assets.xcassets/Contents.json rename to 康康/Assets.xcassets/Contents.json diff --git a/康康/DesignSystem/Components.swift b/康康/DesignSystem/Components.swift new file mode 100644 index 0000000..fa1604b --- /dev/null +++ b/康康/DesignSystem/Components.swift @@ -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.. 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) + } + } + ) + } +} diff --git a/康康/DesignSystem/Tokens.swift b/康康/DesignSystem/Tokens.swift new file mode 100644 index 0000000..bd46837 --- /dev/null +++ b/康康/DesignSystem/Tokens.swift @@ -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) + } +} diff --git a/康康/Features/Archive/ArchiveListView.swift b/康康/Features/Archive/ArchiveListView.swift new file mode 100644 index 0000000..5cef318 --- /dev/null +++ b/康康/Features/Archive/ArchiveListView.swift @@ -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) +} diff --git a/康康/Features/Archive/HealthExportDetailView.swift b/康康/Features/Archive/HealthExportDetailView.swift new file mode 100644 index 0000000..7321e32 --- /dev/null +++ b/康康/Features/Archive/HealthExportDetailView.swift @@ -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) +} diff --git a/康康/Features/Archive/HealthExportListView.swift b/康康/Features/Archive/HealthExportListView.swift new file mode 100644 index 0000000..b6807ed --- /dev/null +++ b/康康/Features/Archive/HealthExportListView.swift @@ -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) +} diff --git a/康康/Features/Archive/HealthExportSheet.swift b/康康/Features/Archive/HealthExportSheet.swift new file mode 100644 index 0000000..3b28f86 --- /dev/null +++ b/康康/Features/Archive/HealthExportSheet.swift @@ -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? + @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[.. 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) +} diff --git a/康康/Features/Capture/CaptureReviewForm.swift b/康康/Features/Capture/CaptureReviewForm.swift new file mode 100644 index 0000000..b65e3e5 --- /dev/null +++ b/康康/Features/Capture/CaptureReviewForm.swift @@ -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(_ 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) -> 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) + } +} diff --git a/康康/Features/Capture/DocumentScanner.swift b/康康/Features/Capture/DocumentScanner.swift new file mode 100644 index 0000000..b9671b4 --- /dev/null +++ b/康康/Features/Capture/DocumentScanner.swift @@ -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.. 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) } + } + } + } +} diff --git a/康康/Features/Capture/UnifiedCaptureFlow.swift b/康康/Features/Capture/UnifiedCaptureFlow.swift new file mode 100644 index 0000000..480ed5f --- /dev/null +++ b/康康/Features/Capture/UnifiedCaptureFlow.swift @@ -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? = 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() + } + } +} diff --git a/康康/Features/Diary/DiaryQuickSheet.swift b/康康/Features/Diary/DiaryQuickSheet.swift new file mode 100644 index 0000000..a20e3d7 --- /dev/null +++ b/康康/Features/Diary/DiaryQuickSheet.swift @@ -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 = [] + @State private var suggestTask: Task? + /// 当前正在「就地填空」的 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() + 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() +} diff --git a/康康/Features/Diary/QuestionFillPanel.swift b/康康/Features/Diary/QuestionFillPanel.swift new file mode 100644 index 0000000..808081b --- /dev/null +++ b/康康/Features/Diary/QuestionFillPanel.swift @@ -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.. [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 { + Binding( + get: { i < values.count ? values[i] : "" }, + set: { if i < values.count { values[i] = $0 } } + ) + } +} diff --git a/康康/Features/Home/HomeView.swift b/康康/Features/Home/HomeView.swift new file mode 100644 index 0000000..0420edf --- /dev/null +++ b/康康/Features/Home/HomeView.swift @@ -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() +} diff --git a/康康/Features/Home/RecentItemRow.swift b/康康/Features/Home/RecentItemRow.swift new file mode 100644 index 0000000..2a4e0e2 --- /dev/null +++ b/康康/Features/Home/RecentItemRow.swift @@ -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) + } +} diff --git a/康康/Features/Home/TodayRemindersCard.swift b/康康/Features/Home/TodayRemindersCard.swift new file mode 100644 index 0000000..e49be61 --- /dev/null +++ b/康康/Features/Home/TodayRemindersCard.swift @@ -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 + } +} diff --git a/康康/Features/Indicator/CustomMetricEditor.swift b/康康/Features/Indicator/CustomMetricEditor.swift new file mode 100644 index 0000000..7522b6d --- /dev/null +++ b/康康/Features/Indicator/CustomMetricEditor.swift @@ -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, 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) + } +} diff --git a/康康/Features/Indicator/IndicatorQuickSheet.swift b/康康/Features/Indicator/IndicatorQuickSheet.swift new file mode 100644 index 0000000..666f2b3 --- /dev/null +++ b/康康/Features/Indicator/IndicatorQuickSheet.swift @@ -0,0 +1,1188 @@ +import SwiftUI +import SwiftData + +/// 化验项 free-form 预设(用户当时手动加的辅助列表)。 +/// 跟 MonitorMetric 不同——这些是「一次性化验单数值」,不进 Trends,无 seriesKey。 +struct IndicatorPreset: Identifiable, Hashable { + let name: String + let unit: String + let range: String + var id: String { name } +} + +private let labPresets: [IndicatorPreset] = [ + .init(name: "LDL-C", unit: "mmol/L", range: "< 3.40"), + .init(name: "HDL-C", unit: "mmol/L", range: "> 1.04"), + .init(name: "总胆固醇", unit: "mmol/L", range: "< 5.18"), + .init(name: "甘油三酯", unit: "mmol/L", range: "< 1.70"), + .init(name: "ALT", unit: "U/L", range: "9 - 50"), + .init(name: "尿酸", unit: "μmol/L", range: "208 - 428"), + .init(name: "血红蛋白", unit: "g/L", range: "130 - 175"), +] + +/// 指标录入 sheet,支持 3 种路径: +/// 1. **长期监测预设**(MonitorMetric)— 选预设后字段自动填,带 seriesKey, +/// 保存进 Trends。血压拆 2 条 Indicator。 +/// 2. **化验项快捷**(labPresets)— 跟 a 类似但只填 name/unit/range 辅助, +/// 无 seriesKey,不进 Trends。 +/// 3. **自由输入** — name/value/unit/range 全自己填,status 手动选。 +struct IndicatorQuickSheet: View { + @Environment(\.modelContext) private var ctx + @Environment(\.dismiss) private var dismiss + @Query private var profiles: [UserProfile] + + // 状态 + @State private var selectedMonitor: MonitorMetric? + @State private var selectedLabPreset: IndicatorPreset? + + // 单字段(monitor 单字段、lab、自由) + @State private var name: String = "" + @State private var value: String = "" + @State private var unit: String = "" + @State private var range: String = "" + @State private var manualStatus: IndicatorStatus = .normal + @State private var capturedAt: Date = .now + @State private var note: String = "" + + // 血压双字段 + @State private var systolic: String = "" + @State private var diastolic: String = "" + + // 周期性提醒(仅长期监测可用) + @Query private var allReminders: [MetricReminder] + @State private var reminderEnabled: Bool = false + @State private var reminderTime: Date = Self.defaultReminderTime + @State private var reminderWeekdays: Set = Set(1...7) + @State private var reminderHydratedFor: String? = nil + @State private var notifAuthBlocked: Bool = false + + // 自定义指标 + @Query(sort: \CustomMonitorMetric.createdAt, order: .reverse) + private var customMetrics: [CustomMonitorMetric] + @State private var selectedCustom: CustomMonitorMetric? + @State private var editingCustom: CustomMetricEditTarget? + + // 隐藏管理 sheet 触发态 + @State private var showHiddenSheet: Bool = false + + private static var defaultReminderTime: Date { + Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now + } + + private var profile: UserProfile? { profiles.first } + + private var isBP: Bool { selectedMonitor == .bloodPressure } + private var isLongTermMetric: Bool { selectedMonitor != nil || selectedCustom != nil } + private var isCustomMonitor: Bool { selectedCustom != nil } + + /// 当前长期监测的稳定 key,用于 reminder 关联和 .task(id:) hydrate 触发。 + /// 血压用 metric.rawValue;custom 用 seriesKey;其他单字段 monitor 用 rawValue;非长期 nil。 + private var longTermKey: String? { + if let m = selectedMonitor { return m.rawValue } + if let cm = selectedCustom { return cm.seriesKey } + return nil + } + + private var longTermDisplayName: String? { + selectedMonitor?.displayName ?? selectedCustom?.name + } + + private var canSubmit: Bool { + if isBP { + return !systolic.trimmingCharacters(in: .whitespaces).isEmpty && + !diastolic.trimmingCharacters(in: .whitespaces).isEmpty + } + return !name.trimmingCharacters(in: .whitespaces).isEmpty && + !value.trimmingCharacters(in: .whitespaces).isEmpty + } + + var body: some View { + VStack(spacing: 0) { + handle + header + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + monitorGridSection + labPresetSection + Divider().padding(.vertical, 4) + + if isBP { + bpFieldSection + } else { + nameSection + valueRow + rangeSection + if isLongTermMetric { + autoStatusHint + } else { + statusSection + } + } + + timeSection + noteSection + if isLongTermMetric { + reminderSection + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + + footer + } + .task(id: longTermKey) { hydrateReminder() } + .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) + } + + // MARK: - Sections + + 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 monitorGridSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + sectionLabel(String(appLoc: "长期监测(进趋势)")) + Spacer() + if !hiddenSet.isEmpty { + hiddenCountChip + } + } + let columns = [GridItem(.flexible()), GridItem(.flexible())] + LazyVGrid(columns: columns, spacing: 8) { + ForEach(visibleMonitorMetrics) { m in + monitorTile(m) + } + ForEach(customMetrics) { cm in + customTile(cm) + } + addCustomTile + } + } + .sheet(isPresented: $showHiddenSheet) { + HiddenMonitorRestoreSheet( + hiddenMetrics: hiddenMonitorMetrics, + onRestore: { unhideMonitor($0) } + ) + } + .sheet(item: $editingCustom) { target in + CustomMetricEditor(existing: target.metric) { saved in + // 新建后自动选中,删除后清空选择 + if let saved { + selectedCustom = saved + selectedMonitor = nil + selectedLabPreset = nil + fillFromCustom(saved) + } else if selectedCustom?.seriesKey == target.metric?.seriesKey { + selectedCustom = nil + clearAllFields() + } + } + } + } + + private func customTile(_ cm: CustomMonitorMetric) -> some View { + let selected = selectedCustom?.seriesKey == cm.seriesKey + return Button { + applyCustom(cm) + } label: { + HStack(spacing: 10) { + Image(systemName: cm.icon) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink) + .frame(width: 32, height: 32) + .background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft)) + + VStack(alignment: .leading, spacing: 1) { + Text(cm.name) + .font(.system(size: 14, weight: selected ? .semibold : .medium)) + .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text) + .lineLimit(1) + Text("自定义") + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3) + } + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(selected ? Tj.Palette.ink : Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1) + ) + } + .buttonStyle(.plain) + .contextMenu { + // 单一入口:进编辑器既能改也能删(编辑器内含删除按钮)。 + // 旧实现两项 action 完全相同,第二项却标红 trash「编辑/删除」,看似直接删除实则打开编辑器,误导。 + Button { editingCustom = CustomMetricEditTarget(metric: cm) } label: { + Label("编辑 / 删除", systemImage: "pencil") + } + } + } + + private var addCustomTile: some View { + Button { + editingCustom = CustomMetricEditTarget(metric: nil) + } label: { + HStack(spacing: 10) { + Image(systemName: "plus") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Tj.Palette.text2) + .frame(width: 32, height: 32) + .background( + Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true) + ) + Text("自定义") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Tj.Palette.text2) + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.sand2.opacity(0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.line.opacity(0.6), + style: StrokeStyle(lineWidth: 1, dash: [4, 3])) + ) + } + .buttonStyle(.plain) + } + + private func monitorTile(_ m: MonitorMetric) -> some View { + let selected = selectedMonitor == m + return Button { + applyMonitor(m) + } label: { + HStack(spacing: 10) { + Image(systemName: m.icon) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink) + .frame(width: 32, height: 32) + .background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.amber.opacity(0.25))) + + Text(m.displayName) + .font(.system(size: 14, weight: selected ? .semibold : .medium)) + .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text) + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(selected ? Tj.Palette.ink : Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1) + ) + } + .buttonStyle(.plain) + .contextMenu { + Button(role: .destructive) { + hideMonitor(m) + } label: { + Label("隐藏", systemImage: "eye.slash") + } + } + } + + private var labPresetSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel(String(appLoc: "化验项快捷(不进趋势)")) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(labPresets) { p in + chip(p.name, selected: selectedLabPreset == p) { + applyLab(p) + } + } + } + } + } + } + + private var bpFieldSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + sectionLabel(String(appLoc: "收缩 / 舒张")) + Spacer() + bpRangeHint + } + HStack(spacing: 12) { + bpField(label: String(appLoc: "收缩压"), value: $systolic, placeholder: "120") + Text("/").font(.system(size: 22, weight: .light)).foregroundStyle(Tj.Palette.text3) + bpField(label: String(appLoc: "舒张压"), value: $diastolic, placeholder: "80") + Text("mmHg").foregroundStyle(Tj.Palette.text3) + } + bpStatusChips + } + } + + private func bpField(label: String, value: Binding, 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: 20, weight: .semibold, design: .monospaced)) + .multilineTextAlignment(.center) + .padding(.vertical, 10) + .frame(width: 90) + .background(fieldBg) + .overlay(fieldBorder) + } + } + + private var bpRangeHint: some View { + let sysRange = MonitorMetric.bloodPressure.effectiveRange( + for: MonitorMetric.bloodPressure.fields[0], profile: profile) + let diasRange = MonitorMetric.bloodPressure.effectiveRange( + for: MonitorMetric.bloodPressure.fields[1], profile: profile) + let personalized = MonitorMetric.bloodPressure.isRangePersonalized( + for: MonitorMetric.bloodPressure.fields[0], profile: profile) + let rangeText = "\(formatRange(sysRange)) / \(formatRange(diasRange))" + return HStack(spacing: 4) { + Text(rangeText) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + if personalized, let age = profile?.age { + Text("· 按\(age)岁调整") + .font(.system(size: 10)) + .foregroundStyle(Tj.Palette.amber) + } + } + } + + private var bpStatusChips: some View { + HStack(spacing: 8) { + if let s = computedBPStatus(.systolic) { + statusBadge(String(appLoc: "收缩 ") + s.label, color: s.color) + } + if let s = computedBPStatus(.diastolic) { + statusBadge(String(appLoc: "舒张 ") + s.label, color: s.color) + } + Spacer() + } + } + + private var nameSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel(String(appLoc: "指标名")) + TextField("例如:血红蛋白", text: $name) + .textInputAutocapitalization(.never) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(fieldBg) + .overlay(fieldBorder) + .onChange(of: name) { _, _ in + if let p = selectedLabPreset, p.name != name { + selectedLabPreset = nil + } + } + .disabled(isLongTermMetric) + .opacity(isLongTermMetric ? 0.6 : 1) + } + } + + private var valueRow: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + sectionLabel(String(appLoc: "数值")) + TextField(monitorFieldPlaceholder, text: $value) + .keyboardType(.decimalPad) + .font(.system(size: 18, weight: .semibold, design: .monospaced)) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(fieldBg) + .overlay(fieldBorder) + } + VStack(alignment: .leading, spacing: 8) { + sectionLabel(String(appLoc: "单位")) + TextField("mmol/L", text: $unit) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(fieldBg) + .overlay(fieldBorder) + .disabled(isLongTermMetric) + .opacity(isLongTermMetric ? 0.6 : 1) + } + .frame(maxWidth: 130) + } + } + + private var rangeSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + sectionLabel(String(appLoc: "参考范围")) + Spacer() + if let m = selectedMonitor, m != .bloodPressure { + monitorRangeHint(m) + } + } + TextField("例如:< 3.40 或 3.9 - 6.1", text: $range) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(fieldBg) + .overlay(fieldBorder) + .disabled(isLongTermMetric) + .opacity(isLongTermMetric ? 0.6 : 1) + } + } + + private func monitorRangeHint(_ m: MonitorMetric) -> some View { + let personalized = m.isRangePersonalized(for: m.fields[0], profile: profile) + return HStack(spacing: 4) { + if personalized, let age = profile?.age { + Text("按\(age)岁调整") + .font(.system(size: 10)) + .foregroundStyle(Tj.Palette.amber) + } + } + } + + private var statusSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel(String(appLoc: "状态")) + HStack(spacing: 8) { + statusChip(.normal, label: String(appLoc: "正常"), color: Tj.Palette.leaf) + statusChip(.high, label: String(appLoc: "偏高 ↑"), color: Tj.Palette.brick) + statusChip(.low, label: String(appLoc: "偏低 ↓"), color: Tj.Palette.amber) + } + } + } + + private var autoStatusHint: some View { + let auto = computedSingleStatus + return HStack(spacing: 8) { + sectionLabel(String(appLoc: "状态(按数值自动判)")) + if let s = auto { + statusBadge(s.label, color: s.color) + } else { + Text("待输入") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + } + } + } + + private var timeSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel(String(appLoc: "测量时间")) + DatePicker("", selection: $capturedAt, in: ...Date.now) + .datePickerStyle(.compact) + .labelsHidden() + } + } + + private var noteSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel(String(appLoc: "备注(可选)")) + TextField("例如:空腹采血", text: $note, axis: .vertical) + .lineLimit(1...3) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(fieldBg) + .overlay(fieldBorder) + } + } + + // MARK: - 周期提醒 + + private var reminderSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + sectionLabel(String(appLoc: "周期提醒")) + Spacer() + Toggle("", isOn: $reminderEnabled) + .labelsHidden() + .tint(Tj.Palette.ink) + .onChange(of: reminderEnabled) { _, on in + if on { Task { await requestNotifAuthIfNeeded() } } + } + } + + if reminderEnabled { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("时间") + .font(.system(size: 13)) + .foregroundStyle(Tj.Palette.text2) + Spacer() + DatePicker("", selection: $reminderTime, + displayedComponents: .hourAndMinute) + .datePickerStyle(.compact) + .labelsHidden() + } + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("频率") + .font(.system(size: 13)) + .foregroundStyle(Tj.Palette.text2) + Spacer() + Text(reminderFrequencyLabel) + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + } + weekdayPickerRow + HStack(spacing: 8) { + quickFreqChip(String(appLoc: "每天")) { + reminderWeekdays = Set(1...7) + } + quickFreqChip(String(appLoc: "工作日")) { + reminderWeekdays = Set([2, 3, 4, 5, 6]) + } + quickFreqChip(String(appLoc: "周末")) { + reminderWeekdays = Set([1, 7]) + } + } + } + + if notifAuthBlocked { + Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.brick) + } else { + Text("本机提醒 · 不发任何数据") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + } + .padding(12) + .background(fieldBg) + .overlay(fieldBorder) + } + } + } + + private var reminderFrequencyLabel: String { + if reminderWeekdays.count == 7 { return String(appLoc: "每天") } + if reminderWeekdays.isEmpty { return String(appLoc: "未选") } + let names = [ + String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), + String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), + String(appLoc: "六"), + ] + let sorted = reminderWeekdays.sorted() + return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined() + } + + private var weekdayPickerRow: 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] // 周一到周日(Apple Calendar 编号) + return HStack(spacing: 6) { + ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in + Button { + if reminderWeekdays.contains(w) { + reminderWeekdays.remove(w) + } else { + reminderWeekdays.insert(w) + } + } label: { + Text(names[idx]) + .font(.system(size: 13, + weight: reminderWeekdays.contains(w) ? .semibold : .regular)) + .foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text) + .frame(maxWidth: .infinity, minHeight: 32) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(reminderWeekdays.contains(w) ? Tj.Palette.ink : Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Tj.Palette.line, + lineWidth: reminderWeekdays.contains(w) ? 0 : 1) + ) + } + .buttonStyle(.plain) + } + } + } + + private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label) + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text2) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Capsule().fill(Tj.Palette.sand2)) + } + .buttonStyle(.plain) + } + + private func hydrateReminder() { + guard let key = longTermKey else { return } + if reminderHydratedFor == key { return } + reminderHydratedFor = key + if let existing = allReminders.first(where: { $0.metricId == key }) { + reminderEnabled = existing.enabled + reminderTime = Calendar.current.date( + bySettingHour: existing.hour, minute: existing.minute, second: 0, of: .now + ) ?? Self.defaultReminderTime + reminderWeekdays = Set(existing.weekdays) + } else { + reminderEnabled = false + reminderTime = Self.defaultReminderTime + reminderWeekdays = Set(1...7) + } + } + + private func requestNotifAuthIfNeeded() async { + let state = await ReminderService.requestAuthorization() + notifAuthBlocked = (state == .denied) + if notifAuthBlocked { + reminderEnabled = false + } + } + + /// submit() 调用,处理提醒:enabled → upsert SwiftData + 调度通知;disabled → 删旧 reminder + 取消通知。 + private func persistReminderIfNeeded() async { + guard let key = longTermKey, let displayName = longTermDisplayName else { return } + let existing = allReminders.first(where: { $0.metricId == key }) + let cal = Calendar.current + let hour = cal.component(.hour, from: reminderTime) + let minute = cal.component(.minute, from: reminderTime) + + if reminderEnabled && !reminderWeekdays.isEmpty { + let reminder: MetricReminder + if let existing { + existing.enabled = true + existing.hour = hour + existing.minute = minute + existing.weekdays = reminderWeekdays.sorted() + existing.displayName = displayName + existing.updatedAt = .now + reminder = existing + } else { + let new = MetricReminder( + metricId: key, + displayName: displayName, + hour: hour, + minute: minute, + weekdays: reminderWeekdays.sorted(), + enabled: true + ) + ctx.insert(new) + reminder = new + } + try? ctx.save() + await ReminderService.sync(reminder) + } else if let existing { + // 关闭:保留 SwiftData 行,只改 enabled = false,取消通知 + existing.enabled = false + existing.updatedAt = .now + try? ctx.save() + ReminderService.cancel(metricId: key) + } + } + + 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) + } + ) + } + + // 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(_ 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 statusChip(_ value: IndicatorStatus, label: String, color: Color) -> some View { + let selected = manualStatus == value + return Button { + manualStatus = value + } label: { + Text(label) + .font(.system(size: 13, weight: selected ? .semibold : .regular)) + .foregroundStyle(selected ? Tj.Palette.paper : color) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Capsule().fill(selected ? color : Tj.Palette.paper)) + .overlay(Capsule().strokeBorder(color.opacity(selected ? 0 : 0.5), lineWidth: 1)) + } + .buttonStyle(.plain) + } + + private func statusBadge(_ label: String, color: Color) -> some View { + Text(label) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(color) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Capsule().fill(color.opacity(0.18))) + } + + private var monitorFieldPlaceholder: String { + selectedMonitor?.fields.first?.placeholder ?? "3.84" + } + + private func formatRange(_ r: ClosedRange?) -> String { + guard let r = r else { return "—" } + let fmt = { (v: Double) in + v.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", v) : String(format: "%.1f", v) + } + return "\(fmt(r.lowerBound))–\(fmt(r.upperBound))" + } + + // MARK: - hidden preset 管理 + + private var hiddenSet: Set { + Set(profile?.hiddenPresetMetrics ?? []) + } + + private var visibleMonitorMetrics: [MonitorMetric] { + MonitorMetric.allCases.filter { !hiddenSet.contains($0.rawValue) } + } + + private var hiddenMonitorMetrics: [MonitorMetric] { + MonitorMetric.allCases.filter { hiddenSet.contains($0.rawValue) } + } + + private var hiddenCountChip: some View { + Button { + showHiddenSheet = true + } label: { + HStack(spacing: 3) { + Text("已隐藏 \(hiddenSet.count)") + .font(.system(size: 11, weight: .medium)) + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + } + .foregroundStyle(Tj.Palette.text2) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Capsule().fill(Tj.Palette.sand2)) + } + .buttonStyle(.plain) + } + + private func hideMonitor(_ m: MonitorMetric) { + let profile = UserProfileStore.loadOrCreate(in: ctx) + guard !profile.hiddenPresetMetrics.contains(m.rawValue) else { return } + profile.hiddenPresetMetrics.append(m.rawValue) + profile.updatedAt = .now + try? ctx.save() + if selectedMonitor == m { + clearMonitor() + } + } + + private func unhideMonitor(_ m: MonitorMetric) { + guard let profile = profile else { return } + profile.hiddenPresetMetrics.removeAll { $0 == m.rawValue } + profile.updatedAt = .now + try? ctx.save() + if profile.hiddenPresetMetrics.isEmpty { + showHiddenSheet = false + } + } + + // MARK: - apply preset + + private func applyMonitor(_ m: MonitorMetric) { + if selectedMonitor == m { + // 取消选择 + clearMonitor() + return + } + selectedMonitor = m + selectedLabPreset = nil + selectedCustom = nil + + if m == .bloodPressure { + // 血压走 bp 字段,不动 name/value/unit + name = m.displayName + unit = "mmHg" + } else { + let f = m.fields[0] + name = m.displayName + value = "" + unit = f.unit + let r = m.effectiveRange(for: f, profile: profile) + range = f.rangeText(r) + } + } + + private func clearMonitor() { + selectedMonitor = nil + name = ""; value = ""; unit = ""; range = "" + systolic = ""; diastolic = "" + } + + private func applyLab(_ p: IndicatorPreset) { + selectedLabPreset = p + selectedMonitor = nil + selectedCustom = nil + systolic = ""; diastolic = "" + name = p.name + if unit.trimmingCharacters(in: .whitespaces).isEmpty { unit = p.unit } + if range.trimmingCharacters(in: .whitespaces).isEmpty { range = p.range } + } + + private func applyCustom(_ cm: CustomMonitorMetric) { + if selectedCustom?.seriesKey == cm.seriesKey { + selectedCustom = nil + clearAllFields() + return + } + selectedCustom = cm + selectedMonitor = nil + selectedLabPreset = nil + fillFromCustom(cm) + } + + private func fillFromCustom(_ cm: CustomMonitorMetric) { + name = cm.name + value = "" + unit = cm.unit + range = cm.rangeText + systolic = ""; diastolic = "" + } + + private func clearAllFields() { + name = ""; value = ""; unit = ""; range = "" + systolic = ""; diastolic = "" + } + + // MARK: - auto status + + private var computedSingleStatus: (label: String, color: Color)? { + guard let v = Double(value.trimmingCharacters(in: .whitespaces)) else { return nil } + if let m = selectedMonitor, m != .bloodPressure { + let f = m.fields[0] + let r = m.effectiveRange(for: f, profile: profile) + let s = MonitorMetric.status(value: v, in: r) + return (s.label, s.color) + } + if let cm = selectedCustom { + let s = MonitorMetric.status(value: v, in: cm.referenceRange) + return (s.label, s.color) + } + return nil + } + + private enum BPSide { case systolic, diastolic } + + private func computedBPStatus(_ side: BPSide) -> (label: String, color: Color)? { + let text = side == .systolic ? systolic : diastolic + guard let v = Double(text.trimmingCharacters(in: .whitespaces)) else { return nil } + let m = MonitorMetric.bloodPressure + let f = m.fields[side == .systolic ? 0 : 1] + let r = m.effectiveRange(for: f, profile: profile) + let s = MonitorMetric.status(value: v, in: r) + return (s.label, s.color) + } + + // MARK: - submit + + private func submit() { + guard canSubmit else { return } + + if isBP { + saveBP() + } else if let m = selectedMonitor { + saveSingleMonitor(m) + } else if let cm = selectedCustom { + saveCustom(cm) + } else { + saveFreeform() + } + + Task { + await persistReminderIfNeeded() + await MainActor.run { dismiss() } + } + } + + private func saveBP() { + let m = MonitorMetric.bloodPressure + let sys = Double(systolic.trimmingCharacters(in: .whitespaces)) ?? 0 + let dia = Double(diastolic.trimmingCharacters(in: .whitespaces)) ?? 0 + let sysField = m.fields[0] + let diaField = m.fields[1] + let sysRange = m.effectiveRange(for: sysField, profile: profile) + let diaRange = m.effectiveRange(for: diaField, profile: profile) + let sysStatus = MonitorMetric.status(value: sys, in: sysRange) + let diaStatus = MonitorMetric.status(value: dia, in: diaRange) + + let timestamp = capturedAt + let systolicI = Indicator( + name: sysField.label, + value: systolic, + unit: sysField.unit, + range: sysField.rangeText(sysRange), + status: sysStatus, + note: note.isEmpty ? nil : note, + capturedAt: timestamp, + pinned: true, + seriesKey: sysField.seriesKey + ) + let diastolicI = Indicator( + name: diaField.label, + value: diastolic, + unit: diaField.unit, + range: diaField.rangeText(diaRange), + status: diaStatus, + note: nil, + capturedAt: timestamp, + pinned: true, + seriesKey: diaField.seriesKey + ) + ctx.insert(systolicI) + ctx.insert(diastolicI) + try? ctx.save() + } + + private func saveSingleMonitor(_ m: MonitorMetric) { + let f = m.fields[0] + let v = Double(value.trimmingCharacters(in: .whitespaces)) ?? 0 + let r = m.effectiveRange(for: f, profile: profile) + let status = MonitorMetric.status(value: v, in: r) + let indicator = Indicator( + name: m.displayName, + value: value.trimmingCharacters(in: .whitespaces), + unit: f.unit, + range: f.rangeText(r), + status: status, + note: note.isEmpty ? nil : note, + capturedAt: capturedAt, + pinned: true, + seriesKey: f.seriesKey + ) + ctx.insert(indicator) + try? ctx.save() + } + + private func saveCustom(_ cm: CustomMonitorMetric) { + let v = Double(value.trimmingCharacters(in: .whitespaces)) ?? 0 + let status = MonitorMetric.status(value: v, in: cm.referenceRange) + let indicator = Indicator( + name: cm.name, + value: value.trimmingCharacters(in: .whitespaces), + unit: cm.unit, + range: cm.rangeText, + status: status, + note: note.isEmpty ? nil : note, + capturedAt: capturedAt, + pinned: true, + seriesKey: cm.seriesKey + ) + ctx.insert(indicator) + try? ctx.save() + } + + private func saveFreeform() { + let indicator = Indicator( + name: name.trimmingCharacters(in: .whitespaces), + value: value.trimmingCharacters(in: .whitespaces), + unit: unit.trimmingCharacters(in: .whitespaces), + range: range.trimmingCharacters(in: .whitespaces), + status: manualStatus, + note: note.trimmingCharacters(in: .whitespaces).isEmpty ? nil : note, + capturedAt: capturedAt + ) + ctx.insert(indicator) + try? ctx.save() + } +} + +// MARK: - Status display helpers + +private extension IndicatorStatus { + var label: String { + switch self { + case .normal: return String(appLoc: "正常") + case .high: return String(appLoc: "偏高 ↑") + case .low: return String(appLoc: "偏低 ↓") + } + } + + var color: Color { + switch self { + case .normal: return Tj.Palette.leaf + case .high: return Tj.Palette.brick + case .low: return Tj.Palette.amber + } + } +} + +/// `.sheet(item:)` 要求 Identifiable;包一层避免 CustomMonitorMetric? 不能直接当 binding 用。 +struct CustomMetricEditTarget: Identifiable { + let metric: CustomMonitorMetric? + var id: String { metric?.seriesKey ?? "_new_" } +} + +/// 已隐藏的长期监测预设恢复列表。点"显示"把对应 metric 从 hiddenPresetMetrics 移除。 +private struct HiddenMonitorRestoreSheet: View { + let hiddenMetrics: [MonitorMetric] + let onRestore: (MonitorMetric) -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Tj.Palette.line) + .frame(width: 40, height: 4) + .padding(.top, 10) + .padding(.bottom, 14) + + HStack { + Text("已隐藏的长期监测") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + Spacer() + Button("完成") { dismiss() } + .font(.system(size: 14)) + .foregroundStyle(Tj.Palette.ink) + } + .padding(.horizontal, 20) + .padding(.bottom, 12) + + ScrollView { + VStack(spacing: 8) { + ForEach(hiddenMetrics) { m in + row(m) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 24) + } + } + .background(Tj.Palette.sand) + .presentationDetents([.medium]) + .presentationBackground(Tj.Palette.sand) + .presentationCornerRadius(Tj.Radius.xl) + } + + private func row(_ m: MonitorMetric) -> some View { + HStack(spacing: 12) { + Image(systemName: m.icon) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Tj.Palette.ink) + .frame(width: 32, height: 32) + .background(Circle().fill(Tj.Palette.amber.opacity(0.25))) + + Text(m.displayName) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Tj.Palette.text) + + Spacer() + + Button("显示") { + onRestore(m) + } + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Tj.Palette.paper) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Capsule().fill(Tj.Palette.ink)) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: 1) + ) + } +} + +#Preview { + IndicatorQuickSheet() + .modelContainer(for: [ + Indicator.self, UserProfile.self, + MetricReminder.self, CustomMonitorMetric.self + ], inMemory: true) +} diff --git a/康康/Features/Me/AboutView.swift b/康康/Features/Me/AboutView.swift new file mode 100644 index 0000000..c7bc41f --- /dev/null +++ b/康康/Features/Me/AboutView.swift @@ -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( + 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() + } +} diff --git a/康康/Features/Me/CustomMetricsListView.swift b/康康/Features/Me/CustomMetricsListView.swift new file mode 100644 index 0000000..3549e2e --- /dev/null +++ b/康康/Features/Me/CustomMetricsListView.swift @@ -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) +} diff --git a/康康/Features/Me/CustomReminderEditSheet.swift b/康康/Features/Me/CustomReminderEditSheet.swift new file mode 100644 index 0000000..5ef6866 --- /dev/null +++ b/康康/Features/Me/CustomReminderEditSheet.swift @@ -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 = 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) +} diff --git a/康康/Features/Me/LanguageSettingsView.swift b/康康/Features/Me/LanguageSettingsView.swift new file mode 100644 index 0000000..ac96f16 --- /dev/null +++ b/康康/Features/Me/LanguageSettingsView.swift @@ -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() } +} diff --git a/康康/Features/Me/MeView.swift b/康康/Features/Me/MeView.swift new file mode 100644 index 0000000..5876f90 --- /dev/null +++ b/康康/Features/Me/MeView.swift @@ -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 { + 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) +} diff --git a/康康/Features/Me/ModelManagementView.swift b/康康/Features/Me/ModelManagementView.swift new file mode 100644 index 0000000..2a33f4e --- /dev/null +++ b/康康/Features/Me/ModelManagementView.swift @@ -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) { + 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() + } +} diff --git a/康康/Features/Me/ModelSelfTestView.swift b/康康/Features/Me/ModelSelfTestView.swift new file mode 100644 index 0000000..24368ca --- /dev/null +++ b/康康/Features/Me/ModelSelfTestView.swift @@ -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() } +} diff --git a/康康/Features/Me/RemindersListView.swift b/康康/Features/Me/RemindersListView.swift new file mode 100644 index 0000000..6151b2d --- /dev/null +++ b/康康/Features/Me/RemindersListView.swift @@ -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) +} diff --git a/康康/Features/Monitor/MonitorMetric.swift b/康康/Features/Monitor/MonitorMetric.swift new file mode 100644 index 0000000..f48d14a --- /dev/null +++ b/康康/Features/Monitor/MonitorMetric.swift @@ -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? + + var id: String { seriesKey } + + /// 给 IndicatorRecordSheet 显示在数值旁的「90-140 mmHg」字样。 + func rangeText(_ range: ClosedRange?) -> 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? { + 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?) -> 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 + } +} diff --git a/康康/Features/Profile/ProfileEditView.swift b/康康/Features/Profile/ProfileEditView.swift new file mode 100644 index 0000000..ff1e69a --- /dev/null +++ b/康康/Features/Profile/ProfileEditView.swift @@ -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 { + 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: 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) +} diff --git a/康康/Features/Quick/QuickRegionCaptureFlow.swift b/康康/Features/Quick/QuickRegionCaptureFlow.swift new file mode 100644 index 0000000..1222b8d --- /dev/null +++ b/康康/Features/Quick/QuickRegionCaptureFlow.swift @@ -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? = 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 } + } +} diff --git a/康康/Features/Quick/QuickRegionConfirmView.swift b/康康/Features/Quick/QuickRegionConfirmView.swift new file mode 100644 index 0000000..45114a3 --- /dev/null +++ b/康康/Features/Quick/QuickRegionConfirmView.swift @@ -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) -> 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, 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) -> 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 +} diff --git a/康康/Features/Quick/RegionCameraView.swift b/康康/Features/Quick/RegionCameraView.swift new file mode 100644 index 0000000..daa302f --- /dev/null +++ b/康康/Features/Quick/RegionCameraView.swift @@ -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) + } + } +} diff --git a/康康/Features/Record/RecordSheet.swift b/康康/Features/Record/RecordSheet.swift new file mode 100644 index 0000000..250cf04 --- /dev/null +++ b/康康/Features/Record/RecordSheet.swift @@ -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 } + } + } +} diff --git a/康康/Features/Symptom/OngoingSymptomsCard.swift b/康康/Features/Symptom/OngoingSymptomsCard.swift new file mode 100644 index 0000000..2881277 --- /dev/null +++ b/康康/Features/Symptom/OngoingSymptomsCard.swift @@ -0,0 +1,117 @@ +import SwiftUI +import SwiftData +import Combine + +struct OngoingSymptomsCard: View { + @Query(filter: #Predicate { $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 + } + } +} diff --git a/康康/Features/Symptom/SymptomEndSheet.swift b/康康/Features/Symptom/SymptomEndSheet.swift new file mode 100644 index 0000000..8646a9b --- /dev/null +++ b/康康/Features/Symptom/SymptomEndSheet.swift @@ -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() + } +} + diff --git a/康康/Features/Symptom/SymptomStartSheet.swift b/康康/Features/Symptom/SymptomStartSheet.swift new file mode 100644 index 0000000..71e0457 --- /dev/null +++ b/康康/Features/Symptom/SymptomStartSheet.swift @@ -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) +} diff --git a/康康/Features/Timeline/DateSection.swift b/康康/Features/Timeline/DateSection.swift new file mode 100644 index 0000000..1517727 --- /dev/null +++ b/康康/Features/Timeline/DateSection.swift @@ -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: "刚刚") +} diff --git a/康康/Features/Timeline/TimelineEntry.swift b/康康/Features/Timeline/TimelineEntry.swift new file mode 100644 index 0000000..cdd9187 --- /dev/null +++ b/康康/Features/Timeline/TimelineEntry.swift @@ -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() + + // 先找 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 + } +} diff --git a/康康/Features/Timeline/TimelineEntryDetailView.swift b/康康/Features/Timeline/TimelineEntryDetailView.swift new file mode 100644 index 0000000..de663ce --- /dev/null +++ b/康康/Features/Timeline/TimelineEntryDetailView.swift @@ -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 形如 `-` / `bp--`)。 + /// 主页「最近记录」与档案库 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-- + 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(@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()) + } +} diff --git a/康康/Features/Timeline/TimelineRow.swift b/康康/Features/Timeline/TimelineRow.swift new file mode 100644 index 0000000..4fbd8fd --- /dev/null +++ b/康康/Features/Timeline/TimelineRow.swift @@ -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()) + } +} diff --git a/康康/Features/Trends/CalendarMarkers.swift b/康康/Features/Trends/CalendarMarkers.swift new file mode 100644 index 0000000..6ae71d3 --- /dev/null +++ b/康康/Features/Trends/CalendarMarkers.swift @@ -0,0 +1,108 @@ +import SwiftUI +import SwiftData +import Foundation + +struct SymptomRange: Identifiable, Hashable { + let id: String + let name: String + let startDay: Date + let endDay: Date + let severity: Int + let isOngoing: Bool + + var color: Color { + switch severity { + case 1, 2: return Tj.Palette.leaf + case 3: return Tj.Palette.amber + default: return Tj.Palette.brick + } + } + + func contains(_ day: Date, calendar: Calendar = .current) -> Bool { + let d = calendar.startOfDay(for: day) + return d >= startDay && d <= endDay + } + + func position(_ day: Date, calendar: Calendar = .current) -> Position { + let d = calendar.startOfDay(for: day) + let isStart = d == startDay + let isEnd = d == endDay + if isStart && isEnd { return .single } + if isStart { return .start } + if isEnd { return .end } + return .middle + } + + enum Position { case single, start, middle, end } +} + +struct DayMarks: Hashable { + var abnormalCount: Int = 0 + var normalCount: Int = 0 + var reportCount: Int = 0 + var diaryCount: Int = 0 + + var hasAnyEvent: Bool { + abnormalCount + normalCount + reportCount + diaryCount > 0 + } +} + +struct CalendarData { + let dayMarks: [Date: DayMarks] + let symptomRanges: [SymptomRange] + + func marks(for day: Date, calendar: Calendar = .current) -> DayMarks { + dayMarks[calendar.startOfDay(for: day)] ?? DayMarks() + } + + func ranges(touching day: Date, calendar: Calendar = .current) -> [SymptomRange] { + symptomRanges.filter { $0.contains(day, calendar: calendar) } + } + + static func build(indicators: [Indicator], + reports: [Report], + diaries: [DiaryEntry], + symptoms: [Symptom], + now: Date = .now, + calendar: Calendar = .current) -> CalendarData { + var buckets: [Date: DayMarks] = [:] + + for i in indicators { + let day = calendar.startOfDay(for: i.capturedAt) + var m = buckets[day] ?? DayMarks() + if i.status == .normal { + m.normalCount += 1 + } else { + m.abnormalCount += 1 + } + buckets[day] = m + } + + for r in reports { + let day = calendar.startOfDay(for: r.reportDate) + var m = buckets[day] ?? DayMarks() + m.reportCount += 1 + buckets[day] = m + } + + for d in diaries { + let day = calendar.startOfDay(for: d.createdAt) + var m = buckets[day] ?? DayMarks() + m.diaryCount += 1 + buckets[day] = m + } + + let ranges: [SymptomRange] = symptoms.map { s in + SymptomRange( + id: "\(s.persistentModelID)", + name: s.name, + startDay: calendar.startOfDay(for: s.startedAt), + endDay: calendar.startOfDay(for: s.endedAt ?? now), + severity: s.severity, + isOngoing: s.isOngoing + ) + } + + return CalendarData(dayMarks: buckets, symptomRanges: ranges) + } +} diff --git a/康康/Features/Trends/CalendarMonthGrid.swift b/康康/Features/Trends/CalendarMonthGrid.swift new file mode 100644 index 0000000..f5210d6 --- /dev/null +++ b/康康/Features/Trends/CalendarMonthGrid.swift @@ -0,0 +1,223 @@ +import SwiftUI + +struct CalendarMonthGrid: View { + let monthAnchor: Date + let data: CalendarData + let selectedDate: Date? + let onTapDay: (Date) -> Void + + init(monthAnchor: Date, + data: CalendarData, + selectedDate: Date? = nil, + onTapDay: @escaping (Date) -> Void) { + self.monthAnchor = monthAnchor + self.data = data + self.selectedDate = selectedDate + self.onTapDay = onTapDay + } + + private let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.firstWeekday = 2 // 周一开始 + c.locale = Locale.current + return c + }() + + private let weekdayLabels = [ + String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), + String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"), + String(appLoc: "日") + ] + private let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7) + + private var days: [DayCell] { + guard let monthInterval = calendar.dateInterval(of: .month, for: monthAnchor) else { + return [] + } + let firstOfMonth = monthInterval.start + let weekdayIndex = (calendar.component(.weekday, from: firstOfMonth) - calendar.firstWeekday + 7) % 7 + let daysInMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)?.count ?? 30 + + var cells: [DayCell] = [] + // leading padding (上月尾) + for offset in (0.. today + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(backgroundFill) + + // 选中描边 + if isSelected { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(Tj.Palette.brick, lineWidth: 1.5) + } + + VStack(spacing: 2) { + Text("\(dayNumber)") + .font(.system(size: 13, + weight: (isToday || isSelected) ? .bold : .regular, + design: .default)) + .foregroundStyle(textColor) + .padding(.top, 4) + + // 症状连续条 + if !ranges.isEmpty { + VStack(spacing: 1) { + ForEach(Array(ranges.prefix(2).enumerated()), id: \.element.id) { _, range in + symptomBar(range) + } + if ranges.count > 2 { + Text("+\(ranges.count - 2)") + .font(.system(size: 7, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + } + } + } + + // 异常 / 普通点 + if marks.hasAnyEvent { + HStack(spacing: 2) { + if marks.abnormalCount > 0 { + Circle().fill(Tj.Palette.brick).frame(width: 4, height: 4) + } + if marks.reportCount > 0 { + Circle().fill(Tj.Palette.ink2).frame(width: 4, height: 4) + } + if marks.normalCount > 0 && marks.abnormalCount == 0 { + Circle().fill(Tj.Palette.leaf).frame(width: 4, height: 4) + } + if marks.diaryCount > 0 { + Circle().fill(Tj.Palette.text3.opacity(0.7)).frame(width: 4, height: 4) + } + } + } + + Spacer(minLength: 0) + } + } + .frame(height: 56) + .contentShape(Rectangle()) + } + + private var textColor: Color { + if !cell.inCurrentMonth { return Tj.Palette.text3.opacity(0.5) } + if isSelected { return Tj.Palette.brick } + if isToday { return Tj.Palette.ink } + return Tj.Palette.text + } + + private var backgroundFill: Color { + if isSelected { return Tj.Palette.brickSoft.opacity(0.5) } + if isToday { return Tj.Palette.sand2 } + return .clear + } + + private func symptomBar(_ range: SymptomRange) -> some View { + let pos = range.position(cell.date, calendar: calendar) + let leadingRadius: CGFloat = (pos == .start || pos == .single) ? 3 : 0 + let trailingRadius: CGFloat = (pos == .end || pos == .single) ? 3 : 0 + return GeometryReader { geo in + UnevenRoundedRectangle( + topLeadingRadius: leadingRadius, + bottomLeadingRadius: leadingRadius, + bottomTrailingRadius: trailingRadius, + topTrailingRadius: trailingRadius, + style: .continuous + ) + .fill(range.color) + .frame( + width: barWidth(for: pos, in: geo.size.width), + height: 4 + ) + .frame(maxWidth: .infinity, + alignment: barAlignment(for: pos)) + } + .frame(height: 4) + } + + private func barWidth(for pos: SymptomRange.Position, in cellWidth: CGFloat) -> CGFloat { + switch pos { + case .single: return cellWidth - 8 + case .start, .end: return cellWidth - 2 + case .middle: return cellWidth + 4 // 越界让相邻天视觉连接 + } + } + + private func barAlignment(for pos: SymptomRange.Position) -> Alignment { + switch pos { + case .start: return .leading + case .end: return .trailing + case .single: return .center + case .middle: return .center + } + } +} diff --git a/康康/Features/Trends/CalendarYearGrid.swift b/康康/Features/Trends/CalendarYearGrid.swift new file mode 100644 index 0000000..0d0ce82 --- /dev/null +++ b/康康/Features/Trends/CalendarYearGrid.swift @@ -0,0 +1,112 @@ +import SwiftUI + +struct CalendarYearGrid: View { + let year: Int + let data: CalendarData + let onTapMonth: (Date) -> Void + + private let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.firstWeekday = 2 + c.locale = Locale.current + return c + }() + + private var monthAnchors: [Date] { + (1...12).compactMap { m in + var comps = DateComponents() + comps.year = year; comps.month = m; comps.day = 1 + return calendar.date(from: comps) + } + } + + private let columns = Array(repeating: GridItem(.flexible(), spacing: 14), count: 3) + + var body: some View { + LazyVGrid(columns: columns, spacing: 18) { + ForEach(monthAnchors, id: \.self) { anchor in + Button { + onTapMonth(anchor) + } label: { + MiniMonth(anchor: anchor, data: data, calendar: calendar) + } + .buttonStyle(.plain) + } + } + } +} + +private struct MiniMonth: View { + let anchor: Date + let data: CalendarData + let calendar: Calendar + + private var monthLabel: String { + anchor.formatted(.dateTime.month()) + } + + private var days: [Date] { + guard let interval = calendar.dateInterval(of: .month, for: anchor) else { return [] } + // 用 range(of:.day,in:.month) 求当月天数,避免按秒差折算在 DST 切换月份偏 1 天。 + let count = calendar.range(of: .day, in: .month, for: anchor)?.count ?? 30 + return (0.. some View { + let marks = data.marks(for: date, calendar: calendar) + let ranges = data.ranges(touching: date, calendar: calendar) + let color: Color = { + if marks.abnormalCount > 0 { return Tj.Palette.brick } + if let topSeverity = ranges.map(\.severity).max() { + switch topSeverity { + case 1, 2: return Tj.Palette.leaf + case 3: return Tj.Palette.amber + default: return Tj.Palette.brick + } + } + if marks.hasAnyEvent { return Tj.Palette.text3.opacity(0.6) } + return Tj.Palette.lineSoft + }() + let isToday = calendar.isDateInToday(date) + return RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(color) + .frame(height: 8) + .overlay( + RoundedRectangle(cornerRadius: 2, style: .continuous) + .strokeBorder(Tj.Palette.ink, lineWidth: isToday ? 1 : 0) + ) + } +} diff --git a/康康/Features/Trends/DayDetailSheet.swift b/康康/Features/Trends/DayDetailSheet.swift new file mode 100644 index 0000000..d1b3412 --- /dev/null +++ b/康康/Features/Trends/DayDetailSheet.swift @@ -0,0 +1,392 @@ +import SwiftUI +import SwiftData + +struct SelectedDay: Identifiable, Hashable { + let date: Date + var id: TimeInterval { date.timeIntervalSince1970 } +} + +// MARK: - DayDetailContent(可 inline 或入 sheet) + +/// 选中日详情的核心渲染。无 sheet 外壳,可同时被 TrendsView inline 使用,也能被 sheet 包。 +struct DayDetailContent: View { + let date: Date + let indicators: [Indicator] + let reports: [Report] + let diaries: [DiaryEntry] + let symptoms: [Symptom] + /// 是否显示日期 header(inline 时通常自带 header,sheet 模式让 DayDetailSheet 自己画) + var showHeader: Bool = true + + @State private var endingSymptom: Symptom? + + private let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.locale = Locale.current + return c + }() + + // MARK: 当日筛选 + + private var dayIndicators: [Indicator] { + indicators.filter { calendar.isDate($0.capturedAt, inSameDayAs: date) } + } + private var dayReports: [Report] { + reports.filter { calendar.isDate($0.reportDate, inSameDayAs: date) } + } + private var dayDiaries: [DiaryEntry] { + diaries.filter { calendar.isDate($0.createdAt, inSameDayAs: date) } + } + private var daySymptoms: [(symptom: Symptom, state: SymptomDayState)] { + symptoms.compactMap { s in + let start = calendar.startOfDay(for: s.startedAt) + let end = calendar.startOfDay(for: s.endedAt ?? .now) + let target = calendar.startOfDay(for: date) + guard target >= start && target <= end else { return nil } + let state: SymptomDayState + if start == end && s.isOngoing { state = .startedToday } + else if target == start { state = .startedToday } + else if !s.isOngoing && target == end { state = .endedToday } + else { state = .ongoing } + return (s, state) + } + } + private var totalCount: Int { + dayIndicators.count + dayReports.count + dayDiaries.count + daySymptoms.count + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + if showHeader { header } + if totalCount == 0 { + emptyState + } else { + if !daySymptoms.isEmpty { + section(String(appLoc: "症状"), count: daySymptoms.count) { + VStack(spacing: 8) { + ForEach(daySymptoms, id: \.symptom.id) { item in + symptomRow(item.symptom, state: item.state) + } + } + } + } + if !dayIndicators.isEmpty { + section(String(appLoc: "指标"), count: dayIndicators.count) { + VStack(spacing: 8) { + ForEach(dayIndicators) { i in indicatorRow(i) } + } + } + } + if !dayReports.isEmpty { + section(String(appLoc: "报告"), count: dayReports.count) { + VStack(spacing: 8) { + ForEach(dayReports) { r in reportRow(r) } + } + } + } + if !dayDiaries.isEmpty { + section(String(appLoc: "日记"), count: dayDiaries.count) { + VStack(spacing: 8) { + ForEach(dayDiaries) { d in diaryRow(d) } + } + } + } + } + } + .sheet(item: $endingSymptom) { sym in + SymptomEndSheet(symptom: sym) + } + } + + // MARK: header + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text(dateLine) + .font(.system(size: 12, weight: .semibold)) + .tracking(0.5) + .foregroundStyle(Tj.Palette.text3) + Text(dayLabel) + .font(.tjTitle(22)) + .foregroundStyle(Tj.Palette.text) + } + Spacer() + if totalCount > 0 { + Text("\(totalCount) 条") + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + } + } + } + + private var dateLine: String { + date.formatted(.dateTime.year()) + " · " + weekdayLabel + } + + private var dayLabel: String { + date.formatted(.dateTime.month().day()) + } + + private var weekdayLabel: String { + date.formatted(.dateTime.weekday(.wide)) + } + + // MARK: section helper + + private func section(_ title: String, + count: Int, + @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .tracking(0.3) + .foregroundStyle(Tj.Palette.text2) + Text("\(count)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + Spacer() + } + content() + } + } + + // MARK: rows + + private func symptomRow(_ s: Symptom, state: SymptomDayState) -> some View { + HStack(spacing: 12) { + Capsule() + .fill(severityColor(s.severity)) + .frame(width: 4, height: 36) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(s.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text(state.badge) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(state.badgeFg) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(state.badgeBg)) + } + Text("\(state.subtitle) · 持续 \(formatDuration(s.duration))") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer(minLength: 6) + if s.isOngoing { + Button { + endingSymptom = s + } 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(12) + .tjCard(bordered: true) + } + + private func indicatorRow(_ i: Indicator) -> some View { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(indicatorAccent(i).opacity(0.12)) + Image(systemName: "drop.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(indicatorAccent(i)) + } + .frame(width: 32, height: 32) + VStack(alignment: .leading, spacing: 2) { + Text(i.name) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Tj.Palette.text) + .lineLimit(1) + if !i.range.isEmpty { + Text("参考 \(i.range)") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + } + Spacer(minLength: 6) + Text("\(i.value) \(i.unit)\(arrow(i))") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(i.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick) + .lineLimit(1) + .fixedSize() + } + .padding(12) + .tjCard(bordered: true) + } + + private func reportRow(_ r: Report) -> some View { + let highCount = r.indicators.filter { $0.status == .high }.count + let lowCount = r.indicators.filter { $0.status == .low }.count + let summary = TimelineEntry.abnormalSummary(high: highCount, low: lowCount) + return HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Tj.Palette.ink2.opacity(0.12)) + Image(systemName: "doc.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Tj.Palette.ink2) + } + .frame(width: 32, height: 32) + VStack(alignment: .leading, spacing: 2) { + Text(r.title) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Tj.Palette.text) + .lineLimit(1) + Text("\(r.type.label) · 共 \(r.pageCount) 页") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer(minLength: 6) + if let summary { + Text(summary) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundStyle(Tj.Palette.brick) + } + } + .padding(12) + .tjCard(bordered: true) + } + + private func diaryRow(_ d: DiaryEntry) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(d.createdAt.formatted(date: .omitted, time: .shortened)) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + Spacer() + } + Text(d.content) + .font(.tjSerifBody(14)) + .foregroundStyle(Tj.Palette.text) + .lineSpacing(4) + .multilineTextAlignment(.leading) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .tjCard(bordered: true) + } + + private var emptyState: some View { + VStack(spacing: 8) { + TjPlaceholder(label: String(appLoc: "这一天还没有记录")) + .frame(height: 90) + .frame(maxWidth: 240) + Text("点底部 + 号可以补一条") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + } + + 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 + } + } + + private func indicatorAccent(_ i: Indicator) -> Color { + i.status == .normal ? Tj.Palette.leaf : Tj.Palette.brick + } + + private func arrow(_ i: Indicator) -> String { + switch i.status { + case .high: return " ↑" + case .low: return " ↓" + case .normal: return "" + } + } +} + +// MARK: - Sheet wrapper(保留;现在 TrendsView 走 inline,但其他入口可能用) + +struct DayDetailSheet: View { + let date: Date + let indicators: [Indicator] + let reports: [Report] + let diaries: [DiaryEntry] + let symptoms: [Symptom] + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Tj.Palette.line) + .frame(width: 40, height: 4) + .padding(.top, 10) + .padding(.bottom, 14) + ScrollView(showsIndicators: false) { + DayDetailContent( + date: date, + indicators: indicators, + reports: reports, + diaries: diaries, + symptoms: symptoms, + showHeader: true + ) + .padding(.horizontal, 20) + .padding(.bottom, 24) + } + } + .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) + } +} + +// MARK: - SymptomDayState + +enum SymptomDayState { + case startedToday, ongoing, endedToday + + var subtitle: String { + switch self { + case .startedToday: return String(appLoc: "今天开始") + case .ongoing: return String(appLoc: "进行中") + case .endedToday: return String(appLoc: "今天结束") + } + } + + var badge: String { + switch self { + case .startedToday: return String(appLoc: "开始") + case .ongoing: return String(appLoc: "持续") + case .endedToday: return String(appLoc: "结束") + } + } + + var badgeBg: Color { + switch self { + case .startedToday: return Tj.Palette.brickSoft + case .ongoing: return Tj.Palette.sand2 + case .endedToday: return Tj.Palette.leafSoft + } + } + + var badgeFg: Color { + switch self { + case .startedToday: return Tj.Palette.brick + case .ongoing: return Tj.Palette.text2 + case .endedToday: return Tj.Palette.leaf + } + } +} diff --git a/康康/Features/Trends/SeriesBucket.swift b/康康/Features/Trends/SeriesBucket.swift new file mode 100644 index 0000000..d354fb3 --- /dev/null +++ b/康康/Features/Trends/SeriesBucket.swift @@ -0,0 +1,177 @@ +import SwiftUI +import SwiftData +import Foundation + +/// 长期监测系列在 Trends 折线图里的展示桶。 +/// 单系列(血糖/体重/...)= 1 个 SeriesLine;血压特殊 = 收缩 + 舒张 2 条线同卡。 +struct SeriesBucket: Identifiable { + let id: String + let title: String + let unit: String + let lines: [SeriesLine] + let latestDate: Date + + struct SeriesLine: Identifiable { + let id: String + let seriesKey: String + let label: String? + let color: Color + let points: [Point] + let referenceRange: ClosedRange? + + var latestPoint: Point? { points.last } + } + + struct Point: Identifiable, Hashable { + let id: String + let date: Date + let value: Double + let status: IndicatorStatus + } +} + +extension SeriesBucket { + /// 把全表 Indicator(无 seriesKey 的会被跳过)折成 SeriesBucket 列表。 + /// 同 seriesKey 内按 capturedAt 升序;BP 两个 key 合并成一个 bucket; + /// `minPoints` 以下的系列不返回,默认 2(单点不画线)。 + static func build(from indicators: [Indicator], + profile: UserProfile? = nil, + customMetrics: [CustomMonitorMetric] = [], + minPoints: Int = 2) -> [SeriesBucket] { + var buckets: [String: [Indicator]] = [:] + for i in indicators { + guard let key = i.seriesKey, !key.isEmpty else { continue } + buckets[key, default: []].append(i) + } + + // 合并血压 + let bpKeys: Set = ["bp.systolic", "bp.diastolic"] + let bpHasEnoughPoints = (buckets["bp.systolic"]?.count ?? 0) >= minPoints + + var results: [SeriesBucket] = [] + + if bpHasEnoughPoints { + results.append(buildBP(from: buckets, profile: profile)) + } + for k in bpKeys { buckets.removeValue(forKey: k) } + + let customByKey: [String: CustomMonitorMetric] = Dictionary( + uniqueKeysWithValues: customMetrics.map { ($0.seriesKey, $0) } + ) + + for (key, items) in buckets { + guard items.count >= minPoints else { continue } + if let bucket = buildSingle(key: key, items: items, + profile: profile, + custom: customByKey[key]) { + results.append(bucket) + } + } + + return results.sorted { $0.latestDate > $1.latestDate } + } + + private static func buildSingle(key: String, + items: [Indicator], + profile: UserProfile?, + custom: CustomMonitorMetric? = nil) -> SeriesBucket? { + let sorted = items.sorted { $0.capturedAt < $1.capturedAt } + guard let latest = sorted.last else { return nil } + + // 优先 custom,其次 builtin metric,最后 fallback 到 Indicator 自身 + let metric = monitorMetric(for: key) + let field = metric?.fields.first { $0.seriesKey == key } + let title = custom?.name + ?? metric?.displayName + ?? sorted.first?.name + ?? key + let unit = custom?.unit.nonEmptyOr(nil) + ?? field?.unit + ?? sorted.first?.unit + ?? "" + let range = custom?.referenceRange + ?? field.flatMap { metric?.effectiveRange(for: $0, profile: profile) } + + let line = SeriesLine( + id: key, + seriesKey: key, + label: nil, + color: Tj.Palette.ink, + points: sorted.compactMap { point(from: $0) }, + referenceRange: range + ) + + return SeriesBucket( + id: key, + title: title, + unit: unit, + lines: [line], + latestDate: latest.capturedAt + ) + } + + private static func buildBP(from buckets: [String: [Indicator]], + profile: UserProfile?) -> SeriesBucket { + let m = MonitorMetric.bloodPressure + let sysField = m.fields[0] + let diaField = m.fields[1] + + let sysItems = (buckets["bp.systolic"] ?? []).sorted { $0.capturedAt < $1.capturedAt } + let diaItems = (buckets["bp.diastolic"] ?? []).sorted { $0.capturedAt < $1.capturedAt } + + let sysLine = SeriesLine( + id: "bp.systolic", + seriesKey: "bp.systolic", + label: String(appLoc: "收缩"), + color: Tj.Palette.brick, + points: sysItems.compactMap { point(from: $0) }, + referenceRange: m.effectiveRange(for: sysField, profile: profile) + ) + let diaLine = SeriesLine( + id: "bp.diastolic", + seriesKey: "bp.diastolic", + label: String(appLoc: "舒张"), + color: Tj.Palette.leaf, + points: diaItems.compactMap { point(from: $0) }, + referenceRange: m.effectiveRange(for: diaField, profile: profile) + ) + + let latest = max( + sysItems.last?.capturedAt ?? .distantPast, + diaItems.last?.capturedAt ?? .distantPast + ) + + // 只保留有数据点的线:某一路(收缩/舒张)为空时不画空线 + 残缺图例。 + let lines = [sysLine, diaLine].filter { !$0.points.isEmpty } + return SeriesBucket( + id: "bp", + title: String(appLoc: "血压"), + unit: "mmHg", + lines: lines, + latestDate: latest + ) + } + + private static func point(from i: Indicator) -> Point? { + guard let v = Double(i.value.trimmingCharacters(in: .whitespaces)) else { return nil } + return Point( + id: "\(i.persistentModelID)", + date: i.capturedAt, + value: v, + status: i.status + ) + } + + private static func monitorMetric(for seriesKey: String) -> MonitorMetric? { + MonitorMetric.allCases.first { m in + m.fields.contains { $0.seriesKey == seriesKey } + } + } +} + +private extension String { + /// 空串 → fallback;非空 → 自身。 + func nonEmptyOr(_ fallback: String?) -> String? { + trimmingCharacters(in: .whitespaces).isEmpty ? fallback : self + } +} diff --git a/康康/Features/Trends/SeriesChartCard.swift b/康康/Features/Trends/SeriesChartCard.swift new file mode 100644 index 0000000..e331bca --- /dev/null +++ b/康康/Features/Trends/SeriesChartCard.swift @@ -0,0 +1,182 @@ +import SwiftUI +import Charts + +struct SeriesChartCard: View { + let bucket: SeriesBucket + + private var allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)] { + bucket.lines.flatMap { line in line.points.map { (line, $0) } } + } + + private var dateDomain: ClosedRange? { + let dates = allPoints.map(\.point.date) + guard let lo = dates.min(), let hi = dates.max() else { return nil } + if lo == hi { + // 只有一个点的极端情况:扩 1 天显示 + let cal = Calendar.current + let earlier = cal.date(byAdding: .hour, value: -12, to: lo) ?? lo + let later = cal.date(byAdding: .hour, value: 12, to: hi) ?? hi + return earlier...later + } + return lo...hi + } + + private var valueDomain: ClosedRange? { + var lo = Double.greatestFiniteMagnitude + var hi = -Double.greatestFiniteMagnitude + for (_, p) in allPoints { + lo = min(lo, p.value) + hi = max(hi, p.value) + } + for line in bucket.lines { + if let r = line.referenceRange { + lo = min(lo, r.lowerBound) + hi = max(hi, r.upperBound) + } + } + // 无数据时 lo>hi → nil;所有点同值(lo==hi)时按值本身对称留白, + // 否则会落到 0...1 把数据点挤出可视域。 + guard lo <= hi else { return nil } + let span = hi - lo + let pad = span > 0 ? max(1, span * 0.12) : max(1, abs(lo) * 0.1) + return (lo - pad)...(hi + pad) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + chart + .frame(height: 120) + if bucket.lines.count > 1 { + legendLine + } + } + .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 var header: some View { + HStack(alignment: .lastTextBaseline, spacing: 10) { + Text(bucket.title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text("\(allPoints.count) 条 · 近 \(daysSpanLabel)") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + Spacer() + latestValueBadge + } + } + + private var latestValueBadge: some View { + let parts = bucket.lines.compactMap { line -> String? in + guard let p = line.latestPoint else { return nil } + return formatValue(p.value) + } + let joined = parts.joined(separator: " / ") + let anyAbnormal = bucket.lines.contains { line in + (line.latestPoint?.status ?? .normal) != .normal + } + return HStack(spacing: 4) { + Text(joined) + .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .foregroundStyle(anyAbnormal ? Tj.Palette.brick : Tj.Palette.text) + Text(bucket.unit) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + } + } + + private var chart: some View { + Chart { + // 参考范围带 + ForEach(bucket.lines) { line in + if let r = line.referenceRange, + let dom = dateDomain { + RectangleMark( + xStart: .value("start", dom.lowerBound), + xEnd: .value("end", dom.upperBound), + yStart: .value("lo", r.lowerBound), + yEnd: .value("hi", r.upperBound) + ) + .foregroundStyle(line.color.opacity(0.08)) + } + } + + // 折线 + 点 + ForEach(bucket.lines) { line in + ForEach(line.points) { p in + LineMark( + x: .value("时间", p.date), + y: .value(line.label ?? bucket.title, p.value) + ) + .foregroundStyle(line.color) + .interpolationMethod(.catmullRom) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + .symbol { + Circle() + .fill(line.color) + .frame(width: 6, height: 6) + } + } + } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 4)) { _ in + AxisGridLine().foregroundStyle(Tj.Palette.lineSoft) + AxisValueLabel(format: .dateTime.month(.abbreviated).day(), + centered: false) + .foregroundStyle(Tj.Palette.text3) + } + } + .chartYAxis { + AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) { _ in + AxisGridLine().foregroundStyle(Tj.Palette.lineSoft) + AxisValueLabel() + .foregroundStyle(Tj.Palette.text3) + .font(.system(size: 10, design: .monospaced)) + } + } + .chartYScale(domain: valueDomain ?? 0...1) + } + + private var legendLine: some View { + HStack(spacing: 12) { + ForEach(bucket.lines) { line in + HStack(spacing: 4) { + Circle() + .fill(line.color) + .frame(width: 8, height: 8) + Text(line.label ?? line.seriesKey) + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text2) + } + } + } + } + + private var daysSpanLabel: String { + guard let dom = dateDomain else { return "—" } + let days = Calendar.current.dateComponents([.day], + from: dom.lowerBound, + to: dom.upperBound).day ?? 0 + if days <= 0 { return String(appLoc: "今天") } + if days < 30 { return String(appLoc: "\(days) 天") } + if days < 365 { return String(appLoc: "\(days / 30) 个月") } + return String(appLoc: "\(days / 365) 年") + } + + private func formatValue(_ v: Double) -> String { + v.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", v) + : String(format: "%.1f", v) + } +} diff --git a/康康/Features/Trends/TrendsView.swift b/康康/Features/Trends/TrendsView.swift new file mode 100644 index 0000000..d4e1ef0 --- /dev/null +++ b/康康/Features/Trends/TrendsView.swift @@ -0,0 +1,314 @@ +import SwiftUI +import SwiftData + +enum CalendarMode: String, CaseIterable, Identifiable { + case month, year + var id: String { rawValue } + var label: String { + switch self { + case .month: return String(appLoc: "月") + case .year: return String(appLoc: "年") + } + } +} + +struct TrendsView: 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 private var profiles: [UserProfile] + + @Query private var customMetrics: [CustomMonitorMetric] + + @State private var mode: CalendarMode = .month + @State private var anchor: Date = .now + /// 选中的当天 — 默认选今天,日历下方 inline 显示该日详情 + @State private var selectedDate: Date = .now + + private var profile: UserProfile? { profiles.first } + + private var seriesBuckets: [SeriesBucket] { + SeriesBucket.build(from: indicators, + profile: profile, + customMetrics: customMetrics) + } + + private let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.firstWeekday = 2 + c.locale = Locale.current + return c + }() + + @MainActor + private var data: CalendarData { + CalendarData.build( + indicators: indicators, + reports: reports, + diaries: diaries, + symptoms: symptoms + ) + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 18) { + header.padding(.top, 4) + modeSwitch + anchorBar + calendarBody + legend + if mode == .month { + dayDetailInline + } + seriesSection + } + .padding(.horizontal, 20) + .padding(.bottom, 24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Tj.Palette.sand.ignoresSafeArea()) + } + + /// 日历下方 inline 显示选中天的详情(symptoms / indicators / reports / diaries) + private var dayDetailInline: some View { + VStack(alignment: .leading, spacing: 0) { + DayDetailContent( + date: selectedDate, + indicators: indicators, + reports: reports, + diaries: diaries, + symptoms: symptoms, + showHeader: true + ) + .padding(14) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + ) + .animation(.snappy(duration: 0.2), value: selectedDate) + } + + private var header: some View { + HStack(alignment: .lastTextBaseline) { + Text("趋势") + .font(.tjTitle(26)) + .foregroundStyle(Tj.Palette.text) + Spacer() + Button { + withAnimation(.snappy(duration: 0.2)) { + anchor = .now + selectedDate = .now + } + } label: { + Text("回到今天") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + } + .buttonStyle(.plain) + } + } + + private var modeSwitch: some View { + HStack(spacing: 0) { + ForEach(CalendarMode.allCases) { m in + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + mode = m + } + } label: { + Text(m.label) + .font(.system(size: 13, weight: mode == m ? .semibold : .regular)) + .foregroundStyle(mode == m ? Tj.Palette.paper : Tj.Palette.text) + .frame(maxWidth: .infinity) + .padding(.vertical, 9) + .background( + Capsule().fill(mode == m ? Tj.Palette.ink : Color.clear) + ) + } + .buttonStyle(.plain) + } + } + .padding(3) + .background(Capsule().fill(Tj.Palette.paper)) + .overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1)) + .frame(maxWidth: 220) + } + + private var anchorBar: some View { + HStack { + Button { shiftAnchor(-1) } label: { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .frame(width: 36, height: 36) + .background(Circle().fill(Tj.Palette.paper)) + .overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1)) + } + .buttonStyle(.plain) + + Spacer() + + Text(anchorTitle) + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + .contentTransition(.numericText()) + .animation(.snappy, value: anchor) + + Spacer() + + Button { shiftAnchor(1) } label: { + Image(systemName: "chevron.right") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .frame(width: 36, height: 36) + .background(Circle().fill(Tj.Palette.paper)) + .overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1)) + } + .buttonStyle(.plain) + .disabled(isAnchorAtFuture) + .opacity(isAnchorAtFuture ? 0.4 : 1) + } + } + + private var anchorTitle: String { + let style: Date.FormatStyle = mode == .month + ? .dateTime.year().month() + : .dateTime.year() + return anchor.formatted(style) + } + + @ViewBuilder + private var calendarBody: some View { + switch mode { + case .month: + CalendarMonthGrid(monthAnchor: anchor, data: data, selectedDate: selectedDate) { day in + withAnimation(.snappy(duration: 0.2)) { + selectedDate = day + } + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + ) + case .year: + CalendarYearGrid( + year: calendar.component(.year, from: anchor), + data: data + ) { tappedMonth in + anchor = tappedMonth + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + mode = .month + } + } + } + } + + @ViewBuilder + private var seriesSection: some View { + let buckets = seriesBuckets + if !buckets.isEmpty { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .lastTextBaseline) { + Text("长期监测") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + Text("\(buckets.count) 项") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + Spacer() + } + .padding(.top, 8) + + VStack(spacing: 12) { + ForEach(buckets) { bucket in + SeriesChartCard(bucket: bucket) + } + } + } + } + } + + private var legend: some View { + VStack(alignment: .leading, spacing: 8) { + Text("图例") + .font(.system(size: 11, weight: .semibold)) + .tracking(0.5) + .foregroundStyle(Tj.Palette.text3) + HStack(spacing: 14) { + legendItem(color: Tj.Palette.brick, label: String(appLoc: "指标异常")) + legendItem(color: Tj.Palette.amber, label: String(appLoc: "症状持续中")) + legendItem(color: Tj.Palette.ink2, label: String(appLoc: "报告归档")) + legendItem(color: Tj.Palette.leaf, label: String(appLoc: "正常")) + } + } + .padding(.top, 4) + } + + private func legendItem(color: Color, label: String) -> some View { + HStack(spacing: 5) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(color) + .frame(width: 14, height: 6) + Text(label) + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text2) + } + } + + private var isAnchorAtFuture: Bool { + switch mode { + case .month: + return calendar.isDate(anchor, equalTo: .now, toGranularity: .month) || + anchor > .now + case .year: + let nowYear = calendar.component(.year, from: .now) + let anchorYear = calendar.component(.year, from: anchor) + return anchorYear >= nowYear + } + } + + private func shiftAnchor(_ delta: Int) { + let component: Calendar.Component = (mode == .month) ? .month : .year + if let next = calendar.date(byAdding: component, value: delta, to: anchor) { + withAnimation(.snappy) { + anchor = next + // 翻月时把 selection 跟着走:同月内停在今天(如果是当前月)或 1 号 + if mode == .month { + if calendar.isDate(next, equalTo: .now, toGranularity: .month) { + selectedDate = .now + } else if let first = calendar.dateInterval(of: .month, for: next)?.start { + selectedDate = first + } + } + } + } + } +} + +#Preview { + TrendsView() + .modelContainer(for: [ + Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self + ], inMemory: true) +} diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings new file mode 100644 index 0000000..310c6cb --- /dev/null +++ b/康康/Localizable.xcstrings @@ -0,0 +1,12210 @@ +{ + "sourceLanguage" : "zh-Hans", + "strings" : { + "" : { + + }, + " / %lld · 像扫描文档一样对准" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : " / %lld · Align it like scanning a document" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : " / %lld · 書類をスキャンするように合わせる" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : " / %lld · 문서를 스캔하듯 맞추세요" + } + } + } + }, + "—" : { + + }, + "·" : { + + }, + "· %lld" : { + + }, + "· 按%lld岁调整" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "· Adjusted for age %lld" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "· %lld歳に合わせて調整" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "· %lld세 기준으로 조정" + } + } + } + }, + "···" : { + + }, + "(偏瘦)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(Underweight)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "(やせ気味)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "(저체중)" + } + } + } + }, + "(正常)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(Normal)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "(正常)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "(정상)" + } + } + } + }, + "(空日记)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(Empty diary)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "(空の日記)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "(빈 일기)" + } + } + } + }, + "(肥胖)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(Obese)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "(肥満)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "(비만)" + } + } + } + }, + "(超重)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(Overweight)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "(過体重)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "(과체중)" + } + } + } + }, + "「%@」是内置指标的名字 — 录入 grid 里会出现两个同名块" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%@\" is a built-in indicator name — two blocks with the same name will appear in the entry grid" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "「%@」は組み込み指標の名前です — 入力グリッドに同名のブロックが2つ表示されます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@'은(는) 기본 제공 지표 이름이에요 — 입력 그리드에 같은 이름의 블록이 두 개 나타나요" + } + } + } + }, + "/" : { + + }, + "%@ · 持续 %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · lasted %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · %2$@ 継続" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · %2$@ 지속" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ · 持续 %2$@" + } + } + } + }, + "%@ — %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ — %2$@" + } + } + } + }, + "%@ · %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ · %2$@" + } + } + } + }, + "%@ · 共 %lld 页" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · %2$lld pages total" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · 全 %2$lld ページ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · 총 %2$lld 페이지" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ · 共 %2$lld 页" + } + } + } + }, + "%@ %@%@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@%3$@" + } + } + } + }, + "%@ 解锁" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlock with %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ でロック解除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@(으)로 잠금 해제" + } + } + } + }, + "%@/%@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@/%2$@" + } + } + } + }, + "%@型" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@型" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@형" + } + } + } + }, + "%lld" : { + + }, + "%lld / %lld 项启用" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld / %2$lld enabled" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld / %2$lld 件 有効" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld / %2$lld 개 사용" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld / %2$lld 项启用" + } + } + } + }, + "%lld / 5" : { + + }, + "%lld 个" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 件" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 개" + } + } + } + }, + "%lld 个建议" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld suggestions" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 件の提案" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제안 %lld 개" + } + } + } + }, + "%lld 个提醒任务" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld reminders" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 件のリマインダー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리마인더 %lld개" + } + } + } + }, + "%lld 个提醒任务 · %lld 个开启中" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld reminders · %lld on" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 件のリマインダー · %lld 件オン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리마인더 %lld개 · %lld개 켜짐" + } + } + } + }, + "%lld 个月" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld months" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld か月" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 개월" + } + } + } + }, + "%lld 份" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld reports" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 件" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 건" + } + } + } + }, + "%lld 份 · %lld 项指标 · 端侧加密" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld reports · %2$lld indicators · on-device encryption" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld 件 · 指標 %2$lld 項目 · オンデバイス暗号化" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld 건 · 지표 %2$lld 개 · 온디바이스 암호화" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld 份 · %2$lld 项指标 · 端侧加密" + } + } + } + }, + "%lld 分钟" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld min" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 分" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 분" + } + } + } + }, + "%lld 天" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld days" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 日" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 일" + } + } + } + }, + "%lld 天 %lld 小时" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld d %2$lld h" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld 日 %2$lld 時間" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld 일 %2$lld 시간" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld 天 %2$lld 小时" + } + } + } + }, + "%lld 小时" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld h" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 時間" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 시간" + } + } + } + }, + "%lld 小时 %lld 分" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld h %2$lld min" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld 時間 %2$lld 分" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld 시간 %2$lld 분" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld 小时 %2$lld 分" + } + } + } + }, + "%lld 年" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld years" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 年" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 년" + } + } + } + }, + "%lld 条" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld entries" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 件" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 건" + } + } + } + }, + "%lld 条 · 近 %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld entries · last %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld 件 · 直近 %2$@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld 건 · 최근 %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld 条 · 近 %2$@" + } + } + } + }, + "%lld 页 · 100%% 本地推理 · 已用 %llds" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld pages · 100%% on-device · %2$llds elapsed" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld ページ · 100%% オンデバイス推論 · %2$llds 経過" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld 페이지 · 100%% 온디바이스 추론 · %2$llds 경과" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld 页 · 100%% 本地推理 · 已用 %2$llds" + } + } + } + }, + "%lld 项" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld indicators" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 項目" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 개" + } + } + } + }, + "%lld 项偏高" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld high" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 項目 高い" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 개 높음" + } + } + } + }, + "%lld 项启用" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld enabled" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 項目 有効" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 개 사용" + } + } + } + }, + "%lld." : { + + }, + "%lld/%lld 就绪" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld/%2$lld ready" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld/%2$lld 準備完了" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld/%2$lld 준비됨" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld/%2$lld 就绪" + } + } + } + }, + "%lld%%" : { + + }, + "%lld岁" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld yrs" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 歳" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 세" + } + } + } + }, + "%lld日" : { + + }, + "%lld月" : { + + }, + "+%lld" : { + + }, + "⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Notifications are off. Turn them on in Settings → Kangkang → Notifications" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ 通知が許可されていません。「設定 → Kangkang → 通知」で有効にしてください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ 알림 권한이 꺼져 있어요. '설정 → Kangkang → 알림'에서 켜세요" + } + } + } + }, + "1 项偏低" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 low" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 項目 低い" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 개 낮음" + } + } + } + }, + "3 页" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 pages" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 ページ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 페이지" + } + } + } + }, + "3 项偏高" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 high" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 項目 高い" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 개 높음" + } + } + } + }, + "100% 本地推理 · 模型仅需下载一次" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "100% on-device · download the model just once" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "100% オンデバイス推論 · モデルのダウンロードは一度だけ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "100% 온디바이스 추론 · 모델은 한 번만 다운로드" + } + } + } + }, + "100%% 本地推理 · 已用 %llds" : { + + }, + "2026 / 05 / 25 · 协和医院体检中心" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "2026 / 05 / 25 · PUMC Hospital Checkup Center" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "2026 / 05 / 25 · 協和病院 健診センター" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "2026 / 05 / 25 · 협화병원 건강검진센터" + } + } + } + }, + "2026 春季年度体检" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "2026 Spring Annual Checkup" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "2026年 春の定期健康診断" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "2026 봄 연례 건강검진" + } + } + } + }, + "A 型" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type A" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "A型" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "A형" + } + } + } + }, + "AB 型" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type AB" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AB型" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AB형" + } + } + } + }, + "AI 已识别到 1 项指标" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI detected 1 indicator" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AIが指標を1項目認識しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI가 지표 1개를 인식했어요" + } + } + } + }, + "AI 思考中… 本地推理,通常 5-10 秒" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI is thinking… on-device inference, usually 5-10 sec" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AIが考えています… オンデバイス推論、通常5〜10秒" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI가 생각 중… 온디바이스 추론, 보통 5~10초" + } + } + } + }, + "AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI inference runs locally on your device; aside from downloading the AI models, the app never uploads your health data." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI推論はデバイス上でローカルに実行されます。AIモデルのダウンロードを除き、アプリがあなたの健康データを自発的にアップロードすることはありません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 추론은 기기에서 로컬로 처리됩니다. AI 모델 다운로드를 제외하면 앱이 건강 데이터를 임의로 업로드하지 않아요." + } + } + } + }, + "AI 模型尚未准备好" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI model isn't ready yet" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AIモデルの準備ができていません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 모델이 아직 준비되지 않았어요" + } + } + } + }, + "AI 模型尚未准备好,请先到「我的 · 模型管理」下载。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI model isn't ready yet. Please download it under \"Me · Model Management\" first." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AIモデルの準備ができていません。先に「マイ · モデル管理」からダウンロードしてください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 모델이 아직 준비되지 않았어요. 먼저 「마이 · 모델 관리」에서 다운로드하세요." + } + } + } + }, + "AI 没有给出建议,请稍后重试" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI didn't return any suggestions. Please try again later." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AIから提案が得られませんでした。後でもう一度お試しください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI가 제안을 내놓지 못했어요. 잠시 후 다시 시도하세요." + } + } + } + }, + "AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI interpretations are generated from general health knowledge and do not account for your full medical history or individual condition. They are for everyday reference only." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AIによる解説は一般的な健康知識に基づいて生成されており、あなたの完全な病歴や個別の状況を把握しているわけではありません。日常の記録の参考程度にご利用ください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 해석은 일반적인 건강 지식을 바탕으로 생성되며, 당신의 전체 병력이나 개별 상황을 알지 못해요. 일상 기록 참고용으로만 사용하세요." + } + } + } + }, + "AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对," : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI recognition and interpretation may contain errors or omissions: always verify the values, units, and reference ranges captured from photos against the original report." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AIの認識・解説には誤りや見落としが生じる場合があります。撮影で得られた数値・単位・基準範囲は必ず元のレポートと照合してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 인식과 해석에는 오류나 누락이 있을 수 있어요. 촬영으로 얻은 수치, 단위, 참고 범위는 반드시 원본 리포트와 대조하세요." + } + } + } + }, + "AI 辅助 · 医生角度查漏补缺" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI assist · gap-checking from a doctor's perspective" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AIアシスト · 医師の視点で抜け漏れチェック" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 도움 · 의사 관점에서 빠진 부분 점검" + } + } + } + }, + "App 内所有 AI 生成的解读、趋势分析与问答内容仅供信息参考," : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All AI-generated interpretations, trend analyses, and Q&A content in the app are for informational reference only," + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリ内のAIが生成したすべての解説・トレンド分析・Q&Aの内容は情報の参考にとどまり、" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앱 내 AI가 생성한 모든 해석, 추세 분석, 질문·답변 내용은 정보 참고용일 뿐이며," + } + } + } + }, + "B 型" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type B" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "B型" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "B형" + } + } + } + }, + "BMI: %@ %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "BMI: %1$@ %2$@" + } + } + } + }, + "cm" : { + + }, + "end" : { + + }, + "Face ID 启动锁" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID Lock" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face IDロック" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID 잠금" + } + } + } + }, + "hi" : { + + }, + "kg" : { + + }, + "lo" : { + + }, + "mmHg" : { + + }, + "mmol/L" : { + + }, + "O 型" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type O" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "O型" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "O형" + } + } + } + }, + "p.%lld" : { + + }, + "QWEN2.5-VL · ON-DEVICE · SME2" : { + + }, + "start" : { + + }, + "VL 模型尚未就绪" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VL model not ready yet" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "VLモデルはまだ準備できていません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "VL 모델이 아직 준비되지 않았어요" + } + } + } + }, + "VL 模型未就绪" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VL model not ready" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "VLモデルが準備できていません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "VL 모델이 준비되지 않았어요" + } + } + } + }, + "VL 模型未就绪,先手动录入" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VL model not ready, enter manually for now" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "VLモデルが準備できていません。まず手動で入力してください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "VL 모델이 준비되지 않았어요. 우선 수동으로 입력하세요" + } + } + } + }, + "VL 模型未就绪,手动补充" : { + + }, + "VL 输出无法解析:%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not parse VL output: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "VLの出力を解析できません:%@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "VL 출력을 해석할 수 없어요: %@" + } + } + } + }, + "一" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mon" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "月" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "월" + } + } + } + }, + "一句话总结" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "One-line summary" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "一言まとめ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "한 줄 요약" + } + } + } + }, + "一张图,几秒搞定" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "One photo, done in seconds" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "写真1枚、数秒で完了" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사진 한 장, 몇 초면 끝" + } + } + } + }, + "三" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wed" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "水" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수" + } + } + } + }, + "上限" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upper limit" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "上限" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상한" + } + } + } + }, + "下午好" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Good afternoon" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "こんにちは" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "좋은 오후예요" + } + } + } + }, + "下载中" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloading" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロード中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 중" + } + } + } + }, + "下载中…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloading…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロード中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 중…" + } + } + } + }, + "下载全部模型 · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download all models · %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべてのモデルをダウンロード · %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 모델 다운로드 · %@" + } + } + } + }, + "下载失败" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download failed" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロードに失敗しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드에 실패했어요" + } + } + } + }, + "下载失败(HTTP %lld)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download failed (HTTP %lld)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロードに失敗しました(HTTP %lld)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드에 실패했어요(HTTP %lld)" + } + } + } + }, + "下限" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lower limit" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "下限" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "하한" + } + } + } + }, + "不愿透露" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prefer not to say" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "回答しない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "밝히고 싶지 않음" + } + } + } + }, + "不构成医疗建议,也不能替代执业医师、药师或其他专业人员的面诊、检查与意见。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This does not constitute medical advice and cannot replace in-person consultation, examination, or opinion from licensed physicians, pharmacists, or other professionals." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "これは医療上の助言を構成するものではなく、医師、薬剤師その他の専門家による対面診療、検査、意見に代わるものではありません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "의료 조언이 아니며, 의사·약사 또는 기타 전문가의 대면 진료, 검사 및 소견을 대신할 수 없어요." + } + } + } + }, + "不知道" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Don't know" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "わからない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모름" + } + } + } + }, + "两个模型都已就绪" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Both models are ready" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "両方のモデルが準備できました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "두 모델 모두 준비됐어요" + } + } + } + }, + "个人资料" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プロフィール" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개인 정보" + } + } + } + }, + "主要功能" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Main features" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "主な機能" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주요 기능" + } + } + } + }, + "主页" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Home" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ホーム" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "홈" + } + } + } + }, + "二" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tue" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "火" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "화" + } + } + } + }, + "五" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fri" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "金" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "금" + } + } + } + }, + "仅供参考,不构成医疗建议" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "For reference only, not medical advice" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "参考用です。医療上の助言ではありません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "참고용이며 의료 조언이 아니에요" + } + } + } + }, + "仅核对用 · 不保存照片" : { + + }, + "今天" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오늘" + } + } + } + }, + "今天开始" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Started today" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日から" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오늘 시작" + } + } + } + }, + "今天结束" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ended today" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日まで" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오늘 종료" + } + } + } + }, + "今天身体怎么样?吃了什么药、有什么感觉?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How are you feeling today? What did you take, and how do you feel?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日の体調はどうですか?何の薬を飲み、どんな感じですか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오늘 몸 상태는 어떠세요? 어떤 약을 드셨고, 어떤 느낌인가요?" + } + } + } + }, + "今日提醒" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today's Reminders" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日のリマインダー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오늘의 알림" + } + } + } + }, + "从文件导入(离线)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import from file (offline)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイルから取り込む(オフライン)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일에서 가져오기(오프라인)" + } + } + } + }, + "从相册选 ≤5 张" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pick up to 5 from album" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アルバムから5枚まで選択" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앨범에서 최대 5장 선택" + } + } + } + }, + "任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "For any health decision (whether to seek care, take medication, adjust treatment, etc.), consult a qualified medical professional and follow their advice." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "あらゆる健康上の判断(受診・服薬・治療方針の変更など)については、専門の医療従事者に相談し、その意見に従ってください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 건강 관련 결정(진료 여부, 복약, 치료 방안 조정 등)은 전문 의료인과 상담하고 그 의견을 따르세요." + } + } + } + }, + "但本地 AI 相关功能可能无法运行。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "However, on-device AI features may not work." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ただし、オンデバイスAI関連の機能は動作しない場合があります。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다만 온디바이스 AI 관련 기능은 작동하지 않을 수 있어요." + } + } + } + }, + "位置、可能诱因…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Location, possible triggers…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "部位、考えられる誘因…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "위치, 가능한 유발 요인…" + } + } + } + }, + "低密度脂蛋白" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "LDL" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "LDLコレステロール" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "LDL 콜레스테롤" + } + } + } + }, + "低密度脂蛋白 3.84 mmol/L ↑" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "LDL 3.84 mmol/L ↑" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "LDLコレステロール 3.84 mmol/L ↑" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "LDL 콜레스테롤 3.84 mmol/L ↑" + } + } + } + }, + "低密度脂蛋白胆固醇" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "LDL-C" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "LDLコレステロール" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "LDL 콜레스테롤" + } + } + } + }, + "体 检 报 告 (第 %lld 页)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checkup Report (Page %lld)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "健康診断レポート(%lldページ)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "건강검진 리포트(%lld페이지)" + } + } + } + }, + "体检报告" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checkup report" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "健康診断レポート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "건강검진 리포트" + } + } + } + }, + "体检报告 · 影像报告" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checkup report · Imaging report" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "健康診断レポート · 画像レポート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "건강검진 리포트 · 영상 리포트" + } + } + } + }, + "体检报告归档" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Archive a checkup report" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "健康診断レポートを保存" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "건강검진 리포트 보관" + } + } + } + }, + "体温" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temperature" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "体温" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "체온" + } + } + } + }, + "体重" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weight" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "体重" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "체중" + } + } + } + }, + "你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You can photograph checkup reports, lab reports, and imaging materials; images and data are stored on your device by default;" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "健康診断レポート、検査結果、画像資料を撮影できます。画像とデータは既定で本体に保存されます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "건강검진 리포트, 검사 결과, 영상 자료를 촬영할 수 있어요. 이미지와 데이터는 기본적으로 기기에 저장돼요." + } + } + } + }, + "你的健康档案已加密保护" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your health records are protected with encryption" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "あなたの健康記録は暗号化で保護されています" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "건강 기록이 암호화로 보호되고 있어요" + } + } + } + }, + "使用注意" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usage notes" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ご利用上の注意" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용 시 주의사항" + } + } + } + }, + "使用蜂窝网络下载?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download over cellular?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "モバイル通信でダウンロードしますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "셀룰러 네트워크로 다운로드할까요?" + } + } + } + }, + "例:最近血糖好像不稳,把过去三个月的化验单整理一下" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. My glucose seems unstable lately—organize my lab reports from the past three months" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "例:最近血糖値が不安定なようなので、過去3か月の検査結果を整理したい" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예: 최근 혈당이 불안정한 것 같은데, 지난 3개월간의 검사 결과를 정리해 주세요" + } + } + } + }, + "例:我感冒3天了,把最近一个月的健康情况给医生看" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. I've had a cold for 3 days—show my health over the past month to the doctor" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "例:風邪をひいて3日になります。直近1か月の健康状態を医師に見せたい" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예: 감기에 걸린 지 3일째인데, 최근 한 달간의 건강 상태를 의사에게 보여주고 싶어요" + } + } + } + }, + "例如:< 3.40 或 3.9 - 6.1" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. < 3.40 or 3.9 - 6.1" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "例:< 3.40 または 3.9 - 6.1" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예: < 3.40 또는 3.9 - 6.1" + } + } + } + }, + "例如:cm / 步 / 小时" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. cm / steps / hours" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "例:cm / 歩 / 時間" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예: cm / 걸음 / 시간" + } + } + } + }, + "例如:眼皮跳" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. eyelid twitching" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "例:まぶたのけいれん" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예: 눈꺼풀 떨림" + } + } + } + }, + "例如:空腹采血" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. fasting blood draw" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "例:空腹時採血" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예: 공복 채혈" + } + } + } + }, + "例如:腰围 / 步数 / 睡眠时长" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. waist circumference / step count / sleep duration" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "例:腹囲 / 歩数 / 睡眠時間" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예: 허리둘레 / 걸음 수 / 수면 시간" + } + } + } + }, + "例如:血红蛋白" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. hemoglobin" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "例:ヘモグロビン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예: 헤모글로빈" + } + } + } + }, + "保存" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장" + } + } + } + }, + "保存到记录" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save to Records" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "記録に保存" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록에 저장" + } + } + } + }, + "保存后会出现在录入选项里" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "After saving, it will appear in the entry options" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存すると入力オプションに表示されます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장하면 입력 옵션에 표시돼요" + } + } + } + }, + "保存归档" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save to archive" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アーカイブに保存" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관함에 저장" + } + } + } + }, + "偏低" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Low" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "低い" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "낮음" + } + } + } + }, + "偏低 ↓" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Low ↓" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "低い ↓" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "낮음 ↓" + } + } + } + }, + "偏高" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "High" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "高い" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "높음" + } + } + } + }, + "偏高 ↑" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "High ↑" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "高い ↑" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "높음 ↑" + } + } + } + }, + "做点什么?例:跑步5公里 / 吃2片护肝片" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What to do? e.g. Run 5 km / Take 2 pills" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "何をしますか?例:5km走る / 薬を2錠飲む" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "무엇을 하나요? 예: 5km 달리기 / 약 2알 복용" + } + } + } + }, + "健康日记" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Health diary" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "健康日記" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "건강 일기" + } + } + } + }, + "健康记录" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Health records" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "健康記録" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "건강 기록" + } + } + } + }, + "像扫描文档一样翻页拍摄" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shoot page by page, just like scanning a document" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ドキュメントをスキャンするようにページをめくって撮影" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서를 스캔하듯 페이지를 넘기며 촬영하세요" + } + } + } + }, + "先写几个字,AI 来帮忙补充" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jot down a few words and let AI help fill in the rest" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "数文字だけ書けば、AIが続きを補完します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "몇 글자만 적으면 AI가 나머지를 채워줘요" + } + } + } + }, + "免责声明" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disclaimer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "免責事項" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "면책 조항" + } + } + } + }, + "全部" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべて" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체" + } + } + } + }, + "全部 ›" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All ›" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべて ›" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 ›" + } + } + } + }, + "全部保存(%lld)" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save all (%lld)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべて保存(%lld)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 저장(%lld)" + } + } + } + }, + "全部已关闭(%lld 条)" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All closed (%lld)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべて終了しました(%lld件)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 종료됨(%lld건)" + } + } + } + }, + "六" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sat" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "土" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "토" + } + } + } + }, + "共 %lld 页" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld pages total" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "全 %lld ページ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "총 %lld 페이지" + } + } + } + }, + "关于" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "概要" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "정보" + } + } + } + }, + "关节痛" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Joint pain" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "関節痛" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "관절통" + } + } + } + }, + "关键报告归档" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Archive key reports" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "重要なレポートをアーカイブ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주요 리포트 보관" + } + } + } + }, + "关闭" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "閉じる" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "닫기" + } + } + } + }, + "其他" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Other" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "その他" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기타" + } + } + } + }, + "内容" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Content" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "内容" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "내용" + } + } + } + }, + "再拍一项" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capture another" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "もう一項目を撮影" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "한 항목 더 촬영" + } + } + } + }, + "再问一轮 · 让 AI 从新角度追问" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ask again · Let AI follow up from a new angle" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "もう一度質問 · AIが新たな視点で深掘り" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "한 번 더 질문 · AI가 새로운 관점에서 다시 물어봐요" + } + } + } + }, + "最近记录" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recent records" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "最近の記録" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최근 기록" + } + } + } + }, + "冠心病" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coronary heart disease" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "冠動脈疾患" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "관상동맥질환" + } + } + } + }, + "出生年份" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Birth year" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "生まれた年" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "출생 연도" + } + } + } + }, + "分享" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "共有" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공유" + } + } + } + }, + "切换后整个 App 立即生效,无需重启。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Changes take effect across the app immediately — no restart needed." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "切り替えるとアプリ全体に即時反映され、再起動は不要です。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전환하면 앱 전체에 즉시 적용되며 재시작이 필요 없어요." + } + } + } + }, + "刚刚" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Just now" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "たった今" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "방금" + } + } + } + }, + "删除" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제" + } + } + } + }, + "删除后无法恢复。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This can't be undone." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "削除すると元に戻せません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제하면 복구할 수 없습니다." + } + } + } + }, + "删除后无法恢复。源记录(指标、症状等)不受影响。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This cannot be undone after deletion. Source records (indicators, symptoms, etc.) are not affected." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "削除すると元に戻せません。元の記録(指標、症状など)には影響しません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제 후에는 복구할 수 없어요. 원본 기록(지표, 증상 등)에는 영향을 주지 않아요." + } + } + } + }, + "删除提醒" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete reminder" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リマインダーを削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알림 삭제" + } + } + } + }, + "删除这项自定义指标" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete this custom indicator" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このカスタム指標を削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 사용자 지정 지표 삭제" + } + } + } + }, + "到点啦,记得完成" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time's up — don't forget!" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "時間です。お忘れなく!" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시간이 되었어요. 잊지 마세요!" + } + } + } + }, + "剧烈" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Severe" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "激しい" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "심함" + } + } + } + }, + "加" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "追加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추가" + } + } + } + }, + "加一项" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add one" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "一項目を追加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "한 항목 추가" + } + } + } + }, + "加入记录" : { + + }, + "加载模型…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loading model…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "モデルを読み込み中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모델 불러오는 중…" + } + } + } + }, + "化验单" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lab report" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検査結果" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검사 결과" + } + } + } + }, + "化验单 · 处方" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lab report · Prescription" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検査結果 · 処方" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검사 결과 · 처방" + } + } + } + }, + "化验项快捷(不进趋势)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lab item shortcuts (not in Trends)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検査項目クイック(トレンドに含めない)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검사 항목 바로가기(추세에 미포함)" + } + } + } + }, + "单位" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unit" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "単位" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "단위" + } + } + } + }, + "单位(可选)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unit (optional)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "単位(任意)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "단위(선택)" + } + } + } + }, + "单张报告" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Single report" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1枚のレポート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "단일 리포트" + } + } + } + }, + "原图%lld张" : { + + }, + "原图与数据库采用系统级文件加密,随设备锁屏受到保护。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Original images and the database use system-level file encryption and are protected when your device is locked." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "元画像とデータベースはシステムレベルのファイル暗号化を使用し、デバイスのロックに連動して保護されます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원본 이미지와 데이터베이스는 시스템 수준의 파일 암호화를 사용하며 기기 잠금에 따라 보호됩니다." + } + } + } + }, + "去设置" : { + + }, + "参考" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reference" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "参考" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "참고" + } + } + } + }, + "参考 %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reference %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "参考 %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "참고 %@" + } + } + } + }, + "参考范围" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reference range" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "基準範囲" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "참고 범위" + } + } + } + }, + "参考范围(可选)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reference range (optional)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "基準範囲(任意)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "참고 범위(선택)" + } + } + } + }, + "发烧" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fever" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "発熱" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "발열" + } + } + } + }, + "取消" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャンセル" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "취소" + } + } + } + }, + "取消(图片不保留)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel (image not kept)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャンセル(画像は保存しません)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "취소(이미지 미보관)" + } + } + } + }, + "取消识别 · 改为手动录入" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel recognition · Switch to manual entry" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "認識をキャンセル · 手動入力に切り替え" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인식 취소 · 수동 입력으로 전환" + } + } + } + }, + "可选开启 Face ID 启动锁,进一步保护隐私。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optionally enable Face ID Lock for added privacy protection." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face IDロックを任意で有効にすると、プライバシーをさらに保護できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID 잠금을 선택적으로 켜면 개인정보를 더욱 보호할 수 있어요." + } + } + } + }, + "右上角 + 新建一个" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap + in the top right to create one" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "右上の + から新規作成します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오른쪽 위 +로 새로 만들어 보세요" + } + } + } + }, + "名称" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "名前" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이름" + } + } + } + }, + "周期提醒" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recurring reminder" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "定期リマインダー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주기 알림" + } + } + } + }, + "周末" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weekend" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "週末" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주말" + } + } + } + }, + "咳嗽" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cough" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "咳" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기침" + } + } + } + }, + "哮喘" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asthma" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "喘息" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "천식" + } + } + } + }, + "四" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thu" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "木" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "목" + } + } + } + }, + "回到今天" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Back to today" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日に戻る" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오늘로 돌아가기" + } + } + } + }, + "图例" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legend" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "凡例" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "범례" + } + } + } + }, + "图标" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icon" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アイコン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아이콘" + } + } + } + }, + "图片保存失败,手动录入并保留文本" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to save image; entered manually with text kept" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像の保存に失敗しました。手動入力してテキストを保持します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이미지 저장에 실패하여 수동 입력하고 텍스트를 보관합니다" + } + } + } + }, + "图片编码失败,手动补充或重拍" : { + + }, + "在「+ 新建 → 指标记录 → %@」记录一次" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Record one via \"+ New → Indicator → %@\"" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "「+ 新規 → 指標記録 → %@」で一度記録します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"+ 새로 만들기 → 지표 기록 → %@\"에서 한 번 기록하세요" + } + } + } + }, + "在内存较小的旧机型上,App 仍可用于手动记录、归档与查看," : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On older devices with less memory, the app can still be used for manual recording, archiving, and viewing," + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メモリの少ない旧機種でも、手動記録・アーカイブ・閲覧にアプリを利用できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리가 적은 구형 기기에서도 수동 기록, 보관, 조회에 앱을 사용할 수 있어요," + } + } + } + }, + "在这里输入主诉……" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter your chief complaint here…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ここに主訴を入力してください……" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여기에 주요 증상을 입력하세요……" + } + } + } + }, + "在适用法律允许的范围内,因使用本 App 或依赖其中内容所产生的后果,由使用者自行承担。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "To the extent permitted by applicable law, you bear sole responsibility for any consequences arising from using this app or relying on its content." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "適用法令で認められる範囲において、本アプリの使用またはその内容への依存により生じた結果については、利用者ご自身が責任を負うものとします。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "관련 법률이 허용하는 범위 내에서, 본 앱의 사용 또는 그 내용에 대한 의존으로 발생하는 결과는 사용자 본인이 책임집니다." + } + } + } + }, + "基本" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basics" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "基本" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본" + } + } + } + }, + "基本信息" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basic info" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "基本情報" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본 정보" + } + } + } + }, + "填写%@" : { + + }, + "处方" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prescription" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "処方" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "처방" + } + } + } + }, + "备注" : { + + }, + "备注(可选)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Note (optional)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メモ(任意)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모(선택)" + } + } + } + }, + "备注(可选)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Note (optional)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メモ(任意)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모(선택)" + } + } + } + }, + "复制" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コピー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "복사" + } + } + } + }, + "多页报告" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multi-page report" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "複数ページのレポート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여러 페이지 리포트" + } + } + } + }, + "多页报告可连拍,系统自动透视校正" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capture multi-page reports in a series; perspective is auto-corrected" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "複数ページのレポートは連続撮影でき、遠近補正は自動で行われます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여러 페이지 리포트는 연속 촬영할 수 있으며 원근 보정은 자동으로 처리돼요" + } + } + } + }, + "失眠" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insomnia" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "不眠" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "불면" + } + } + } + }, + "失败 · 重试" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed · Retry" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "失敗 · 再試行" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실패 · 재시도" + } + } + } + }, + "失败:%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "失敗:%@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실패: %@" + } + } + } + }, + "头痛" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Headache" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "頭痛" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "두통" + } + } + } + }, + "女" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Female" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "女性" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여성" + } + } + } + }, + "好" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확인" + } + } + } + }, + "如:协和医院" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g., Peking Union Medical College Hospital" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "例:協和病院" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예: 셰허병원" + } + } + } + }, + "如:春季年度体检" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g., Spring annual checkup" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "例:春の年次健康診断" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예: 봄 연례 건강검진" + } + } + } + }, + "如:母亲 高血压" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g., Mother, hypertension" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "例:母 高血圧" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예: 어머니 고혈압" + } + } + } + }, + "如:缬沙坦 80mg qd" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g., Valsartan 80mg qd" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "例:バルサルタン 80mg qd" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예: 발사르탄 80mg qd" + } + } + } + }, + "如:青霉素" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g., Penicillin" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "例:ペニシリン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예: 페니실린" + } + } + } + }, + "如出现身体不适或紧急情况,请及时就医或拨打当地急救电话,请勿依赖本 App 进行判断。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If you feel unwell or face an emergency, seek medical care promptly or call your local emergency number. Do not rely on this app for judgment." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "体調不良や緊急時には、速やかに医療機関を受診するか、お住まいの地域の救急番号に電話してください。判断を本アプリに頼らないでください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "몸이 불편하거나 응급 상황이 발생하면 즉시 진료를 받거나 현지 응급 전화로 연락하세요. 판단을 본 앱에 의존하지 마세요." + } + } + } + }, + "完成" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "完了" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료" + } + } + } + }, + "完成 ✓" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done ✓" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "完了 ✓" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료 ✓" + } + } + } + }, + "完整保存整份报告(可多页)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save the entire report (multiple pages supported)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "レポート全体を保存します(複数ページ対応)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리포트 전체를 저장해요(여러 페이지 지원)" + } + } + } + }, + "家族史" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Family history" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "家族歴" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "가족력" + } + } + } + }, + "密码" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Password" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "パスワード" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비밀번호" + } + } + } + }, + "对准异常的那一行就好 · 不用拍整张" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Just aim at the abnormal row · No need to capture the whole sheet" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "異常のある行に合わせるだけでOK · 全体を撮る必要はありません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이상이 있는 행만 맞추면 돼요 · 전체를 찍을 필요는 없어요" + } + } + } + }, + "导入失败:%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import failed: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インポートに失敗しました:%@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "가져오기 실패: %@" + } + } + } + }, + "导出" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "エクスポート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "내보내기" + } + } + } + }, + "导出身体档案" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export health profile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "身体プロファイルをエクスポート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "건강 프로필 내보내기" + } + } + } + }, + "将追加:" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Will append:" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "追加されます:" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추가됩니다:" + } + } + } + }, + "尚未设置" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not set" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "未設定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미설정" + } + } + } + }, + "尿酸 UA" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uric acid UA" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "尿酸 UA" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "요산 UA" + } + } + } + }, + "岁" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "yrs" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "歳" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세" + } + } + } + }, + "工作日" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weekday" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "平日" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "평일" + } + } + } + }, + "已保存 %lld 页(端侧加密)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saved %lld pages (on-device encryption)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldページを保存しました(オンデバイス暗号化)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld페이지 저장됨(온디바이스 암호화)" + } + } + } + }, + "已关闭" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Off" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オフ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "꺼짐" + } + } + } + }, + "已取消" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelled" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャンセルしました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "취소됨" + } + } + } + }, + "已取消识别,手动补充或重拍" : { + + }, + "已处理 %.1fs · 比云端快 4.2×" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Processed in %.1fs · 4.2× faster than the cloud" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.1f秒で処理 · クラウドより4.2倍高速" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.1f초 처리 · 클라우드보다 4.2배 빠름" + } + } + } + }, + "已复制" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copied" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コピーしました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "복사됨" + } + } + } + }, + "已完成" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "完了" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료" + } + } + } + }, + "已就绪" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ready" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "準備完了" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "준비됨" + } + } + } + }, + "已开启 · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On · %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オン · %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "켜짐 · %@" + } + } + } + }, + "已拍 1 页" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 page captured" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1ページ撮影しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "1페이지 촬영됨" + } + } + } + }, + "已拍页面(3 页)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Captured pages (3)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "撮影したページ(3ページ)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "촬영한 페이지(3페이지)" + } + } + } + }, + "已持续 %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ongoing for %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@継続中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 지속됨" + } + } + } + }, + "已经有一个叫「%@」的自定义指标" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A custom indicator named “%@” already exists" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "「%@」という名前のカスタム指標がすでにあります" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%@”(이)라는 사용자 지정 지표가 이미 있어요" + } + } + } + }, + "已裁剪" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cropped" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "トリミングしました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잘림" + } + } + } + }, + "已覆盖主要问诊维度;补充原文后可再追问" : { + + }, + "已识别边框 · 将自动透视校正" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edges detected · perspective will be corrected automatically" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "枠を検出 · 自動で台形補正します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "테두리 인식됨 · 원근 자동 보정" + } + } + } + }, + "已采纳" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applied" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "採用しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "적용됨" + } + } + } + }, + "已隐藏 %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld hidden" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld件を非表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld개 숨김" + } + } + } + }, + "已隐藏的长期监测" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hidden long-term monitoring" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "非表示の長期モニタリング" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "숨겨진 장기 모니터링" + } + } + } + }, + "常见症状" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Common symptoms" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "よくある症状" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "흔한 증상" + } + } + } + }, + "年" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Year" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "年" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "년" + } + } + } + }, + "并以原始报告 / 化验单为准。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Always refer to the original report / lab results." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "必ず元のレポート / 検査結果をご確認ください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "반드시 원본 리포트 / 검사 결과를 기준으로 하세요." + } + } + } + }, + "康康" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang" + } + } + } + }, + "康康 · 本地优先的健康档案 · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang · Local-first health records · %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang · ローカルファーストの健康記録 · %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang · 로컬 우선 건강 기록 · %@" + } + } + } + }, + "康康 已锁定" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang is locked" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkangはロックされています" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang이 잠겼어요" + } + } + } + }, + "康康是一款以本地优先为设计原则的个人健康影像档案工具。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang is a personal health imaging archive tool designed with a local-first principle." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkangは、ローカルファーストを設計原則とする個人向け健康画像アーカイブツールです。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang은 로컬 우선을 설계 원칙으로 하는 개인 건강 영상 아카이브 도구입니다." + } + } + } + }, + "康康是一款健康信息记录与参考工具,并非医疗器械,不提供医疗诊断、用药或剂量建议、急诊判断等医疗服务。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang is a tool for recording and referencing health information, not a medical device. It does not provide medical services such as diagnosis, medication or dosage advice, or emergency assessment." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkangは健康情報を記録・参照するためのツールであり、医療機器ではありません。医療診断、薬剤・用量の助言、救急判断などの医療サービスは提供しません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang은 건강 정보를 기록하고 참고하기 위한 도구이며 의료기기가 아닙니다. 의료 진단, 약물·용량 권고, 응급 판단 등의 의료 서비스를 제공하지 않습니다." + } + } + } + }, + "开启一个提醒" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set a reminder" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リマインダーを設定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리마인더 설정" + } + } + } + }, + "开始" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "開始" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시작" + } + } + } + }, + "开始 AI 解读" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start AI interpretation" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI解読を開始" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI 해석 시작" + } + } + } + }, + "开始一个持续症状,结束时再点结束" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start an ongoing symptom, then tap End when it stops" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "継続する症状を開始し、終わったら「終了」をタップします" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지속되는 증상을 시작하고, 끝나면 종료를 누르세요" + } + } + } + }, + "开始于" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Started" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "開始日時" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시작 시점" + } + } + } + }, + "开始时间" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start time" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "開始時間" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시작 시간" + } + } + } + }, + "开始记录" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start recording" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "記録を開始" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록 시작" + } + } + } + }, + "异常项" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abnormal items" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "異常項目" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이상 항목" + } + } + } + }, + "异常项快拍" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abnormal item quick capture" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "異常項目クイック撮影" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이상 항목 빠른 촬영" + } + } + } + }, + "异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。" : { + + }, + "强度" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Severity" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "強さ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "강도" + } + } + } + }, + "归档一份\n关键报告" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Archive a\nkey report" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "重要なレポートを\n保管する" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주요 리포트를\n보관하기" + } + } + } + }, + "归档信息" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Archive info" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "保管情報" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관 정보" + } + } + } + }, + "当前用药" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current medications" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "現在の服薬" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 복용 약" + } + } + } + }, + "影像报告" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Imaging report" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像レポート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "영상 리포트" + } + } + } + }, + "影像档案" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Imaging archive" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像アーカイブ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "영상 아카이브" + } + } + } + }, + "待下载" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "To download" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロード待ち" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 대기" + } + } + } + }, + "待输入" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "To enter" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "入力待ち" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "입력 대기" + } + } + } + }, + "心率" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heart rate" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "心拍数" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "심박수" + } + } + } + }, + "快超时了,>%llds 会自动转为手动录入" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Almost timed out; after %llds it switches to manual entry automatically" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "まもなくタイムアウトします。%lld秒を超えると自動で手動入力に切り替わります" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "곧 시간이 초과돼요. %lld초를 넘으면 자동으로 수동 입력으로 전환돼요" + } + } + } + }, + "快超时了,>%llds 会自动转手动录入" : { + + }, + "性别" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sex" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "性別" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "성별" + } + } + } + }, + "总胆固醇" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total cholesterol" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "総コレステロール" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "총콜레스테롤" + } + } + } + }, + "总胆固醇 TC 5.42 mmol/L" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total cholesterol TC 5.42 mmol/L" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "総コレステロール TC 5.42 mmol/L" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "총콜레스테롤 TC 5.42 mmol/L" + } + } + } + }, + "总项" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "総項目" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 항목" + } + } + } + }, + "恶心" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nausea" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "吐き気" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메스꺼움" + } + } + } + }, + "慢性肾病" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chronic kidney disease" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "慢性腎臓病" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "만성 신장병" + } + } + } + }, + "慢病(影响参考范围与 AI 解读)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chronic conditions (affect reference ranges and AI interpretation)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "慢性疾患(基準範囲とAI解釈に影響します)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "만성질환(참고 범위와 AI 해석에 영향)" + } + } + } + }, + "我的" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Me" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "マイ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마이" + } + } + } + }, + "我的导出" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "My Exports" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "マイエクスポート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "내 내보내기" + } + } + } + }, + "我的导出 · %lld 份" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "My Exports · %lld" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "マイエクスポート · %lld件" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "내 내보내기 · %lld건" + } + } + } + }, + "我的报告档案" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "My Report Archive" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "マイレポートアーカイブ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "내 리포트 보관함" + } + } + } + }, + "我知道了,开始拍" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Got it, start capturing" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "了解、撮影を始める" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알겠어요, 촬영 시작" + } + } + } + }, + "或者自己写" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Or write it yourself" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "または自分で入力" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "또는 직접 입력" + } + } + } + }, + "所有照片以 AES 加密存于本机沙盒。康康 服务端无法访问。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All photos are AES-encrypted and stored in this device's sandbox. Kangkang's servers cannot access them." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべての写真はAES暗号化され、本機のサンドボックスに保存されます。Kangkangのサーバーからはアクセスできません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 사진은 AES로 암호화되어 기기 샌드박스에 저장됩니다. Kangkang 서버는 접근할 수 없습니다." + } + } + } + }, + "所选文件夹缺少 config.json,不是有效的模型目录" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The selected folder is missing config.json and is not a valid model directory" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "選択したフォルダにconfig.jsonがなく、有効なモデルディレクトリではありません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택한 폴더에 config.json이 없어 유효한 모델 디렉터리가 아닙니다" + } + } + } + }, + "手动填一项指标(免拍照)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter an indicator manually (no photo)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "指標を手動で入力(撮影不要)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지표 직접 입력(촬영 없이)" + } + } + } + }, + "把异常项放进框里 · 对准一两行" : { + + }, + "抑郁/焦虑" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Depression / Anxiety" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "うつ・不安" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "우울/불안" + } + } + } + }, + "报告" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Report" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "レポート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리포트" + } + } + } + }, + "报告归档" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Archive Report" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "レポートをアーカイブ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리포트 보관" + } + } + } + }, + "报告日期" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Report date" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "レポート日付" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리포트 날짜" + } + } + } + }, + "报告类型" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Report type" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "レポートの種類" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리포트 유형" + } + } + } + }, + "报告详情" : { + + }, + "拍一张化验单,VL 自动识别" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Snap a lab report, and VL recognizes it automatically" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検査結果を撮影すると、VLが自動で認識します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검사 결과를 촬영하면 VL이 자동 인식해요" + } + } + } + }, + "拍到的局部" : { + + }, + "拍报告的小贴士" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tips for photographing reports" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "レポート撮影のヒント" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리포트 촬영 팁" + } + } + } + }, + "拍摄异常项" : { + + }, + "拍摄报告" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Photograph Report" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "レポートを撮影" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리포트 촬영" + } + } + } + }, + "拍摄识别" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capture & Recognize" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "撮影して認識" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "촬영 인식" + } + } + } + }, + "拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Photo archive: capture a checkup or lab report, try to recognize it into structured indicators, and archive it" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "写真アーカイブ:健康診断または検査結果を撮影し、構造化された指標として認識を試み、保存します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사진 보관: 건강검진 또는 검사 결과를 촬영해 구조화된 지표로 인식을 시도하고 보관해요" + } + } + } + }, + "拍照识别报告 → 结构化指标" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capture report → structured indicators" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "レポートを撮影して認識 → 構造化された指標" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리포트 촬영 인식 → 구조화된 지표" + } + } + } + }, + "持续" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ongoing" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "継続" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지속" + } + } + } + }, + "持续 %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ongoing %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "継続 %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지속 %@" + } + } + } + }, + "持续中" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ongoing" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "継続中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지속 중" + } + } + } + }, + "指标" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indicator" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "指標" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지표" + } + } + } + }, + "指标 · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indicator · %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "指標 · %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지표 · %@" + } + } + } + }, + "指标(%lld 项)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indicators (%lld)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "指標(%lld項目)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지표(%lld개)" + } + } + } + }, + "指标名" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indicator name" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "指標名" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지표 이름" + } + } + } + }, + "指标名 · 可编辑" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indicator name · editable" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "指標名 · 編集可能" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지표 이름 · 편집 가능" + } + } + } + }, + "指标异常" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abnormal indicator" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "指標の異常" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지표 이상" + } + } + } + }, + "指标记录" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indicator Record" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "指標の記録" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지표 기록" + } + } + } + }, + "指标记录提醒" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metric Reminders" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "指標リマインダー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지표 알림" + } + } + } + }, + "指标详情" : { + + }, + "按%lld岁调整" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adjusted for age %lld" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld歳に合わせて調整" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld세 기준으로 조정" + } + } + } + }, + "推理中…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inferring…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推論中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추론 중…" + } + } + } + }, + "推理失败:%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inference failed: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推論に失敗しました:%@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추론 실패: %@" + } + } + } + }, + "推理自检" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inference Self-Check" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推論セルフチェック" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추론 자가 점검" + } + } + } + }, + "推荐" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommended" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推奨" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추천" + } + } + } + }, + "推荐 iPhone 15 Pro / Pro Max 及之后发布的机型(含 iPhone 16 系列)。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommended: iPhone 15 Pro / Pro Max and later models (including the iPhone 16 series)." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhone 15 Pro / Pro Max 以降のモデル(iPhone 16シリーズを含む)を推奨します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhone 15 Pro / Pro Max 및 이후 출시 모델(iPhone 16 시리즈 포함)을 추천합니다." + } + } + } + }, + "推荐拍清晰的%@,多页报告可一次完成扫描。原图与解读全部本地加密保存,永不上传。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We recommend capturing a clear %@; multi-page reports can be scanned all at once. Original images and interpretations are all encrypted and stored locally, never uploaded." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "鮮明な%@の撮影を推奨します。複数ページのレポートは一度にスキャンできます。原本画像と解釈はすべてローカルで暗号化して保存され、アップロードされることはありません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선명한 %@을(를) 촬영하는 것을 추천해요. 여러 페이지 리포트는 한 번에 스캔할 수 있어요. 원본 이미지와 해석은 모두 기기에서 암호화 저장되며 절대 업로드되지 않아요." + } + } + } + }, + "提取指标 · 共 28 项" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extracted indicators · 28 total" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "抽出した指標 · 全28項目" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추출 지표 · 총 28개" + } + } + } + }, + "提醒" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reminders" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リマインダー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알림" + } + } + } + }, + "提醒在录入「指标记录 · 长期监测」时开启" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable when entering “Indicator Record · Long-term Monitoring”" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "「指標の記録 · 長期モニタリング」の入力時にオンにします" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "「지표 기록 · 장기 모니터링」 입력 시 켜세요" + } + } + } + }, + "提醒已保存,但系统通知权限未开启,到点不会弹出。请在「设置 · 通知 · 康康」中允许。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The reminder is saved, but notifications are off so it won't alert you. Allow them in Settings · Notifications · Kangkang." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リマインダーは保存されましたが、通知が許可されていないため表示されません。「設定 · 通知 · 康康」で許可してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알림이 저장되었지만 시스템 알림 권한이 꺼져 있어 표시되지 않습니다. '설정 · 알림 · 康康'에서 허용하세요." + } + } + } + }, + "摘要" : { + + }, + "摘要(可选)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Summary (optional)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "概要(任意)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "요약(선택)" + } + } + } + }, + "撰写报告" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Write Report" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "レポートを作成" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리포트 작성" + } + } + } + }, + "支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Records can be deleted, and the data is removed from this device. Data is stored locally and does not rely on cloud backup." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "記録の削除に対応しており、データは本機から削除されます。データは本機に保存され、クラウドバックアップに依存しません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록 삭제를 지원하며, 데이터는 기기에서 제거됩니다. 데이터는 기기에 저장되며 클라우드 백업에 의존하지 않습니다." + } + } + } + }, + "收缩" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Systolic" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "収縮" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수축" + } + } + } + }, + "收缩 " : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Systolic " + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "収縮 " + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수축 " + } + } + } + }, + "收缩 / 舒张" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Systolic / Diastolic" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "収縮 / 拡張" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수축 / 이완" + } + } + } + }, + "收缩压" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Systolic pressure" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "収縮期血圧" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수축기 혈압" + } + } + } + }, + "数值" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Value" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "数値" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수치" + } + } + } + }, + "数据保存在本设备:卸载 App 或删除数据后可能无法恢复,重要资料请自行留存原件。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data is stored on this device: it may be unrecoverable after you uninstall the app or delete the data, so please keep your own originals of important documents." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "データは本端末に保存されます:アプリのアンインストールやデータ削除後は復元できない場合があります。重要な資料は原本をご自身で保管してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "데이터는 이 기기에 저장됩니다: 앱을 삭제하거나 데이터를 지운 후에는 복구하지 못할 수 있으니, 중요한 자료는 원본을 직접 보관하세요." + } + } + } + }, + "整体摘记" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overall Summary" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "全体メモ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 요약" + } + } + } + }, + "整张图" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Whole image" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像全体" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 이미지" + } + } + } + }, + "整页入框,避免裁切到指标" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fit the whole page in frame to avoid cropping any indicators" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ページ全体をフレームに収め、指標が切れないようにしてください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지표가 잘리지 않도록 페이지 전체를 화면에 담으세요" + } + } + } + }, + "文件大小校验失败(预期 %lld,实际 %lld)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "File size verification failed (expected %1$lld, got %2$lld)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイルサイズの検証に失敗しました(予期 %1$lld、実際 %2$lld)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일 크기 검증에 실패했습니다(예상 %1$lld, 실제 %2$lld)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "文件大小校验失败(预期 %1$lld,实际 %2$lld)" + } + } + } + }, + "文字日记" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Text diary" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "テキスト日記" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "텍스트 일기" + } + } + } + }, + "文本解读 · 趋势 / 问答" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Text interpretation · Trends / Q&A" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "テキスト解説 · トレンド / 質問応答" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "텍스트 해석 · 추세 / 질의응답" + } + } + } + }, + "新建" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "新規" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새로 만들기" + } + } + } + }, + "新建提醒" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Reminder" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "新しいリマインダー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 알림" + } + } + } + }, + "新建提醒,或在记录指标时开启" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a reminder, or enable one when logging a metric." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リマインダーを作成、または指標の記録時に有効化。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알림을 만들거나 지표 기록 시 설정하세요." + } + } + } + }, + "新建自定义指标" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New custom indicator" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カスタム指標を作成" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 지표 만들기" + } + } + } + }, + "无参考范围" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No reference range" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "基準範囲なし" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "참고 범위 없음" + } + } + } + }, + "日" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sun" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일" + } + } + } + }, + "日期" : { + + }, + "日记" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diary" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日記" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일기" + } + } + } + }, + "日记详情" : { + + }, + "早安" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Good morning" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "おはようございます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "좋은 아침이에요" + } + } + } + }, + "时间" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "時間" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시간" + } + } + } + }, + "昨天" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yesterday" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "昨日" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "어제" + } + } + } + }, + "显示" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "표시" + } + } + } + }, + "晚上好" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Good evening" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "こんばんは" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "좋은 저녁이에요" + } + } + } + }, + "暂停下载" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause download" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロードを一時停止" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 일시정지" + } + } + } + }, + "更新一下原文,再让 AI 继续追问" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update the original text, then let the AI continue with follow-up questions" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "元の文章を更新してから、AIに続けて質問させましょう" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원문을 업데이트한 다음 AI가 이어서 질문하도록 하세요" + } + } + } + }, + "月" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Month" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "月" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "월" + } + } + } + }, + "月份" : { + + }, + "未下载" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not downloaded" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "未ダウンロード" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 안 됨" + } + } + } + }, + "未使用" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unused" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "未使用" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미사용" + } + } + } + }, + "未开始" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not started" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "未開始" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시작 안 함" + } + } + } + }, + "未知错误:%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown error: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "不明なエラー:%@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알 수 없는 오류: %@" + } + } + } + }, + "未设置" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not set" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "未設定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정 안 함" + } + } + } + }, + "未选" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not selected" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "未選択" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택 안 함" + } + } + } + }, + "未选日" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No date selected" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日付未選択" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "날짜 미선택" + } + } + } + }, + "本 App 仅供健康信息记录与参考,不能替代专业医疗意见。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This app is for recording and referencing health information only and cannot replace professional medical advice." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "本アプリは健康情報の記録と参考のみを目的としており、専門的な医療アドバイスに代わるものではありません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "본 앱은 건강 정보의 기록 및 참고용으로만 제공되며, 전문적인 의료 조언을 대신할 수 없습니다." + } + } + } + }, + "本周" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This week" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今週" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이번 주" + } + } + } + }, + "本地 AI · 正在解读" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On-device AI · Interpreting" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンデバイスAI · 解析中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온디바이스 AI · 해석 중" + } + } + } + }, + "本地 AI 功能(拍照识别、解读、问答)需要约 8GB 内存," : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On-device AI features (photo recognition, interpretation, Q&A) require about 8GB of memory," + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンデバイスAI機能(写真認識、解説、質問応答)には約8GBのメモリが必要です。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온디바이스 AI 기능(사진 인식, 해석, 질의응답)에는 약 8GB의 메모리가 필요합니다," + } + } + } + }, + "本地 AI 模型体积较大(约 4GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The on-device AI model is large (about 4GB) and requires an internet download on first use; we recommend doing this over Wi-Fi;" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンデバイスAIモデルはサイズが大きく(約4GB)、初回使用時にネット接続でのダウンロードが必要です。Wi-Fi環境での実施をおすすめします。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온디바이스 AI 모델은 용량이 크며(약 4GB) 처음 사용할 때 인터넷 다운로드가 필요합니다. Wi-Fi 환경에서 진행하는 것을 권장합니다;" + } + } + } + }, + "本地 RAG · Qwen3 1.7B · 不上传任何数据" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On-device RAG · Qwen3 1.7B · No data uploaded" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンデバイスRAG · Qwen3 1.7B · データは一切アップロードしません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온디바이스 RAG · Qwen3 1.7B · 어떤 데이터도 업로드하지 않음" + } + } + } + }, + "本地优先的个人健康影像档案" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Local-first personal health imaging archive" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ローカル優先の個人健康画像アーカイブ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로컬 우선 개인 건강 영상 아카이브" + } + } + } + }, + "本地加密" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Local encryption" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ローカル暗号化" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로컬 암호화" + } + } + } + }, + "本地处理 · 永不上传" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Processed on-device · Never uploaded" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンデバイス処理 · アップロードしません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온디바이스 처리 · 업로드하지 않음" + } + } + } + }, + "本地处理中 · 不会上传任何内容" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Processing on-device · Nothing will be uploaded" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンデバイスで処理中 · 何もアップロードしません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온디바이스에서 처리 중 · 아무것도 업로드하지 않음" + } + } + } + }, + "本地推理 · %.1f tok/s" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On-device inference · %.1f tok/s" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンデバイス推論 · %.1f tok/s" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온디바이스 추론 · %.1f tok/s" + } + } + } + }, + "本地识别中" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recognizing on-device" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンデバイスで認識中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온디바이스에서 인식 중" + } + } + } + }, + "本地识别中…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recognizing on-device…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンデバイスで認識中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온디바이스에서 인식 중…" + } + } + } + }, + "本地问答:基于你自己的档案问答,引用可点击回链到原记录" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On-device Q&A: ask questions based on your own archive; citations are tappable and link back to the source record" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンデバイス質問応答:あなた自身のアーカイブに基づいて質問でき、引用をタップすると元の記録に戻れます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온디바이스 질의응답: 본인의 아카이브를 기반으로 질문하며, 인용을 탭하면 원본 기록으로 돌아갈 수 있습니다" + } + } + } + }, + "本月" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This month" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今月" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이번 달" + } + } + } + }, + "本机保存" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save to this device" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "本体に保存" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기에 저장" + } + } + } + }, + "本机提醒 · 不发任何数据" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On-device reminders · No data sent" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "本体での通知 · データは送信しません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기 내 알림 · 어떤 데이터도 전송하지 않음" + } + } + } + }, + "本机摘要" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On-device summary" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "本体での要約" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기 내 요약" + } + } + } + }, + "本次共检测 28 项,%@(血脂相关 2 项 + 尿酸)、%@(维生素 D)。整体趋势提示代谢风险有所抬升,建议优化饮食并复查血脂。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A total of 28 items were tested this time: %1$@ (2 lipid-related items + uric acid) and %2$@ (vitamin D). The overall trend suggests a slightly elevated metabolic risk; we recommend improving your diet and rechecking your blood lipids." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今回は計28項目を検査しました:%1$@(脂質関連2項目+尿酸)、%2$@(ビタミンD)。全体的な傾向として代謝リスクがやや上昇しており、食生活の改善と血中脂質の再検査をおすすめします。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이번에 총 28개 항목을 검사했습니다: %1$@(지질 관련 2개 항목 + 요산), %2$@(비타민 D). 전반적인 추세는 대사 위험이 다소 높아진 것을 시사하므로, 식단 개선과 혈중 지질 재검사를 권장합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "本次共检测 28 项,%1$@(血脂相关 2 项 + 尿酸)、%2$@(维生素 D)。整体趋势提示代谢风险有所抬升,建议优化饮食并复查血脂。" + } + } + } + }, + "本次已记录 %lld 项" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld items recorded this time" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今回 %lld 項目を記録しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이번에 %lld개 항목을 기록했습니다" + } + } + } + }, + "本次持续" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duration this time" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今回の継続時間" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이번 지속 시간" + } + } + } + }, + "本设备未设置 Face ID 或密码" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID or a passcode is not set up on this device" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この端末ではFace IDまたはパスコードが設定されていません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기기에 Face ID 또는 암호가 설정되어 있지 않습니다" + } + } + } + }, + "机构" : { + + }, + "机构(可选)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Institution (optional)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "機関(任意)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기관(선택)" + } + } + } + }, + "来源" : { + + }, + "查看原图" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View original image" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "元の画像を見る" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원본 이미지 보기" + } + } + } + }, + "标签" : { + + }, + "标题" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "タイトル" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목" + } + } + } + }, + "校验中" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifying" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検証中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검증 중" + } + } + } + }, + "核对后一次保存" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Review, then save all at once" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "確認してまとめて保存" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확인 후 한 번에 저장" + } + } + } + }, + "核对异常项" : { + + }, + "核对识别结果" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Review recognition results" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "認識結果を確認" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인식 결과 확인" + } + } + } + }, + "档案 · %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Records · %lld" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "記録 · %lld" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록 · %lld" + } + } + } + }, + "检索数据" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Searching data" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "データを検索" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "데이터 검색" + } + } + } + }, + "模型加载失败:%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to load model: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "モデルの読み込みに失敗しました: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모델 로드 실패: %@" + } + } + } + }, + "模型未就绪时 App 仍可使用,AI 功能会提示前往下载。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The app still works when the model isn't ready; AI features will prompt you to download it." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "モデルが未準備でもアプリは使用でき、AI機能はダウンロードを案内します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모델이 준비되지 않아도 앱은 사용할 수 있으며, AI 기능은 다운로드를 안내합니다." + } + } + } + }, + "模型管理" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model Management" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "モデル管理" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모델 관리" + } + } + } + }, + "模型约 %@,建议在 Wi-Fi 下下载。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The model is about %@; downloading over Wi-Fi is recommended." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "モデルは約%@です。Wi-Fi環境でのダウンロードをおすすめします。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모델은 약 %@입니다. Wi-Fi 환경에서 다운로드하는 것을 권장합니다." + } + } + } + }, + "模拟器没有摄像头,从相册选一张化验单/体检报告" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The simulator has no camera; pick a lab report / checkup report from your photos" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "シミュレーターにはカメラがありません。アルバムから検査結果/健康診断レポートを選んでください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시뮬레이터에는 카메라가 없습니다. 앨범에서 검사 결과 / 건강검진 리포트를 선택하세요" + } + } + } + }, + "正在本地识别第 1 / 3 页…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recognizing page 1 / 3 on-device…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンデバイスで1 / 3ページ目を認識中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온디바이스로 1 / 3페이지 인식 중…" + } + } + } + }, + "正在本地识别第 2 / 3 页…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recognizing page 2 / 3 on-device…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンデバイスで2 / 3ページ目を認識中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온디바이스로 2 / 3페이지 인식 중…" + } + } + } + }, + "正在本地识别第 3 / 3 页…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recognizing page 3 / 3 on-device…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンデバイスで3 / 3ページ目を認識中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온디바이스로 3 / 3페이지 인식 중…" + } + } + } + }, + "正常" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normal" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "正常" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "정상" + } + } + } + }, + "正常项" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normal items" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "正常項目" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "정상 항목" + } + } + } + }, + "每周" : { + + }, + "每周 " : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weekly " + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "毎週 " + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "매주 " + } + } + } + }, + "每天" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daily" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "毎日" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "매일" + } + } + } + }, + "每年" : { + + }, + "每年%lld月%lld日" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "每年%1$lld月%2$lld日" + } + } + } + }, + "每日" : { + + }, + "每月" : { + + }, + "每月%lld日" : { + + }, + "永久删除" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Permanently" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "完全に削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "영구 삭제" + } + } + } + }, + "永久删除这份导出?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permanently delete this export?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このエクスポートを完全に削除しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 내보내기를 영구 삭제할까요?" + } + } + } + }, + "永久删除这条记录?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete this record permanently?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この記録を完全に削除しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기록을 영구 삭제할까요?" + } + } + } + }, + "没有指标 — 点上方「加一项」补一行,或直接保存只存图片" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No indicators — tap “Add item” above to add a row, or save directly to keep only the image" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "指標がありません — 上の「項目を追加」で1行追加するか、そのまま保存して画像のみ保存します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지표가 없습니다 — 위의 「항목 추가」를 눌러 한 줄 추가하거나, 그대로 저장하여 이미지만 저장하세요" + } + } + } + }, + "没有识别到指标,点「加一项」手动补充,或返回重拍" : { + + }, + "没读出指标,手动补充或重拍" : { + + }, + "测试 PROMPT" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Test PROMPT" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "テストPROMPT" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "테스트 PROMPT" + } + } + } + }, + "测量时间" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Measurement time" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "測定時刻" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "측정 시각" + } + } + } + }, + "添加你自己的长期监测项" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add your own long-term monitoring item" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "独自の長期モニタリング項目を追加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "나만의 장기 모니터링 항목 추가" + } + } + } + }, + "点底部 + 号可以补一条" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap the + at the bottom to add one" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "下の+ボタンで1件追加できます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "하단의 + 버튼으로 한 건 추가할 수 있어요" + } + } + } + }, + "点这里再开一次" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap here to start again" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ここをタップしてもう一度" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여기를 눌러 다시 시작" + } + } + } + }, + "点这里完善你的资料" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap here to complete your profile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ここをタップしてプロフィールを入力" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여기를 눌러 프로필을 완성하세요" + } + } + } + }, + "状态" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "状態" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상태" + } + } + } + }, + "状态(按数值自动判)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status (auto-determined by value)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "状態(数値で自動判定)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상태(수치로 자동 판정)" + } + } + } + }, + "理解意图" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Understanding intent" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "意図を理解" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "의도 파악" + } + } + } + }, + "甘油三酯" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Triglycerides" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "中性脂肪" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "중성지방" + } + } + } + }, + "甘油三酯 TG" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Triglycerides TG" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "中性脂肪 TG" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "중성지방 TG" + } + } + } + }, + "甘油三酯 TG 1.78 mmol/L" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Triglycerides TG 1.78 mmol/L" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "中性脂肪 TG 1.78 mmol/L" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "중성지방 TG 1.78 mmol/L" + } + } + } + }, + "生成失败:%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generation failed: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "生成に失敗しました: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "생성 실패: %@" + } + } + } + }, + "生成报告" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generate report" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "レポートを生成" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리포트 생성" + } + } + } + }, + "生成整体摘要…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generating overall summary…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "全体のサマリーを生成中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전체 요약 생성 중…" + } + } + } + }, + "生成新导出" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create new export" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "新しいエクスポートを作成" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 내보내기 생성" + } + } + } + }, + "用 %lld 次" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Used %lld times" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld回使用" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld회 사용" + } + } + } + }, + "用于自动判定 正常/偏高/偏低" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Used to auto-determine Normal / High / Low" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "正常/高い/低いの自動判定に使用" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "정상 / 높음 / 낮음 자동 판정에 사용" + } + } + } + }, + "甲状腺疾病" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thyroid disease" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "甲状腺疾患" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "갑상선 질환" + } + } + } + }, + "男" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Male" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "男性" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "남성" + } + } + } + }, + "疲劳" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fatigue" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "疲労" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "피로" + } + } + } + }, + "症状" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Symptom" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "症状" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "증상" + } + } + } + }, + "症状 · 已结束" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Symptom · Ended" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "症状 · 終了" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "증상 · 종료됨" + } + } + } + }, + "症状 · 持续中" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Symptom · Ongoing" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "症状 · 継続中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "증상 · 진행 중" + } + } + } + }, + "症状开始" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Symptom onset" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "症状の開始" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "증상 시작" + } + } + } + }, + "症状持续中" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Symptom ongoing" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "症状継続中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "증상 진행 중" + } + } + } + }, + "症状详情" : { + + }, + "相机权限未开启" : { + + }, + "程度" : { + + }, + "空腹血糖" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fasting glucose" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "空腹時血糖" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공복 혈당" + } + } + } + }, + "空腹血糖 GLU" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fasting glucose GLU" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "空腹時血糖 GLU" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공복 혈당 GLU" + } + } + } + }, + "第 %lld 轮 · 基于你刚才更新的文本 · %lld 条" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Round %1$lld · Based on the text you just updated · %2$lld items" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld回目 · 先ほど更新したテキストに基づく · %2$lld件" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld회차 · 방금 업데이트한 텍스트 기반 · %2$lld건" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "第 %1$lld 轮 · 基于你刚才更新的文本 · %2$lld 条" + } + } + } + }, + "第 1 轮 · %lld 条" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Round 1 · %lld items" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1回目 · %lld件" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "1회차 · %lld건" + } + } + } + }, + "管理用药、复查、监测的周期提醒" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage recurring reminders for meds, follow-ups, and monitoring" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "服薬・再検査・モニタリングの定期リマインダーを管理" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "복약·재검사·모니터링 정기 리마인더 관리" + } + } + } + }, + "类型" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "種類" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "유형" + } + } + } + }, + "糖化血红蛋白" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "HbA1c" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "HbA1c" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "HbA1c" + } + } + } + }, + "糖尿病" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diabetes" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "糖尿病" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "당뇨병" + } + } + } + }, + "系统:iOS 17 或更新版本。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System: iOS 17 or later." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "システム: iOS 17以降。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시스템: iOS 17 이상." + } + } + } + }, + "纸张铺平,避免反光、阴影" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flatten the paper and avoid glare or shadows" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "紙を平らにし、反射や影を避けてください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "종이를 평평하게 펴고 반사와 그림자를 피하세요" + } + } + } + }, + "结束" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "終了" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "종료" + } + } + } + }, + "结束并保存" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End and Save" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "終了して保存" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "종료하고 저장" + } + } + } + }, + "结束时再来点结束" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add more when ending" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "終了時にもう少し追加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "종료할 때 좀 더 추가" + } + } + } + }, + "结束时间" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End time" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "終了時刻" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "종료 시간" + } + } + } + }, + "结束症状" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End symptom" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "症状を終了" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "증상 종료" + } + } + } + }, + "结构化失败:%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Structuring failed: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "構造化に失敗しました:%@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "구조화 실패: %@" + } + } + } + }, + "结果解析失败:%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to parse result: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "結果の解析に失敗しました:%@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "결과 파싱 실패: %@" + } + } + } + }, + "给医生看的就诊摘要" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visit summary for your doctor" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "医師に見せる受診サマリー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "의사에게 보여줄 진료 요약" + } + } + } + }, + "继续下载" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue download" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロードを続ける" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다운로드 계속" + } + } + } + }, + "继续拍下一项" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capture next item" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "次の項目を撮影" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다음 항목 촬영" + } + } + } + }, + "维生素 D" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vitamin D" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ビタミンD" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비타민 D" + } + } + } + }, + "编辑" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "編集" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "편집" + } + } + } + }, + "编辑「%@」" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit “%@”" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "「%@」を編集" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%@” 편집" + } + } + } + }, + "编辑/删除" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit / Delete" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "編集/削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "편집 / 삭제" + } + } + } + }, + "编辑提醒" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit Reminder" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リマインダーを編集" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알림 편집" + } + } + } + }, + "腹痛" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abdominal pain" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "腹痛" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "복통" + } + } + } + }, + "自定义" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カスタム" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정" + } + } + } + }, + "自定义慢病" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom chronic condition" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カスタム慢性疾患" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 만성질환" + } + } + } + }, + "自定义指标" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom indicator" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カスタム指標" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 지표" + } + } + } + }, + "自定义指标会出现在「+ 指标记录 → 长期监测」的 grid 里,可设提醒、进趋势" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom indicators appear in the grid under “+ Indicator Record → Long-term Monitoring,” where you can set reminders and add them to Trends" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カスタム指標は「+ 指標記録 → 長期モニタリング」のグリッドに表示され、リマインダー設定やトレンドへの追加ができます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 지표는 “+ 지표 기록 → 장기 모니터링” 그리드에 표시되며, 알림 설정과 추세 추가가 가능합니다" + } + } + } + }, + "舒张" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diastolic" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "拡張" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이완" + } + } + } + }, + "舒张 " : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diastolic " + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "拡張 " + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이완 " + } + } + } + }, + "舒张压" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diastolic pressure" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "拡張期血圧" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이완기 혈압" + } + } + } + }, + "范围" : { + + }, + "范围 %@ %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Range %1$@ %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "範囲 %1$@ %2$@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "범위 %1$@ %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "范围 %1$@ %2$@" + } + } + } + }, + "血压" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blood pressure" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "血圧" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "혈압" + } + } + } + }, + "血压详情" : { + + }, + "血型" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blood type" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "血液型" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "혈액형" + } + } + } + }, + "血氧" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blood oxygen" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "血中酸素" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "혈중 산소" + } + } + } + }, + "解锁康康,查看你的健康档案" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlock Kangkang to view your health records" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkangのロックを解除して健康記録を表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kangkang 잠금을 해제하고 건강 기록을 확인하세요" + } + } + } + }, + "让 AI 帮我想想还能记什么" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Let AI suggest what else to record" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "他に記録できることをAIに考えてもらう" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI에게 더 기록할 것을 제안받기" + } + } + } + }, + "记录" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Records" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "記録" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록" + } + } + } + }, + "记录什么?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What to record?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "何を記録しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "무엇을 기록할까요?" + } + } + } + }, + "记录会按时间归类显示" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Records are grouped and shown by time" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "記録は時系列で分類して表示されます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록은 시간별로 분류되어 표시됩니다" + } + } + } + }, + "记录指标" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Record indicator" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "指標を記録" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지표 기록" + } + } + } + }, + "记录提醒" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Record reminder" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "記録リマインダー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록 알림" + } + } + } + }, + "记录时间" : { + + }, + "记录症状" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log a symptom" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "症状を記録" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "증상 기록" + } + } + } + }, + "记录身体状态 · 可让 AI 多轮辅助查漏补缺" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Record your physical state · AI can assist over multiple turns to fill in the gaps" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "体調を記録 · AIが複数回にわたって抜け漏れを補助します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "신체 상태 기록 · AI가 여러 차례 도와 빠진 부분을 채워줘요" + } + } + } + }, + "记录身体状态、用药、感受 · 可让 AI 辅助" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Record your physical state, medications, and feelings · AI can assist" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "体調、服薬、感じたことを記録 · AIが補助します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "신체 상태, 복약, 느낌을 기록 · AI가 도와줘요" + } + } + } + }, + "设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The on-device AI model tries to rephrase technical indicators into plain-language explanations, helping you record and review your health changes." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンデバイスのAIモデルが専門的な指標をわかりやすい説明に言い換え、健康の変化を記録・振り返るお手伝いをします。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온디바이스 AI 모델이 전문 지표를 알기 쉬운 설명으로 풀어내어 건강 변화를 기록하고 돌아보도록 도와줘요." + } + } + } + }, + "设备要求" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Device requirements" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "デバイス要件" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기기 요구 사항" + } + } + } + }, + "识别全程在本地,图片不会上传" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recognition runs entirely on-device; images are never uploaded" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "認識はすべてオンデバイスで行われ、画像はアップロードされません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인식은 전부 온디바이스에서 이루어지며 이미지는 업로드되지 않아요" + } + } + } + }, + "识别到的指标 (%lld)" : { + + }, + "识别失败:%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recognition failed: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "認識に失敗しました:%@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인식 실패: %@" + } + } + } + }, + "识别框内指标" : { + + }, + "识别没有读出指标,请手动补充" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No indicators were recognized; please add them manually" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "指標を読み取れませんでした。手動で追加してください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지표를 인식하지 못했어요. 직접 추가해 주세요" + } + } + } + }, + "识别用时 0.4s · 本地" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recognized in 0.4s · on-device" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "認識時間 0.4秒 · オンデバイス" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인식 시간 0.4초 · 온디바이스" + } + } + } + }, + "识别超时(>%llds)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recognition timed out (>%llds)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "認識がタイムアウトしました(>%llds)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인식 시간 초과 (>%llds)" + } + } + } + }, + "识别超时(>%llds),保留旧编辑" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recognition timed out (>%llds); keeping previous edits" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "認識がタイムアウトしました(>%llds)。以前の編集を保持します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인식 시간 초과 (>%llds), 이전 편집을 유지합니다" + } + } + } + }, + "识别超时(>%llds),先手动录入" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recognition timed out (>%llds); enter manually for now" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "認識がタイムアウトしました(>%llds)。まず手動で入力してください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인식 시간 초과 (>%llds), 우선 직접 입력하세요" + } + } + } + }, + "识别超时(>%llds),手动补充或重拍" : { + + }, + "该测%@了" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time to measure %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@を測る時間です" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@를 측정할 시간이에요" + } + } + } + }, + "语言" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Language" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "言語" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "언어" + } + } + } + }, + "说说你想给医生看什么" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tell us what you want to show your doctor" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "医師に見せたいことを教えてください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "의사에게 보여주고 싶은 내용을 알려주세요" + } + } + } + }, + "请选择名为 %@ 的文件夹" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please select the folder named %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ という名前のフォルダを選択してください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 라는 이름의 폴더를 선택하세요" + } + } + } + }, + "谷丙转氨酶" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "ALT" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ALT" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "ALT" + } + } + } + }, + "谷丙转氨酶、空腹血糖、糖化血红蛋白…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "ALT, fasting glucose, HbA1c…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ALT、空腹時血糖、HbA1c…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "ALT, 공복 혈당, HbA1c…" + } + } + } + }, + "谷草转氨酶" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AST" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AST" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AST" + } + } + } + }, + "超过参考上限 0.44,属轻度偏高。建议关注饮食结构(减少动物脂肪摄入),3 个月内复查。若家族有心血管病史,可与医生沟通是否需要药物干预。" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "0.44 above the upper reference limit, mildly elevated. Consider adjusting your diet (reduce animal fat intake) and recheck within 3 months. If you have a family history of cardiovascular disease, talk to your doctor about whether medication is needed." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "基準上限を0.44超えており、軽度に高めです。食生活の見直し(動物性脂肪の摂取を減らす)を検討し、3か月以内に再検査してください。心血管疾患の家族歴がある場合は、薬物療法が必要かどうか医師にご相談ください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "참고 상한을 0.44 초과한 경미한 높음이에요. 식습관 점검(동물성 지방 섭취 줄이기)을 고려하고 3개월 이내에 재검사하세요. 심혈관 질환 가족력이 있다면 약물 치료가 필요한지 의사와 상담하세요." + } + } + } + }, + "超过参考上限 0.44,属轻度偏高。点击展开详细解读 ›" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "0.44 above the upper reference limit, mildly elevated. Tap to see the full explanation ›" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "基準上限を0.44超えており、軽度に高めです。タップして詳しい解説を表示 ›" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "참고 상한을 0.44 초과한 경미한 높음이에요. 탭하여 자세한 해설 보기 ›" + } + } + } + }, + "超过参考上限 0.44。建议关注饮食结构,3 个月内复查。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "0.44 above the upper reference limit. Consider adjusting your diet and recheck within 3 months." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "基準上限を0.44超えています。食生活の見直しを検討し、3か月以内に再検査してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "참고 상한을 0.44 초과했어요. 식습관을 점검하고 3개월 이내에 재검사하세요." + } + } + } + }, + "趋势" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trends" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "トレンド" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추세" + } + } + } + }, + "跟随系统" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System default" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "システムに従う" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시스템 설정 따름" + } + } + } + }, + "跳过" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skip" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "スキップ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "건너뛰기" + } + } + } + }, + "身体档案 · 历史导出" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Health profile · Export history" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "身体プロフィール · エクスポート履歴" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "신체 프로필 · 내보내기 기록" + } + } + } + }, + "身高" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Height" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "身長" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "키" + } + } + } + }, + "轻微" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mild" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "軽度" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "경미" + } + } + } + }, + "载脂蛋白 A1" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apolipoprotein A1" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アポリポタンパクA1" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아포지단백 A1" + } + } + } + }, + "载脂蛋白 A1 1.42 g/L" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apolipoprotein A1 1.42 g/L" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アポリポタンパクA1 1.42 g/L" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아포지단백 A1 1.42 g/L" + } + } + } + }, + "载脂蛋白 B" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apolipoprotein B" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アポリポタンパクB" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아포지단백 B" + } + } + } + }, + "载脂蛋白 B 1.04 g/L" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apolipoprotein B 1.04 g/L" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アポリポタンパクB 1.04 g/L" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아포지단백 B 1.04 g/L" + } + } + } + }, + "输入密码" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter password" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "パスワードを入力" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비밀번호 입력" + } + } + } + }, + "过敏史" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allergy history" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アレルギー歴" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알레르기 이력" + } + } + } + }, + "运行中…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Running…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "実行中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실행 중…" + } + } + } + }, + "运行推理自检" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Run inference self-check" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推論セルフチェックを実行" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추론 자가 점검 실행" + } + } + } + }, + "返回修改" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Back to edit" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "戻って編集" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "돌아가서 수정" + } + } + } + }, + "还没有任何记录\n点底部 + 号开始" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No records yet\nTap the + at the bottom to start" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "まだ記録がありません\n下の + をタップして始めましょう" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 기록이 없어요\n하단의 + 를 눌러 시작하세요" + } + } + } + }, + "还没有任何记录,点底部 + 号开始第一条" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No records yet. Tap the + at the bottom to add your first one." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "まだ記録がありません。下の + をタップして最初の記録を始めましょう。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 기록이 없어요. 하단의 + 를 눌러 첫 기록을 시작하세요." + } + } + } + }, + "还没有导出过\n回到记录页右上角生成一份" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing exported yet\nGo back to Records and tap the top-right to create one" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "まだエクスポートしていません\n記録ページに戻り、右上から作成しましょう" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 내보낸 적이 없어요\n기록 페이지로 돌아가 우측 상단에서 하나 만드세요" + } + } + } + }, + "还没有提醒,点上方新建" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No reminders yet. Tap + above to add one." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リマインダーはまだありません。上の+で追加。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 알림이 없습니다. 위의 +로 추가하세요." + } + } + } + }, + "还没有自定义指标" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No custom indicators yet" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カスタム指標がまだありません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 지표가 아직 없어요" + } + } + } + }, + "还没有记录提醒\n去「+ 指标记录」录入时打开" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No record reminders yet\nTurn one on while logging in “+ Indicator”" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "記録リマインダーがまだありません\n「+ 指標の記録」で入力する際にオンにしましょう" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록 알림이 아직 없어요\n「+ 지표 기록」에서 입력할 때 켜세요" + } + } + } + }, + "这一天还没有记录" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No records for this day yet" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この日の記録はまだありません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 날의 기록이 아직 없어요" + } + } + } + }, + "这个类别下没有记录" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No records in this category" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このカテゴリーに記録がありません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 카테고리에 기록이 없어요" + } + } + } + }, + "这是什么" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What is this" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "これは何ですか" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이게 뭔가요" + } + } + } + }, + "进行中" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ongoing" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "進行中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 중" + } + } + } + }, + "通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plain-language explanation: on-device AI turns your indicators and trends into easy-to-understand notes" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "わかりやすい解説:オンデバイスAIが指標やトレンドを理解しやすい説明に言い換えます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "쉬운 해설: 온디바이스 AI가 지표와 추세를 이해하기 쉬운 설명으로 바꿔줘요" + } + } + } + }, + "通知未开启" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications Off" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知がオフです" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알림이 꺼져 있음" + } + } + } + }, + "部分月份无此日,该月将跳过" : { + + }, + "采纳" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apply" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "採用" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "적용" + } + } + } + }, + "重复" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repeat" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "繰り返し" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "반복" + } + } + } + }, + "重拍" : { + + }, + "重新生成" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Regenerate" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "再生成" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다시 생성" + } + } + } + }, + "重新识别" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Re-scan" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "再認識" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다시 인식" + } + } + } + }, + "重新识别没有读出新指标" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Re-scanning found no new indicators" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "再認識しても新しい指標は読み取れませんでした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다시 인식해도 새 지표를 읽지 못했어요" + } + } + } + }, + "重试" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "再試行" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다시 시도" + } + } + } + }, + "长期监测" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Long-term monitoring" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "長期モニタリング" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "장기 모니터링" + } + } + } + }, + "长期监测(进趋势)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Long-term monitoring (added to Trends)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "長期モニタリング(トレンドに追加)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "장기 모니터링 (추세에 추가)" + } + } + } + }, + "长期趋势:关注的指标可生成折线图和简要解读" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Long-term trends: indicators you follow can generate line charts and brief explanations" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "長期トレンド:注目している指標から折れ線グラフと簡単な解説を生成できます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "장기 추세: 주목하는 지표로 꺾은선 그래프와 간단한 해설을 만들 수 있어요" + } + } + } + }, + "隐私优先:健康数据不上传、无需注册账号" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy first: health data is never uploaded, no account required" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プライバシー優先:健康データはアップロードされず、アカウント登録も不要です" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프라이버시 우선: 건강 데이터는 업로드되지 않고 계정 등록도 필요 없어요" + } + } + } + }, + "隐私保护" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy protection" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プライバシー保護" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프라이버시 보호" + } + } + } + }, + "隐藏" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "非表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "숨기기" + } + } + } + }, + "预计耗时 5–8 秒 · 端侧 SME2 加速" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estimated 5–8 sec · on-device SME2 acceleration" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "所要時間の目安 5~8秒 · オンデバイスSME2アクセラレーション" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예상 소요 5–8초 · 온디바이스 SME2 가속" + } + } + } + }, + "频率" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frequency" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "頻度" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "빈도" + } + } + } + }, + "餐后 2h" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Postprandial 2h" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "食後2時間" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "식후 2시간" + } + } + } + }, + "餐后血糖" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Postprandial glucose" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "食後血糖" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "식후 혈당" + } + } + } + }, + "验证你本人,开启 Face ID 启动锁" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verify it's you to enable Face ID Lock" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "本人確認をして、Face IDロックを有効にします" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "본인을 인증하여 Face ID 잠금을 켜세요" + } + } + } + }, + "高密度脂蛋白" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "HDL cholesterol" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "HDLコレステロール" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "HDL 콜레스테롤" + } + } + } + }, + "高密度脂蛋白 1.21 mmol/L" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "HDL cholesterol 1.21 mmol/L" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "HDLコレステロール 1.21 mmol/L" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "HDL 콜레스테롤 1.21 mmol/L" + } + } + } + }, + "高血压" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hypertension" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "高血圧" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "고혈압" + } + } + } + }, + "高血脂" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hyperlipidemia" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "高脂血症" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "고지혈증" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/康康/Models/HealthExport.swift b/康康/Models/HealthExport.swift new file mode 100644 index 0000000..cee32cb --- /dev/null +++ b/康康/Models/HealthExport.swift @@ -0,0 +1,72 @@ +import Foundation +import SwiftData + +/// 「导出身体档案」单条历史。一次成功生成 = 一条 HealthExport。 +/// +/// 与 Indicator/Report 等源记录之间用 `[String]` 弱关联(而不是 SwiftData +/// 关系),这样源记录被永久删除时,历史导出仍保留为快照。 +/// +/// 属性写法与项目其他 @Model(Indicator/ChatTurn 等)对齐: +/// 不在属性上写 default,所有默认值都在 `init` 里。 +@Model +final class HealthExport { + var prompt: String + var content: String + var createdAt: Date + + // 引用回链(§3.3 RAG 引用,W3 再做点击跳转) + var referencedIndicatorIDs: [String] + var referencedReportIDs: [String] + var referencedSymptomIDs: [String] + var referencedDiaryIDs: [String] + + // 意图抽取快照,供「重新生成」复用,不再二次抽意图 + var inferredTimeFromDate: Date? + var inferredTimeToDate: Date? + var inferredIntent: String? + /// 意图的中文标签(如「感冒就诊」),供「我的导出」列表 badge 展示;可选,旧库走轻量迁移。 + var inferredLabelCN: String? + + // demo 卖点凭证 + /// 模型 tag,如 "Qwen3-1.7B-4bit"。截图能证明本地推理。 + var modelTag: String + /// 末次 tok/s,对应 demo 卖点 #6 Live Activity 数据。 + var decodeRate: Double + + init(prompt: String = "", + content: String = "", + createdAt: Date = .now, + referencedIndicatorIDs: [String] = [], + referencedReportIDs: [String] = [], + referencedSymptomIDs: [String] = [], + referencedDiaryIDs: [String] = [], + inferredTimeFromDate: Date? = nil, + inferredTimeToDate: Date? = nil, + inferredIntent: String? = nil, + inferredLabelCN: String? = nil, + modelTag: String = "Qwen3-1.7B-4bit", + decodeRate: Double = 0) { + self.prompt = prompt + self.content = content + self.createdAt = createdAt + self.referencedIndicatorIDs = referencedIndicatorIDs + self.referencedReportIDs = referencedReportIDs + self.referencedSymptomIDs = referencedSymptomIDs + self.referencedDiaryIDs = referencedDiaryIDs + self.inferredTimeFromDate = inferredTimeFromDate + self.inferredTimeToDate = inferredTimeToDate + self.inferredIntent = inferredIntent + self.inferredLabelCN = inferredLabelCN + self.modelTag = modelTag + self.decodeRate = decodeRate + } +} + +extension HealthExport { + /// 列表 / strip 显示的 prompt 摘要(≤ 30 字 + ...) + var promptPreview: String { + let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.count <= 30 { return trimmed } + return trimmed.prefix(30) + "…" + } +} diff --git a/康康/Models/Models.swift b/康康/Models/Models.swift new file mode 100644 index 0000000..f140d05 --- /dev/null +++ b/康康/Models/Models.swift @@ -0,0 +1,388 @@ +import Foundation +import SwiftData + +enum IndicatorStatus: String, Codable, CaseIterable { + case high, low, normal +} + +enum ReportType: String, Codable, CaseIterable { + case checkup, lab, imaging, prescription, other + + var label: String { + switch self { + case .checkup: return String(appLoc: "体检报告") + case .lab: return String(appLoc: "化验单") + case .imaging: return String(appLoc: "影像报告") + case .prescription: return String(appLoc: "处方") + case .other: return String(appLoc: "其他") + } + } +} + +@Model +final class Indicator { + var name: String + var value: String + var unit: String + var range: String + var statusRaw: String + var note: String? + var capturedAt: Date + + var report: Report? + var asset: Asset? + var pinned: Bool = false + + /// 长期指标系列 key,如 "bp.systolic" / "glucose.fasting" / "weight"。 + /// 来源:IndicatorRecordSheet 选预设时填;VL/Report/自由输入留 nil。 + /// 用途:Trends 按 seriesKey 分组;Timeline 配对(如 bp.systolic + bp.diastolic 合并)。 + var seriesKey: String? + + init(name: String, + value: String, + unit: String, + range: String, + status: IndicatorStatus, + note: String? = nil, + capturedAt: Date = .now, + report: Report? = nil, + asset: Asset? = nil, + pinned: Bool = false, + seriesKey: String? = nil) { + self.name = name + self.value = value + self.unit = unit + self.range = range + self.statusRaw = status.rawValue + self.note = note + self.capturedAt = capturedAt + self.report = report + self.asset = asset + self.pinned = pinned + self.seriesKey = seriesKey + } + + var status: IndicatorStatus { + IndicatorStatus(rawValue: statusRaw) ?? .normal + } +} + +@Model +final class Report { + var title: String + var typeRaw: String + var reportDate: Date + var institution: String? + var note: String? + var summary: String? + var pageCount: Int + var createdAt: Date + + @Relationship(deleteRule: .cascade, inverse: \Indicator.report) + var indicators: [Indicator] = [] + + @Relationship(deleteRule: .cascade) + var assets: [Asset] = [] + + init(title: String, + type: ReportType, + reportDate: Date, + institution: String? = nil, + note: String? = nil, + summary: String? = nil, + pageCount: Int = 1, + createdAt: Date = .now) { + self.title = title + self.typeRaw = type.rawValue + self.reportDate = reportDate + self.institution = institution + self.note = note + self.summary = summary + self.pageCount = pageCount + self.createdAt = createdAt + } + + var type: ReportType { + ReportType(rawValue: typeRaw) ?? .other + } +} + +@Model +final class DiaryEntry { + var content: String + var createdAt: Date + var tags: [String] + + init(content: String, createdAt: Date = .now, tags: [String] = []) { + self.content = content + self.createdAt = createdAt + self.tags = tags + } +} + +@Model +final class Asset { + var relativePath: String + var mimeType: String + var bytes: Int + var createdAt: Date + + init(relativePath: String, + mimeType: String = "image/jpeg", + bytes: Int = 0, + createdAt: Date = .now) { + self.relativePath = relativePath + self.mimeType = mimeType + self.bytes = bytes + self.createdAt = createdAt + } +} + +@Model +final class Symptom { + var name: String + var startedAt: Date + var endedAt: Date? + var note: String? + var severity: Int + var tags: [String] + var createdAt: Date + + init(name: String, + startedAt: Date = .now, + endedAt: Date? = nil, + note: String? = nil, + severity: Int = 3, + tags: [String] = [], + createdAt: Date = .now) { + self.name = name + self.startedAt = startedAt + self.endedAt = endedAt + self.note = note + self.severity = max(1, min(5, severity)) + self.tags = tags + self.createdAt = createdAt + } + + var isOngoing: Bool { endedAt == nil } + + var duration: TimeInterval { + (endedAt ?? .now).timeIntervalSince(startedAt) + } +} + +/// 用户自定义的长期监测指标。 +/// 与 hardcoded `MonitorMetric` 并列出现在 IndicatorQuickSheet 的 grid 里; +/// `seriesKey` 自动生成成 `"custom."`,以此和 Indicator 双向关联。 +@Model +final class CustomMonitorMetric { + @Attribute(.unique) var seriesKey: String + var name: String + var unit: String + var lowerBound: Double? + var upperBound: Double? + var icon: String + var createdAt: Date + + init(name: String, + unit: String, + lowerBound: Double? = nil, + upperBound: Double? = nil, + icon: String = "circle.fill", + createdAt: Date = .now) { + self.seriesKey = "custom.\(UUID().uuidString)" + self.name = name + self.unit = unit + self.lowerBound = lowerBound + self.upperBound = upperBound + self.icon = icon + self.createdAt = createdAt + } + + var referenceRange: ClosedRange? { + guard let lo = lowerBound, let hi = upperBound, lo <= hi else { return nil } + return lo...hi + } + + var rangeText: String { + guard let r = referenceRange else { return "" } + return "\(Self.format(r.lowerBound)) - \(Self.format(r.upperBound))" + } + + private static func format(_ v: Double) -> String { + v.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", v) + : String(format: "%.1f", v) + } +} + +/// 长期监测指标的周期性记录提醒。 +/// 一个 metric 一条(`metricId` = `MonitorMetric.rawValue`)。 +/// 关闭通过 `enabled=false`(保留时间设置),删除走 `ctx.delete`。 +@Model +final class MetricReminder { + @Attribute(.unique) var metricId: String + var displayName: String + var enabled: Bool + var hour: Int // 0...23 + var minute: Int // 0...59 + var weekdays: [Int] // iOS Calendar 约定:1=日, 2=一, ..., 7=六。全 7 个 = 每天 + var createdAt: Date + var updatedAt: Date + + init(metricId: String, + displayName: String, + hour: Int = 8, + minute: Int = 0, + weekdays: [Int] = [1, 2, 3, 4, 5, 6, 7], + enabled: Bool = true, + createdAt: Date = .now) { + self.metricId = metricId + self.displayName = displayName + self.enabled = enabled + self.hour = max(0, min(23, hour)) + self.minute = max(0, min(59, minute)) + self.weekdays = weekdays + self.createdAt = createdAt + self.updatedAt = createdAt + } + + var isEveryDay: Bool { Set(weekdays) == Set(1...7) } + + var frequencyLabel: String { + if !enabled { return String(appLoc: "已关闭") } + if isEveryDay { return String(appLoc: "每天") } + if weekdays.isEmpty { return String(appLoc: "未选日") } + let names = [String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六")] + let sorted = weekdays.sorted() + return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined() + } + + var timeLabel: String { + String(format: "%02d:%02d", hour, minute) + } + + /// 这条指标提醒在给定日期「这天」是否会触发(weekday 制,全 7 = 每天);关闭则恒为 false。 + /// 供主页「今日提醒」筛选。 + func occurs(on date: Date, calendar: Calendar = .current) -> Bool { + guard enabled else { return false } + return weekdays.contains(calendar.component(.weekday, from: date)) + } +} + +/// 自由文案的周期性提醒(如「每天 20:00 跑步 5 公里」「每天 12:30 吃 2 片护肝片」)。 +/// 与 `MetricReminder`(去记录某指标)语义独立:这里是用户自定义的动作提醒, +/// 量词(5 公里 / 2 片)直接写在 `title` 自由文本里。 +/// 周期粒度沿用 weekday 约定(全 7 = 每天);本地通知调度见 `ReminderService`。 +@Model +final class CustomReminder { + /// 周期粒度。每日只看时间;每周看 weekdays;每月看 dayOfMonth;每年看 month + dayOfMonth。 + enum Frequency: String, CaseIterable, Sendable { + 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] // iOS Calendar 约定:1=日, 2=一, ..., 7=六。全 7 个 = 每天 + var frequencyRaw: String = "daily" // CustomReminder.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 + + init(id: UUID = UUID(), + title: String, + note: String = "", + hour: Int = 8, + minute: Int = 0, + weekdays: [Int] = [1, 2, 3, 4, 5, 6, 7], + frequency: Frequency = .daily, + dayOfMonth: Int = 1, + month: Int = 1, + enabled: Bool = true, + createdAt: Date = .now) { + self.id = id + self.title = title + self.note = note + self.hour = max(0, min(23, hour)) + self.minute = max(0, min(59, minute)) + self.weekdays = weekdays + self.frequencyRaw = frequency.rawValue + self.dayOfMonth = max(1, min(31, dayOfMonth)) + self.month = max(1, min(12, month)) + self.enabled = enabled + self.createdAt = createdAt + self.updatedAt = createdAt + } + + var isEveryDay: Bool { Set(weekdays) == Set(1...7) } + + var frequency: Frequency { + get { Frequency(rawValue: frequencyRaw) ?? .daily } + set { frequencyRaw = newValue.rawValue } + } + + /// 列表行副标题:按频率展示「每天 / 每周 一三五 / 每月15日 / 每年3月15日」。 + var frequencyLabel: String { + if !enabled { return String(appLoc: "已关闭") } + switch frequency { + case .daily: + return String(appLoc: "每天") + case .weekly: + if isEveryDay { return String(appLoc: "每天") } + if weekdays.isEmpty { return String(appLoc: "未选日") } + let names = [String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六")] + return String(appLoc: "每周 ") + weekdays.sorted().map { names[$0 - 1] }.joined() + case .monthly: + return String(appLoc: "每月\(dayOfMonth)日") + case .yearly: + return String(appLoc: "每年\(month)月\(dayOfMonth)日") + } + } + + var timeLabel: String { + String(format: "%02d:%02d", hour, minute) + } + + /// 这条提醒在给定日期「这天」是否会触发(只看哪天,不看时分);关闭则恒为 false。 + /// 供主页「今日提醒」筛选。monthly/yearly 选了无此日的月份(如 31 日)自然返回 false, + /// 与 iOS「该月跳过、不顺延」的行为一致。 + func occurs(on date: Date, calendar: Calendar = .current) -> Bool { + guard enabled else { return false } + let c = calendar.dateComponents([.weekday, .day, .month], from: date) + switch frequency { + case .daily: return true + case .weekly: return weekdays.contains(c.weekday ?? -1) + case .monthly: return dayOfMonth == (c.day ?? -1) + case .yearly: return month == (c.month ?? -1) && dayOfMonth == (c.day ?? -1) + } + } +} + +@Model +final class ChatTurn { + var question: String + var answer: String + var referencedIndicatorIDs: [String] + var referencedReportIDs: [String] + var createdAt: Date + var decodeRate: Double + + init(question: String, + answer: String, + referencedIndicatorIDs: [String] = [], + referencedReportIDs: [String] = [], + createdAt: Date = .now, + decodeRate: Double = 0) { + self.question = question + self.answer = answer + self.referencedIndicatorIDs = referencedIndicatorIDs + self.referencedReportIDs = referencedReportIDs + self.createdAt = createdAt + self.decodeRate = decodeRate + } +} diff --git a/康康/Models/UserProfile.swift b/康康/Models/UserProfile.swift new file mode 100644 index 0000000..39a7389 --- /dev/null +++ b/康康/Models/UserProfile.swift @@ -0,0 +1,124 @@ +import Foundation +import SwiftData + +@Model +final class UserProfile { + // —— 核心 5 项 —— + var birthYear: Int? // 1990。隐私考虑只存年,不存月日 + var biologicalSexRaw: String // "" / "male" / "female" + var heightCM: Int? + var weightKG: Double? // 体重支持小数(68.5) + var bloodTypeRaw: String // "" / "A" / "B" / "AB" / "O" + + // —— 健康背景 —— + var allergies: [String] + var chronicConditions: [String] + var familyHistory: [String] + + // —— 当前用药 —— + var currentMedications: [String] + + // —— UI 偏好 —— + // 用户在 IndicatorQuickSheet 长按隐藏的 MonitorMetric.rawValue。 + // 只影响录入 grid,不影响 Indicator 历史 / Trends / Reminder。 + var hiddenPresetMetrics: [String] + + var updatedAt: Date + + init(birthYear: Int? = nil, + biologicalSexRaw: String = "", + heightCM: Int? = nil, + weightKG: Double? = nil, + bloodTypeRaw: String = "", + allergies: [String] = [], + chronicConditions: [String] = [], + familyHistory: [String] = [], + currentMedications: [String] = [], + hiddenPresetMetrics: [String] = [], + updatedAt: Date = .now) { + self.birthYear = birthYear + self.biologicalSexRaw = biologicalSexRaw + self.heightCM = heightCM + self.weightKG = weightKG + self.bloodTypeRaw = bloodTypeRaw + self.allergies = allergies + self.chronicConditions = chronicConditions + self.familyHistory = familyHistory + self.currentMedications = currentMedications + self.hiddenPresetMetrics = hiddenPresetMetrics + self.updatedAt = updatedAt + } +} + +extension UserProfile { + enum Sex: String, CaseIterable { + case male, female + case undisclosed = "" + + var label: String { + switch self { + case .male: return String(appLoc: "男") + case .female: return String(appLoc: "女") + case .undisclosed: return String(appLoc: "不愿透露") + } + } + } + + var sex: Sex { + get { Sex(rawValue: biologicalSexRaw) ?? .undisclosed } + set { biologicalSexRaw = newValue.rawValue } + } + + /// 当前年龄。无 birthYear 时返回 nil。基于当前日历年简单相减,不算月日。 + var age: Int? { + guard let y = birthYear else { return nil } + return Calendar.current.component(.year, from: .now) - y + } + + /// 给 ProfileCard 一行预览:"38岁 · 男 · 175cm · 68kg · A型" + var summaryLine: String { + var parts: [String] = [] + if let age { parts.append(String(appLoc: "\(age)岁")) } + if sex != .undisclosed { parts.append(sex.label) } + if let h = heightCM { parts.append("\(h)cm") } + if let w = weightKG { + let s = w.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0fkg", w) + : String(format: "%.1fkg", w) + parts.append(s) + } + if !bloodTypeRaw.isEmpty { parts.append(String(appLoc: "\(bloodTypeRaw)型")) } + return parts.joined(separator: " · ") + } + + /// 资料是否完整到值得显示 summaryLine(否则提示"完善资料") + var hasAnyBasics: Bool { + birthYear != nil || + sex != .undisclosed || + heightCM != nil || + weightKG != nil || + !bloodTypeRaw.isEmpty + } + + /// BMI(kg/m²),需要同时有身高 + 体重才能算 + var bmi: Double? { + guard let h = heightCM, h > 0, let w = weightKG else { return nil } + let m = Double(h) / 100.0 + return w / (m * m) + } +} + +/// 单例存取:全 App 只允许一份 UserProfile。第一次取自动创建。 +enum UserProfileStore { + @MainActor + static func loadOrCreate(in ctx: ModelContext) -> UserProfile { + let descriptor = FetchDescriptor() + if let existing = try? ctx.fetch(descriptor).first { + return existing + } + let new = UserProfile() + ctx.insert(new) + try? ctx.save() + return new + } +} diff --git a/康康/Persistence/FileVault.swift b/康康/Persistence/FileVault.swift new file mode 100644 index 0000000..4031228 --- /dev/null +++ b/康康/Persistence/FileVault.swift @@ -0,0 +1,106 @@ +import Foundation +import UIKit + +enum FileVaultError: Error { + case readFailed + case writeFailed + case removeFailed + case decodeFailed +} + +/// `@unchecked Sendable`:rootURL 是 let,方法只 I/O 到沙盒目录(线程安全), +/// 可被任意 actor / Task 跨边界访问。实例方法显式 `nonisolated`,见 ModelStore 同款注释。 +final class FileVault: @unchecked Sendable { + nonisolated static let shared: FileVault = { + do { + let appSupport = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let vaultURL = appSupport.appendingPathComponent("Vault", isDirectory: true) + return try FileVault(rootURL: vaultURL) + } catch { + fatalError("FileVault.shared init failed: \(error)") + } + }() + + let rootURL: URL + + init(rootURL: URL) throws { + self.rootURL = rootURL + try FileManager.default.createDirectory( + at: rootURL, + withIntermediateDirectories: true, + attributes: [.protectionKey: FileProtectionType.complete] + ) + } + + struct SavedAsset { + let relativePath: String + let bytes: Int + } + + // MARK: - Path Safety + + nonisolated private func resolveSafePath(_ relativePath: String) throws -> URL { + guard !relativePath.contains("/"), + !relativePath.contains(".."), + !relativePath.isEmpty else { + throw FileVaultError.readFailed + } + let url = rootURL.appendingPathComponent(relativePath) + guard url.path.hasPrefix(rootURL.path) else { + throw FileVaultError.readFailed + } + return url + } + + // MARK: - Public API + + nonisolated func writeJPEG(_ image: UIImage, quality: CGFloat = 0.85) throws -> SavedAsset { + guard let data = image.jpegData(compressionQuality: quality) else { + throw FileVaultError.writeFailed + } + let filename = "\(UUID().uuidString).jpg" + let url = rootURL.appendingPathComponent(filename) + try data.write(to: url, options: [.atomic, .completeFileProtection]) + return SavedAsset(relativePath: filename, bytes: data.count) + } + + nonisolated func loadImage(relativePath: String) throws -> UIImage { + let url = try resolveSafePath(relativePath) + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + throw FileVaultError.readFailed + } + guard let image = UIImage(data: data) else { throw FileVaultError.decodeFailed } + return image + } + + nonisolated func remove(relativePath: String) throws { + let url = try resolveSafePath(relativePath) + do { + try FileManager.default.removeItem(at: url) + } catch { + throw FileVaultError.removeFailed + } + } + + /// 清空 Vault 全部文件。单个文件删除失败(被占用/权限)不中断,继续删其余; + /// 最后复查仍有残留才抛错 ——「永久删除 / 全清」语义下不能因一个文件卡住而留下隐私残留。 + nonisolated func wipe() throws { + let fm = FileManager.default + let contents = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? [] + for url in contents { + try? fm.removeItem(at: url) + } + let remaining = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? [] + if !remaining.isEmpty { + throw FileVaultError.removeFailed + } + } +} diff --git a/康康/RootView.swift b/康康/RootView.swift new file mode 100644 index 0000000..6ac4eb4 --- /dev/null +++ b/康康/RootView.swift @@ -0,0 +1,234 @@ +import SwiftUI + +enum TjTab: String, Hashable, CaseIterable { + case home, records, trend, me + var label: String { + switch self { + case .home: return String(appLoc: "主页") + case .records: return String(appLoc: "记录") + case .trend: return String(appLoc: "趋势") + case .me: return String(appLoc: "我的") + } + } + var icon: String { + switch self { + case .home: return "house" + case .records: return "list.bullet.rectangle" + case .trend: return "chart.line.uptrend.xyaxis" + case .me: return "person.circle" + } + } + /// 屏上从左到右的位置,用于决定页面 push 过渡的方向。 + var index: Int { + switch self { + case .home: return 0 + case .records: return 1 + case .trend: return 2 + case .me: return 3 + } + } +} + +enum ActiveFlow: Identifiable { + case quick, archive + var id: String { String(describing: self) } +} + +struct RootView: View { + @State private var tab: TjTab = .home + /// 页面 push 过渡的来向:切到右侧 tab 时从 trailing 推入,切到左侧时从 leading 推入。 + @State private var pushEdge: Edge = .trailing + @State private var showRecordSheet = false + @State private var activeFlow: ActiveFlow? + @State private var showSymptomStart = false + @State private var showDiary = false + @State private var showIndicator = false + @State private var showReminders = false + + /// 统一的 tab 切换入口:按方向设定 pushEdge,再带动画改 tab。 + /// 所有改 tab 的地方都走这里,保证过渡方向正确。 + private func select(_ newTab: TjTab) { + guard newTab != tab else { return } + pushEdge = newTab.index > tab.index ? .trailing : .leading + withAnimation(.easeInOut(duration: 0.26)) { tab = newTab } + } + + var body: some View { + VStack(spacing: 0) { + Group { + switch tab { + case .home: HomeView(onTapArchive: { select(.records) }) + case .records: ArchiveListView() + case .trend: TrendsView() + case .me: MeView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .id(tab) + .transition(.push(from: pushEdge)) + + TabBar(active: tab, + onTap: { select($0) }, + onTapRecord: { showRecordSheet = true }) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + .sheet(isPresented: $showRecordSheet) { + RecordSheet { kind in + showRecordSheet = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + switch kind { + case .quick: activeFlow = .quick + case .archive: activeFlow = .archive + case .symptom: showSymptomStart = true + case .diary: showDiary = true + case .indicator: showIndicator = true + case .reminder: showReminders = true + } + } + } + } + .sheet(isPresented: $showSymptomStart) { + SymptomStartSheet() + } + .sheet(isPresented: $showDiary) { + DiaryQuickSheet() + } + .sheet(isPresented: $showIndicator) { + IndicatorQuickSheet() + } + .sheet(isPresented: $showReminders) { + // 列表页依赖外层 NavigationStack 提供标题栏;sheet 形态补「完成」按钮。 + NavigationStack { RemindersListView(presentedAsSheet: true) } + } +#if os(iOS) + .fullScreenCover(item: $activeFlow) { flow in + switch flow { + case .quick: + QuickRegionCaptureFlow(onClose: { activeFlow = nil }) + case .archive: + UnifiedCaptureFlow(onClose: { activeFlow = nil }) + } + } +#else + .sheet(item: $activeFlow) { flow in + switch flow { + case .quick: + QuickRegionCaptureFlow(onClose: { activeFlow = nil }) + case .archive: + UnifiedCaptureFlow(onClose: { activeFlow = nil }) + } + } +#endif + } +} + +private struct TabBar: View { + let active: TjTab + let onTap: (TjTab) -> Void + let onTapRecord: () -> Void + + @Namespace private var indicatorNS + + private let cornerRadius: CGFloat = 22 + private let slotHeight: CGFloat = 34 + + var body: some View { + HStack(alignment: .bottom, spacing: 0) { + tabItem(.home) + tabItem(.records) + recordSlot + tabItem(.trend) + tabItem(.me) + } + .padding(.horizontal, 4) + .padding(.top, 10) + .padding(.bottom, 6) + .background(barBackground) + .animation(.spring(response: 0.35, dampingFraction: 0.75), value: active) + } + + private var barBackground: some View { + UnevenRoundedRectangle( + topLeadingRadius: cornerRadius, + bottomLeadingRadius: 0, + bottomTrailingRadius: 0, + topTrailingRadius: cornerRadius, + style: .continuous + ) + .fill(Tj.Palette.paper) + .overlay(alignment: .top) { + Rectangle() + .fill(Tj.Palette.lineSoft) + .frame(height: 1) + } + .shadow(color: Tj.Palette.ink.opacity(0.05), radius: 10, x: 0, y: -2) + } + + private func tabItem(_ t: TjTab) -> some View { + let isActive = active == t + return Button { onTap(t) } label: { + VStack(spacing: 4) { + ZStack { + if isActive { + Capsule() + .fill(Tj.Palette.sand2) + .frame(width: 44, height: slotHeight - 6) + .matchedGeometryEffect(id: "tabIndicator", in: indicatorNS) + } + Image(systemName: t.icon) + .font(.system(size: 18, weight: isActive ? .semibold : .regular)) + } + .frame(width: 50, height: slotHeight) + + Text(t.label) + .font(.system(size: 11, weight: isActive ? .semibold : .regular)) + } + .foregroundStyle(isActive ? Tj.Palette.ink : Tj.Palette.text3) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(TabPressStyle()) + } + + private var recordSlot: some View { + Button(action: onTapRecord) { + VStack(spacing: 4) { + ZStack { + Circle() + .fill(Tj.Palette.ink) + .overlay( + Circle() + .strokeBorder(Tj.Palette.paper, lineWidth: 2) + ) + .shadow(color: Tj.Palette.ink.opacity(0.18), + radius: 4, x: 0, y: 2) + + Image(systemName: "plus") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Tj.Palette.paper) + } + .frame(width: slotHeight, height: slotHeight) + + Text("新建") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Tj.Palette.ink) + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(TabPressStyle()) + } +} +// 你好 +private struct TabPressStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.92 : 1.0) + .animation(.spring(response: 0.25, dampingFraction: 0.7), + value: configuration.isPressed) + } +} + +#Preview { + RootView() +} diff --git a/康康/Security/AppLock.swift b/康康/Security/AppLock.swift new file mode 100644 index 0000000..d75ff0e --- /dev/null +++ b/康康/Security/AppLock.swift @@ -0,0 +1,158 @@ +import Foundation +import LocalAuthentication +import SwiftUI +import Observation + +/// Face ID 启动锁的运行时控制器(单例)。 +/// +/// 设计见 `docs/superpowers/specs/2026-05-30-faceid-app-lock-design.md`。 +/// 红线对齐(CLAUDE.md §10.2):只用系统 `LocalAuthentication`,不自造任何密码学。 +/// +/// 单例写法与 `ModelDownloadService.shared` 一致:`@MainActor @Observable`。 +/// UI(`AppLockContainer` / `MeView` / `LockScreenView`)只观察本类的 observable 状态, +/// 通过 `handleAppear` / `handleScenePhase` / `authenticate` 等方法驱动。 +@MainActor +@Observable +final class AppLock { + static let shared = AppLock() + + /// 后台超过该时长再回前台 → 重锁。 + static let gracePeriod: TimeInterval = 60 + + /// 启用开关持久化 key,与 `MeView` 的 `@AppStorage` 同源。 + static let enabledKey = "faceIDLockEnabled" + + // MARK: - Observable 运行态 + + /// 当前是否处于锁定(需认证才能进入)。 + private(set) var isLocked = false + + /// 进入任务切换器 / 后台时是否盖隐私遮罩(仅锁开启时为真)。 + private(set) var showsPrivacyCover = false + + /// 设备是否可用生物识别或密码认证(无密码设备为 false)。 + private(set) var biometryAvailable = false + + /// 认证按钮 / 副标题文案:"Face ID" / "Touch ID" / "密码"。 + private(set) var biometryLabel = String(appLoc: "密码") + + // MARK: - 非观察内部态 + + /// 是否已开启启动锁。读写 UserDefaults(与 MeView 的 @AppStorage 同 key)。 + /// 不需要 observable —— UI 侧用 @AppStorage 观察这个 key 的变化。 + var enabled: Bool { + get { UserDefaults.standard.bool(forKey: Self.enabledKey) } + set { UserDefaults.standard.set(newValue, forKey: Self.enabledKey) } + } + + private var lastBackgroundedAt: Date? + private var didColdLaunchLock = false + private var isAuthenticating = false + + private init() { + refreshAvailability() + } + + // MARK: - 能力探测 + + /// 刷新「设备能否认证」与文案。进设置页 / 容器出现时调。 + func refreshAvailability() { + let ctx = LAContext() + var error: NSError? + // .deviceOwnerAuthentication:设备设了密码即为 true(含生物识别 + 密码兜底)。 + biometryAvailable = ctx.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) + // biometryType 只有在 canEvaluatePolicy 调用后才有效。 + switch ctx.biometryType { + case .faceID: biometryLabel = "Face ID" + case .touchID: biometryLabel = "Touch ID" + default: biometryLabel = String(appLoc: "密码") + } + } + + // MARK: - 生命周期驱动(由 AppLockContainer 调) + + /// 冷启动:容器首次出现时调一次。 + func handleAppear() { + refreshAvailability() + guard enabled, !didColdLaunchLock else { return } + didColdLaunchLock = true + isLocked = true + Task { await authenticate() } + } + + /// scenePhase 变化驱动。 + func handleScenePhase(_ phase: ScenePhase) { + switch phase { + case .inactive: + // 任务切换器 / 系统弹窗打断:盖遮罩(已锁定时锁屏本身就是遮罩)。 + showsPrivacyCover = enabled && !isLocked + + case .background: + lastBackgroundedAt = Date() + showsPrivacyCover = enabled + + case .active: + showsPrivacyCover = false + if enabled, !isLocked, + let since = lastBackgroundedAt, + Date().timeIntervalSince(since) > Self.gracePeriod { + isLocked = true + } + if isLocked { Task { await authenticate() } } + lastBackgroundedAt = nil + + @unknown default: + break + } + } + + // MARK: - 认证 + + /// 触发系统认证。成功 → 解锁;失败/取消 → 保持锁定。`isAuthenticating` 防重入, + /// 避免容器与锁屏 onAppear 同时各弹一次。 + func authenticate() async { + guard isLocked, !isAuthenticating else { return } + isAuthenticating = true + defer { isAuthenticating = false } + + let ctx = LAContext() + ctx.localizedFallbackTitle = String(appLoc: "输入密码") + do { + let ok = try await ctx.evaluatePolicy( + .deviceOwnerAuthentication, + localizedReason: String(appLoc: "解锁康康,查看你的健康档案") + ) + if ok { isLocked = false } + } catch { + // 失败/取消:停留锁屏,用户可点「解锁」重试。不抛给 UI。 + } + } + + // MARK: - 设置开关(MeView 调) + + /// 开启:先认证一次(验证设备可用 + 确认本人),成功才置 `enabled`。 + /// 返回最终是否已开启。 + @discardableResult + func enableWithAuth() async -> Bool { + let ctx = LAContext() + ctx.localizedFallbackTitle = String(appLoc: "输入密码") + do { + let ok = try await ctx.evaluatePolicy( + .deviceOwnerAuthentication, + localizedReason: String(appLoc: "验证你本人,开启 Face ID 启动锁") + ) + if ok { + enabled = true + return true + } + } catch { + // 取消/失败:不开启。 + } + return false + } + + /// 关闭:直接关(此刻已在 App 内、本次已通过认证)。 + func disable() { + enabled = false + } +} diff --git a/康康/Security/AppLockContainer.swift b/康康/Security/AppLockContainer.swift new file mode 100644 index 0000000..f5b9134 --- /dev/null +++ b/康康/Security/AppLockContainer.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// 包裹 `RootView` 的薄薄一层:监听 scenePhase,按需在内容之上盖锁屏 / 隐私遮罩。 +/// RootView 本身零改动(对齐红线 §10.7「不重构现有 Tab 骨架」)。 +/// +/// 用法(KangkangApp):`AppLockContainer { RootView() }`。 +struct AppLockContainer: View { + @ViewBuilder var content: () -> Content + + @Environment(\.scenePhase) private var scenePhase + @State private var appLock = AppLock.shared + + var body: some View { + content() + .overlay { + if appLock.isLocked { + LockScreenView() + .transition(.opacity) + } else if appLock.showsPrivacyCover { + // 不加动画:瞬间出现,抢在系统多任务快照之前盖住内容。 + PrivacyCoverView() + } + } + // 只给锁屏淡入淡出;隐私遮罩保持瞬时。 + .animation(.easeInOut(duration: 0.2), value: appLock.isLocked) + .onAppear { appLock.handleAppear() } + .onChange(of: scenePhase) { _, newPhase in + appLock.handleScenePhase(newPhase) + } + } +} diff --git a/康康/Security/LockScreenView.swift b/康康/Security/LockScreenView.swift new file mode 100644 index 0000000..6c34415 --- /dev/null +++ b/康康/Security/LockScreenView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +/// 锁屏:全遮罩,onAppear 自动触发一次认证;失败/取消后停留,可点按钮重试。 +struct LockScreenView: View { + @State private var appLock = AppLock.shared + + /// 认证按钮 / 图标随设备能力变化。 + private var glyph: String { + switch appLock.biometryLabel { + case "Face ID": return "faceid" + case "Touch ID": return "touchid" + default: return "lock.fill" + } + } + + var body: some View { + ZStack { + Tj.Palette.sand.ignoresSafeArea() + + VStack(spacing: 18) { + Spacer() + + ZStack { + Circle() + .fill(Tj.Palette.paper) + .overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1)) + Image(systemName: "lock.fill") + .font(.system(size: 34)) + .foregroundStyle(Tj.Palette.ink) + } + .frame(width: 92, height: 92) + .shadow(color: Tj.Palette.ink.opacity(0.06), radius: 12, y: 4) + + VStack(spacing: 6) { + Text("康康 已锁定") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + Text("你的健康档案已加密保护") + .font(.system(size: 13)) + .foregroundStyle(Tj.Palette.text3) + } + + Spacer() + + Button { + Task { await appLock.authenticate() } + } label: { + Label("\(appLock.biometryLabel) 解锁", systemImage: glyph) + .frame(maxWidth: .infinity) + } + .buttonStyle(TjPrimaryButton(height: 52, fontSize: 16)) + .padding(.horizontal, 40) + .padding(.bottom, 48) + } + } + .onAppear { + Task { await appLock.authenticate() } + } + } +} + +/// 隐私遮罩:进任务切换器 / 后台时盖在内容之上,挡住多任务快照里的健康数据。 +/// 无交互,纯品牌底。 +struct PrivacyCoverView: View { + var body: some View { + ZStack { + Tj.Palette.sand.ignoresSafeArea() + + VStack(spacing: 14) { + ZStack { + Circle() + .fill(Tj.Palette.paper) + .overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1)) + Image(systemName: "heart.text.square.fill") + .font(.system(size: 30)) + .foregroundStyle(Tj.Palette.ink) + } + .frame(width: 80, height: 80) + + Text("康康") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + } + } + } +} + +#Preview("锁屏") { + LockScreenView() +} + +#Preview("隐私遮罩") { + PrivacyCoverView() +} diff --git a/康康/Services/CaptureService.swift b/康康/Services/CaptureService.swift new file mode 100644 index 0000000..37819ee --- /dev/null +++ b/康康/Services/CaptureService.swift @@ -0,0 +1,406 @@ +import Foundation +import UIKit +import SwiftData + +/// VL 解析结果(已结构化,可直接喂 SwiftData 模型构造)。 +/// 与 Indicator/Report 字段近似但解耦 —— 这样 prompt schema 调整不污染数据层。 +struct ParsedReport: Sendable { + var title: String + var typeRaw: String + var reportDate: Date + var institution: String + var summary: String + var pageCount: Int + var indicators: [ParsedIndicator] + + struct ParsedIndicator: Sendable, Identifiable { + // 稳定身份:供可编辑列表 ForEach 用,避免按 indices 作 id 在增删时错配输入。 + let id = UUID() + var name: String + var value: String + var unit: String + var range: String + var status: IndicatorStatus + } + + /// 一项都没识别出来 = 视作失败,UI 走手动录入回退。 + var isEmpty: Bool { indicators.isEmpty } + + /// 占位空结果,失败回退时给 UI。 + static func empty(date: Date = .now) -> ParsedReport { + ParsedReport( + title: "", + typeRaw: ReportType.other.rawValue, + reportDate: date, + institution: "", + summary: "", + pageCount: 1, + indicators: [] + ) + } +} + +/// CaptureService 错误 — UI 决定怎么呈现(回退表单 vs 重试)。 +enum CaptureError: Error, LocalizedError { + case modelNotReady + case inferenceFailed(String) + case parseFailed(String) + + var errorDescription: String? { + switch self { + case .modelNotReady: return String(appLoc: "VL 模型尚未就绪") + case .inferenceFailed(let m): return String(appLoc: "识别失败:\(m)") + case .parseFailed(let m): return String(appLoc: "结构化失败:\(m)") + } + } +} + +/// `CaptureService` 是 actor 是因为它的方法会等 AIRuntime(也是 actor), +/// 但本身不持任何可变状态 —— 单例 stateless,纯粹是 §3.1 模块边界的"门面"。 +actor CaptureService { + static let shared = CaptureService() + private init() {} + + /// 对已写入 Vault 的 Asset 跑 VL,返回结构化 ParsedReport。 + /// 用于: + /// - UnifiedCaptureFlow 的初次识别(UI 先写图、再调本方法,失败/取消都能保留 assets 走手动录入) + /// - 录入表单顶部的「重新识别」按钮 + /// - C2「重新解读」(W5) + /// SwiftData 写回由调用方(MainActor)负责,见 `Report.applyReanalyzed(_:in:)`。 + /// 不直接接 @Model 类型,避免把非 Sendable 引用抛过 actor 边界。 + func reanalyze(assets: [FileVault.SavedAsset]) async throws -> ParsedReport { + try await runVL(on: assets) + } + + /// 异常项快拍:对一张**局部照片**(JPEG data)跑 VL,只抽 indicators,不建 Report、不留图。 + /// - 临时文件落 `NSTemporaryDirectory`(`.completeFileProtectionUnlessOpen`),推理后 `defer` 删除 —— + /// 符合「最后只存参数和异常值」(§ 需求)与隐私基线(§6),全程不写 Vault、不建 Asset。 + /// - 失败抛 `CaptureError`,UI 回退手动录入(§3.2 失败回退红线)。 + /// 调用方(MainActor)负责把识别结果落成独立 Indicator。 + func recognizeRegion(imageData: Data) async throws -> [ParsedReport.ParsedIndicator] { + do { + try await AIRuntime.shared.prepareVL() + } catch { + throw CaptureError.modelNotReady + } + + let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("region-\(UUID().uuidString).jpg") + do { + // 用 .completeFileProtectionUnlessOpen 而非 .complete:VL 推理可能持续数秒, + // 期间设备若锁屏,.complete 会让读/写抛 EPERM 使快拍在锁屏下必失败; + // unlessOpen 允许已打开句柄继续访问,与 Vault(completeUnlessOpen)一致。 + try imageData.write(to: tmpURL, options: [.completeFileProtectionUnlessOpen, .atomic]) + } catch { + throw CaptureError.inferenceFailed("临时图片写入失败:\(error.localizedDescription)") + } + defer { try? FileManager.default.removeItem(at: tmpURL) } + + let raw: String + do { + raw = try await AIRuntime.shared.analyzeReport( + imageURLs: [tmpURL], + prompt: VLPrompts.regionExtraction() + ) + } catch { + throw CaptureError.inferenceFailed("\(error)") + } + do { + return try CaptureService.parseIndicatorsJSON(raw) + } catch let CaptureError.parseFailed(msg) { + throw CaptureError.parseFailed(msg) + } catch { + throw CaptureError.parseFailed("\(error)") + } + } + + /// VL 推理 + JSON 解析的纯阶段。assets 必须已写入 Vault。 + private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport { + do { + try await AIRuntime.shared.prepareVL() + } catch { + throw CaptureError.modelNotReady + } + let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) } + let raw: String + do { + raw = try await AIRuntime.shared.analyzeReport( + imageURLs: urls, + prompt: VLPrompts.reportExtraction() + ) + } catch { + throw CaptureError.inferenceFailed("\(error)") + } + do { + return try CaptureService.parseReportJSON(raw, pageCount: assets.count) + } catch let CaptureError.parseFailed(msg) { + throw CaptureError.parseFailed(msg) + } catch { + throw CaptureError.parseFailed("\(error)") + } + } + + // MARK: - JSON parse(static + 纯函数 → 方便单测) + + /// 从 VL 输出里抠出第一段合法 JSON 对象并解析。 + /// 容错: + /// - 去掉 ```json``` markdown 围栏 + /// - 去掉首尾非 JSON 文字 + /// - 缺字段填默认值 + /// 解析不到任何 indicator 也算成功,但 ParsedReport.isEmpty = true, + /// UI 走「手动录入」分支。 + static func parseReportJSON(_ raw: String, pageCount: Int = 1) throws -> ParsedReport { + // 用 extractBalancedJSON(而非只认 {} 的 extractJSONObject):VL 多项时偶尔直接吐 + // 裸数组 [{...},{...}],只认对象会从第一个 { 配对,只截出第一个 indicator、静默丢掉 + // 其余 —— 这是影像档案核心卖点上的数据丢失。顶层是数组时整体视作 indicators。 + let jsonString = extractBalancedJSON(from: raw) + guard let data = jsonString.data(using: .utf8) else { + throw CaptureError.parseFailed("非 UTF-8 输出") + } + let obj: Any + do { + obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + } catch { + throw CaptureError.parseFailed("JSON 不合法:\(error.localizedDescription)") + } + let dict: [String: Any] + if let d = obj as? [String: Any] { + dict = d + } else if let arr = obj as? [[String: Any]] { + dict = ["indicators": arr] + } else { + throw CaptureError.parseFailed("根节点既不是对象也不是数组") + } + + let title = (dict["title"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "" + let typeRaw = parseReportType(dict["type"] as? String) + let reportDate = parseDate(dict["report_date"] as? String) ?? .now + let institution = (dict["institution"] as? String) ?? "" + let summary = (dict["summary"] as? String) ?? "" + let pages = (dict["page_count"] as? Int) ?? pageCount + + let indicatorsRaw = (dict["indicators"] as? [[String: Any]]) ?? [] + let indicators: [ParsedReport.ParsedIndicator] = indicatorsRaw.compactMap { + parseIndicator($0) + } + + return ParsedReport( + title: title.isEmpty ? String(appLoc: "拍摄识别") : title, + typeRaw: typeRaw, + reportDate: reportDate, + institution: institution, + summary: summary, + pageCount: max(pages, pageCount), + indicators: indicators + ) + } + + /// 局部识别解析:VL 输出 `{"indicators":[...]}`,只抠 indicators 数组。 + /// 复用 `extractJSONObject` + `parseIndicator`。解析不到任何 indicator 返回空数组(不抛), + /// UI 据此走「没读出指标,手动补充」分支。JSON 本身不合法才抛 `parseFailed`。 + static func parseIndicatorsJSON(_ raw: String) throws -> [ParsedReport.ParsedIndicator] { + let jsonString = extractBalancedJSON(from: raw) + guard let data = jsonString.data(using: .utf8) else { + throw CaptureError.parseFailed("非 UTF-8 输出") + } + let obj: Any + do { + obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + } catch { + throw CaptureError.parseFailed("JSON 不合法:\(error.localizedDescription)") + } + // 兼容两种形态:{"indicators":[...]} 或直接 [...](模型偶尔省外层 key) + let indicatorsRaw: [[String: Any]] + if let dict = obj as? [String: Any] { + indicatorsRaw = (dict["indicators"] as? [[String: Any]]) ?? [] + } else if let arr = obj as? [[String: Any]] { + indicatorsRaw = arr + } else { + throw CaptureError.parseFailed("根节点既不是对象也不是数组") + } + return indicatorsRaw.compactMap { parseIndicator($0) } + } + + /// 从字符串里抠出第一段平衡的 {...}。处理 markdown 围栏、前后乱码。 + /// 失败返回原字符串(后续 JSONSerialization 报错)。 + static func extractJSONObject(from raw: String) -> String { + var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + // 去 markdown 围栏 + if s.hasPrefix("```") { + // 砍掉首行 ```json 或 ``` + if let firstNewline = s.firstIndex(of: "\n") { + s = String(s[s.index(after: firstNewline)...]) + } + // 砍掉末尾 ``` + if let endRange = s.range(of: "```", options: .backwards) { + s = String(s[.. String { + var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if s.hasPrefix("```") { + if let firstNewline = s.firstIndex(of: "\n") { + s = String(s[s.index(after: firstNewline)...]) + } + if let endRange = s.range(of: "```", options: .backwards) { + s = String(s[.. String { + guard let raw = raw?.lowercased() else { return ReportType.other.rawValue } + return ReportType(rawValue: raw)?.rawValue ?? ReportType.other.rawValue + } + + private static func parseDate(_ raw: String?) -> Date? { + guard let s = raw?.trimmingCharacters(in: .whitespaces), !s.isEmpty else { return nil } + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + // VL 不同来源会吐多种日期格式;逐一尝试,避免解析失败回退到「今天」(parseReportJSON 里 + // ?? .now)导致归档按 reportDate 分年份时错位(C1)。 + let patterns = ["yyyy-MM-dd", "yyyy/MM/dd", "yyyy.MM.dd", + "yyyy年MM月dd日", "yyyy年M月d日", "yyyy年MM月", "yyyy-MM", "yyyy/MM"] + for p in patterns { + f.dateFormat = p + if let d = f.date(from: s) { return d } + } + return nil + } + + private static func parseIndicator(_ d: [String: Any]) -> ParsedReport.ParsedIndicator? { + guard let name = (d["name"] as? String)?.trimmingCharacters(in: .whitespaces), + !name.isEmpty else { return nil } + let value: String + if let v = d["value"] as? String { value = v } + else if let v = d["value"] as? NSNumber { value = v.stringValue } + else { value = "" } + let unit = (d["unit"] as? String) ?? "" + let range = (d["range"] as? String) ?? "" + let statusRaw = (d["status"] as? String)?.lowercased() ?? "normal" + let status = IndicatorStatus(rawValue: statusRaw) ?? .normal + return .init(name: name, value: value, unit: unit, range: range, status: status) + } +} + +// MARK: - Report ↔ CaptureService 桥接(MainActor 侧) +// +// CaptureService 是 actor,不能直接收 Report(@Model 非 Sendable)。 +// C2「重新解读」UI 走这条路径: +// ``` +// let assets = report.savedAssets +// let parsed = try await CaptureService.shared.reanalyze(assets: assets) +// report.applyReanalyzed(parsed, in: ctx) +// ``` + +extension Report { + /// 关联 Asset 转 SavedAsset,直接喂 CaptureService.reanalyze。 + var savedAssets: [FileVault.SavedAsset] { + assets.map { .init(relativePath: $0.relativePath, bytes: $0.bytes) } + } + + /// 把 VL 重新识别结果写回 Report。 + /// - indicators:旧的全删,新的整批插入并维持关联(cascade delete 会清缓存) + /// - summary / institution:非空才覆盖,避免空摘要把好结果清掉 + /// 必须在 MainActor / SwiftData 主上下文里调用。 + @MainActor + func applyReanalyzed(_ parsed: ParsedReport, in ctx: ModelContext) { + if !parsed.summary.isEmpty { + self.summary = parsed.summary + } + if !parsed.institution.isEmpty { + self.institution = parsed.institution + } + // 旧 indicators 全删。各自挂的 Asset(若有局部快拍图)关系是 nullify 不 cascade, + // 必须手动 unlink Vault 文件 + 删 Asset 记录,否则留下孤儿图片(违反 §6 隐私承诺)。 + // 对照正确写法见 TimelineEntryDetailView.deleteIndicator。 + for old in indicators { + if let asset = old.asset { + try? FileVault.shared.remove(relativePath: asset.relativePath) + ctx.delete(asset) + } + ctx.delete(old) + } + indicators.removeAll() + // 新 indicators 重新插入 + for p in parsed.indicators { + let i = Indicator( + name: p.name, + value: p.value, + unit: p.unit, + range: p.range, + status: p.status, + capturedAt: reportDate, + report: self + ) + ctx.insert(i) + } + try? ctx.save() + } +} diff --git a/康康/Services/DiaryAssistService.swift b/康康/Services/DiaryAssistService.swift new file mode 100644 index 0000000..ca3cf6c --- /dev/null +++ b/康康/Services/DiaryAssistService.swift @@ -0,0 +1,101 @@ +import Foundation + +/// 「健康记录」AI 辅助:让 LLM 从医生角度提 3-4 个追问问题。 +/// +/// 设计上和 HealthExportService 同款门面,但输出量小(< 400 token), +/// 不流式 —— 直接 await 收完整结果再解析。 +/// +/// 调用方:DiaryQuickSheet。 +@MainActor +struct DiaryAssistService { + static let shared = DiaryAssistService() + private init() {} + + /// 单条追问。fill 是带方括号占位符的模板,采纳时追加到原文末尾。 + /// `dim` 是问诊维度(取自 `DiaryAssistPrompts.dimensions`),用于跨轮按维度去重。 + /// `adopted` 由 UI 标记;`round` 由 UI 在 append 前打戳,用于多轮分组显示。 + struct Question: Identifiable, Hashable { + let id: UUID + let q: String + let fill: String + let dim: String + var adopted: Bool + var round: Int + + init(id: UUID = UUID(), + q: String, + fill: String, + dim: String = "", + adopted: Bool = false, + round: Int = 0) { + self.id = id + self.q = q + self.fill = fill + self.dim = dim + self.adopted = adopted + self.round = round + } + } + + enum AssistError: Error, LocalizedError { + case modelNotReady + case empty + case parseFailed(String) + + var errorDescription: String? { + switch self { + case .modelNotReady: return String(appLoc: "AI 模型尚未准备好") + case .empty: return String(appLoc: "AI 没有给出建议,请稍后重试") + case .parseFailed(let m): return String(appLoc: "结果解析失败:\(m)") + } + } + } + + /// 返回 3-4 条追问。 + /// - coveredDimensions: 多轮场景下,把之前各轮已覆盖的维度名(取自 question.dim)传进来, + /// prompt 会明确要求本轮避开这些维度。第一轮传空数组。 + /// 注意:本方法在 AIRuntime 的 actor 队列里串行排队,与 Capture / Export 互不抢占 GPU。 + func suggest(content: String, + coveredDimensions: [String] = []) async throws -> (questions: [Question], decodeRate: Double) { + do { + try await AIRuntime.shared.prepare() + } catch { + throw AssistError.modelNotReady + } + + let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions) + var collected = "" + var lastRate: Double = 0 + let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400) + for try await chunk in stream { + collected += chunk.text + if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } + } + + // 1. 去 ...(复用 HealthExportService 的兜底) + let stripped = HealthExportService.stripThinkBlocks(collected) + // 2. 抠出第一段平衡 JSON(复用 CaptureService.extractJSONObject) + let jsonStr = CaptureService.extractJSONObject(from: stripped) + guard let data = jsonStr.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]), + let dict = obj as? [String: Any] else { + throw AssistError.parseFailed("非 JSON 输出") + } + guard let rawQuestions = dict["questions"] as? [[String: Any]] else { + throw AssistError.parseFailed("缺少 questions 字段") + } + let questions = rawQuestions.compactMap { d -> Question? in + guard let q = (d["q"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else { + return nil + } + let fill = (d["fill"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let dim = (d["dim"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return Question(q: q, fill: fill, dim: dim) + } + guard !questions.isEmpty else { throw AssistError.empty } + return (Array(questions.prefix(4)), lastRate) + } +} diff --git a/康康/Services/HealthExportService.swift b/康康/Services/HealthExportService.swift new file mode 100644 index 0000000..babeb93 --- /dev/null +++ b/康康/Services/HealthExportService.swift @@ -0,0 +1,524 @@ +import Foundation +import SwiftData + +/// 「导出身体档案」的服务层。 +/// +/// 流程(对齐 spec §6): +/// prepare → extractingIntent → retrieving → generating → completed +/// +/// 红线对齐: +/// - UI 只通过本服务调用 AI(§3.1) +/// - 两次 LLM 调用都进 `AIRuntime.shared` 的 actor 队列,与 CaptureService 串行(§3.1) +/// - 意图 JSON 解析失败 → 用 30 天 + 空关键词兜底,流程不中断(§3.2 / spec §9) +/// - 不引入云、不写密码学、不重构现有结构(§10) +@MainActor +struct HealthExportService { + + static let shared = HealthExportService() + private init() {} + + // MARK: - Public types + + enum Phase: String, Sendable { + case extractingIntent + case retrieving + case generating + case completed + + var label: String { + switch self { + case .extractingIntent: return String(appLoc: "理解意图") + case .retrieving: return String(appLoc: "检索数据") + case .generating: return String(appLoc: "撰写报告") + case .completed: return String(appLoc: "已完成") + } + } + } + + enum Event { + case phaseChanged(Phase) + case token(TokenChunk) + case completed(persistentID: PersistentIdentifier) + // .failed 走 stream throw,不在 Event 里 + } + + enum ServiceError: Error, LocalizedError { + case modelNotReady + case generationFailed(String) + case cancelled + + var errorDescription: String? { + switch self { + case .modelNotReady: return String(appLoc: "AI 模型尚未准备好,请先到「我的 · 模型管理」下载。") + case .generationFailed(let m): return String(appLoc: "生成失败:\(m)") + case .cancelled: return String(appLoc: "已取消") + } + } + } + + // MARK: - Entry point + + /// 主入口。返回事件流;UI 关闭 sheet → stream 取消 → Service 不入库。 + /// 调用方需在 MainActor。 + func export(prompt: String, + in modelContext: ModelContext) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { @MainActor in + do { + // —— 预热模型(幂等) —— + do { + try await AIRuntime.shared.prepare() + } catch { + throw ServiceError.modelNotReady + } + + // —— Phase 1: 抽意图 —— + continuation.yield(.phaseChanged(.extractingIntent)) + let intent = await Self.extractIntent(userPrompt: prompt) + try Task.checkCancellation() + + // —— Phase 2: 检索 —— + continuation.yield(.phaseChanged(.retrieving)) + let snapshot = Self.retrieve(intent: intent, ctx: modelContext) + try Task.checkCancellation() + + // —— Phase 3: 生成 —— + continuation.yield(.phaseChanged(.generating)) + let dataJSON = Self.serializeData(snapshot: snapshot) + + var generated = "" + var lastRate: Double = 0 + + if Self.isEffectivelyEmpty(snapshot) { + // 没有任何真实记录:跳过 LLM,直接产出确定性「无记录」摘要, + // 从根上杜绝小模型在空数据上编造病例(用户红线:严格按历史信息)。 + generated = Self.fallbackReport(label: intent.labelCN, userPrompt: prompt) + continuation.yield(.token(TokenChunk(text: generated, decodeRate: 0))) + } else { + let genPrompt = HealthExportPrompts.reportGeneration( + userPrompt: prompt, + intentLabelCN: intent.labelCN, + dataJSON: dataJSON + ) + + // —— 流式去 ... 兜底 —— + // Prompt 里已加 Qwen3 的 `/no_think`,但模型偶尔仍带 thinking。 + // 用「全文累计 + 每 chunk 重清 + diff yield」: + // - thinking 阶段,UI 看到的 generated 始终为空 + // - 看到 后,真实内容流式出现 + var rawAccum = "" + let stream = await AIRuntime.shared.generate( + prompt: genPrompt, + maxTokens: 1024 + ) + for try await chunk in stream { + try Task.checkCancellation() + if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } + rawAccum += chunk.text + let clean = Self.stripThinkBlocks(rawAccum) + if clean.count > generated.count, clean.hasPrefix(generated) { + let delta = String(clean.dropFirst(generated.count)) + generated = clean + continuation.yield(.token(TokenChunk( + text: delta, + decodeRate: chunk.decodeRate + ))) + } else if clean != generated { + // 极少:清理后比上次还短(模型补了开标签)。让 UI 不要回退, + // 直接对齐 generated = clean 但不 yield(避免显示倒退)。 + generated = clean + } + } + } + + guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ServiceError.generationFailed("模型未输出任何内容") + } + + // —— Phase 4: 持久化 —— + let export = HealthExport( + prompt: prompt, + content: generated, + referencedIndicatorIDs: snapshot.indicators.map { Self.idString($0.persistentModelID) }, + referencedReportIDs: snapshot.reports.map { Self.idString($0.persistentModelID) }, + referencedSymptomIDs: snapshot.symptoms.map { Self.idString($0.persistentModelID) }, + referencedDiaryIDs: snapshot.diaries.map { Self.idString($0.persistentModelID) }, + inferredTimeFromDate: snapshot.fromDate, + inferredTimeToDate: snapshot.toDate, + inferredIntent: intent.intent, + inferredLabelCN: intent.labelCN, + modelTag: ModelKind.llm.rawValue, // 取实际加载的 LLM tag,而非写死默认值(本地推理凭证 §12#6) + decodeRate: lastRate + ) + modelContext.insert(export) + do { try modelContext.save() } catch { + // 保存失败不阻塞 UI 显示文本;仅记日志(W6 可接 telemetry) + print("[HealthExportService] save failed: \(error)") + } + continuation.yield(.phaseChanged(.completed)) + continuation.yield(.completed(persistentID: export.persistentModelID)) + continuation.finish() + } catch is CancellationError { + continuation.finish(throwing: ServiceError.cancelled) + } catch let e as ServiceError { + continuation.finish(throwing: e) + } catch { + continuation.finish(throwing: ServiceError.generationFailed("\(error)")) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Phase 1: intent extraction + + struct Intent: Sendable { + var timeRangeDays: Int + var keywords: [String] + var symptomKeywords: [String] + var intent: String + var labelCN: String + + /// 兜底:抽不出 → 30 天 + 空关键词。 + static let fallback = Intent( + timeRangeDays: 30, + keywords: [], + symptomKeywords: [], + intent: "general_review", + labelCN: "近期健康摘要" + ) + } + + /// 调一次 LLM 拿 JSON,失败用 `Intent.fallback`。 + /// 不流式 —— 直接拼成完整字符串再解析。 + private static func extractIntent(userPrompt: String) async -> Intent { + let prompt = HealthExportPrompts.intentExtraction(userPrompt: userPrompt) + var collected = "" + do { + let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 200) + for try await chunk in stream { + collected += chunk.text + } + } catch { + return .fallback + } + return parseIntent(collected) ?? .fallback + } + + /// 解析 JSON。容错:抠出第一段 `{…}`,缺字段填默认值。 + /// 公开 (internal) 给单测调用。 + static func parseIntent(_ raw: String) -> Intent? { + let jsonString = CaptureService.extractJSONObject(from: raw) + guard let data = jsonString.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]), + let dict = obj as? [String: Any] else { + return nil + } + let days = clampDays(dict["time_range_days"]) + let keywords = stringArray(dict["keywords"]) + let symptomKeywords = stringArray(dict["symptom_keywords"]) + let intent = (dict["intent"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "general_review" + let labelCN = (dict["intent_label_cn"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "近期健康摘要" + return Intent( + timeRangeDays: days, + keywords: keywords, + symptomKeywords: symptomKeywords, + intent: intent.isEmpty ? "general_review" : intent, + labelCN: labelCN.isEmpty ? "近期健康摘要" : labelCN + ) + } + + private static func clampDays(_ raw: Any?) -> Int { + if let n = raw as? Int { return max(1, min(365, n)) } + if let n = raw as? Double { return max(1, min(365, Int(n))) } + if let s = raw as? String, let n = Int(s) { return max(1, min(365, n)) } + return 30 + } + + private static func stringArray(_ raw: Any?) -> [String] { + guard let arr = raw as? [Any] else { return [] } + return arr.compactMap { ($0 as? String)?.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + // MARK: - Phase 2: retrieve + + struct Snapshot { + var fromDate: Date + var toDate: Date + var indicators: [Indicator] + var symptoms: [Symptom] + var reports: [Report] + var diaries: [DiaryEntry] + var profile: UserProfile + } + + /// 同步 SwiftData 查询。@MainActor。 + private static func retrieve(intent: Intent, ctx: ModelContext) -> Snapshot { + let toDate = Date() + let fromDate = Calendar.current.date( + byAdding: .day, value: -intent.timeRangeDays, to: toDate + ) ?? toDate.addingTimeInterval(-30 * 86400) + + // —— Indicators(时间窗 + 关键词软过滤) —— + let indDesc = FetchDescriptor( + predicate: #Predicate { $0.capturedAt >= fromDate && $0.capturedAt <= toDate }, + sortBy: [SortDescriptor(\.capturedAt, order: .reverse)] + ) + var indicators = (try? ctx.fetch(indDesc)) ?? [] + if !intent.keywords.isEmpty { + let filtered = indicators.filter { ind in + intent.keywords.contains { kw in + ind.name.localizedCaseInsensitiveContains(kw) + } + } + // 关键词命中为主,但保留所有异常项(避免漏掉医生关心的) + let abnormal = indicators.filter { $0.status != .normal } + let combined = (filtered + abnormal).reduce(into: [Indicator]()) { acc, x in + if !acc.contains(where: { $0.persistentModelID == x.persistentModelID }) { + acc.append(x) + } + } + indicators = combined.isEmpty ? indicators : combined + } + indicators = Array(indicators.prefix(20)) + + // —— Symptoms(时间窗有交叠) —— + let symptomDesc = FetchDescriptor( + sortBy: [SortDescriptor(\.startedAt, order: .reverse)] + ) + let allSymptoms = (try? ctx.fetch(symptomDesc)) ?? [] + let symptoms = Array( + allSymptoms.filter { sym in + let overlapsStart = sym.startedAt <= toDate + let overlapsEnd = (sym.endedAt ?? Date.distantFuture) >= fromDate + return overlapsStart && overlapsEnd + }.prefix(10) + ) + + // —— Reports(时间窗) —— + let reportDesc = FetchDescriptor( + predicate: #Predicate { $0.reportDate >= fromDate && $0.reportDate <= toDate }, + sortBy: [SortDescriptor(\.reportDate, order: .reverse)] + ) + let reports = Array(((try? ctx.fetch(reportDesc)) ?? []).prefix(8)) + + // —— Diary —— + // 有具体症状词 → 按词过滤(targeted,保留隐私); + // 无症状词(泛化请求,如「最近身体异常」)→ 纳入时间窗内最近 5 条日记。 + // 之前「无词即清空」会让真实记录完全不进 prompt → 数据为空 → 小模型编造,是本次 bug 主因之一。 + let diaryDesc = FetchDescriptor( + predicate: #Predicate { $0.createdAt >= fromDate && $0.createdAt <= toDate }, + sortBy: [SortDescriptor(\.createdAt, order: .reverse)] + ) + let allDiaries = (try? ctx.fetch(diaryDesc)) ?? [] + let diaries: [DiaryEntry] + if intent.symptomKeywords.isEmpty { + diaries = Array(allDiaries.prefix(5)) + } else { + diaries = Array( + allDiaries.filter { d in + intent.symptomKeywords.contains { kw in + d.content.localizedCaseInsensitiveContains(kw) + } + }.prefix(5) + ) + } + + // —— Profile(单例) —— + let profile = UserProfileStore.loadOrCreate(in: ctx) + + return Snapshot( + fromDate: fromDate, + toDate: toDate, + indicators: indicators, + symptoms: symptoms, + reports: reports, + diaries: diaries, + profile: profile + ) + } + + // MARK: - Phase 3: serialize data for prompt + + /// 把 Snapshot 序列化成给 LLM 的精简 JSON。 + /// 不用 Codable —— 字段命名要保持 prompt 里描述的英文 key,顺序也要稳定。 + static func serializeData(snapshot: Snapshot) -> String { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POSIX") + df.dateFormat = "yyyy-MM-dd" + + let profile = snapshot.profile + var root: [String: Any] = [:] + + // profile + var profDict: [String: Any] = [:] + if let age = profile.age { profDict["age"] = age } + let sexLabel = profile.sex.label + if profile.sex != .undisclosed { profDict["sex"] = sexLabel } + if let h = profile.heightCM { profDict["height_cm"] = h } + if let w = profile.weightKG { + profDict["weight_kg"] = w.truncatingRemainder(dividingBy: 1) == 0 + ? Int(w) : Double(round(w * 10) / 10) + } + if !profile.bloodTypeRaw.isEmpty { profDict["blood_type"] = profile.bloodTypeRaw } + if !profile.allergies.isEmpty { profDict["allergies"] = profile.allergies } + if !profile.chronicConditions.isEmpty { profDict["chronic"] = profile.chronicConditions } + if !profile.familyHistory.isEmpty { profDict["family_history"] = profile.familyHistory } + if !profile.currentMedications.isEmpty { profDict["current_meds"] = profile.currentMedications } + root["profile"] = profDict + + // symptoms + root["symptoms"] = snapshot.symptoms.map { s -> [String: Any] in + var d: [String: Any] = [ + "name": s.name, + "started": df.string(from: s.startedAt), + "severity": s.severity, + "ongoing": s.isOngoing + ] + if let ended = s.endedAt { d["ended"] = df.string(from: ended) } + if let note = s.note, !note.isEmpty { d["note"] = note } + return d + } + + // indicators + root["indicators"] = snapshot.indicators.map { i -> [String: Any] in + [ + "name": i.name, + "value": i.value, + "unit": i.unit, + "range": i.range, + "status": i.status.rawValue, + "date": df.string(from: i.capturedAt) + ] + } + + // reports + root["reports"] = snapshot.reports.map { r -> [String: Any] in + var d: [String: Any] = [ + "title": r.title, + "type": r.type.label, + "date": df.string(from: r.reportDate) + ] + if let inst = r.institution, !inst.isEmpty { d["institution"] = inst } + if let sum = r.summary, !sum.isEmpty { d["summary"] = sum } + return d + } + + // diaries + root["diaries"] = snapshot.diaries.map { d -> [String: Any] in + let excerpt = String(d.content.prefix(80)) + return [ + "date": df.string(from: d.createdAt), + "excerpt": excerpt + ] + } + + // 时间窗也给 LLM 看 + root["time_window"] = [ + "from": df.string(from: snapshot.fromDate), + "to": df.string(from: snapshot.toDate) + ] + + guard let data = try? JSONSerialization.data( + withJSONObject: root, + options: [.prettyPrinted, .sortedKeys] + ), + let str = String(data: data, encoding: .utf8) else { + return "{}" + } + return str + } + + // MARK: - 空数据兜底(杜绝编造) + + /// 检索结果是否「实质为空」:无症状/指标/报告/日记,且 profile 也没有任何可写字段。 + /// 为真时跳过 LLM,改用确定性「无记录」摘要,避免小模型凭先验编造病例。 + static func isEffectivelyEmpty(_ s: Snapshot) -> Bool { + guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty, s.diaries.isEmpty else { + return false + } + let p = s.profile + return p.age == nil + && p.sex == .undisclosed + && p.heightCM == nil + && p.weightKG == nil + && p.bloodTypeRaw.isEmpty + && p.allergies.isEmpty + && p.chronicConditions.isEmpty + && p.familyHistory.isEmpty + && p.currentMedications.isEmpty + } + + /// 无真实记录时的确定性摘要:6 段全「无记录」,主诉仅照搬患者原话,不做任何推断。 + static func fallbackReport(label: String, userPrompt: String) -> String { + let title = label.isEmpty ? "# 就诊摘要" : "# 就诊摘要 — \(label)" + let complaint = userPrompt.trimmingCharacters(in: .whitespacesAndNewlines) + let complaintLine = complaint.isEmpty ? "无记录" : complaint + return """ + \(title) + + > 本次未检索到可用的健康记录(指标 / 症状 / 报告 / 日记均为空),以下仅据患者原话,未做任何推断。 + + ## 主诉 + \(complaintLine) + + ## 患者背景 + 无记录 + + ## 近期症状(按时间倒序) + 无记录 + + ## 关键指标(异常项优先) + 无记录 + + ## 在服药与过敏 + 无记录 + + ## 患者疑问 + 无记录 + """ + } + + // MARK: - Helpers + + /// 把 SwiftData persistentModelID 编成稳定字符串。 + /// W3 引用回链跳源记录时,用这个字符串反查(暂未实现)。 + private static func idString(_ id: PersistentIdentifier) -> String { + String(describing: id) + } + + // MARK: - 标签清理 + + /// 在全文累计上做一次性清理,返回应展示给用户的干净文本。 + /// 用「累计 + 重清 + diff yield」方式调用,确保: + /// - 配对 `...` 整段移除(包括空 think 块) + /// - 未闭合 `...`(还没等到闭标签)→ 全部暂存,等闭标签出现再放 + /// - Qwen3 偶尔只吐 `` 闭标签 → 它之前的内容也当 thinking 丢弃 + /// - 头部空白 trim,避免 `## 标题` 前面有多余空行 + static func stripThinkBlocks(_ raw: String) -> String { + var s = raw + + // 1. 反复删配对 ...(包括 think 块体为空的情况) + while let openR = s.range(of: ""), + let closeR = s.range(of: "", range: openR.upperBound..") { + s = String(s[..") { + s = String(s[closeR.upperBound...]) + } + + // 4. 顶部空白 trim + while let first = s.first, first.isWhitespace { + s.removeFirst() + } + return s + } +} diff --git a/康康/Services/ModelDownloadService.swift b/康康/Services/ModelDownloadService.swift new file mode 100644 index 0000000..260a219 --- /dev/null +++ b/康康/Services/ModelDownloadService.swift @@ -0,0 +1,148 @@ +import Foundation +import Observation + +/// 模型下载编排:遍历 ModelManifest 逐文件串行下载,聚合进度,支持暂停/重试/旁路导入。 +/// UI 只观察 `states`,不直接碰 URLSession(§3.1 模块边界)。 +/// 核心下载/校验逻辑在 `FileDownloader`,文件路径/就绪判定在 `ModelStore`。 +@MainActor +@Observable +final class ModelDownloadService { + static let shared = ModelDownloadService() + + private(set) var states: [ModelKind: DownloadState] = [:] + + private let store: ModelStore + private var tasks: [ModelKind: Task] = [:] + private var lastSampleTime: [ModelKind: Date] = [:] + private var lastSampleBytes: [ModelKind: Int] = [:] + + init(store: ModelStore = .shared) { + self.store = store + refreshStates() + } + + /// 根据沙盒现状刷新每个模型的状态(已完整→ready,否则 idle)。 + func refreshStates() { + for kind in ModelKind.allCases { + let total = ModelManifest.totalBytes(for: kind) + if store.isComplete(for: kind) { + states[kind] = DownloadState(phase: .ready, receivedBytes: total, + totalBytes: total, bytesPerSecond: 0) + } else if states[kind]?.phase == .downloading { + continue // 不打断进行中的下载 + } else { + states[kind] = DownloadState(phase: .idle, receivedBytes: completedBytes(for: kind), + totalBytes: total, bytesPerSecond: 0) + } + } + } + + var isAnyDownloading: Bool { + states.values.contains { $0.phase == .downloading } + } + + /// 下载某个模型。幂等:已在下载或已就绪则忽略。 + func download(_ kind: ModelKind) { + guard tasks[kind] == nil, states[kind]?.phase != .ready else { return } + let total = ModelManifest.totalBytes(for: kind) + states[kind] = DownloadState(phase: .downloading, receivedBytes: completedBytes(for: kind), + totalBytes: total, bytesPerSecond: 0) + lastSampleTime[kind] = Date() + lastSampleBytes[kind] = completedBytes(for: kind) + + let task = Task { [weak self] in + guard let self else { return } + await self.run(kind) + } + tasks[kind] = task + } + + func downloadAll() { + for kind in ModelKind.allCases { download(kind) } + } + + /// 暂停下载。已下载的 .part 保留,下次从断点续传。 + func cancel(_ kind: ModelKind) { + tasks[kind]?.cancel() + tasks[kind] = nil + let total = ModelManifest.totalBytes(for: kind) + states[kind] = DownloadState(phase: .idle, receivedBytes: completedBytes(for: kind), + totalBytes: total, bytesPerSecond: 0) + } + + /// 旁路导入:从用户选择的文件夹拷入模型(现场重装兜底)。 + func importModel(_ kind: ModelKind, from folder: URL) throws { + try store.importModel(kind, from: folder) + refreshStates() + } + + // MARK: - 内部 + + private func run(_ kind: ModelKind) async { + let files = ModelManifest.files(for: kind) + let downloader = FileDownloader() + var completedBefore = 0 + + do { + for file in files { + if Task.isCancelled { return } + let destination = store.fileURL(for: kind, relativePath: file.path) + let base = completedBefore + try await downloader.download( + from: ModelManifest.fileURL(for: kind, file: file), + to: destination, + expectedBytes: file.bytes, + onProgress: { [weak self] received in + guard let self else { return } + Task { @MainActor in + self.applyProgress(kind, currentTotal: base + received) + } + } + ) + completedBefore += file.bytes + } + finish(kind, success: true, message: nil) + } catch { + if Task.isCancelled { + // cancel() 已设置 idle 状态 + } else { + finish(kind, success: false, message: error.localizedDescription) + } + } + } + + private func applyProgress(_ kind: ModelKind, currentTotal: Int) { + guard var state = states[kind], state.phase == .downloading else { return } + let now = Date() + if let lastTime = lastSampleTime[kind], let lastBytes = lastSampleBytes[kind] { + let dt = now.timeIntervalSince(lastTime) + if dt >= 0.5 { + state.bytesPerSecond = Double(currentTotal - lastBytes) / dt + lastSampleTime[kind] = now + lastSampleBytes[kind] = currentTotal + } + } + state.receivedBytes = currentTotal + states[kind] = state + } + + private func finish(_ kind: ModelKind, success: Bool, message: String?) { + tasks[kind] = nil + let total = ModelManifest.totalBytes(for: kind) + if success { + states[kind] = DownloadState(phase: .ready, receivedBytes: total, + totalBytes: total, bytesPerSecond: 0) + } else { + states[kind] = DownloadState(phase: .failed(message ?? String(appLoc: "下载失败")), + receivedBytes: completedBytes(for: kind), + totalBytes: total, bytesPerSecond: 0) + } + } + + /// 已完整下载的文件字节之和(用于续传时的起始进度)。 + private func completedBytes(for kind: ModelKind) -> Int { + ModelManifest.files(for: kind).reduce(0) { sum, file in + store.localBytes(for: kind, relativePath: file.path) == file.bytes ? sum + file.bytes : sum + } + } +} diff --git a/康康/Services/ModelDownloadTypes.swift b/康康/Services/ModelDownloadTypes.swift new file mode 100644 index 0000000..3ceefff --- /dev/null +++ b/康康/Services/ModelDownloadTypes.swift @@ -0,0 +1,22 @@ +import Foundation + +/// 单个模型的下载阶段。 +enum DownloadPhase: Equatable, Sendable { + case idle // 待下载 + case downloading // 下载中 + case verifying // 校验中 + case ready // 已就绪 + case failed(String) // 失败 · 可重试 +} + +/// 单个模型的下载状态快照,供 UI 观察。 +struct DownloadState: Equatable, Sendable { + var phase: DownloadPhase + var receivedBytes: Int + var totalBytes: Int + var bytesPerSecond: Double + + var fraction: Double { + totalBytes > 0 ? Double(receivedBytes) / Double(totalBytes) : 0 + } +} diff --git a/康康/Services/ReminderService.swift b/康康/Services/ReminderService.swift new file mode 100644 index 0000000..8bc0427 --- /dev/null +++ b/康康/Services/ReminderService.swift @@ -0,0 +1,156 @@ +import Foundation +import UserNotifications + +/// 周期性指标提醒的本地通知调度。 +/// 同一 `metricId` 在 iOS 通知中心展开成 N 条 weekly-repeats 通知,id 形如 +/// `kangkang.reminder..w`,方便按 weekday 单独 cancel。 +/// +/// 数据存 SwiftData `MetricReminder`;本服务只负责系统通知中心的同步, +/// 不写 SwiftData。两边写入的协调由调用方负责。 +enum ReminderService { + + static let idPrefix = "kangkang.reminder." + static let customIdPrefix = "kangkang.custom." + + enum AuthState: String { + case granted, denied, notDetermined, provisional + } + + // MARK: - authorization + + static func currentAuthState() async -> AuthState { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized: return .granted + case .denied: return .denied + case .provisional: return .provisional + case .ephemeral: return .granted + case .notDetermined: return .notDetermined + @unknown default: return .notDetermined + } + } + + /// 申请通知权限。已 granted/denied 时直接返回当前状态。 + @discardableResult + static func requestAuthorization() async -> AuthState { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + if settings.authorizationStatus != .notDetermined { + return await currentAuthState() + } + do { + let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) + return granted ? .granted : .denied + } catch { + return .denied + } + } + + // MARK: - upsert / cancel + + /// 取消该 metric 在通知中心所有 pending 通知,再按当前 enabled/时间/weekdays 重排。 + /// 调用方在 `MetricReminder` save 之后调用。 + static func sync(_ reminder: MetricReminder) async { + cancel(metricId: reminder.metricId) + guard reminder.enabled else { return } + let slots = reminder.weekdays.map { wd in + Slot(suffix: "w\(wd)", + dc: DateComponents(hour: reminder.hour, minute: reminder.minute, weekday: wd)) + } + await schedule( + idBase: "\(idPrefix)\(reminder.metricId)", + title: String(appLoc: "该测\(reminder.displayName)了"), + body: String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"), + thread: "kangkang.reminder.\(reminder.metricId)", + slots: slots + ) + } + + /// 取消某个 metric 的所有 pending 通知(7 个 weekday 一并取消,不漏)。 + static func cancel(metricId: String) { + cancelBase("\(idPrefix)\(metricId)") + } + + // MARK: - 自由提醒(CustomReminder) + + /// 取消并按当前设置重排一条自由提醒。调用方在 `CustomReminder` save 之后调用。 + static func sync(_ reminder: CustomReminder) async { + cancel(customId: reminder.id) + guard reminder.enabled else { return } + let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines) + let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines) + let h = reminder.hour, m = reminder.minute + let slots: [Slot] + switch reminder.frequency { + case .daily: + slots = [Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m))] + case .weekly: + slots = reminder.weekdays.map { wd in + Slot(suffix: "w\(wd)", dc: DateComponents(hour: h, minute: m, weekday: wd)) + } + case .monthly: + slots = [Slot(suffix: "monthly", + dc: DateComponents(day: reminder.dayOfMonth, hour: h, minute: m))] + case .yearly: + slots = [Slot(suffix: "yearly", + dc: DateComponents(month: reminder.month, day: reminder.dayOfMonth, hour: h, minute: m))] + } + await schedule( + idBase: "\(customIdPrefix)\(reminder.id.uuidString)", + title: title.isEmpty ? String(appLoc: "提醒") : title, + body: body.isEmpty ? String(appLoc: "到点啦,记得完成") : body, + thread: "\(customIdPrefix)\(reminder.id.uuidString)", + slots: slots + ) + } + + /// 取消某条自由提醒的所有 pending 通知。 + static func cancel(customId: UUID) { + cancelBase("\(customIdPrefix)\(customId.uuidString)") + } + + /// 全清。Me Tab 一键关闭所有提醒时用。 + static func cancelAll() { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + } + + // MARK: - 共享调度核心 + + /// 一条触发槽:`suffix` 用于拼出稳定且可单独取消的通知 id(`.`, + /// 如 `.daily` / `.w2` / `.monthly` / `.yearly`),`dc` 为对应的重复触发时间分量。 + private struct Slot { + let suffix: String + let dc: DateComponents + } + + /// 把若干 `Slot` 展开成 N 条 repeats 通知。每日/每周/每月/每年两类提醒共用本核心。 + private static func schedule(idBase: String, + title: String, + body: String, + thread: String, + slots: [Slot]) async { + guard !slots.isEmpty else { return } + let center = UNUserNotificationCenter.current() + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.threadIdentifier = thread + + for slot in slots { + let trigger = UNCalendarNotificationTrigger(dateMatching: slot.dc, repeats: true) + let request = UNNotificationRequest(identifier: "\(idBase).\(slot.suffix)", + content: content, + trigger: trigger) + try? await center.add(request) + } + } + + /// 取消某个 idBase 下所有可能后缀的 pending 通知(daily/monthly/yearly + 7 个 weekday,不漏)。 + private static func cancelBase(_ idBase: String) { + let center = UNUserNotificationCenter.current() + var ids = ["\(idBase).daily", "\(idBase).monthly", "\(idBase).yearly"] + ids += (1...7).map { "\(idBase).w\($0)" } + center.removePendingNotificationRequests(withIdentifiers: ids) + } +} diff --git a/康康/康康.entitlements b/康康/康康.entitlements new file mode 100644 index 0000000..ff111f6 --- /dev/null +++ b/康康/康康.entitlements @@ -0,0 +1,14 @@ + + + + + + com.apple.developer.kernel.increased-memory-limit + + + diff --git a/康康Tests/CaptureServiceJSONTests.swift b/康康Tests/CaptureServiceJSONTests.swift new file mode 100644 index 0000000..02adf26 --- /dev/null +++ b/康康Tests/CaptureServiceJSONTests.swift @@ -0,0 +1,136 @@ +import Testing +import Foundation +@testable import 康康 + +struct CaptureServiceJSONTests { + + @Test func parsesCleanJSON() throws { + let raw = """ + {"title":"春检","type":"checkup","report_date":"2026-04-12","institution":"协和","page_count":2,"summary":"血脂偏高","indicators":[{"name":"LDL-C","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.title == "春检") + #expect(parsed.typeRaw == ReportType.checkup.rawValue) + #expect(parsed.institution == "协和") + #expect(parsed.pageCount == 2) + #expect(parsed.indicators.count == 1) + #expect(parsed.indicators.first?.status == .high) + } + + @Test func stripsMarkdownCodeFence() throws { + let raw = """ + ```json + {"title":"x","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[]} + ``` + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.title == "x") + #expect(parsed.typeRaw == ReportType.lab.rawValue) + #expect(parsed.indicators.isEmpty) + } + + @Test func extractsObjectAfterLeadingText() throws { + let raw = """ + 好的,识别结果如下: + {"title":"y","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[]} + 以上。 + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.title == "y") + } + + @Test func handlesNestedBraces() throws { + let raw = """ + {"title":"y","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"含嵌套{x}对象","indicators":[]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.summary == "含嵌套{x}对象") + } + + @Test func handlesEscapedQuotesInStrings() throws { + let raw = #"{"title":"y \"内嵌\" 引号","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[]}"# + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.title == #"y "内嵌" 引号"#) + } + + @Test func fillsDefaultsForMissingFields() throws { + // 缺 title / type / report_date / institution / summary / page_count + let raw = """ + {"indicators":[{"name":"X","value":"1","unit":"","range":"","status":"normal"}]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.title == "拍摄识别") // 默认值 + #expect(parsed.typeRaw == ReportType.other.rawValue) + #expect(parsed.indicators.count == 1) + } + + @Test func skipsIndicatorsWithEmptyName() throws { + let raw = """ + {"title":"t","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[ + {"name":"","value":"1","unit":"","range":"","status":"normal"}, + {"name":" ","value":"1","unit":"","range":"","status":"normal"}, + {"name":"OK","value":"1","unit":"","range":"","status":"normal"} + ]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.indicators.count == 1) + #expect(parsed.indicators.first?.name == "OK") + } + + @Test func malformedJSONThrows() { + let raw = "完全不是 JSON" + #expect(throws: CaptureError.self) { + _ = try CaptureService.parseReportJSON(raw) + } + } + + @Test func valueAsNumberStillParses() throws { + let raw = """ + {"title":"t","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[{"name":"X","value":3.84,"unit":"","range":"","status":"high"}]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.indicators.first?.value == "3.84") + } + + @Test func unknownStatusFallsBackToNormal() throws { + let raw = """ + {"title":"t","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[{"name":"X","value":"1","unit":"","range":"","status":"abnormal"}]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.indicators.first?.status == .normal) + } + + @Test func badReportDateFallsBackToNow() throws { + let raw = """ + {"title":"t","type":"lab","report_date":"昨天","institution":"","page_count":1,"summary":"","indicators":[]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + let now = Date() + let diff = abs(parsed.reportDate.timeIntervalSince(now)) + #expect(diff < 5) // 5 秒内算 .now + } + + /// VL 多项时偶尔直接吐裸数组 [{...},{...}],旧实现(只认 {})会只截第一项静默丢其余。 + @Test func parsesTopLevelArrayAsIndicators() throws { + let raw = """ + [{"name":"A","value":"1","unit":"","range":"","status":"high"}, + {"name":"B","value":"2","unit":"","range":"","status":"low"}] + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.indicators.count == 2) + #expect(parsed.indicators.first?.name == "A") + #expect(parsed.indicators.last?.status == .low) + } + + /// VL 不同来源会吐 yyyy/MM/dd 等格式,不应回退到「今天」导致归档年份错位。 + @Test func parsesSlashAndCJKDateFormats() throws { + for ds in ["2026/04/12", "2026.04.12", "2026年04月12日"] { + let raw = """ + {"title":"t","type":"lab","report_date":"\(ds)","institution":"","page_count":1,"summary":"","indicators":[]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + let c = Calendar(identifier: .gregorian).dateComponents([.year, .month, .day], from: parsed.reportDate) + #expect(c.year == 2026 && c.month == 4 && c.day == 12, "格式 \(ds) 解析失败") + } + } +} diff --git a/康康Tests/CustomMonitorMetricTests.swift b/康康Tests/CustomMonitorMetricTests.swift new file mode 100644 index 0000000..3c0f7db --- /dev/null +++ b/康康Tests/CustomMonitorMetricTests.swift @@ -0,0 +1,145 @@ +import Testing +import SwiftData +import Foundation +@testable import 康康 + +struct CustomMonitorMetricTests { + + private func makeContainer() throws -> ModelContainer { + let schema = Schema([ + CustomMonitorMetric.self, + Indicator.self, + ]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + @Test func seriesKeyAutoPrefixedWithCustom() { + let m = CustomMonitorMetric(name: "腰围", unit: "cm") + #expect(m.seriesKey.hasPrefix("custom.")) + #expect(m.seriesKey.count > "custom.".count) + } + + @Test func seriesKeyUniquePerInstance() { + let a = CustomMonitorMetric(name: "腰围", unit: "cm") + let b = CustomMonitorMetric(name: "腰围", unit: "cm") + #expect(a.seriesKey != b.seriesKey) + } + + @Test func referenceRangeNilWhenBoundsAbsentOrInverted() { + let none = CustomMonitorMetric(name: "x", unit: "") + #expect(none.referenceRange == nil) + + let inverted = CustomMonitorMetric(name: "x", unit: "", lowerBound: 100, upperBound: 50) + #expect(inverted.referenceRange == nil) + + let valid = CustomMonitorMetric(name: "x", unit: "", lowerBound: 60, upperBound: 100) + #expect(valid.referenceRange == 60...100) + } + + @Test func rangeTextFormattingDropsTrailingZero() { + let intRange = CustomMonitorMetric(name: "x", unit: "cm", + lowerBound: 70, upperBound: 90) + #expect(intRange.rangeText == "70 - 90") + + let decimalRange = CustomMonitorMetric(name: "y", unit: "kg", + lowerBound: 60.5, upperBound: 65.5) + #expect(decimalRange.rangeText == "60.5 - 65.5") + } + + @Test func roundtripsThroughSwiftData() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + let m = CustomMonitorMetric(name: "腰围", unit: "cm", + lowerBound: 70, upperBound: 90, + icon: "flame.fill") + ctx.insert(m) + try ctx.save() + + let fetched = try #require(try ctx.fetch(FetchDescriptor()).first) + #expect(fetched.name == "腰围") + #expect(fetched.unit == "cm") + #expect(fetched.lowerBound == 70) + #expect(fetched.upperBound == 90) + #expect(fetched.icon == "flame.fill") + #expect(fetched.seriesKey.hasPrefix("custom.")) + } + + @Test func seriesBucketResolvesCustomTitleAndRange() { + let custom = CustomMonitorMetric(name: "腰围", unit: "cm", + lowerBound: 70, upperBound: 90) + let key = custom.seriesKey + let now = Date() + let day = { (offset: Int) -> Date in + Calendar.current.date(byAdding: .day, value: offset, to: now)! + } + let items = [ + Indicator(name: "腰围", value: "80", unit: "cm", range: "70-90", + status: .normal, capturedAt: day(-2), seriesKey: key), + Indicator(name: "腰围", value: "82", unit: "cm", range: "70-90", + status: .normal, capturedAt: day(-1), seriesKey: key), + ] + let buckets = SeriesBucket.build(from: items, customMetrics: [custom]) + + #expect(buckets.count == 1) + let b = try! #require(buckets.first) + #expect(b.title == "腰围") + #expect(b.unit == "cm") + #expect(b.lines.first?.referenceRange == 70...90) + } + + @Test func nameConflictEmptyNameYieldsNone() { + let result = detectNameConflict(candidate: " ", customs: []) + #expect(result == .none) + } + + @Test func nameConflictDetectsBuiltinMatch() { + let result = detectNameConflict(candidate: "血压", customs: []) + #expect(result == .builtin("血压")) + } + + @Test func nameConflictBuiltinIgnoresWhitespace() { + let result = detectNameConflict(candidate: " 空腹血糖 ", customs: []) + #expect(result == .builtin("空腹血糖")) + } + + @Test func nameConflictDetectsExistingCustom() { + let existing = CustomMonitorMetric(name: "腰围", unit: "cm") + let result = detectNameConflict(candidate: "腰围", customs: [existing]) + #expect(result == .existingCustom("腰围")) + } + + @Test func nameConflictAllowsRenamingSelf() { + // 编辑自己时,即使没改名也不应该报冲突 + let me = CustomMonitorMetric(name: "腰围", unit: "cm") + let result = detectNameConflict( + candidate: "腰围", + customs: [me], + excludingSeriesKey: me.seriesKey + ) + #expect(result == .none) + } + + @Test func nameConflictUnique() { + let result = detectNameConflict(candidate: "步数", customs: []) + #expect(result == .none) + } + + @Test func seriesBucketFallsBackToIndicatorNameWhenCustomMissing() { + // 用户删了 CustomMonitorMetric 但 Indicator 还在 → title fallback 到 indicator.name + let orphanKey = "custom.deleted-xxxxx" + let now = Date() + let items = [ + Indicator(name: "睡眠时长", value: "7", unit: "h", range: "", + status: .normal, capturedAt: now, seriesKey: orphanKey), + Indicator(name: "睡眠时长", value: "8", unit: "h", range: "", + status: .normal, capturedAt: now.addingTimeInterval(60), + seriesKey: orphanKey), + ] + let buckets = SeriesBucket.build(from: items, customMetrics: []) + let b = try! #require(buckets.first) + #expect(b.title == "睡眠时长") + #expect(b.unit == "h") + #expect(b.lines.first?.referenceRange == nil) + } +} diff --git a/康康Tests/FileVaultTests.swift b/康康Tests/FileVaultTests.swift new file mode 100644 index 0000000..e26b9a9 --- /dev/null +++ b/康康Tests/FileVaultTests.swift @@ -0,0 +1,59 @@ +import Testing +import UIKit +@testable import 康康 + +struct FileVaultTests { + + private func makeIsolatedVault() throws -> FileVault { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return try FileVault(rootURL: temp) + } + + private func makeTestImage() -> UIImage { + let size = CGSize(width: 16, height: 16) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + UIColor.red.setFill() + ctx.fill(CGRect(origin: .zero, size: size)) + } + } + + @Test func writeAndReadJPEGRoundtrip() throws { + let vault = try makeIsolatedVault() + defer { try? FileManager.default.removeItem(at: vault.rootURL) } + let image = makeTestImage() + + let saved = try vault.writeJPEG(image, quality: 0.8) + + #expect(saved.bytes > 0) + #expect(saved.relativePath.hasSuffix(".jpg")) + + let loaded = try vault.loadImage(relativePath: saved.relativePath) + #expect(loaded.size != .zero) + } + + @Test func removeMakesFileGone() throws { + let vault = try makeIsolatedVault() + defer { try? FileManager.default.removeItem(at: vault.rootURL) } + let saved = try vault.writeJPEG(makeTestImage()) + + try vault.remove(relativePath: saved.relativePath) + + #expect(throws: (any Error).self) { + _ = try vault.loadImage(relativePath: saved.relativePath) + } + } + + @Test func wipeRemovesAllFiles() throws { + let vault = try makeIsolatedVault() + defer { try? FileManager.default.removeItem(at: vault.rootURL) } + let a = try vault.writeJPEG(makeTestImage()) + let b = try vault.writeJPEG(makeTestImage()) + + try vault.wipe() + + #expect(throws: (any Error).self) { _ = try vault.loadImage(relativePath: a.relativePath) } + #expect(throws: (any Error).self) { _ = try vault.loadImage(relativePath: b.relativePath) } + } +} diff --git a/康康Tests/LLMSessionSmokeTests.swift b/康康Tests/LLMSessionSmokeTests.swift new file mode 100644 index 0000000..c0376e8 --- /dev/null +++ b/康康Tests/LLMSessionSmokeTests.swift @@ -0,0 +1,55 @@ +import Testing +import Foundation +@testable import 康康 + +/// LLM session 接口烟测。 +/// +/// **不验证真实推理**——iOS Simulator sandbox 拿不到 `~/tiji-models/`, +/// Mac Designed for iPad 又卡在 code signing。真实推理走 macOS App 的 +/// `DebugAIRunner` 手动验证(`MeView` 底部),结果记录在 W2 retro。 +/// +/// 这里只断言: +/// 1. `LLMSession.load(folderURL:)` 是 async throws 的 static API +/// 2. `TokenChunk` 是 Sendable 值类型,字段齐 +/// 3. `AIRuntimeError` 三种 case 都有可读 errorDescription +/// +/// 真实推理通过环境变量 `KK_LLM_MODEL_PATH` 启用的测试在 W3 重写—— +/// 届时把核心 LLM 接口拆出独立 SPM target,可在 macOS 原生跑。 +@MainActor +struct LLMSessionSmokeTests { + + @Test func tokenChunkExposesTextAndRate() { + let chunk = TokenChunk(text: "ALT", decodeRate: 15.4) + #expect(chunk.text == "ALT") + #expect(chunk.decodeRate == 15.4) + } + + @Test func aiRuntimeErrorsHaveLocalizedDescriptions() { + let errors: [AIRuntimeError] = [ + .notReady, + .modelLoadFailed("config missing"), + .inferenceFailed("OOM"), + ] + for err in errors { + #expect(err.errorDescription != nil) + #expect(!(err.errorDescription ?? "").isEmpty) + } + } + + @Test func aiRuntimeStartsNotReady() async { + // 新建 actor 通过 shared 暴露;status 初值必为 .notReady。 + // 注:shared 是进程级单例,前序测试可能已 prepare 过,所以这里只断言可读。 + let status = await AIRuntime.shared.status + let validStatuses: [AIRuntime.Status] = [.notReady, .loading, .ready, .error("")] + let kind: String = { + switch status { + case .notReady: return "notReady" + case .loading: return "loading" + case .ready: return "ready" + case .error: return "error" + } + }() + #expect(validStatuses.map { String(describing: $0) }.contains { _ in true }) // 烟测:能 await + #expect(["notReady", "loading", "ready", "error"].contains(kind)) + } +} diff --git a/康康Tests/MetricReminderTests.swift b/康康Tests/MetricReminderTests.swift new file mode 100644 index 0000000..67c9b68 --- /dev/null +++ b/康康Tests/MetricReminderTests.swift @@ -0,0 +1,79 @@ +import Testing +import SwiftData +import Foundation +@testable import 康康 + +struct MetricReminderTests { + + private func makeContainer() throws -> ModelContainer { + let schema = Schema([MetricReminder.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + @Test func defaultsToEveryDayAt8AM() { + let r = MetricReminder(metricId: "bloodPressure", displayName: "血压") + #expect(r.hour == 8) + #expect(r.minute == 0) + #expect(r.weekdays == [1, 2, 3, 4, 5, 6, 7]) + #expect(r.enabled == true) + #expect(r.isEveryDay) + #expect(r.frequencyLabel == "每天") + } + + @Test func hourMinuteClampedToValidRange() { + let early = MetricReminder(metricId: "x", displayName: "x", hour: -5, minute: 80) + #expect(early.hour == 0) + #expect(early.minute == 59) + let late = MetricReminder(metricId: "y", displayName: "y", hour: 25, minute: -3) + #expect(late.hour == 23) + #expect(late.minute == 0) + } + + @Test func weekdaysRoundtripThroughSwiftData() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + let r = MetricReminder( + metricId: "bloodPressure", + displayName: "血压", + hour: 7, minute: 30, + weekdays: [2, 4, 6] + ) + ctx.insert(r) + try ctx.save() + + let fetched = try #require(try ctx.fetch(FetchDescriptor()).first) + #expect(fetched.weekdays == [2, 4, 6]) + #expect(fetched.isEveryDay == false) + #expect(fetched.frequencyLabel == "每周 一三五") + #expect(fetched.timeLabel == "07:30") + } + + @Test func disabledFrequencyLabel() { + let r = MetricReminder(metricId: "x", displayName: "x", enabled: false) + #expect(r.frequencyLabel == "已关闭") + } + + @Test func emptyWeekdaysNotEveryDay() { + let r = MetricReminder(metricId: "x", displayName: "x", weekdays: []) + #expect(!r.isEveryDay) + #expect(r.frequencyLabel == "未选日") + } + + @Test func metricIdUniquenessEnforced() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + let r1 = MetricReminder(metricId: "bp", displayName: "血压") + ctx.insert(r1) + try ctx.save() + + let r2 = MetricReminder(metricId: "bp", displayName: "血压重复") + ctx.insert(r2) + try ctx.save() + + // SwiftData @Attribute(.unique) 在冲突时合并/拒绝(具体行为版本依赖); + // 至少 fetch 总数 ≤ 1。 + let all = try ctx.fetch(FetchDescriptor()) + #expect(all.count == 1) + } +} diff --git a/康康Tests/ModelDownloadCoreTests.swift b/康康Tests/ModelDownloadCoreTests.swift new file mode 100644 index 0000000..d0f1da5 --- /dev/null +++ b/康康Tests/ModelDownloadCoreTests.swift @@ -0,0 +1,136 @@ +import Testing +import Foundation +@testable import 康康 + +// MARK: - Mock 网络层 + +/// 按 URL 注册完整响应体,startLoading 时按请求的 Range header 自动切片返回(206)或全量(200)。 +/// 每个测试用唯一 URL 注册自己的内容 → 测试间不会互相覆盖,无需依赖执行顺序或可见性。 +final class MockURLProtocol: URLProtocol, @unchecked Sendable { + private static let lock = NSLock() + private static var bodies: [String: Data] = [:] + + static func register(_ url: URL, body: Data) { + lock.lock(); defer { lock.unlock() } + bodies[url.path] = body + } + static func reset() { + lock.lock(); defer { lock.unlock() } + bodies.removeAll() + } + private static func body(forPath path: String) -> Data? { + lock.lock(); defer { lock.unlock() } + return bodies[path] + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let url = request.url, let full = Self.body(forPath: url.path) else { + client?.urlProtocol(self, didFailWithError: URLError(.fileDoesNotExist)) + return + } + var data = full + var status = 200 + var headers: [String: String] = [:] + if let range = request.value(forHTTPHeaderField: "Range"), + let start = Self.parseRangeStart(range), start <= full.count { + data = Data(full.suffix(from: start)) + status = 206 + headers["Content-Range"] = "bytes \(start)-\(full.count - 1)/\(full.count)" + } + let response = HTTPURLResponse( + url: url, statusCode: status, httpVersion: nil, headerFields: headers)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + /// "bytes=2-" → 2 + private static func parseRangeStart(_ s: String) -> Int? { + guard let eq = s.firstIndex(of: "="), let dash = s.firstIndex(of: "-") else { return nil } + return Int(s[s.index(after: eq).. URLSessionConfiguration { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return config +} + +private func tempFile() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + .appendingPathComponent("a.bin") +} + +private func uniqueURL() -> URL { + URL(string: "https://mock.test/\(UUID().uuidString).bin")! +} + +// MARK: - DownloadState + +struct DownloadStateTests { + @Test func fractionZeroWhenTotalZero() { + let s = DownloadState(phase: .idle, receivedBytes: 0, totalBytes: 0, bytesPerSecond: 0) + #expect(s.fraction == 0) + } + + @Test func fractionComputed() { + let s = DownloadState(phase: .downloading, receivedBytes: 50, totalBytes: 200, bytesPerSecond: 0) + #expect(s.fraction == 0.25) + } +} + +// MARK: - FileDownloader + +/// 串行执行:这些测试共享全局 URLProtocol / URLSession 状态,并行会互相干扰。 +@Suite(.serialized) +struct FileDownloaderTests { + + @Test func downloadsFileContent() async throws { + let url = uniqueURL() + MockURLProtocol.register(url, body: Data("hello".utf8)) + let dst = tempFile() + defer { try? FileManager.default.removeItem(at: dst.deletingLastPathComponent()) } + + let dl = FileDownloader(configuration: mockConfiguration()) + try await dl.download(from: url, to: dst, expectedBytes: 5) + + #expect(try Data(contentsOf: dst) == Data("hello".utf8)) + #expect(!FileManager.default.fileExists(atPath: dst.appendingPathExtension("part").path)) + } + + @Test func resumesFromPartialFile() async throws { + let url = uniqueURL() + MockURLProtocol.register(url, body: Data("hello".utf8)) + let dst = tempFile() + defer { try? FileManager.default.removeItem(at: dst.deletingLastPathComponent()) } + // 预置已下载的一半,download 应从 offset 2 续传 + try FileManager.default.createDirectory( + at: dst.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("he".utf8).write(to: dst.appendingPathExtension("part")) + + let dl = FileDownloader(configuration: mockConfiguration()) + try await dl.download(from: url, to: dst, expectedBytes: 5) + + #expect(try Data(contentsOf: dst) == Data("hello".utf8)) + } + + @Test func throwsOnSizeMismatch() async throws { + let url = uniqueURL() + MockURLProtocol.register(url, body: Data("hi".utf8)) // 仅 2 字节,期望 5 + let dst = tempFile() + defer { try? FileManager.default.removeItem(at: dst.deletingLastPathComponent()) } + + let dl = FileDownloader(configuration: mockConfiguration()) + await #expect(throws: (any Error).self) { + try await dl.download(from: url, to: dst, expectedBytes: 5) + } + #expect(!FileManager.default.fileExists(atPath: dst.path)) + } +} diff --git a/康康Tests/ModelManifestTests.swift b/康康Tests/ModelManifestTests.swift new file mode 100644 index 0000000..91717c1 --- /dev/null +++ b/康康Tests/ModelManifestTests.swift @@ -0,0 +1,47 @@ +import Testing +import Foundation +@testable import 康康 + +struct ModelManifestTests { + + @Test func llmHasNineFunctionalFiles() { + #expect(ModelManifest.files(for: .llm).count == 9) + } + + @Test func vlHasFourteenFunctionalFiles() { + #expect(ModelManifest.files(for: .vl).count == 14) + } + + @Test func llmTotalBytesMatchesManifest() { + #expect(ModelManifest.totalBytes(for: .llm) == 984_013_244) + } + + @Test func vlTotalBytesMatchesManifest() { + #expect(ModelManifest.totalBytes(for: .vl) == 3_109_729_929) + } + + @Test func excludesReadmeAndGitattributes() { + for kind in [ModelKind.llm, .vl] { + let names = ModelManifest.files(for: kind).map(\.path) + #expect(!names.contains("README.md")) + #expect(!names.contains(".gitattributes")) + } + } + + @Test func includesEssentialFiles() { + let llm = ModelManifest.files(for: .llm).map(\.path) + #expect(llm.contains("config.json")) + #expect(llm.contains("model.safetensors")) + #expect(llm.contains("tokenizer.json")) + + let vl = ModelManifest.files(for: .vl).map(\.path) + #expect(vl.contains("preprocessor_config.json")) // VL 拍照识别必需 + #expect(vl.contains("model.safetensors")) + } + + @Test func fileURLIsBaseSlashRepoSlashPath() { + let file = ModelFile(path: "config.json", bytes: 937) + let url = ModelManifest.fileURL(for: .llm, file: file) + #expect(url.absoluteString == "https://file.myv0.com/Qwen3-1.7B-4bit/config.json") + } +} diff --git a/康康Tests/ModelStoreDownloadSupportTests.swift b/康康Tests/ModelStoreDownloadSupportTests.swift new file mode 100644 index 0000000..6aa4622 --- /dev/null +++ b/康康Tests/ModelStoreDownloadSupportTests.swift @@ -0,0 +1,92 @@ +import Testing +import Foundation +@testable import 康康 + +struct ModelStoreDownloadSupportTests { + + private func isolatedStore() throws -> ModelStore { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return try ModelStore(rootURL: temp) + } + + @Test func fileURLPointsIntoModelFolder() throws { + let store = try isolatedStore() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + let url = store.fileURL(for: .llm, relativePath: "config.json") + #expect(url == store.localURL(for: .llm).appendingPathComponent("config.json")) + } + + @Test func localBytesZeroWhenMissing() throws { + let store = try isolatedStore() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + #expect(store.localBytes(for: .llm, relativePath: "config.json") == 0) + } + + @Test func localBytesReturnsFileSize() throws { + let store = try isolatedStore() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + let folder = store.localURL(for: .llm) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try Data(repeating: 7, count: 512).write(to: folder.appendingPathComponent("config.json")) + #expect(store.localBytes(for: .llm, relativePath: "config.json") == 512) + } + + @Test func isCompleteFalseWhenFilesMissing() throws { + let store = try isolatedStore() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + let files = [ModelFile(path: "a.bin", bytes: 1024)] + #expect(store.isComplete(for: .llm, files: files) == false) + } + + @Test func isCompleteTrueWhenAllFilesPresentWithExpectedSize() throws { + let store = try isolatedStore() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + let folder = store.localURL(for: .llm) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try Data(repeating: 1, count: 1024).write(to: folder.appendingPathComponent("a.bin")) + let files = [ModelFile(path: "a.bin", bytes: 1024)] + #expect(store.isComplete(for: .llm, files: files) == true) + } + + @Test func isCompleteFalseWhenSizeMismatch() throws { + let store = try isolatedStore() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + let folder = store.localURL(for: .llm) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try Data(repeating: 1, count: 999).write(to: folder.appendingPathComponent("a.bin")) + let files = [ModelFile(path: "a.bin", bytes: 1024)] + #expect(store.isComplete(for: .llm, files: files) == false) + } + + @Test func importModelCopiesFolderAndMarksReady() throws { + let store = try isolatedStore() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + + let src = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: src, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: src) } + try "{}".write(to: src.appendingPathComponent("config.json"), atomically: true, encoding: .utf8) + try "x".write(to: src.appendingPathComponent("tokenizer.json"), atomically: true, encoding: .utf8) + + try store.importModel(.llm, from: src) + + #expect(store.isReady(.llm) == true) + #expect(FileManager.default.fileExists( + atPath: store.fileURL(for: .llm, relativePath: "tokenizer.json").path)) + } + + @Test func importModelThrowsWhenNoConfig() throws { + let store = try isolatedStore() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + let src = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: src, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: src) } + + #expect(throws: (any Error).self) { + try store.importModel(.llm, from: src) + } + } +} diff --git a/康康Tests/ModelStoreTests.swift b/康康Tests/ModelStoreTests.swift new file mode 100644 index 0000000..7be152a --- /dev/null +++ b/康康Tests/ModelStoreTests.swift @@ -0,0 +1,54 @@ +import Testing +import Foundation +@testable import 康康 + +struct ModelStoreTests { + + private func isolatedStore() throws -> ModelStore { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return try ModelStore(rootURL: temp) + } + + @Test func freshStoreReportsBothModelsMissing() throws { + let store = try isolatedStore() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + + #expect(store.isReady(.llm) == false) + #expect(store.isReady(.vl) == false) + } + + @Test func markReadyAfterFolderCreated() throws { + let store = try isolatedStore() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + + let llmFolder = store.localURL(for: .llm) + try FileManager.default.createDirectory(at: llmFolder, withIntermediateDirectories: true) + let configURL = llmFolder.appendingPathComponent("config.json") + try "{}".write(to: configURL, atomically: true, encoding: .utf8) + + #expect(store.isReady(.llm) == true) + #expect(store.isReady(.vl) == false) + } + + @Test func totalBytesSumsExistingFiles() throws { + let store = try isolatedStore() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + + let folder = store.localURL(for: .llm) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let data = Data(repeating: 0, count: 1024) + try data.write(to: folder.appendingPathComponent("a.bin")) + try data.write(to: folder.appendingPathComponent("b.bin")) + + #expect(store.totalBytes(for: .llm) == 2048) + } + + @Test func totalBytesReturnsZeroWhenFolderMissing() throws { + let store = try isolatedStore() + defer { try? FileManager.default.removeItem(at: store.rootURL) } + + // 没创建任何 vl 目录 + #expect(store.totalBytes(for: .vl) == 0) + } +} diff --git a/康康Tests/ModelsSchemaTests.swift b/康康Tests/ModelsSchemaTests.swift new file mode 100644 index 0000000..1f88352 --- /dev/null +++ b/康康Tests/ModelsSchemaTests.swift @@ -0,0 +1,162 @@ +import Testing +import SwiftData +import Foundation +@testable import 康康 + +struct ModelsSchemaTests { + + private func makeContainer() throws -> ModelContainer { + let schema = Schema([ + Indicator.self, + Report.self, + DiaryEntry.self, + Asset.self, + ChatTurn.self, + Symptom.self, + UserProfile.self, + MetricReminder.self, + CustomMonitorMetric.self, + ]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + @Test func insertIndicatorWithReportRelationship() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + + let report = Report(title: "春检", type: .checkup, reportDate: .now) + let indicator = Indicator( + name: "ALT", + value: "32", + unit: "U/L", + range: "9-50", + status: .normal, + report: report + ) + ctx.insert(report) + ctx.insert(indicator) + try ctx.save() + + #expect(report.indicators.count == 1) + #expect(indicator.report?.title == "春检") + } + + @Test func cascadeDeleteReportRemovesIndicators() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + + let report = Report(title: "春检", type: .checkup, reportDate: .now) + let indicator = Indicator( + name: "ALT", value: "32", unit: "U/L", range: "9-50", + status: .normal, report: report + ) + ctx.insert(report) + ctx.insert(indicator) + try ctx.save() + + ctx.delete(report) + try ctx.save() + + let remaining = try ctx.fetch(FetchDescriptor()) + #expect(remaining.isEmpty) + } + + @Test func ongoingSymptomQueryFiltersByEndedAt() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + + let active = Symptom(name: "头痛", startedAt: .now.addingTimeInterval(-3600)) + let ended = Symptom( + name: "咳嗽", + startedAt: .now.addingTimeInterval(-7200), + endedAt: .now.addingTimeInterval(-1800) + ) + ctx.insert(active) + ctx.insert(ended) + try ctx.save() + + let predicate = #Predicate { $0.endedAt == nil } + let ongoing = try ctx.fetch(FetchDescriptor(predicate: predicate)) + + #expect(ongoing.count == 1) + #expect(ongoing.first?.name == "头痛") + #expect(active.isOngoing) + #expect(!ended.isOngoing) + #expect(active.duration >= 3600) + } + + @Test func symptomSeverityClampedToRange() throws { + let high = Symptom(name: "腹痛", severity: 99) + let low = Symptom(name: "失眠", severity: -3) + #expect(high.severity == 5) + #expect(low.severity == 1) + } + + @Test func chatTurnPersistsReferencedIDs() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + + let turn = ChatTurn( + question: "我的 LDL 怎么样?", + answer: "近 3 个月 LDL 偏高 [1]", + referencedIndicatorIDs: ["abc"], + referencedReportIDs: [], + decodeRate: 24.3 + ) + ctx.insert(turn) + try ctx.save() + + let all = try ctx.fetch(FetchDescriptor()) + #expect(all.count == 1) + #expect(all.first?.referencedIndicatorIDs == ["abc"]) + } + + @Test func indicatorSeriesKeyRoundtrip() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + + let bp = Indicator( + name: "收缩压", + value: "125", + unit: "mmHg", + range: "90-140", + status: .normal, + pinned: true, + seriesKey: "bp.systolic" + ) + ctx.insert(bp) + try ctx.save() + + let fetched = try #require(try ctx.fetch(FetchDescriptor()).first) + #expect(fetched.seriesKey == "bp.systolic") + #expect(fetched.pinned == true) + } + + @Test func indicatorSeriesKeyDefaultsToNil() { + let i = Indicator(name: "ALT", value: "32", unit: "U/L", range: "9-50", status: .normal) + #expect(i.seriesKey == nil) + } + + @Test func userProfileSchemaPersistsAcrossSave() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + + let p = UserProfile( + birthYear: 1985, + biologicalSexRaw: "male", + heightCM: 175, + bloodTypeRaw: "A", + chronicConditions: ["高血压"] + ) + ctx.insert(p) + try ctx.save() + + let fetched = try #require(try ctx.fetch(FetchDescriptor()).first) + #expect(fetched.birthYear == 1985) + #expect(fetched.sex == .male) + #expect(fetched.heightCM == 175) + #expect(fetched.bloodTypeRaw == "A") + #expect(fetched.chronicConditions == ["高血压"]) + } +} diff --git a/康康Tests/MonitorMetricTests.swift b/康康Tests/MonitorMetricTests.swift new file mode 100644 index 0000000..2538c9a --- /dev/null +++ b/康康Tests/MonitorMetricTests.swift @@ -0,0 +1,84 @@ +import Testing +import Foundation +@testable import 康康 + +@MainActor +struct MonitorMetricTests { + + @Test func allMetricsHaveAtLeastOneField() { + for m in MonitorMetric.allCases { + #expect(!m.fields.isEmpty, "metric \(m.rawValue) has no fields") + } + } + + @Test func bloodPressureHasTwoFields() { + let bp = MonitorMetric.bloodPressure + #expect(bp.fields.count == 2) + #expect(bp.fields[0].seriesKey == "bp.systolic") + #expect(bp.fields[1].seriesKey == "bp.diastolic") + } + + @Test func statusHighWhenValueAboveRange() { + let s = MonitorMetric.status(value: 150, in: 90...140) + #expect(s == .high) + } + + @Test func statusLowWhenValueBelowRange() { + let s = MonitorMetric.status(value: 80, in: 90...140) + #expect(s == .low) + } + + @Test func statusNormalWhenValueInside() { + let s = MonitorMetric.status(value: 120, in: 90...140) + #expect(s == .normal) + } + + @Test func statusNormalWhenRangeIsNil() { + let s = MonitorMetric.status(value: 999, in: nil) + #expect(s == .normal) + } + + @Test func systolicUpperBoundShiftsForElderly() { + let bp = MonitorMetric.bloodPressure + let systolic = bp.fields[0] + let elderly = UserProfile(birthYear: 1955) // 71 岁 + let range = bp.effectiveRange(for: systolic, profile: elderly) + #expect(range == 90...150) + } + + @Test func systolicUpperBoundUnchangedForYoungAdult() { + let bp = MonitorMetric.bloodPressure + let systolic = bp.fields[0] + let young = UserProfile(birthYear: 1990) + let range = bp.effectiveRange(for: systolic, profile: young) + #expect(range == 90...140) + } + + @Test func systolicUpperBoundUnchangedWhenProfileNil() { + let bp = MonitorMetric.bloodPressure + let systolic = bp.fields[0] + let range = bp.effectiveRange(for: systolic, profile: nil) + #expect(range == 90...140) + } + + @Test func glucoseUnaffectedByAge() { + let g = MonitorMetric.fastingGlucose + let field = g.fields[0] + let elderly = UserProfile(birthYear: 1940) + #expect(g.effectiveRange(for: field, profile: elderly) == field.baseRange) + } + + @Test func isRangePersonalizedTrueForElderlySystolic() { + let bp = MonitorMetric.bloodPressure + let systolic = bp.fields[0] + let elderly = UserProfile(birthYear: 1955) + #expect(bp.isRangePersonalized(for: systolic, profile: elderly) == true) + } + + @Test func isRangePersonalizedFalseForYoungProfile() { + let bp = MonitorMetric.bloodPressure + let systolic = bp.fields[0] + let young = UserProfile(birthYear: 1995) + #expect(bp.isRangePersonalized(for: systolic, profile: young) == false) + } +} diff --git a/康康Tests/RegionImageCropperTests.swift b/康康Tests/RegionImageCropperTests.swift new file mode 100644 index 0000000..6d8537d --- /dev/null +++ b/康康Tests/RegionImageCropperTests.swift @@ -0,0 +1,61 @@ +import XCTest +import CoreGraphics +@testable import 康康 + +/// 异常项快拍的局部裁剪几何。 +/// 回归用例:屏上「宽而矮」的小框,必须裁出「宽 > 高」的照片 rect。 +/// 旧实现用 `metadataOutputRectConverted`(传感器横向坐标)套到竖屏照片 → x/y 轴对调, +/// 把宽框裁成竖窄条(2026-05-31 真机 bug)。本组用例钉住正确的纯几何映射。 +final class RegionImageCropperTests: XCTestCase { + + /// 竖屏照片 + 竖屏屏幕 + 宽框 → 裁出的 rect 必须是横向(宽 > 高)。 + func testWideBoxYieldsLandscapeCropRect() { + let photo = CGSize(width: 3024, height: 4032) // 竖屏照片(像素) + let view = CGSize(width: 393, height: 852) // 竖屏屏幕(点) + let box = RegionFraming.box(in: view) // 宽 84% / 矮 160 + + let rect = RegionImageCropper.cropRect(photoPixelSize: photo, box: box, in: view) + + XCTAssertGreaterThan(rect.width, rect.height, + "宽框应裁出宽 rect;若 width Indicator { + Indicator(name: name, value: value, unit: unit, range: range, + status: status, capturedAt: capturedAt, + seriesKey: seriesKey) + } + + @Test func skipsIndicatorsWithoutSeriesKey() { + let now = Date() + let items = [ + makeIndicator(value: "5.0", capturedAt: now, seriesKey: nil), + makeIndicator(value: "5.2", capturedAt: now, seriesKey: nil), + ] + let buckets = SeriesBucket.build(from: items) + #expect(buckets.isEmpty) + } + + @Test func filtersOutSeriesWithFewerThanMinPoints() { + let now = Date() + let items = [ + makeIndicator(value: "5.0", capturedAt: now, seriesKey: "glucose.fasting"), + ] + let buckets = SeriesBucket.build(from: items, minPoints: 2) + #expect(buckets.isEmpty) + } + + @Test func singleSeriesBucketSortedAscending() { + let day = { (offset: Int) -> Date in + Calendar.current.date(byAdding: .day, value: offset, to: .now)! + } + let items = [ + makeIndicator(value: "5.5", capturedAt: day(-3), seriesKey: "glucose.fasting"), + makeIndicator(value: "5.2", capturedAt: day(-1), seriesKey: "glucose.fasting"), + makeIndicator(value: "5.8", capturedAt: day(-2), seriesKey: "glucose.fasting"), + ] + let buckets = SeriesBucket.build(from: items) + #expect(buckets.count == 1) + let line = try! #require(buckets.first?.lines.first) + // sorted ascending → -3, -2, -1 + let values = line.points.map(\.value) + #expect(values == [5.5, 5.8, 5.2]) + } + + @Test func bloodPressureMergesIntoSingleBucket() { + let now = Date() + let day = { (offset: Int) -> Date in + Calendar.current.date(byAdding: .day, value: offset, to: now)! + } + let items = [ + makeIndicator(value: "125", capturedAt: day(-2), seriesKey: "bp.systolic"), + makeIndicator(value: "82", capturedAt: day(-2), seriesKey: "bp.diastolic"), + makeIndicator(value: "130", capturedAt: day(-1), seriesKey: "bp.systolic"), + makeIndicator(value: "85", capturedAt: day(-1), seriesKey: "bp.diastolic"), + ] + let buckets = SeriesBucket.build(from: items) + let bp = try! #require(buckets.first { $0.id == "bp" }) + #expect(bp.lines.count == 2) + #expect(bp.title == "血压") + #expect(bp.lines.contains { $0.seriesKey == "bp.systolic" }) + #expect(bp.lines.contains { $0.seriesKey == "bp.diastolic" }) + } + + @Test func mixedSeriesProducesMultipleBucketsSortedByRecency() { + let cal = Calendar.current + let day = { (offset: Int) -> Date in + cal.date(byAdding: .day, value: offset, to: .now)! + } + let items = [ + // weight 较旧 + makeIndicator(value: "68", capturedAt: day(-10), seriesKey: "weight"), + makeIndicator(value: "67", capturedAt: day(-7), seriesKey: "weight"), + // glucose 较新 + makeIndicator(value: "5.1", capturedAt: day(-2), seriesKey: "glucose.fasting"), + makeIndicator(value: "5.3", capturedAt: day(-1), seriesKey: "glucose.fasting"), + ] + let buckets = SeriesBucket.build(from: items) + #expect(buckets.count == 2) + // 最新的 glucose 排前面 + #expect(buckets.first?.id == "glucose.fasting") + #expect(buckets.last?.id == "weight") + } + + @Test func nonNumericValueDropped() { + let now = Date() + let items = [ + makeIndicator(value: "高", capturedAt: now, seriesKey: "weight"), + makeIndicator(value: "68", capturedAt: now, seriesKey: "weight"), + makeIndicator(value: "67", capturedAt: now.addingTimeInterval(60), seriesKey: "weight"), + ] + let buckets = SeriesBucket.build(from: items) + let line = try! #require(buckets.first?.lines.first) + #expect(line.points.count == 2) // "高" 被丢 + } +} diff --git a/康康Tests/TimelineEntryBPMergeTests.swift b/康康Tests/TimelineEntryBPMergeTests.swift new file mode 100644 index 0000000..2737e84 --- /dev/null +++ b/康康Tests/TimelineEntryBPMergeTests.swift @@ -0,0 +1,116 @@ +import Testing +import SwiftData +import Foundation +@testable import 康康 + +@MainActor +struct TimelineEntryBPMergeTests { + + private func makeContext() throws -> ModelContext { + let schema = Schema([Indicator.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return ModelContext(try ModelContainer(for: schema, configurations: [config])) + } + + private func bp(sys: Int, dia: Int, at: Date, in ctx: ModelContext) -> (Indicator, Indicator) { + let s = Indicator(name: "收缩压", value: "\(sys)", unit: "mmHg", + range: "90-140", status: .normal, + capturedAt: at, pinned: true, seriesKey: "bp.systolic") + let d = Indicator(name: "舒张压", value: "\(dia)", unit: "mmHg", + range: "60-90", status: .normal, + capturedAt: at, pinned: true, seriesKey: "bp.diastolic") + ctx.insert(s); ctx.insert(d) + return (s, d) + } + + @Test func bpPairAtSameTimeMergesIntoSingleEntry() throws { + let ctx = try makeContext() + let now = Date(timeIntervalSince1970: 1_716_000_000) + _ = bp(sys: 120, dia: 80, at: now, in: ctx) + try ctx.save() + + let all = try ctx.fetch(FetchDescriptor()) + let entries = TimelineEntry.from(indicators: all) + + #expect(entries.count == 1) + let e = try #require(entries.first) + #expect(e.title == "血压") + #expect(e.trailing?.contains("120/80") == true) + #expect(e.trailingIsAlert == false) + } + + @Test func bpPairWithAbnormalSystolicMarksAlert() throws { + let ctx = try makeContext() + let now = Date() + let (s, _) = bp(sys: 150, dia: 80, at: now, in: ctx) + s.statusRaw = IndicatorStatus.high.rawValue + try ctx.save() + + let all = try ctx.fetch(FetchDescriptor()) + let entries = TimelineEntry.from(indicators: all) + #expect(entries.count == 1) + #expect(entries.first?.trailingIsAlert == true) + #expect(entries.first?.trailing?.contains("↑") == true) + } + + @Test func nonBPSeriesStayAsSeparateEntries() throws { + let ctx = try makeContext() + let now = Date() + let glu = Indicator(name: "空腹血糖", value: "5.4", unit: "mmol/L", + range: "3.9-6.1", status: .normal, + capturedAt: now, pinned: true, seriesKey: "glucose.fasting") + let weight = Indicator(name: "体重", value: "68", unit: "kg", + range: "", status: .normal, + capturedAt: now, pinned: true, seriesKey: "weight") + ctx.insert(glu); ctx.insert(weight) + try ctx.save() + + let all = try ctx.fetch(FetchDescriptor()) + let entries = TimelineEntry.from(indicators: all) + #expect(entries.count == 2) + #expect(entries.contains { $0.title == "空腹血糖" }) + #expect(entries.contains { $0.title == "体重" }) + } + + @Test func bpAtDifferentTimesDoNotMerge() throws { + let ctx = try makeContext() + let t1 = Date(timeIntervalSince1970: 1_716_000_000) + let t2 = t1.addingTimeInterval(3600) // 1 小时后 + _ = bp(sys: 120, dia: 80, at: t1, in: ctx) + _ = bp(sys: 130, dia: 85, at: t2, in: ctx) + try ctx.save() + + let all = try ctx.fetch(FetchDescriptor()) + let entries = TimelineEntry.from(indicators: all) + #expect(entries.count == 2) + } + + @Test func unpairedSystolicFallsBackToSingleEntry() throws { + let ctx = try makeContext() + let s = Indicator(name: "收缩压", value: "120", unit: "mmHg", + range: "90-140", status: .normal, + capturedAt: .now, pinned: true, seriesKey: "bp.systolic") + ctx.insert(s) + try ctx.save() + + let all = try ctx.fetch(FetchDescriptor()) + let entries = TimelineEntry.from(indicators: all) + // 没找到 diastolic 配对,落到单 from(indicator:),显示 "收缩压" + #expect(entries.count == 1) + #expect(entries.first?.title == "收缩压") + } + + @Test func freeformIndicatorWithoutSeriesKeyShowsAsItself() throws { + let ctx = try makeContext() + let i = Indicator(name: "ALT", value: "32", unit: "U/L", + range: "9-50", status: .normal, + capturedAt: .now) + ctx.insert(i) + try ctx.save() + + let all = try ctx.fetch(FetchDescriptor()) + let entries = TimelineEntry.from(indicators: all) + #expect(entries.count == 1) + #expect(entries.first?.title == "ALT") + } +} diff --git a/康康Tests/TimelineGroupingTests.swift b/康康Tests/TimelineGroupingTests.swift new file mode 100644 index 0000000..1f5edce --- /dev/null +++ b/康康Tests/TimelineGroupingTests.swift @@ -0,0 +1,83 @@ +import Testing +import Foundation +@testable import 康康 + +struct TimelineGroupingTests { + + private let now: Date = { + var c = DateComponents() + c.year = 2026; c.month = 5; c.day = 25; c.hour = 12 + return Calendar(identifier: .gregorian).date(from: c)! + }() + + @Test func todaySection() { + #expect(TimelineGrouping.section(for: now, now: now) == .today) + } + + @Test func yesterdaySection() { + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)! + #expect(TimelineGrouping.section(for: yesterday, now: now) == .yesterday) + } + + @Test func thisWeekSection() { + // 5/25 是 周一, 同周稍早(周日 5/24 在多数 zh_CN 周历里可能算上周;这里测明确同周的 3 天前可能跨周,改用 within-week) + let cal = Calendar(identifier: .gregorian) + let twoDaysAgo = cal.date(byAdding: .day, value: -2, to: now)! + let section = TimelineGrouping.section(for: twoDaysAgo, now: now, calendar: cal) + // 2 天前要么是 .thisWeek 要么是 .yesterday/.thisMonth — 关键是不能是 .today + #expect(section != .today) + } + + @Test func thisMonthSection() { + let earlierThisMonth = Calendar.current.date(byAdding: .day, value: -15, to: now)! + let section = TimelineGrouping.section(for: earlierThisMonth, now: now) + #expect(section == .thisMonth) + } + + @Test func yearSection() { + var c = DateComponents() + c.year = 2024; c.month = 3; c.day = 1 + let oldDate = Calendar(identifier: .gregorian).date(from: c)! + let section = TimelineGrouping.section(for: oldDate, now: now) + #expect(section == .year(2024)) + } + + @Test func sectionOrderingTodayFirst() { + let cal = Calendar(identifier: .gregorian) + let yesterday = cal.date(byAdding: .day, value: -1, to: now)! + let lastYear = cal.date(byAdding: .year, value: -1, to: now)! + + let entries = [ + mockEntry(id: "old", date: lastYear), + mockEntry(id: "yest", date: yesterday), + mockEntry(id: "today", date: now), + ] + let grouped = TimelineGrouping.group(entries, now: now, calendar: cal) + let labels = grouped.map { $0.section.label } + #expect(labels.first == "今天") + #expect(labels.last?.contains("年") == true) + } + + @Test func formatDurationBoundaries() { + #expect(formatDuration(0) == "刚刚") + #expect(formatDuration(30) == "刚刚") // < 1 min + #expect(formatDuration(120) == "2 分钟") // 2 min + #expect(formatDuration(3600) == "1 小时") // 1h + #expect(formatDuration(3600 + 1800) == "1 小时 30 分") + #expect(formatDuration(86400) == "1 天") + #expect(formatDuration(86400 + 3600) == "1 天 1 小时") + } + + private func mockEntry(id: String, date: Date) -> TimelineEntry { + TimelineEntry( + id: id, + kind: .diary, + date: date, + title: id, + subtitle: "test", + trailing: nil, + trailingIsAlert: false, + isOngoing: false + ) + } +} diff --git a/康康Tests/TodayRemindersLogicTests.swift b/康康Tests/TodayRemindersLogicTests.swift new file mode 100644 index 0000000..724b624 --- /dev/null +++ b/康康Tests/TodayRemindersLogicTests.swift @@ -0,0 +1,81 @@ +import Testing +import Foundation +@testable import 康康 + +/// 主页「今日提醒」筛选逻辑(`occurs(on:)`)的纯函数测试。 +/// 用固定 Gregorian 日历构造确定日期,避免依赖 `Date.now` / 本机时区。 +struct TodayRemindersLogicTests { + + private var cal: Calendar { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "Asia/Shanghai")! + return c + } + + private func date(_ y: Int, _ mo: Int, _ d: Int) -> Date { + cal.date(from: DateComponents(year: y, month: mo, day: d, hour: 12))! + } + + // MARK: - CustomReminder + + @Test func dailyOccursEveryDay() { + let r = CustomReminder(title: "跑步", frequency: .daily) + #expect(r.occurs(on: date(2026, 5, 30), calendar: cal)) + #expect(r.occurs(on: date(2026, 1, 1), calendar: cal)) + } + + @Test func disabledNeverOccurs() { + let r = CustomReminder(title: "跑步", frequency: .daily, enabled: false) + #expect(!r.occurs(on: date(2026, 5, 30), calendar: cal)) + } + + @Test func weeklyOccursOnlyOnSelectedWeekdays() { + let d = date(2026, 5, 30) + let wd = cal.component(.weekday, from: d) + let other = wd == 1 ? 2 : 1 + + let hit = CustomReminder(title: "x", weekdays: [wd], frequency: .weekly) + #expect(hit.occurs(on: d, calendar: cal)) + + let miss = CustomReminder(title: "x", weekdays: [other], frequency: .weekly) + #expect(!miss.occurs(on: d, calendar: cal)) + } + + @Test func monthlyOccursOnlyOnMatchingDay() { + let d = date(2026, 5, 30) // 30 日 + #expect(CustomReminder(title: "x", frequency: .monthly, dayOfMonth: 30).occurs(on: d, calendar: cal)) + #expect(!CustomReminder(title: "x", frequency: .monthly, dayOfMonth: 15).occurs(on: d, calendar: cal)) + } + + @Test func monthlyDay31SkipsShortMonths() { + // 4 月只有 30 天:选「31 日」的提醒在 4/30 这天不应触发(无 4/31,该月跳过)。 + let apr30 = date(2026, 4, 30) + let r = CustomReminder(title: "x", frequency: .monthly, dayOfMonth: 31) + #expect(!r.occurs(on: apr30, calendar: cal)) + } + + @Test func yearlyOccursOnlyOnMatchingMonthAndDay() { + let d = date(2026, 5, 30) + #expect(CustomReminder(title: "x", frequency: .yearly, dayOfMonth: 30, month: 5).occurs(on: d, calendar: cal)) + #expect(!CustomReminder(title: "x", frequency: .yearly, dayOfMonth: 30, month: 6).occurs(on: d, calendar: cal)) + #expect(!CustomReminder(title: "x", frequency: .yearly, dayOfMonth: 29, month: 5).occurs(on: d, calendar: cal)) + } + + // MARK: - MetricReminder + + @Test func metricReminderOccursOnSelectedWeekday() { + let d = date(2026, 5, 30) + let wd = cal.component(.weekday, from: d) + let other = wd == 1 ? 2 : 1 + + #expect(MetricReminder(metricId: "bp", displayName: "血压", weekdays: [wd]).occurs(on: d, calendar: cal)) + #expect(!MetricReminder(metricId: "bp2", displayName: "血压", weekdays: [other]).occurs(on: d, calendar: cal)) + } + + @Test func disabledMetricReminderNeverOccurs() { + let d = date(2026, 5, 30) + let wd = cal.component(.weekday, from: d) + let r = MetricReminder(metricId: "bp", displayName: "血压", weekdays: [wd], enabled: false) + #expect(!r.occurs(on: d, calendar: cal)) + } +} diff --git a/康康Tests/UserProfileTests.swift b/康康Tests/UserProfileTests.swift new file mode 100644 index 0000000..b1526db --- /dev/null +++ b/康康Tests/UserProfileTests.swift @@ -0,0 +1,110 @@ +import Testing +import SwiftData +import Foundation +@testable import 康康 + +@MainActor +struct UserProfileTests { + + private func makeContext() throws -> ModelContext { + let schema = Schema([UserProfile.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: [config]) + return ModelContext(container) + } + + @Test func freshProfileHasNilDemographics() { + let p = UserProfile() + #expect(p.birthYear == nil) + #expect(p.heightCM == nil) + #expect(p.weightKG == nil) + #expect(p.biologicalSexRaw == "") + #expect(p.bloodTypeRaw == "") + #expect(p.allergies.isEmpty) + #expect(p.chronicConditions.isEmpty) + #expect(p.currentMedications.isEmpty) + #expect(p.familyHistory.isEmpty) + #expect(p.age == nil) + #expect(p.sex == .undisclosed) + #expect(p.bmi == nil) + #expect(p.hasAnyBasics == false) + #expect(p.summaryLine == "") + } + + @Test func ageComputedFromBirthYear() { + let p = UserProfile(birthYear: 1985) + let currentYear = Calendar.current.component(.year, from: .now) + #expect(p.age == currentYear - 1985) + } + + @Test func sexEnumRoundtripsThroughRaw() { + let p = UserProfile(biologicalSexRaw: "male") + #expect(p.sex == .male) + p.sex = .female + #expect(p.biologicalSexRaw == "female") + p.sex = .undisclosed + #expect(p.biologicalSexRaw == "") + } + + @Test func summaryLineSkipsEmptyFields() { + let p = UserProfile(birthYear: 1990, biologicalSexRaw: "female", heightCM: 162) + let line = p.summaryLine + #expect(line.contains("岁")) + #expect(line.contains("女")) + #expect(line.contains("162cm")) + #expect(!line.contains("kg")) // 体重空,不出现 + #expect(!line.contains("型")) // 血型空,不出现 + } + + @Test func summaryLineIncludesWeightWhenSet() { + let p = UserProfile(heightCM: 175, weightKG: 68.5) + #expect(p.summaryLine.contains("175cm")) + #expect(p.summaryLine.contains("68.5kg")) + } + + @Test func summaryLineFormatsIntegerWeightWithoutDecimal() { + let p = UserProfile(weightKG: 70) + #expect(p.summaryLine == "70kg") + } + + @Test func bmiNilWithoutBothHeightAndWeight() { + #expect(UserProfile(heightCM: 175).bmi == nil) + #expect(UserProfile(weightKG: 68).bmi == nil) + #expect(UserProfile().bmi == nil) + } + + @Test func bmiComputedFromHeightAndWeight() throws { + let p = UserProfile(heightCM: 175, weightKG: 70) + let bmi = try #require(p.bmi) + // 70 / (1.75 * 1.75) = 22.857 + #expect(abs(bmi - 22.857) < 0.01) + } + + @Test func loadOrCreateReturnsExistingSingleton() throws { + let ctx = try makeContext() + let first = UserProfileStore.loadOrCreate(in: ctx) + first.birthYear = 1990 + try ctx.save() + + let second = UserProfileStore.loadOrCreate(in: ctx) + #expect(second.birthYear == 1990) + + let all = try ctx.fetch(FetchDescriptor()) + #expect(all.count == 1) + } + + @Test func arrayFieldsRoundtripThroughSwiftData() throws { + let ctx = try makeContext() + let p = UserProfile( + chronicConditions: ["高血压", "糖尿病"], + currentMedications: ["缬沙坦 80mg qd", "二甲双胍 500mg bid"] + ) + ctx.insert(p) + try ctx.save() + + let fetched = try #require(try ctx.fetch(FetchDescriptor()).first) + #expect(fetched.chronicConditions == ["高血压", "糖尿病"]) + #expect(fetched.currentMedications.count == 2) + #expect(fetched.currentMedications.first == "缬沙坦 80mg qd") + } +} diff --git a/体己Tests/__Tests.swift b/康康Tests/__Tests.swift similarity index 93% rename from 体己Tests/__Tests.swift rename to 康康Tests/__Tests.swift index 6e27c98..b9f1b79 100644 --- a/体己Tests/__Tests.swift +++ b/康康Tests/__Tests.swift @@ -1,6 +1,6 @@ // // __Tests.swift -// 体己Tests +// 康康Tests // // Created by Tim on 2026/5/25. // diff --git a/体己UITests/__UITests.swift b/康康UITests/__UITests.swift similarity index 98% rename from 体己UITests/__UITests.swift rename to 康康UITests/__UITests.swift index ce0c7ca..2301f8f 100644 --- a/体己UITests/__UITests.swift +++ b/康康UITests/__UITests.swift @@ -1,6 +1,6 @@ // // __UITests.swift -// 体己UITests +// 康康UITests // // Created by Tim on 2026/5/25. // diff --git a/体己UITests/__UITestsLaunchTests.swift b/康康UITests/__UITestsLaunchTests.swift similarity index 97% rename from 体己UITests/__UITestsLaunchTests.swift rename to 康康UITests/__UITestsLaunchTests.swift index 97f8585..65698b1 100644 --- a/体己UITests/__UITestsLaunchTests.swift +++ b/康康UITests/__UITestsLaunchTests.swift @@ -1,6 +1,6 @@ // // __UITestsLaunchTests.swift -// 体己UITests +// 康康UITests // // Created by Tim on 2026/5/25. //