Compare commits

..

10 Commits

Author SHA1 Message Date
link2026
bff7cfd4b6 fix(core): 代码审查修复 AI 并发/隐私/解析等多处缺陷
- AIRuntime 加 actor 内串行推理闸门,封死 LLM/VL in-flight 并发解码窄口(jetsam OOM 根因)
- prepare 的 .loading 改轮询等待消除假就绪竞态;就绪判据 isReady→isComplete 防半下载崩溃
- applyReanalyzed 重新解读时 unlink 旧 Asset,消除 Vault 孤儿图片(§6 隐私承诺)
- parseReportJSON 改 extractBalancedJSON + 裸数组兜底,防 VL 多项输出被静默截断丢指标
- 临时文件改 completeUnlessOpen 修锁屏写失败;parseDate 支持多格式防归档年份错位
- TimelineEntry/DayDetailSheet 修「偏高」文案与血压箭头方向(偏低指标不再显示相反结论)
- FileVault.wipe 容错;HealthExportSheet 异常关键词排除否定句;modelTag 取实际枚举值
- 删除 B1-B5 + ArchiveFlow 死代码(含违反 §6 的 AES 加密文案)
- 补 3 个回归测试,编译 + 测试全部通过
2026-06-01 08:16:14 +08:00
link2026
32e7c25ed7 ```
feat(Quick): 优化RegionCameraView裁剪算法

重构RegionImageCropper裁剪逻辑,改用纯几何aspect-fill反算方法,
将屏上小框坐标直接映射到照片像素rect,避免使用
metadataOutputRectConverted导致的坐标轴对调问题。

主要变更:
- 移除基于归一化rect的裁剪方式
- 新增cropRect函数进行几何反算
- 修复传感器横向坐标与竖屏照片方向不一致的问题
- 保持裁剪精度的同时提升算法稳定性
```
2026-05-31 23:51:53 +08:00
link2026
d72a1fec17 ```
feat(AI): 添加MLX内存管理和AI模型互斥卸载机制

为防止应用因内存溢出被系统终止,在项目中添加了MLX框架依赖,
并在应用启动时配置GPU缓存限制,设置256MB缓存上限以避免内存过度使用。

同时实现了LLM和VL模型的互斥卸载机制,确保大模型不会同时常驻内存,
通过在加载一个模型前先卸载另一个模型来控制内存使用,防止jetsam OOM。

chore(project): 配置代码签名授权文件

refactor(localization): 调整本地化字符串并清理冗余条目

修正了提醒任务和建议相关的本地化文本,调整了多个UI字符串,
清理了过时和重复的本地化条目,更新了AI识别相关的新字符串资源。
```
2026-05-31 23:22:50 +08:00
link2026
db7cc1bba7 ```
chore(project): 更新项目版本号从1到2

更新了项目配置文件中的CURRENT_PROJECT_VERSION字段,
将所有构建配置的目标版本从1升级到2
```
2026-05-31 18:42:59 +08:00
link2026
adb589af16 feat(quick): 异常项快拍改为局部小框 + VL 识别
将「异常项快拍」从复用整页报告归档流程,改造成独立的局部识别路径:
小框拍局部 → Qwen-VL 只抽 indicators → 用户确认逐项编辑 → 存成独立
Indicator(不建 Report、不留原图,与「记录指标」统一落库)。

- RegionCameraView: AVFoundation 实时预览 + 居中小框,快门后按
  metadataOutputRectConverted 裁剪到框内区域;含裁剪纯函数与权限态。
- VLPrompts.regionExtraction(): 局部识别 prompt,严格 JSON 只要 indicators。
- CaptureService.recognizeRegion(): 临时文件推理后即删,不写 Vault;
  新增 parseIndicatorsJSON / extractBalancedJSON 解析容错。
- QuickRegionConfirmView: 异常项高亮置顶、默认勾选,可编辑/增删/选纳入。
- QuickRegionCaptureFlow: 状态机 idle→analyzing→confirm,30s 超时回退手动。
- RootView: .quick 路由改指向新流程(.archive 仍走 UnifiedCaptureFlow)。
- 删除 5 个无引用的旧 mockup(A1/A2/A3/SmartFramer/QuickCaptureFlow)。

模拟器无相机退化为相册整图;小框裁剪坐标需真机验证。
设计见 docs/superpowers/specs/2026-05-31-abnormal-quick-capture-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:12:36 +08:00
link2026
da6223e051 chore: 停止跟踪 build/ 构建产物,补全 .gitignore
build/(xcarchive + export ipa + DerivedData)是编译/打包产物,
不该入库。git rm --cached 移出 24 个误提交文件(磁盘保留),
.gitignore 已含 build/ 规则,后续不再污染 status。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:11:56 +08:00
link2026
40155de709 ```
feat(AI): 优化AIRuntime任务取消机制并增强安全保护

- 在AI推理流中添加Task.checkCancellation()检查,使消费者取消时能快速退出
- 为异步流添加onTermination回调以取消内部Task,与LLMSession一致
- 实现SwiftData store的completeUnlessOpen文件保护,提升数据安全性
- 在store备份过程中同样应用加密保护

feat(home): 优化主页交互体验并统一详情查看功能

- 在主页"最近记录"中点击任意条目可打开只读详情sheet
- 将时间线详情解析逻辑统一收敛到TimelineDetail.resolve方法
- 修复血压条目的精确反查逻辑,避免时间窗匹配错误

feat(archive): 新增提醒任务汇总卡并完善档案库功能

- 在档案库页面新增提醒任务汇总卡,显示总数和启用状态
- 添加按更新时间倒序合并的提醒标题预览功能
- 实现RemindersListView导航路由,统一管理提醒任务
- 优化导出列表显示,优先使用中文标签展示

feat(me): 优化个人中心界面并改进语言设置体验

- 将个人中心标题改为内容文字渲染,解决导航栏背景问题
- 为语言选择器添加个性化图标,使用本族语代表字区分
- 修复语言设置视图的图标显示逻辑

feat(timeline): 新增记录详情页删除功能并优化图表显示

- 在时间线详情页添加永久删除按钮和确认弹窗
- 实现完整的删除逻辑,包括SwiftData硬删和Vault原图unlink
- 修复系列图表的数值范围计算,处理同值数据的对称留白
- 优化血压图表合并逻辑,只保留有数据点的线条

refactor(calendar): 修复DST切换导致的月份天数计算错误

- 使用calendar.range(of:.day,in:.month)替代日期间隔计算
- 避免在夏令时切换月份出现天数偏差问题

fix(ui): 修复多个UI组件的交互响应区域问题

- 为纯描边按钮和胶囊添加contentShape以扩大点击区域
- 修复提醒行展开按钮尺寸,保证不同提醒类型的垂直对齐
```
2026-05-31 09:25:49 +08:00
link2026
7ad41c5f09 ```
docs(health-profile): 添加防编造加固修订记录到导出健康档案设计文档

补充了关于导出摘要出现虚构病例问题的详细分析和修复方案,
包括检索策略优化、空数据兜底处理和prompt重写等三层防护措施。
```
2026-05-30 20:06:12 +08:00
link2026
dad9d43486 ```
feat: 添加自定义提醒功能并优化项目配置

- 添加 CustomReminder 模型支持自由文案周期性提醒功能
- 实现自定义提醒的 UI 界面,包括新建、编辑和列表展示
- 集成本地通知服务支持自定义提醒的时间触发
- 更新项目配置文件添加应用显示名称和加密声明
- 修正 iOS 部署目标版本从 26.0 到 17.0
- 修复 FileDownloader 中的线程安全问题
- 优化 ModelManifest 和 Localization 的并发安全性
- 扩展本地化字符串支持多语言提醒相关文本
- 调整项目支持平台范围仅保留 iphoneos 和 iphonesimulator
```
2026-05-30 11:36:29 +08:00
link2026
d2c77d5c51 feat: 国际化(i18n) en/ja/ko + App 内语言切换
主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施:Localizable.xcstrings(String Catalog,sourceLanguage=zh-Hans)
  + pbxproj developmentRegion/knownRegions 注册 en/ja/ko
- 全部硬编码 Locale("zh_CN") → Locale.current;中文 dateFormat → Date.FormatStyle(跟随系统)
- UI 中文字面量统一为 String(appLoc:)(显式绑定所选语言 bundle+locale,即时切换)
  Text 字面量走环境 \.locale + Bundle 重定向
- 549 个 catalog key 全部 en/ja/ko 翻译完成(0 未翻译)
- App 内语言切换:我的 → 语言(LanguageManager + 即时生效,无需重启)
- 双用预设(症状/监测指标/慢病)本地化:static→computed 避免缓存

注:本提交为 WIP,一并打包了并行进行的功能模块
(HealthExport 健康导出、Security/Face ID 锁、DiaryAssist 日记 AI 辅助)
及 App 图标、CLAUDE.md、docs/scripts。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:28:24 +08:00
106 changed files with 19912 additions and 2571 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
# 大模型素材:本地下载用于上传到 OpenList,不入库(~3GB)
/Models/
/build/
.DS_Store

View File

@@ -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 现场重装时用)

View File

@@ -0,0 +1,65 @@
# 康康KK 隐私政策
生效日期2026-05-31
康康KK 是一款本地优先的个人健康记录工具。本政策说明我们如何处理你的信息。
## 我们收集的信息
康康KK 不要求注册账号,不内置广告 SDK不使用第三方分析 SDK也不会主动将你的健康记录上传到我们的服务器。
你可以在 App 内自行记录或导入以下信息:
- 健康指标,例如血压、血糖、血脂、体重等。
- 体检、化验报告或其他健康资料照片。
- 症状记录、健康日记和个人资料。
- 本地提醒设置。
这些信息默认保存在你的设备本地。
## 权限用途
康康KK 可能请求以下系统权限:
- 相机:用于拍摄体检、化验报告或其他健康资料。
- 相册:用于读取你主动选择导入的报告或照片。
- Face ID用于可选的本地 App 启动锁。
- 通知:用于你主动设置的本地提醒。
我们不会因为这些权限而访问与你选择无关的内容。
## AI 模型下载
康康KK 的本地 AI 功能需要下载模型文件。下载模型时App 会连接模型文件服务器获取模型资源。模型下载请求可能包含常规网络信息,例如 IP 地址、请求时间和设备网络环境产生的技术日志。
健康记录、报告照片、症状和日记不会因为下载模型而上传。
## 数据存储
健康记录和导入的资料默认保存在设备本地。App 使用 iOS 系统提供的文件保护能力保护本地文件。你可以在 App 内删除记录;删除后,相关本地数据会从 App 数据库或文件目录中移除。
如果你通过系统备份、迁移或其他第三方工具处理设备数据,相关行为受对应服务或工具的政策约束。
## 数据共享
康康KK 不出售个人数据,不将健康记录用于广告追踪,也不会与第三方广告或分析服务共享你的健康数据。
只有在你主动使用系统分享功能时,相关内容才会由你选择的系统分享目标处理。
## 医疗说明
康康KK 是健康信息记录与整理工具并非医疗器械。App 内的 AI 解读、趋势分析或问答内容仅供日常记录参考,不构成医疗诊断、治疗建议、用药或剂量建议,也不能替代医生、药师或其他专业人员的意见。任何健康决策请咨询专业医疗人员。
## 儿童隐私
康康KK 不面向儿童提供专门服务。未成年人使用本 App 时应取得监护人同意。
## 联系我们
如果你对本隐私政策有疑问,可以通过以下邮箱联系我们:
xuhuayong@gmail.com
## 政策更新
我们可能会根据功能变化或法律要求更新本政策。更新后的政策会在 App 或公开页面中展示。

View File

@@ -0,0 +1,81 @@
# App Store Metadata
## App Name
康康KK
## Subtitle
本地优先的个人健康档案
## Promotional Text
把体检报告、化验指标、症状和日记整理在本机。无需账号,健康数据默认不上传。
## Description
康康KK 是一款本地优先的个人健康记录工具,帮助你把体检报告、化验指标、症状、日记和趋势整理在同一个地方。
你可以手动记录常见健康指标,拍照归档体检或化验报告,在时间线里回顾每次记录,也可以把重点指标加入趋势页,查看长期变化。
主要功能:
- 健康指标记录:记录血压、血糖、血脂、体重等常见指标,也支持自定义指标。
- 报告与照片归档:通过相机或相册导入体检、化验报告照片,保存到本机档案。
- 症状与日记:记录身体感受、症状变化和就医前想补充的信息。
- 趋势回顾:把长期关注的指标加入趋势页,查看变化曲线。
- 本地优先:无需注册账号,健康记录默认保存在设备本地。
- 可选本地 AI下载模型后可在设备本地辅助整理和通俗解释健康记录。
隐私与安全:
康康KK 不提供账号系统,不内置广告或第三方分析 SDK。健康数据默认保存在你的设备上。相机和相册权限仅用于导入你选择的报告或照片Face ID 可用于本地 App 启动锁。
重要说明:
康康KK 是健康信息记录与整理工具并非医疗器械。App 内的任何 AI 解读、趋势分析或问答内容仅供日常记录参考,不构成医疗诊断、治疗建议、用药或剂量建议,也不能替代医生、药师或其他专业人员的意见。任何健康决策请咨询专业医疗人员,并以原始报告和专业意见为准。
## Keywords
健康记录,体检报告,化验单,血压,血糖,健康档案,症状记录,健康日记,本地AI,隐私
## What's New
首次发布:支持健康指标、症状、日记和体检/化验报告的本地记录与趋势查看。
## Support URL
TODO: Add a public support URL before App Store submission.
## Privacy Policy URL
TODO: Add a public privacy policy URL before App Store submission.
## Category
Primary: Medical
Secondary: Health & Fitness
## Age Rating Notes
No gambling, no unrestricted web access, no user-generated public content, no commerce, no alcohol/tobacco/drug promotion, no medical treatment instructions. The app stores personal health records and includes medical disclaimers.
## App Review Notes
No login is required.
KangkangKK is a local-first personal health record app. It is not a medical device and does not provide diagnosis, treatment, medication, dosage, emergency triage, or doctor appointment services.
Suggested review steps:
1. Launch the app.
2. Tap the center + button.
3. Add a manual health metric, symptom, or diary entry.
4. View saved entries in the Records tab.
5. View charts in the Trends tab.
6. Open Me > About to review privacy and medical disclaimer information.
Camera and photo library permissions are used only when the reviewer chooses to import photos of lab reports or health documents. The app stores user records locally on device.
AI features are optional. They require downloading local models from the Model Management page and may require a higher-memory device. If the models are not downloaded, the app will show a model-not-ready state; this is expected and does not block the core record-management flows.

View File

@@ -413,3 +413,18 @@ Output:
| **合计** | **~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% 杜绝小模型臆造;后续可加生成后数值校验。

View File

@@ -0,0 +1,146 @@
# 自由周期提醒(CustomReminder)— 设计文档
**日期**:2026-05-30(W2)
**作者**:link2026 + Claude
**关联卖点**:#4 隐私三件套之外的实用粘性功能(本地通知,无云)
**优先级**:用户明确要求(注:§10.6「用药提醒」原列默认不做,本轮经讨论确认要做,按最小可用实现)
---
## 1. 一句话定位
让用户新建**自由文案的周期性本地提醒**(如「每天 20:00 跑步 5 公里」「每天 12:30 吃 2 片护肝片」),与现有「指标记录提醒」(去录某项指标)并存但相互独立。完全本地 `UserNotifications`,不引云。
---
## 2. 已确认的设计决策
| 决策点 | 选择 |
|---|---|
| 模型 | 新建独立 `CustomReminder` @Model,不动现有 `MetricReminder` |
| 周期粒度 | **每日 / 每周选几天 / 每月某日 / 每年某月某日**(2026-05-30 用户反转原「不做按月/按年」决策)。仍不做「每 N 天间隔」/一次性 |
| 时间选择 | 常用时间快捷预设(8:00/12:00/18:00/22:00 chip)+ 保留 `DatePicker` 精调 |
| 入口 | 新建 → 开启一个提醒 → `RemindersListView`(提醒中心),顶部「+ 新建提醒」打开编辑 sheet |
| 列表范围 | 自由提醒 + 指标提醒**合展**(上次删了「我的」入口,指标提醒也只能从这里管) |
| 量词(5公里/2片) | 写在自由文本 `title` 里,不单设字段 |
| 多语言 | 所有固定文案走 `String(appLoc:)`,新增中文 key 补 en/ja/ko 到 `Localizable.xcstrings` |
---
## 3. 数据模型
`Models/Models.swift` 新增:
```swift
@Model final class CustomReminder {
enum Frequency: String { case daily, weekly, monthly, yearly } //
@Attribute(.unique) var id: UUID
var title: String // :"5"
var note: String //
var hour: Int // 0...23
var minute: Int // 0...59
var weekdays: [Int] // 1=7=, weekly ( MetricReminder )
var frequencyRaw: String = "daily" // Frequency ( )
var dayOfMonth: Int = 1 // monthly / yearly ,1...31
var month: Int = 1 // yearly ,1...12
var enabled: Bool
var createdAt: Date
var updatedAt: Date
// computed: frequency(get/set frequencyRaw)/ isEveryDay / frequencyLabel()/ timeLabel
}
```
Schema 已含 `CustomReminder.self`。**本轮只给已存在的 `CustomReminder` 加 3 个带内联默认值的属性 → SwiftData 自动轻量迁移,不触发删库兜底(见 §10)。**
四档语义 → iOS `UNCalendarNotificationTrigger(repeats:true)`:
| 频率 | DateComponents | 通知数 | id 后缀 |
|---|---|---|---|
| daily | hour,minute | 1 | `.daily` |
| weekly | hour,minute,weekday ×N | N | `.w<weekday>` |
| monthly | day,hour,minute | 1 | `.monthly` |
| yearly | month,day,hour,minute | 1 | `.yearly` |
边界:iOS 重复触发**不顺延**。monthly 选 29/30/31 → 无此日的月份跳过(UI 给浅色提示);yearly 的「日」选项按所选月份最大天数动态收口(避免「4月31日」永不触发),仅闰年 2/29 给提示。
---
## 4. 通知调度(ReminderService 泛化)
抽出私有共享核心,两种提醒复用:
```swift
private static func schedule(idBase:title:body:hour:minute:weekdays:thread:) async
static func sync(_ custom: CustomReminder) async //
static func cancel(customId: UUID) //
static func sync(_ metric: MetricReminder) async // ,,
```
- custom 通知:`title` = 提醒标题,`body` = 备注(空则用默认文案「到点啦,记得完成」)。
- id 前缀 `kangkang.custom.<uuid>.w<weekday>`(与指标的 `kangkang.reminder.<metricId>.w<weekday>` 不冲突)。
- 保存时调 `requestAuthorization()`;被拒则提示去系统设置。
---
## 5. UI
### 5.1 `CustomReminderEditSheet`(新增)
创建 / 编辑共用。字段:
- 标题 TextField(占位:「做点什么?例:跑步5公里 / 吃2片护肝片」),空标题禁用保存。
- 备注 TextField(可选)。
- 时间 DatePicker(.hourAndMinute)。
- 周几选择(复用 RemindersListView 的 chip 行)。
- 保存 / 取消;编辑态多一个「删除提醒」。
保存:写 SwiftData → 请求通知权限 → `ReminderService.sync(custom)`
### 5.2 `RemindersListView`(改造为提醒中心)
- 顶部「+ 新建提醒」按钮 → 打开 `CustomReminderEditSheet`(create)。
- 「我的提醒」区:`@Query CustomReminder`,每行点开走编辑 sheet,行上 Toggle 控 enabled。
- 「指标记录提醒」区:`@Query MetricReminder`,保持现有内联编辑不变(仅非空时显示区头)。
- 表头副文案、空状态文案更新。
---
## 6. 多语言
新增中文 key + en/ja/ko 译文写入 `Localizable.xcstrings`(源语言 zh-Hans,key 即中文)。脚本只增不改,已存在的 key 跳过。复用已有 key:时间/保存/取消/删除提醒/每天/已关闭/周几名等。用户输入的标题/备注是数据,不翻译。
---
## 7. 文件清单
| 文件 | 改动 |
|---|---|
| `Models/Models.swift` | `CustomReminder` +`Frequency` 枚举 +`frequencyRaw/dayOfMonth/month`(均带内联默认)+ 分档 `frequencyLabel` |
| `App/KangkangApp.swift` | **持久化兜底改造**:迁移失败时由「删库」改为「挪到 `StoreBackups/<时间戳>/` 再重建」(见 §10) |
| `Services/ReminderService.swift` | 调度核心泛化为 `Slot(suffix,DateComponents)` 列表;custom sync 按 frequency 分档;`cancelBase` 覆盖 daily/monthly/yearly/w1-7 |
| `Features/Me/CustomReminderEditSheet.swift` | 频率分段 Picker + 各档子控件(周几 / 日 / 月+日)+ 时间快捷预设行 |
| `Features/Me/RemindersListView.swift` | 不变(`frequencyLabel` 来自模型) |
| `Localizable.xcstrings` | 新增 11 个 key × en/ja/ko |
---
## 8. 红线对齐
- 不引云、不碰密码学(纯本地通知)✅
- 不重构 Tab/RecordSheet 骨架 ✅
- §10.6「用药提醒默认不做」→ 已讨论确认,最小实现(无贪睡/铃声/间隔)✅
---
## 9. 验收(真机)
① 新建「每天 20:00 跑步 5 公里」→ 列表出现 → 到点收到本地通知(标题=跑步5公里);② 改时间/周几即时重排;③ 关闭 Toggle 取消通知;④ 删除清除 pending;⑤ 切换语言后固定文案随之变化(用户输入文案不变);⑥ 指标提醒仍在同一列表可管;⑦ **每月/每年**:切频率后子控件随之变化,边界提示出现;改频率后旧档 pending 通知被清掉(不留孤儿);⑧ **时间预设**:点 8:00/12:00/18:00/22:00 即填,精调仍可用。
---
## 10. 顺带修复:重打包数据丢失(根因 + 方案)
**问题**:Demo 期每次改 schema 重打包,SwiftData 数据被清空。
**根因(单点)**:`App/KangkangApp.swift``ModelContainer` 创建 catch 块**直接删 store 文件**。SwiftData 只对**纯增量**改动自动轻量迁移;一旦某次改动超纲(最常见:给已存在的 `@Model` 新增「非可选且无内联默认值」的属性),自动迁移抛错 → 落入 catch → 删库。W2 几乎每次都在改 schema,故体感「每次都丢」。
**方案(两层)**:
1. **治本**:新增 `@Model` 属性一律「可选」或「内联默认值」(本轮 3 个新字段都给了 `= "daily"` / `= 1`)→ 走轻量迁移、不进 catch、数据保留。
2. **兜底**:catch 不再删库,改为把旧 store(含 `-wal`/`-shm`)**挪到 `Application Support/StoreBackups/<时间戳>/`** 再重建——App 仍能启动,旧数据可手动恢复;挪不动才降级删除。
⚠️ 正式发布前仍应升级为 `VersionedSchema` + `SchemaMigrationPlan` 的正式迁移(注释已就地标注)。

View File

@@ -0,0 +1,130 @@
# Face ID 启动锁 — 设计文档
**日期**:2026-05-30(W2)
**作者**:link2026 + Claude
**关联卖点**:#4 隐私三件套(系统级加密 + Face ID + 永久删除)
**优先级**:P1(CLAUDE.md §6 / §8 / §11,原排期 W5 末,提前实现)
---
## 1. 一句话定位
可选的 Face ID/Touch ID 启动锁(默认关)。开启后,冷启动与「后台超过 1 分钟再回前台」都需要系统认证才能进入 App;失败可用设备密码兜底。完全基于系统 `LocalAuthentication`,不自造任何密码学(对齐红线 §10.2)。
---
## 2. 设计决策(已与用户确认)
| 决策点 | 选择 |
|---|---|
| 锁屏时机 | 冷启动 + 后台超过宽限才重锁 |
| 后台宽限 | 60 秒 |
| 认证策略 | `.deviceOwnerAuthentication`(Face ID/Touch ID 优先,自动跳设备密码兜底,避免锁死) |
| 默认状态 | 关(§6) |
| 开关位置 | 「我的」Tab 现有的 Face ID 卡,改为可交互 Toggle |
| 任务切换器隐私遮罩 | 加,**仅锁开启时生效**(进 `.inactive`/`.background` 盖品牌遮罩,防多任务快照泄露;默认关用户无感) |
**关于 §6「截屏黑屏防护…不做」**:那条针对的是**截图防护**(iOS 无官方 API);本设计的任务切换器遮罩是 `.inactive` 盖视图,是官方支持的标准做法,性质不同。
---
## 3. 架构
```
KangkangApp
└─ WindowGroup { AppLockContainer { RootView() } } ← 仅包一层,RootView 零改动(§10.7)
┌─────────────┴──────────────────────────────┐
│ AppLockContainer<Content> │
│ @Environment(\.scenePhase) │
│ 渲染 content │
│ .overlay { if isLocked → LockScreen}│
│ .overlay { else if showsCover → PrivacyCover}│
│ onAppear → handleAppear(); │
│ onChange(scenePhase) → handleScenePhase() │
└─────────────────────────────────────────────┘
│ 读写
┌─────────────┴──────────────────────────────┐
│ AppLock.shared (@MainActor @Observable) │ ← Security/AppLock.swift
│ enabled ←→ UserDefaults("faceIDLockEnabled")│
│ isLocked / showsPrivacyCover │
│ biometryAvailable / biometryLabel │
│ gracePeriod = 60s,lastBackgroundedAt │
│ authenticate() / enableWithAuth() / disable()│
└──────────────────────────────────────────────┘
```
单例写法与项目既有 `ModelDownloadService.shared` 一致(`@MainActor @Observable final class` + `static let shared`)。
---
## 4. 触发逻辑(状态机)
| scenePhase / 事件 | 行为 |
|---|---|
| 容器 `onAppear`(冷启动) | `enabled` 为真且尚未冷启动锁过 → `isLocked = true` + 触发认证 |
| `.background` | `lastBackgroundedAt = now`;`showsPrivacyCover = enabled` |
| `.inactive`(任务切换器) | `showsPrivacyCover = enabled && !isLocked` |
| `.active` | 隐藏遮罩;若 `enabled && !isLocked && 离开 > 60s``isLocked = true`;若 `isLocked` → 触发认证;清空 `lastBackgroundedAt` |
| 认证成功 | `isLocked = false` |
| 认证失败/取消 | 保持锁定,锁屏提供「解锁」按钮重试(`isAuthenticating` 防重入,不重复弹窗) |
冷启动时 scenePhase 初值为 `.active` 不触发 `onChange`,由 `handleAppear()` 负责冷启动锁;两路触发由 `isAuthenticating` 守卫去重。
---
## 5. 能力探测与兜底
- `refreshAvailability()`:`LAContext.canEvaluatePolicy(.deviceOwnerAuthentication)``biometryAvailable`;读 `biometryType` 决定文案(Face ID / Touch ID / 密码)。
- 设备未设密码/无生物识别 → `biometryAvailable = false`,「我的」开关置灰,副标题「本设备未设置 Face ID 或密码」。
- 认证全程系统弹窗;失败/取消不抛错给 UI,只是停留锁屏。
---
## 6. 文件清单
| 文件 | 改动 |
|---|---|
| `康康/Security/AppLock.swift` | **新增**:单例 + LAContext 封装 + 触发逻辑 |
| `康康/Security/AppLockContainer.swift` | **新增**:包裹层 + scenePhase 驱动 + 两个 overlay |
| `康康/Security/LockScreenView.swift` | **新增**:`LockScreenView` + `PrivacyCoverView` |
| `康康/App/KangkangApp.swift` | `RootView()``AppLockContainer { RootView() }` |
| `康康/Features/Me/MeView.swift` | 静态 Face ID 卡 → 可交互 Toggle 卡 |
| `康康.xcodeproj/project.pbxproj` | 加 `INFOPLIST_KEY_NSFaceIDUsageDescription`(Debug + Release) |
工程用文件系统同步组,新增 `Security/` 下的源文件自动纳入编译,无需手改 pbxproj 注册。
---
## 7. UI
锁屏(`LockScreenView`,全遮罩,走 Tj tokens):
```
🔒 (lock glyph)
康康 已锁定
你的健康档案已加密保护
[ Face ID 解锁 ] ← onAppear 自动触发一次认证;按钮文案随设备能力变
```
隐私遮罩(`PrivacyCoverView`):品牌色底 + app 名,无交互,仅用于遮挡多任务快照。
「我的」Face ID 卡:Toggle 开启时先认证一次(成功才置 `enabled`),关闭直接关。副标题动态:「已开启 · Face ID」/「关闭」/「本设备未设置 Face ID 或密码」。
---
## 8. 红线对齐(CLAUDE.md §10)
- 不自造密码学,只用系统 `LocalAuthentication`
- 默认关,可选开关 ✅
- 不引云 ✅
- 不重构 Tab/RecordSheet 骨架,只加一层包裹 ✅
- 清单内功能(§6/§8/§11 明列 Face ID 启动锁)✅
---
## 9. 测试与验收
- 单元测试价值低(核心是系统弹窗 + scenePhase),不强求;`AppLock` 的宽限判定逻辑可抽纯函数测(可选)。
- **真机验收**:① 开关开启走 Face ID;② 杀进程冷启动需认证;③ 后台 <60s 回来不锁、>60s 回来锁;④ 多任务切换器快照被遮罩;⑤ 关 Face ID 录入(模拟失败)能跳设备密码;⑥ 默认关时全程无感。
- 模拟器:Features → Face ID → Enrolled / Matching Face 可模拟。

View File

@@ -0,0 +1,87 @@
# 异常项快拍(局部小框 + VL 识别)— 设计
> 日期:2026-05-31 · 分支:feat/w2-ai-foundation
> 需求:异常项快拍要拍摄局部,采用小框拍局部,用 Qwen-VL 识别被拍区域→检测项目结构化数据;
> 存储前用户确认;最后只存参数和异常值,可和「记录指标」统一保存。
## 1. 现状与缺口
- `RecordSheet.quick`(标题「异常项快拍」)已存在,但 `RootView.recordFlow(.quick)` 当前直接路由到
`UnifiedCaptureFlow` —— 与「体检报告归档」(`.archive`)完全一样,走的是整页文档扫描,**没有局部小框**,
也会把整份当 `Report` + 原图存档。这与需求(局部 / 只存数值 / 不留图 / 并入指标)不符。
- `Features/Quick/``A1ViewfinderView` / `A2ConfirmView` / `SmartFramer` / `QuickCaptureFlow` /
`A3BatchView` 均为早期 mockup,全树无外部引用(纯孤儿)。`A1ViewfinderView` 有小框引导和 AVFoundation
预览,但**快门未接线**(`capturePhoto()` 从不触发)、**不裁剪**。
## 2. 目标流程
```
RecordSheet(.quick)
→ QuickRegionCaptureFlow(状态机)
├ 真机: RegionCameraView(实时预览 + 居中小框 + 快门 → 裁剪到小框的 UIImage)
└ 模拟器: PhotoPickerSheet(无小框,整图送 VL)
→ CaptureService.recognizeRegion(imageData:) ──actor──► AIRuntime.analyzeReport ─► VLSession
↑ VLPrompts.regionExtraction()
→ QuickRegionConfirmView(逐项可编辑 + 勾选纳入 + 测量时间;异常项高亮置顶)
→ 保存:勾选项各插入一条独立 Indicator(无 Report、无 Asset);ctx.save()
```
红线遵守:UI 不直接调 `AIRuntime`,经 `CaptureService`(§3.1);`AIRuntime` actor 串行(复用既有 VL 路径,
不新增并发);无新增 `@Model`,不触发 SwiftData 迁移。
## 3. 组件
### 3.1 RegionCameraView.swift(新建,取代 A1ViewfinderView)
- AVFoundation 实时预览,`videoGravity = .resizeAspectFill`
- 居中**局部小框**(屏宽 ~84% × 高 ~140pt,虚线框 + 半透明遮罩挖空),提示「把异常项放进框里 · 对准一两行」。
- 底部快门键、顶部取消键。
- 拍照后:`previewLayer.metadataOutputRectConverted(fromLayerRect: 小框rect)` → 归一化裁剪 rect;
先把照片方向 bake 成 `.up`,再按归一化 rect 裁 `CGImage`,回调裁剪后的 `UIImage`
- 相机权限:被拒时显示「去设置开启相机」态。
- 纯函数 `RegionImageCropper.crop(_:normalizedRect:)` + `UIImage.normalizedUp()`,与 View 解耦便于推理/复用。
### 3.2 VLPrompts.regionExtraction()(加进 VLPrompts.swift)
- 说明「这是报告的局部照片,可能只有一两行指标」。
- 严格 JSON,只要 `{"indicators":[{name,value,unit,range,status}]}`,**不要**报告元信息。
- status 由 value 与 range 自判;range 保留原文;不发明指标,看不清整行跳过。
- 2 个 few-shot(单行 / 两行)。
### 3.3 CaptureService.recognizeRegion(imageData: Data)(加进 CaptureService.swift)
- 把 JPEG 写临时文件(`NSTemporaryDirectory`,`.completeFileProtection`),`defer` 删除。
- `prepareVL()``analyzeReport(imageURLs:[temp], prompt: regionExtraction())`
- 新增 `parseIndicatorsJSON(_:)`:复用 `extractJSONObject` + `parseIndicator`,抽出 `indicators` 数组,
返回 `[ParsedReport.ParsedIndicator]`。失败抛 `CaptureError`(UI 回退手动录入)。
### 3.4 QuickRegionCaptureFlow.swift(新建,状态机)
- `Phase { idle, analyzing(UIImage), confirm(items, warning) }`
- 裁剪图 → analyzing → Task:JPEG 编码 → `recognizeRegion` → confirm。
- 30s 超时哨兵 → confirm(空 + warning);各类错误 → confirm(空 + warning)。
- 无 Vault 资产需清理(临时文件已在 service 内删除);取消即关闭。
### 3.5 QuickRegionConfirmView.swift(新建,确认 UI)
- 头部「核对异常项 · 只存数值,不保留照片」+ 内存中的裁剪缩略图(仅核对用,**不持久化**)。
- 测量时间 DatePicker(默认 now)。
- 指标列表:逐项可编辑(name/value/unit/range/status)+ 勾选「纳入保存」。
异常(high/low)项红色高亮、置顶、默认勾选;正常项默认也勾选(用户可取消),体现「只存参数和异常值」由用户掌控。
- 「加一项」手动补充(VL 空结果回退)。
- 底栏:取消 / 保存到记录(N 项)。
### 3.6 RootView 路由
- `.quick → QuickRegionCaptureFlow(onClose:)`(原为 `UnifiedCaptureFlow`)。
### 3.7 清理
- 删除 5 个孤儿 mockup:A1ViewfinderView / A2ConfirmView / SmartFramer / QuickCaptureFlow / A3BatchView。
## 4. 数据落库
- 每个勾选项 → 一条 `Indicator(name,value,unit,range,status,capturedAt,note=nil,pinned=false,seriesKey=nil)`
- 不建 `Report`,不存 `Asset`(原图丢弃)→ 符合「最后只存参数和异常值」。
- 与「记录指标」自由输入路径落库一致(同一 Indicator 表,进记录时间线;不带 seriesKey 不强制进趋势)。
## 5. 取舍
- **裁剪 vs 整图**:需求明确「小框拍局部 / 识别被拍区域」,故真机裁剪到小框(也提升小目标 VL 准确率、降 token)。
模拟器无实时小框 → 退化为整图(与既有 UnifiedCaptureFlow 模拟器退化一致)。
- **不留图**:遵循「只存参数和异常值」与隐私基线,临时文件推理后即删,不写 Vault、不建 Asset。
- **正常项是否保存**:默认全部勾选、异常项高亮,正常项可手动取消 —— 不静默丢弃用户可能想留的读数。
- **不动既有归档流程**:UnifiedCaptureFlow / B3 / C2 不变;本功能只重写 `.quick` 这一条路径。

68
scripts/fetch-qwen3vl.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# 下载 Qwen3-VL-4B-Instruct-4bit(MLX 4bit)全量文件到本地镜像目录,并逐个校验字节数。
# 字节数权威来源:康康/AI/ModelManifest.swift(HF API blobs=true,2026-05 核对)。
# 用法: bash scripts/fetch-qwen3vl.sh
set -uo pipefail
REPO="mlx-community/Qwen3-VL-4B-Instruct-4bit"
BASE="https://huggingface.co/${REPO}/resolve/main"
# 目标 = 康康仓库内的 Models/(已被 .gitignore 忽略,App 旁路导入也认这个目录名)。
# 可用环境变量 KK_MODELS_DIR 覆盖根目录(如指向另一块盘)。
ROOT="${KK_MODELS_DIR:-/Users/xuhuayong/apps/康康/Models}"
DEST="$ROOT/Qwen3-VL-4B-Instruct-4bit"
mkdir -p "$DEST"
# 文件名:期望字节数(与 ModelManifest.swift 的 .vl 清单一一对应)
FILES=(
"config.json:7137"
"model.safetensors:3093767283"
"model.safetensors.index.json:64742"
"tokenizer.json:11422654"
"tokenizer_config.json:5445"
"vocab.json:2776833"
"merges.txt:1671853"
"special_tokens_map.json:613"
"added_tokens.json:707"
"generation_config.json:269"
"chat_template.json:5502"
"chat_template.jinja:5292"
"preprocessor_config.json:782"
"video_preprocessor_config.json:817"
)
fsize() { stat -f%z "$1" 2>/dev/null || echo 0; }
fail=0
for entry in "${FILES[@]}"; do
name="${entry%%:*}"; want="${entry##*:}"; out="$DEST/$name"
if [[ -f "$out" && "$(fsize "$out")" == "$want" ]]; then
echo "SKIP $name (已完整 $want)"; continue
fi
echo "GET $name (期望 $want 字节)"
curl -fL -C - --retry 5 --retry-delay 3 --connect-timeout 30 \
-o "$out" "$BASE/$name" || { echo " !! 下载失败 $name"; fail=1; continue; }
have="$(fsize "$out")"
if [[ "$have" != "$want" ]]; then
echo " !! 字节不符 $name: 实得 $have / 期望 $want"; fail=1
else
echo " OK $name $have"
fi
done
# 大权重额外做 SHA256 校验(HF LFS oid,密码学级,字节数相同也能查出脏数据)。
WEIGHT_SHA="90eeb02604181dbcccd0a30a1f550a4a8928ca7dcbee4aee1449239306cfdfca"
if [[ -f "$DEST/model.safetensors" ]]; then
echo "校验 model.safetensors SHA256(约需 10 余秒)..."
got="$(shasum -a 256 "$DEST/model.safetensors" | awk '{print $1}')"
if [[ "$got" == "$WEIGHT_SHA" ]]; then
echo " ✓ SHA256 匹配"
else
echo " !! SHA256 不符: 实得 $got / 期望 $WEIGHT_SHA"; fail=1
fi
fi
echo "================================================"
total=$(du -sh "$DEST" 2>/dev/null | cut -f1)
echo "目录: $DEST (合计 $total)"
if [[ "$fail" == "0" ]]; then echo "✅ 全部 14 个文件下载并校验通过(权重含 SHA256)"; else echo "❌ 有文件失败,重跑本脚本可断点续传"; fi
exit "$fail"

53
scripts/upload-qwen3vl.sh Normal file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# 把本地 Models/Qwen3-VL-4B-Instruct-4bit/ 的 14 个文件上传到模型分发服务器,
# 使 App 的「模型管理 · 下载」能拉到新 VL 模型(否则用户点下载会 404)。
#
# 服务器:Caddy(file_server browse),web 根 = /srv/models,SSH = root@101.132.124.52。
# App 下载 URL 形如:https://file.myv0.com/Qwen3-VL-4B-Instruct-4bit/<file>
# → openresty(终止 HTTPS)回源到 Caddy :80(root /srv/models)。
# → 所以远端目标目录 = /srv/models/Qwen3-VL-4B-Instruct-4bit/。
#
# 认证:已用 ssh-copy-id 装好本机公钥,走免密 key;脚本内不含任何密码。
# 用法: bash scripts/upload-qwen3vl.sh
set -euo pipefail
LOCAL_DIR="/Users/xuhuayong/apps/康康/Models/Qwen3-VL-4B-Instruct-4bit"
SSH_HOST="root@101.132.124.52"
REMOTE_ROOT="/srv/models"
REMOTE_SUBDIR="Qwen3-VL-4B-Instruct-4bit"
REMOTE_DIR="$REMOTE_ROOT/$REMOTE_SUBDIR"
# 上传前本地完整性自检(逐字节,14 文件全 SKIP 才算齐)。
bash "$(dirname "$0")/fetch-qwen3vl.sh" >/dev/null || { echo "本地文件不完整,先跑 fetch-qwen3vl.sh 修复再上传"; exit 1; }
echo "本地 14 文件校验通过,开始上传 → $SSH_HOST:$REMOTE_DIR/"
ssh -o ConnectTimeout=20 "$SSH_HOST" "mkdir -p '$REMOTE_DIR'"
# rsync 断点续传(-P=--partial --progress),--inplace 适合大文件。
# 注意:macOS 自带 rsync 2.6.9 不支持 --info=progress2,用 -P 即可。
rsync -avP --inplace \
-e "ssh -o ConnectTimeout=20" \
"$LOCAL_DIR/" "$SSH_HOST:$REMOTE_DIR/"
echo "✅ rsync 上传完成,开始远端校验..."
# 远端逐文件大小核对(与本地 ModelManifest 的 14 文件一致)。
ssh "$SSH_HOST" "cd '$REMOTE_DIR' && ls -la && echo '--- 总大小 ---' && du -sh ."
cat <<'TIP'
──────────────────────────────────────────────
上传完成。建议再从公网验证一次(应全部 HTTP 200,content-length 与本地一致):
for f in config.json model.safetensors model.safetensors.index.json \
tokenizer.json tokenizer_config.json vocab.json merges.txt \
special_tokens_map.json added_tokens.json generation_config.json \
chat_template.json chat_template.jinja preprocessor_config.json \
video_preprocessor_config.json; do
curl -sI "https://file.myv0.com/Qwen3-VL-4B-Instruct-4bit/$f" \
| awk -v F="$f" '/^HTTP/{c=$2} tolower($1)=="content-length:"{s=$2} END{printf "%-32s %s %s\n",F,c,s}'
done
旧模型 Qwen2.5-VL-3B 仍在服务器上;确认新版可用后再删旧目录:
ssh root@101.132.124.52 'rm -rf /srv/models/Qwen2.5-VL-3B-Instruct-4bit'
──────────────────────────────────────────────
TIP

View File

@@ -199,11 +199,14 @@
};
};
buildConfigurationList = 5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "康康" */;
developmentRegion = en;
developmentRegion = "zh-Hans";
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
"zh-Hans",
ja,
ko,
);
mainGroup = 5E463CF02FC403BB0089145B;
minimizedProjectReferenceProxies = 1;
@@ -405,14 +408,21 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
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_CFBundleDisplayName = "康康";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -423,24 +433,24 @@
"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;
IPHONEOS_DEPLOYMENT_TARGET = 17.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 = "kangkang.--";
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
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";
TARGETED_DEVICE_FAMILY = "1,2";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
@@ -450,14 +460,21 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
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_CFBundleDisplayName = "康康";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -468,24 +485,24 @@
"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;
IPHONEOS_DEPLOYMENT_TARGET = 17.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 = "kangkang.--";
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
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";
TARGETED_DEVICE_FAMILY = "1,2";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
@@ -495,23 +512,23 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--Tests";
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
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";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/康康.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/康康";
XROS_DEPLOYMENT_TARGET = 26.0;
};
@@ -522,23 +539,23 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--Tests";
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
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";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/康康.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/康康";
XROS_DEPLOYMENT_TARGET = 26.0;
};
@@ -548,23 +565,23 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--UITests";
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
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";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = "康康";
XROS_DEPLOYMENT_TARGET = 26.0;
};
@@ -574,23 +591,23 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--UITests";
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
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";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = "康康";
XROS_DEPLOYMENT_TARGET = 26.0;
};

View File

@@ -1,4 +1,5 @@
import Foundation
import MLX
enum AIRuntimeError: Error, LocalizedError {
case notReady
@@ -7,9 +8,9 @@ enum AIRuntimeError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .notReady: return "AI 模型尚未准备好"
case .modelLoadFailed(let m): return "模型加载失败:\(m)"
case .inferenceFailed(let m): return "推理失败:\(m)"
case .notReady: return String(appLoc: "AI 模型尚未准备好")
case .modelLoadFailed(let m): return String(appLoc: "模型加载失败:\(m)")
case .inferenceFailed(let m): return String(appLoc: "推理失败:\(m)")
}
}
}
@@ -31,27 +32,80 @@ actor AIRuntime {
private var llmSession: LLMSession?
private var vlSession: VLSession?
// MARK: - (§3.1 OOM )
//
// actor , generate() Task;
// analyzeReport await actor,LLM VL,
// GPU App jetsam
//(MEMORY in-flight )
//
// actor (count = 1):( + )
// await acquireGate(), releaseGate()actor
// gateBusy / gateWaiters
private var gateBusy = false
private var gateWaiters: [CheckedContinuation<Void, Never>] = []
private func acquireGate() async {
if !gateBusy {
gateBusy = true
return
}
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
gateWaiters.append(cont)
}
// releaseGate (gateBusy true)
}
private func releaseGate() {
if gateWaiters.isEmpty {
gateBusy = false
} else {
// ,gateBusy true,
let next = gateWaiters.removeFirst()
next.resume()
}
}
private init() {}
/// App : MLX GPU , reuse cache
/// App ( CPU, Metal abort)
/// increased-memory-limit entitlement + LLM/VL , jetsam OOM
nonisolated static func configureMLXMemory() {
#if !targetEnvironment(simulator)
// 256MB cache : 3GB MB
MLX.GPU.set(cacheLimit: 256 * 1024 * 1024)
#endif
}
/// ,
func prepare() async throws {
switch status {
case .ready:
return
case .loading:
// ; prepare ,
// await prepare() status, / UI
// W3 prepare
return
case .error, .notReady:
break
// ,
// return: ready, generate
// `guard status == .ready` ()
while status == .loading {
try await Task.sleep(nanoseconds: 80_000_000)
}
if status == .ready { return }
guard ModelStore.shared.isReady(.llm) else {
// 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(
@@ -73,15 +127,20 @@ actor AIRuntime {
let snapshotSession = llmSession
return AsyncThrowingStream { continuation in
Task {
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)
@@ -91,7 +150,12 @@ actor AIRuntime {
} catch {
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
}
// / / (checkCancellation catch ),
// ,
self.releaseGate()
}
// / Task( LLMSession / HealthExportService )
continuation.onTermination = { _ in task.cancel() }
}
}
@@ -103,18 +167,26 @@ actor AIRuntime {
/// VL , load
func prepareVL() async throws {
switch vlStatus {
case .ready, .loading:
return
case .error, .notReady:
break
while vlStatus == .loading {
try await Task.sleep(nanoseconds: 80_000_000)
}
if vlStatus == .ready { return }
guard ModelStore.shared.isReady(.vl) else {
// 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(
@@ -128,15 +200,36 @@ actor AIRuntime {
}
}
// 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)
/// AIRuntime actor, LLM.generate() , OOM
/// 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,

View File

@@ -7,9 +7,9 @@ enum DownloadError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .badStatus(let code):
return "下载失败(HTTP \(code))"
return String(appLoc: "下载失败(HTTP \(code))")
case .sizeMismatch(let expected, let got):
return "文件大小校验失败(预期 \(expected),实际 \(got))"
return String(appLoc: "文件大小校验失败(预期 \(expected),实际 \(got))")
}
}
}
@@ -74,12 +74,12 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
let fileHandle = try FileHandle(forWritingTo: part)
try fileHandle.seekToEnd()
lock.lock()
self.handle = fileHandle
self.written = offset
self.onProgress = onProgress
self.responseError = nil
lock.unlock()
lock.withLock {
self.handle = fileHandle
self.written = offset
self.onProgress = onProgress
self.responseError = nil
}
var request = URLRequest(url: url)
if offset > 0 {

View File

@@ -10,7 +10,7 @@ struct ModelFile: Equatable, Sendable {
/// , README.md / .gitattributes()
/// ,
/// docs/superpowers/specs/2026-05-29-model-download-design.md A
enum ModelManifest {
nonisolated enum ModelManifest {
/// Caddy ( HTTPS )
/// IP( App ATS ): http://101.132.124.52:5244/
static let baseURL = URL(string: "https://file.myv0.com/")!
@@ -30,18 +30,27 @@ enum ModelManifest {
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: 1_659),
ModelFile(path: "model.safetensors", bytes: 3_073_720_461),
ModelFile(path: "model.safetensors.index.json", bytes: 108_307),
ModelFile(path: "tokenizer.json", bytes: 11_421_896),
ModelFile(path: "tokenizer_config.json", bytes: 7_256),
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: 605),
ModelFile(path: "chat_template.json", bytes: 1_050),
ModelFile(path: "preprocessor_config.json", bytes: 350),
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),
]
}
}

View File

@@ -3,12 +3,12 @@ import Foundation
nonisolated enum ModelKind: String, CaseIterable {
/// HuggingFace mlx-community , Models/
case llm = "Qwen3-1.7B-4bit"
case vl = "Qwen2.5-VL-3B-Instruct-4bit"
case vl = "Qwen3-VL-4B-Instruct-4bit"
var displayName: String {
switch self {
case .llm: return "Qwen3-1.7B"
case .vl: return "Qwen2.5-VL-3B"
case .vl: return "Qwen3-VL-4B"
}
}
@@ -132,7 +132,7 @@ enum ModelStoreError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .missingConfig:
return "所选文件夹缺少 config.json,不是有效的模型目录"
return String(appLoc: "所选文件夹缺少 config.json,不是有效的模型目录")
}
}
}

View File

@@ -0,0 +1,79 @@
import Foundation
/// , LLM 3-4
/// JSON, question dim()+ q()+ fill()
///
/// `dim`( 2026-05-30 prompt ):
/// 1.7B ,
/// , dim,,
/// ,
enum DiaryAssistPrompts {
/// ;UI
/// /, few-shot
static let dimensions: [String] = [
"起病诱因", "症状性质", "伴随症状", "加重缓解",
"持续频率", "既往家族史", "用药过敏", "生活方式",
]
/// - content:
/// - coveredDimensions: (),
///
static func suggest(content: String, coveredDimensions: [String] = []) -> String {
let covered = coveredDimensions.filter { !$0.isEmpty }
let coveredSet = Set(covered)
let allowed = dimensions.filter { !coveredSet.contains($0) }
let allowedLine = allowed.isEmpty ? "(已基本问全)" : allowed.joined(separator: "")
// :1.7B
let scopeRule = covered.isEmpty
? ""
: "\n- 已问过的维度【不要再问】:\(covered.joined(separator: ""))。本轮只能从这些还没问的维度里挑:\(allowedLine)"
return """
你是社区医生的小助手。患者写了一段身体状态的健康记录,信息可能不够完整。
请从医生问诊角度提出 3-4 个最值得追问的问题,帮患者把这条记录补全。
【问诊维度清单】每个问题必须正好归属其中一个,并用 dim 标注:
1. 起病诱因 —— 何时开始、有无诱因
2. 症状性质 —— 部位、性质、程度
3. 伴随症状 —— 是否伴随其他不适
4. 加重缓解 —— 什么情况下加重或缓解
5. 持续频率 —— 持续多久、多频繁、是否反复发作
6. 既往家族史 —— 以前是否有类似、家族相关史
7. 用药过敏 —— 在服药物、过敏史
8. 生活方式 —— 睡眠、饮食、运动习惯、压力
硬性规则:
- 本轮每个问题必须来自【不同】维度,严禁两条落在同一维度(例如不能两条都问"")。\(scopeRule)
- 只问【最新记录】里还没写明的事。方括号 `[xxx]` 表示该话题已被提出、只是细节待填,【不要】再作为新问题重复它。
- 不给诊断、不给用药建议、不写「建议就医」。
- q ≤ 20 字,像真人医生在问;fill 是采纳后追加到原文的中文补充句,可含方括号占位符如 [时间] [部位]。
- 至少 3 条,最多 4 条。
只输出严格 JSON,不要解释、不要 markdown 围栏、不要 <think> 标签。结构:
{"questions":[{"dim":"<>","q":"<>","fill":"<>"}]}
示例 1(第一轮,记录:头痛了一上午):
{"questions":[
{"dim":"","q":"?","fill":" [] ,"},
{"dim":"","q":"?","fill":"/ [//],"},
{"dim":"","q":"?","fill":" [],"},
{"dim":"","q":"?","fill":" [] [],"}
]}
示例 2(后续轮,已覆盖维度:起病诱因、症状性质、伴随症状):
{"questions":[
{"dim":"","q":"?","fill":"[/] [/],"},
{"dim":"","q":"?","fill":"/ [/],"},
{"dim":"","q":"?","fill":" [/,],"}
]}
现在输出 JSON。
本轮可选维度:\(allowedLine)
【最新记录】:
\(content)
Output: /no_think
"""
}
}

View File

@@ -0,0 +1,117 @@
import Foundation
/// LLM prompt:
/// 1. `intentExtraction` + /, JSON
/// 2. `reportGeneration` Markdown
///
/// `HealthExportService`(§3.2 退线:
/// JSON 30 + ,)
enum HealthExportPrompts {
// MARK: -
/// `intentExtraction(userPrompt:)`
/// :
/// ```json
/// {"time_range_days":30,
/// "keywords":["",""],
/// "symptom_keywords":["",""],
/// "intent":"cold_consult",
/// "intent_label_cn":""}
/// ```
static func intentExtraction(userPrompt: String) -> String {
"""
你是健康数据助手。读用户的请求,只输出严格 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
字段说明(全部必填):
{
"time_range_days": int, // 回溯天数,默认 30,最大 365
"keywords": [string], // 指标关键词(中文,如「血压」「血糖」「体温」「肝功」),无则 []
"symptom_keywords": [string], // 症状关键词,无则 []
"intent": string, // 英文 snake_case 标签,如 "cold_consult"
"intent_label_cn": string // 中文短语,会作为报告标题副题,如 ""
}
规则:
- 时间未指定 → 30
- 「最近一个月」→ 30,「最近三个月」→ 90,「最近半年」→ 180
- 关键词要中文,常见健康指标 / 症状词
- intent 简短,4-25 字符,小写下划线
示例 1:
User: 我感冒3天了,要把最近一个月的健康情况给医生看
Output: {"time_range_days":30,"keywords":["","",""],"symptom_keywords":["","","",""],"intent":"cold_consult","intent_label_cn":""}
示例 2:
User: 我最近血糖好像不稳,把上次体检前后的化验单整理一下
Output: {"time_range_days":90,"keywords":["","",""],"symptom_keywords":[],"intent":"glucose_review","intent_label_cn":""}
现在请输出 JSON:
User: \(userPrompt)
Output: /no_think
"""
}
// MARK: -
/// `reportGeneration(userPrompt:intentLabelCN:dataJSON:)` Markdown
static func reportGeneration(userPrompt: String,
intentLabelCN: String,
dataJSON: String) -> String {
let labelLine = intentLabelCN.isEmpty
? "# 就诊摘要"
: "# 就诊摘要 — \(intentLabelCN)"
return """
你是健康数据整理员。任务是把下面【真实数据】(JSON)里**已经存在**的内容,
原样整理成一份给社区医生看的就诊摘要。这是**抽取 / 搬运**任务,不是创作。
【最重要的铁律 —— 违反即失败】
- 只能使用【真实数据】JSON 里**真实出现过**的内容。
- 严禁编造或推测任何数字、日期、症状、药物、检查结果、诊断,哪怕看起来很合理。
- JSON 里没有的信息,对应小节一律写「无记录」,不要补全、不要举例、不要套用常见病例模板。
- 数值必须原样照搬(含单位与参考范围);status 为 high/low/abnormal 的指标前加 ⚠️。
- 「主诉」「患者疑问」可参考【患者原话】,但不得加入原话与数据里都没有的症状。
输出格式:
- 严格 Markdown,标题用 # / ##,不要 markdown 围栏,不要输出 JSON,不写「数据」二字。
- 不给诊断意见、用药建议或「建议就医」。全文中文,简洁,医生 30 秒能扫完。
- 严格按以下 6 段(顺序与标题固定):
\(labelLine)
## 主诉
## 患者背景
## 近期症状(按时间倒序)
## 关键指标(异常项优先)
## 在服药与过敏
## 患者疑问
—— 格式示例(只示范「无记录」与数值写法,内容请勿照抄)——
真实数据:{"profile":{},"symptoms":[],"indicators":[{"name":"","value":"38.5","unit":"","range":"36-37.2","status":"high","date":"2026-05-01"}],"reports":[],"diaries":[],"time_window":{"from":"2026-04-02","to":"2026-05-02"}}
输出:
# 就诊摘要 — 近期健康摘要
## 主诉
无记录
## 患者背景
无记录
## 近期症状(按时间倒序)
无记录
## 关键指标(异常项优先)
⚠️ 体温 38.5 ℃(参考 36-37.2,2026-05-01)
## 在服药与过敏
无记录
## 患者疑问
无记录
—— 示例结束(以上咳嗽/体温等仅示范格式,切勿出现在你的输出里)——
现在,严格根据下面这份【真实数据】生成;数据里没有的就写「无记录」,**禁止编造**:
【真实数据】:
\(dataJSON)
【患者原话】:\(userPrompt)
再次强调:只整理上面【真实数据】里真实出现过的内容,禁止编造任何数字/日期/症状/药物。
直接输出 Markdown,不要思考过程,不要 <think> 标签:
/no_think
"""
}
}

View File

@@ -1,6 +1,6 @@
import Foundation
/// VL (Qwen2.5-VL) / prompt
/// VL (Qwen3-VL) / prompt
/// : JSON,markdown
/// CaptureService 退(§3.2 退线)
enum VLPrompts {
@@ -27,9 +27,21 @@ enum VLPrompts {
/// ```
/// `kind` UI indicators A2() B3()
static let reportExtraction: String = #"""
/// 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,
@@ -52,7 +64,8 @@ JSON schema(严格):
规则:
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"
- range 字段保留原文(如 "< 3.40""3.9 - 6.1""0 - 5"),不要解析成区间对象。
- 无法识别的字段填空字符串(institution / summary)或合理默认值(report_date 用今天)
- 无法识别的字段填空字符串(institution / summary)。
- report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。
- 不要发明指标。看不清的整行跳过。
- 化验单一般 type = "lab",体检套餐 = "checkup"
@@ -67,5 +80,56 @@ JSON schema(严格):
{"title":"","type":"checkup","report_date":"2026-04-12","institution":"","page_count":1,"summary":"","indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"},{"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:
"""#
}

View File

@@ -3,7 +3,7 @@ import MLX
import MLXVLM
import MLXLMCommon
/// MLX VL (Qwen2.5-VL)
/// MLX VL (Qwen3-VL)
/// LLMSession actor , AIRuntime
actor VLSession {
let container: ModelContainer

View File

@@ -3,6 +3,13 @@ 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,
@@ -15,31 +22,84 @@ struct KangkangApp: App {
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 ModelContainer(for: schema, configurations: [config])
return try makeContainer()
} catch {
// Demo schema : store schema ,
// store ()
// SwiftData ,
print("⚠️ ModelContainer 创建失败,重置本地 store 重建: \(error)")
let fm = FileManager.default
let storePath = config.url.path
for path in [storePath, storePath + "-wal", storePath + "-shm"] {
try? fm.removeItem(atPath: path)
}
// Demo schema : SwiftData
// (: @Model ),
// , store -wal/-shm
// App ,()
// VersionedSchema + SchemaMigrationPlan
// : @Model ,
print("⚠️ ModelContainer 创建失败,备份旧 store 后重建: \(error)")
KangkangApp.backupIncompatibleStore(at: config.url)
do {
return try ModelContainer(for: schema, configurations: [config])
return try makeContainer()
} catch {
fatalError("Could not create ModelContainer even after reset: \(error)")
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 {
RootView()
AppLockContainer {
RootView()
.environment(\.locale, lang.locale)
.id(lang.current) // ,
}
}
.modelContainer(sharedModelContainer)
}

View File

@@ -0,0 +1,152 @@
import SwiftUI
import ObjectiveC
/// App `system` = ; .lproj / String Catalog
enum AppLanguage: String, CaseIterable, Identifiable {
case system
case zhHans = "zh-Hans"
case en
case ja
case ko
var id: String { rawValue }
/// ****(,), App
var displayName: String {
switch self {
case .system: return String(appLoc: "跟随系统")
case .zhHans: return "简体中文"
case .en: return "English"
case .ja: return "日本語"
case .ko: return "한국어"
}
}
/// nil = ; .lproj / Locale
var localeIdentifier: String? {
self == .system ? nil : rawValue
}
/// ( / A / / ),
/// , `displayName`
enum PickerIcon: Equatable {
case symbol(String) // SF Symbol
case glyph(String) //
}
var pickerIcon: PickerIcon {
switch self {
case .system: return .symbol("globe")
case .zhHans: return .glyph("")
case .en: return .glyph("A")
case .ja: return .glyph("")
case .ko: return .glyph("")
}
}
}
/// App : lproj bundle locale
/// - `Text("")` `\.locale`(+ Bundle );
/// - `String(appLoc:)` bundle/locale, `.current` ,
/// `.id(current)` ,
@Observable
final class LanguageManager {
static let shared = LanguageManager()
private let storageKey = "appLanguage"
private(set) var current: AppLanguage
/// .lproj bundle(system .main),
private(set) var lprojBundle: Bundle = .main
/// locale(system .autoupdatingCurrent)
private(set) var resolvedLocale: Locale = .autoupdatingCurrent
/// SwiftUI 使(/ + Text )
var locale: Locale { resolvedLocale }
private init() {
let saved = UserDefaults.standard.string(forKey: storageKey)
current = AppLanguage(rawValue: saved ?? "") ?? .system
apply()
}
func set(_ language: AppLanguage) {
guard language != current else { return }
current = language
UserDefaults.standard.set(language.rawValue, forKey: storageKey)
// AppleLanguages:, App
if let id = language.localeIdentifier {
UserDefaults.standard.set([id], forKey: "AppleLanguages")
} else {
UserDefaults.standard.removeObject(forKey: "AppleLanguages")
}
apply()
}
private func apply() {
if let id = current.localeIdentifier {
resolvedLocale = Locale(identifier: id)
if let path = Bundle.main.path(forResource: id, ofType: "lproj"),
let b = Bundle(path: path) {
lprojBundle = b
} else {
lprojBundle = .main
}
} else {
resolvedLocale = .autoupdatingCurrent
lprojBundle = .main
}
Bundle.redirectMain(to: current.localeIdentifier)
// nonisolated , String(appLoc:) MainActor
appLocBundle = lprojBundle
appLocLocale = resolvedLocale
}
}
/// nonisolated :`String(appLoc:)` MainActor
/// (LocalizedError.errorDescriptionnonisolated labelstatic )
/// `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)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 B

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -1,61 +0,0 @@
import SwiftUI
private enum ArchiveStep: Hashable {
case guide
case scan
case meta
case progress
case result
}
struct ArchiveFlow: View {
var onClose: () -> Void
@State private var step: ArchiveStep = .guide
@State private var capturedPages: Int = 1
@State private var totalPages: Int = 3
var body: some View {
ZStack {
switch step {
case .guide:
B1GuideView(
onSingle: { withAnimation { totalPages = 1; step = .scan } },
onMulti: { withAnimation { totalPages = 3; step = .scan } },
onSkip: onClose
)
.transition(.opacity)
case .scan:
B2ScanView(
onShoot: { capturedPages = min(capturedPages + 1, totalPages) },
onDone: { withAnimation { step = .meta } },
onClose: onClose,
page: capturedPages,
total: totalPages
)
.transition(.opacity)
case .meta:
B3MetaView(
onAnalyze: { withAnimation { step = .progress } },
onBack: { withAnimation { step = .scan } }
)
.transition(.opacity)
case .progress:
B4ProgressView(onComplete: {
withAnimation { step = .result }
})
.transition(.opacity)
case .result:
B5ResultView(
onSave: onClose,
onBack: { withAnimation { step = .meta } }
)
.transition(.opacity)
}
}
}
}

View File

@@ -14,8 +14,24 @@ struct ArchiveListView: View {
@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] {
@@ -35,12 +51,30 @@ struct ArchiveListView: View {
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)
@@ -71,12 +105,21 @@ struct ArchiveListView: View {
.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: {
@@ -84,26 +127,131 @@ struct ArchiveListView: View {
}
.buttonStyle(.plain)
} else {
TimelineRow(entry: entry)
// (///):
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 ? "" : "\(totalCount)")
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: "全部", selected: filter == nil) { filter = nil }
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
@@ -152,9 +300,9 @@ struct ArchiveListView: View {
private var emptyState: some View {
VStack(spacing: 14) {
Spacer()
TjPlaceholder(label: "还没有任何记录\n点底部 + 号开始")
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
.frame(width: 240, height: 140)
Text(filter == nil ? "记录会按时间归类显示" : "这个类别下没有记录")
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text3)
Spacer()
@@ -166,6 +314,8 @@ struct ArchiveListView: View {
#Preview {
ArchiveListView()
.modelContainer(for: [
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self,
HealthExport.self, ChatTurn.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self
], inMemory: true)
}

View File

@@ -1,131 +0,0 @@
import SwiftUI
struct B1GuideView: View {
var onSingle: () -> Void
var onMulti: () -> Void
var onSkip: () -> Void
var body: some View {
VStack(spacing: 0) {
HStack {
Button(action: onSkip) {
Image(systemName: "xmark")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
}
Spacer()
Button(action: onSkip) {
Text("跳过")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.padding(8)
}
}
.padding(.horizontal, 12)
VStack(alignment: .leading, spacing: 0) {
ZStack {
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.ink)
Image(systemName: "doc.text.fill")
.font(.system(size: 26, weight: .medium))
.foregroundStyle(Tj.Palette.paper)
}
.frame(width: 60, height: 60)
.padding(.bottom, 18)
Text("归档一份\n关键报告")
.font(.system(size: 30, weight: .bold))
.lineSpacing(6)
.foregroundStyle(Tj.Palette.text)
.padding(.bottom, 12)
Text("推荐拍清晰的\(Text("整张图").underline()),多页报告可一次完成扫描。原图与解读全部本地加密保存,永不上传。")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
.lineSpacing(6)
.padding(.bottom, 26)
VStack(spacing: 12) {
OptCard(title: "单张报告", sub: "一张图,几秒搞定", hint: "化验单 · 处方", badge: nil, action: onSingle)
OptCard(title: "多页报告", sub: "像扫描文档一样翻页拍摄", hint: "体检报告 · 影像报告", badge: "推荐", action: onMulti)
}
Spacer(minLength: 18)
HStack(alignment: .top, spacing: 10) {
Image(systemName: "lock.fill")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text2)
.padding(.top, 2)
Text("所有照片以 AES 加密存于本机沙盒。康康 服务端无法访问。")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text2)
.lineSpacing(4)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
)
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 20)
}
.background(Tj.Palette.sand.ignoresSafeArea())
}
}
private struct OptCard: View {
let title: String
let sub: String
let hint: String
let badge: String?
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
Image(systemName: "doc.text")
.font(.system(size: 18, weight: .regular))
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 8) {
Text(title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
if let badge {
TjBadge(text: badge, style: .ink)
}
}
Text("\(sub) · \(hint)")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(16)
.tjCard(bordered: true)
}
.buttonStyle(.plain)
}
}
#Preview {
B1GuideView(
onSingle: { print("单张报告") },
onMulti: { print("多页报告") },
onSkip: { print("跳过") }
)
}

View File

@@ -1,198 +0,0 @@
import SwiftUI
struct B2ScanView: View {
var onShoot: () -> Void
var onDone: () -> Void
var onClose: () -> Void
var page: Int = 2
var total: Int = 3
var body: some View {
ZStack {
Color(red: 0.04, green: 0.047, blue: 0.04).ignoresSafeArea()
mockPaper
DetectedEdge()
.stroke(Color(red: 0.95, green: 0.78, blue: 0.45),
style: StrokeStyle(lineWidth: 2, dash: [6, 4]))
.opacity(0.95)
.padding(.horizontal, 30)
.padding(.top, 140)
.padding(.bottom, 200)
.allowsHitTesting(false)
VStack(spacing: 0) {
topBar
Spacer()
detectedBadge
Spacer()
thumbnails
bottomControls
}
}
.preferredColorScheme(.dark)
}
private var mockPaper: some View {
VStack(spacing: 2) {
Text("体 检 报 告 (第 \(page) 页)")
.font(.system(size: 12, weight: .bold))
.padding(.bottom, 4)
ForEach(reportRows, id: \.0) { row in
HStack {
Text(row.0).frame(maxWidth: .infinity, alignment: .leading)
Text(row.1)
Text(row.2).foregroundStyle(Tj.Palette.text3)
}
.font(.system(size: 9, design: .monospaced))
}
}
.padding(16)
.foregroundStyle(Tj.Palette.text)
.frame(maxWidth: .infinity)
.background(Color(red: 0.97, green: 0.95, blue: 0.89).opacity(0.95))
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
.rotation3DEffect(.degrees(8), axis: (x: 1, y: 0, z: 0))
.rotationEffect(.degrees(-1))
.shadow(color: .black.opacity(0.6), radius: 20, x: 0, y: 12)
.padding(.horizontal, 40)
.padding(.top, 160)
.padding(.bottom, 220)
}
private var reportRows: [(String, String, String)] {
[
("总胆固醇", "5.42", "3.105.18"),
("甘油三酯", "1.78", "0.451.70"),
("低密度脂蛋白", "3.84↑", "<3.40"),
("高密度脂蛋白", "1.21", ">1.04"),
("载脂蛋白 A1", "1.42", "1.001.60"),
("载脂蛋白 B", "1.04", "0.551.05"),
("谷丙转氨酶", "28", "950"),
("谷草转氨酶", "24", "1540"),
("空腹血糖", "5.4", "3.96.1"),
("糖化血红蛋白", "5.7", "4.06.0"),
]
}
private var topBar: some View {
HStack {
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white)
.frame(width: 36, height: 36)
}
Spacer()
HStack(spacing: 4) {
Text("\(page)").font(.system(size: 12, design: .monospaced))
Text(" / \(total) · 像扫描文档一样对准")
.font(.system(size: 12))
}
.foregroundStyle(Color.white)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(Capsule().fill(Color(red: 0.08, green: 0.11, blue: 0.094).opacity(0.7)))
Spacer()
Color.clear.frame(width: 36, height: 36)
}
.padding(.horizontal, 6)
.padding(.top, 50)
}
private var detectedBadge: some View {
Text("已识别边框 · 将自动透视校正")
.font(.system(size: 10, weight: .semibold))
.tracking(0.4)
.foregroundStyle(Color(red: 0.10, green: 0.115, blue: 0.094))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Capsule().fill(Color(red: 0.95, green: 0.78, blue: 0.45)))
.padding(.top, 140)
}
private var thumbnails: some View {
HStack {
PageThumbStack(index: 1)
Spacer()
Text("已拍 1 页")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Color.white.opacity(0.7))
}
.padding(.horizontal, 18)
.padding(.bottom, 24)
}
private var bottomControls: some View {
HStack {
Color.clear.frame(width: 60, height: 60)
Spacer()
Button(action: onShoot) {
ZStack {
Circle().fill(Tj.Palette.paper)
Circle().strokeBorder(Color.white.opacity(0.4), lineWidth: 4)
}
.frame(width: 72, height: 72)
}
.buttonStyle(.plain)
Spacer()
Button(action: onDone) {
Text("完成")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Capsule().fill(Color.white.opacity(0.1)))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 32)
.padding(.bottom, 40)
}
}
private struct DetectedEdge: Shape {
func path(in rect: CGRect) -> Path {
var p = Path()
let w = rect.width
let h = rect.height
p.move(to: CGPoint(x: w * 0.04, y: h * 0.05))
p.addLine(to: CGPoint(x: w * 0.92, y: h * 0.02))
p.addLine(to: CGPoint(x: w * 0.96, y: h * 0.96))
p.addLine(to: CGPoint(x: 0, y: h * 1.0))
p.closeSubpath()
return p
}
}
struct PageThumbStack: View {
let index: Int
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.7))
.frame(width: 56, height: 76)
.rotationEffect(.degrees(2))
.offset(x: 4, y: 4)
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2)
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(Color(red: 0.97, green: 0.95, blue: 0.89).opacity(0.85))
.frame(width: 56, height: 76)
.rotationEffect(.degrees(-1))
.offset(x: 2, y: 2)
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2)
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(Tj.Palette.paper)
.frame(width: 56, height: 76)
.overlay(
Text("p.\(index)")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
)
.shadow(color: .black.opacity(0.4), radius: 4, x: 0, y: 2)
}
.frame(width: 64, height: 84, alignment: .topLeading)
}
}

View File

@@ -1,137 +0,0 @@
import SwiftUI
struct B3MetaView: View {
var onAnalyze: () -> Void
var onBack: () -> Void
@State private var selectedType = 0
private let types = ["体检报告", "化验单", "影像报告", "处方", "其他"]
var body: some View {
VStack(spacing: 0) {
header
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
Text("报告类型")
.font(.system(size: 11))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
.padding(.bottom, 8)
typeChips.padding(.bottom, 20)
FormRow(label: "报告日期", value: "2026 / 05 / 25", subtle: false)
FormRow(label: "出具机构", value: "协和医院体检中心", subtle: true)
FormRow(label: "备注", value: "春季年度体检", subtle: true)
Text("已拍页面3 页)")
.font(.system(size: 11))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
.padding(.top, 20)
.padding(.bottom, 10)
HStack(spacing: 10) {
ForEach(1...3, id: \.self) { n in
PageCard(index: n)
}
}
}
.padding(.horizontal, 18)
.padding(.bottom, 18)
}
VStack(spacing: 8) {
Button(action: onAnalyze) {
Text("开始 AI 解读").frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
Text("预计耗时 58 秒 · 端侧 SME2 加速")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 18)
.padding(.bottom, 14)
}
.background(Tj.Palette.sand.ignoresSafeArea())
}
private var header: some View {
HStack(spacing: 6) {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
}
Text("归档信息")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Spacer()
}
.padding(.horizontal, 12)
.padding(.top, 4)
.padding(.bottom, 8)
}
private var typeChips: some View {
let columns = [GridItem(.adaptive(minimum: 60, maximum: 200), spacing: 8)]
return LazyVGrid(columns: columns, alignment: .leading, spacing: 8) {
ForEach(Array(types.enumerated()), id: \.offset) { idx, t in
Button { selectedType = idx } label: {
Text(t)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(idx == selectedType ? Tj.Palette.paper : Tj.Palette.text2)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule().fill(idx == selectedType ? Tj.Palette.ink : Tj.Palette.sand2)
)
}
.buttonStyle(.plain)
}
}
}
}
private struct FormRow: View {
let label: String
let value: String
let subtle: Bool
var body: some View {
HStack {
Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text2)
Spacer()
HStack(spacing: 6) {
Text(value)
.font(.system(size: 13))
.foregroundStyle(subtle ? Tj.Palette.text3 : Tj.Palette.text)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
}
.padding(.vertical, 12)
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
}
private struct PageCard: View {
let index: Int
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06),
radius: 2, x: 0, y: 1)
TjPlaceholder(label: "p.\(index)", radius: 4)
.padding(6)
}
.aspectRatio(0.72, contentMode: .fit)
}
}

View File

@@ -1,293 +0,0 @@
import SwiftUI
struct B4ProgressView: View {
var onComplete: () -> Void
@State private var step: Int = 1
@State private var pulse = false
@State private var glow = false
@State private var rotate: Double = 0
@State private var elapsed: Double = 0.2
private let lineLabels = [
"正在本地识别第 1 / 3 页…",
"正在本地识别第 2 / 3 页…",
"正在本地识别第 3 / 3 页…",
"提取指标 · 共 28 项",
"生成整体摘要…",
]
var body: some View {
ZStack {
backgroundGradient.ignoresSafeArea()
VStack(spacing: 0) {
Spacer()
chip.padding(.bottom, 36)
Text("本地 AI · 正在解读")
.font(.system(size: 22, weight: .semibold))
.tracking(1)
.foregroundStyle(Color.white.opacity(0.95))
.padding(.bottom, 6)
Text("QWEN2.5-VL · ON-DEVICE · SME2")
.font(.system(size: 11, design: .monospaced))
.tracking(0.5)
.foregroundStyle(Color.white.opacity(0.55))
.padding(.bottom, 30)
lineList
.padding(.horizontal, 28)
speedBadge.padding(.top, 32)
Spacer()
Text("本地处理中 · 不会上传任何内容")
.font(.system(size: 10, design: .monospaced))
.tracking(0.5)
.foregroundStyle(Color.white.opacity(0.45))
.padding(.bottom, 30)
}
.padding(.horizontal, 28)
}
.preferredColorScheme(.dark)
.onAppear { startAnimations() }
}
private var backgroundGradient: some View {
RadialGradient(
colors: [
Color(red: 0.22, green: 0.21, blue: 0.18),
Color(red: 0.13, green: 0.12, blue: 0.10),
Color(red: 0.08, green: 0.075, blue: 0.06),
],
center: .init(x: 0.5, y: 0.3),
startRadius: 60,
endRadius: 700
)
}
private var chip: some View {
ZStack {
Circle()
.fill(Color(red: 0.93, green: 0.75, blue: 0.40).opacity(glow ? 0.18 : 0.0))
.frame(width: 176, height: 176)
.blur(radius: 30)
Circle()
.strokeBorder(Color.white.opacity(0.18),
style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
.frame(width: 140, height: 140)
.rotationEffect(.degrees(rotate))
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(
LinearGradient(
colors: [Color(red: 0.36, green: 0.34, blue: 0.30),
Color(red: 0.22, green: 0.21, blue: 0.18)],
startPoint: .topLeading, endPoint: .bottomTrailing
)
)
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1)
)
.frame(width: 96, height: 96)
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 12)
.overlay(ChipGlyph())
.overlay(alignment: .topTrailing) {
Circle()
.fill(Color(red: 0.95, green: 0.78, blue: 0.40))
.frame(width: 6, height: 6)
.opacity(pulse ? 1 : 0.35)
.shadow(color: Color(red: 0.95, green: 0.78, blue: 0.40), radius: 6)
.padding(10)
}
.scaleEffect(pulse ? 1.06 : 1.0)
.opacity(pulse ? 0.92 : 1.0)
}
}
private var lineList: some View {
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(lineLabels.enumerated()), id: \.offset) { idx, label in
LineRow(
text: label,
done: step > idx + 1,
active: step == idx + 1,
isLast: idx == lineLabels.count - 1
)
.opacity(step >= idx + 1 ? 1 : 0)
.offset(y: step >= idx + 1 ? 0 : 6)
.animation(.easeOut(duration: 0.4).delay(Double(idx) * 0.05), value: step)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var speedBadge: some View {
Text(String(format: "已处理 %.1fs · 比云端快 4.2×", elapsed))
.font(.system(size: 10, design: .monospaced))
.tracking(0.6)
.foregroundStyle(Color.white.opacity(0.75))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Capsule().fill(Color.white.opacity(0.08)))
}
private func startAnimations() {
withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) {
pulse.toggle()
}
withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) {
glow.toggle()
}
withAnimation(.linear(duration: 14).repeatForever(autoreverses: false)) {
rotate = 360
}
Task {
for _ in 0..<lineLabels.count {
try? await Task.sleep(nanoseconds: 900_000_000)
await MainActor.run {
withAnimation { step += 1 }
elapsed += 0.9
}
}
try? await Task.sleep(nanoseconds: 600_000_000)
await MainActor.run { onComplete() }
}
}
}
private struct LineRow: View {
let text: String
let done: Bool
let active: Bool
let isLast: Bool
@State private var dotPulse = false
var body: some View {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(done
? Color(red: 0.95, green: 0.78, blue: 0.40)
: Color.white.opacity(0.12))
if done {
Image(systemName: "checkmark")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(Color(red: 0.10, green: 0.115, blue: 0.094))
}
}
.frame(width: 14, height: 14)
Text(text)
.font(.system(size: 13))
.foregroundStyle(done ? Color.white.opacity(0.95) : Color.white.opacity(0.45))
if active {
Spacer()
Text("···")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Color.white.opacity(dotPulse ? 0.9 : 0.4))
.onAppear {
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
dotPulse.toggle()
}
}
}
}
}
}
private struct ChipGlyph: View {
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 5, style: .continuous)
.strokeBorder(Color.white.opacity(0.8), lineWidth: 1.4)
.frame(width: 28, height: 28)
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Color(red: 0.95, green: 0.78, blue: 0.40).opacity(0.35))
.overlay(
RoundedRectangle(cornerRadius: 2, style: .continuous)
.strokeBorder(Color(red: 0.95, green: 0.78, blue: 0.40), lineWidth: 1)
)
.frame(width: 16, height: 16)
innerCross
outerPins
}
.frame(width: 56, height: 56)
}
private var innerCross: some View {
Canvas { ctx, size in
let amber = Color(red: 0.95, green: 0.78, blue: 0.40)
let stroke = GraphicsContext.Shading.color(amber)
let cx = size.width / 2
let cy = size.height / 2
let pairs: [(CGPoint, CGPoint)] = [
(CGPoint(x: cx, y: cy - 8), CGPoint(x: cx, y: cy - 4)),
(CGPoint(x: cx, y: cy + 4), CGPoint(x: cx, y: cy + 8)),
(CGPoint(x: cx - 8, y: cy), CGPoint(x: cx - 4, y: cy)),
(CGPoint(x: cx + 4, y: cy), CGPoint(x: cx + 8, y: cy)),
]
for (s, e) in pairs {
var p = Path()
p.move(to: s)
p.addLine(to: e)
ctx.stroke(p, with: stroke, style: StrokeStyle(lineWidth: 1, lineCap: .round))
}
}
.frame(width: 56, height: 56)
}
private var outerPins: some View {
Canvas { ctx, size in
let pinColor = GraphicsContext.Shading.color(Color.white.opacity(0.45))
let cx = size.width / 2
let cy = size.height / 2
let halfChip: CGFloat = 14
let outsideStart: CGFloat = 20
let outsideEnd: CGFloat = 26
let positions: [CGFloat] = [-8, 0, 8]
for offset in positions {
// top
var p = Path()
p.move(to: CGPoint(x: cx + offset, y: cy - outsideEnd))
p.addLine(to: CGPoint(x: cx + offset, y: cy - halfChip))
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
// bottom
p = Path()
p.move(to: CGPoint(x: cx + offset, y: cy + halfChip))
p.addLine(to: CGPoint(x: cx + offset, y: cy + outsideEnd))
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
// left
p = Path()
p.move(to: CGPoint(x: cx - outsideEnd, y: cy + offset))
p.addLine(to: CGPoint(x: cx - halfChip, y: cy + offset))
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
// right
p = Path()
p.move(to: CGPoint(x: cx + halfChip, y: cy + offset))
p.addLine(to: CGPoint(x: cx + outsideStart + 2, y: cy + offset))
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
}
}
.frame(width: 56, height: 56)
}
}
#Preview {
B4ProgressView(onComplete: {})
}

View File

@@ -1,323 +0,0 @@
import SwiftUI
struct B5IndicatorData {
let name: String
let value: String
let unit: String
let range: String
let status: IndicatorStatus
let note: String?
}
struct B5ResultView: View {
var onSave: () -> Void
var onBack: () -> Void
@State private var expandedIndex: Int? = 0
@State private var normalsExpanded = false
let abnormal: [B5IndicatorData] = [
.init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high,
note: "超过参考上限 0.44。建议关注饮食结构3 个月内复查。"),
.init(name: "甘油三酯 TG", value: "1.78", unit: "mmol/L", range: "0.451.70", status: .high, note: nil),
.init(name: "尿酸 UA", value: "428", unit: "μmol/L", range: "150420", status: .high, note: nil),
.init(name: "维生素 D", value: "18", unit: "ng/mL", range: "30100", status: .low, note: nil),
]
let normalCount = 24
var body: some View {
VStack(spacing: 0) {
header
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
reportMeta.padding(.bottom, 16)
summaryCard.padding(.bottom, 18)
SectionLabel("异常项", count: abnormal.count, accent: .brick)
.padding(.bottom, 10)
VStack(spacing: 8) {
ForEach(Array(abnormal.enumerated()), id: \.offset) { idx, it in
IndicatorRow(item: it, expanded: expandedIndex == idx) {
withAnimation { expandedIndex = (expandedIndex == idx) ? nil : idx }
}
}
}
.padding(.bottom, 18)
SectionLabel("正常项", count: normalCount, accent: .leaf)
.padding(.bottom, 10)
normalCollapsed
}
.padding(.horizontal, 18)
.padding(.bottom, 16)
}
HStack(spacing: 10) {
Button(action: onSave) {
Text("保存归档").frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
Button { } label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 16, weight: .semibold))
}
.buttonStyle(TjGhostButton(horizontalPadding: 16))
}
.padding(.horizontal, 18)
.padding(.bottom, 14)
.padding(.top, 10)
}
.background(Tj.Palette.sand.ignoresSafeArea())
}
private var header: some View {
HStack(spacing: 6) {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
}
Spacer()
Button { } label: {
HStack(spacing: 4) {
Image(systemName: "photo")
Text("查看原图")
}
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.padding(8)
}
}
.padding(.horizontal, 12)
.padding(.top, 4)
}
private var reportMeta: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
TjBadge(text: "体检报告", style: .ink)
Text("3 页")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Spacer()
TjLockChip()
}
Text("2026 春季年度体检")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(Tj.Palette.text)
Text("2026 / 05 / 25 · 协和医院体检中心")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
}
private var summaryCard: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 10) {
Text("整体摘记")
.font(.system(size: 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.brick)
.fixedSize()
Rectangle().fill(Tj.Palette.line).frame(height: 1)
Text("本机摘要")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
.fixedSize()
}
.padding(.bottom, 12)
HStack(spacing: 14) {
Stat(n: "28", label: "总项")
Stat(n: "3", label: "偏高", tone: .brick)
Stat(n: "1", label: "偏低", tone: .amber)
Stat(n: "24", label: "正常", tone: .leaf)
}
.padding(.bottom, 14)
Text("本次共检测 28 项,\(Text("3 项偏高").fontWeight(.semibold).underline(color: Tj.Palette.brick))(血脂相关 2 项 + 尿酸)、\(Text("1 项偏低").fontWeight(.semibold).underline(color: Tj.Palette.amber))(维生素 D。整体趋势提示代谢风险有所抬升建议优化饮食并复查血脂。")
.font(.system(size: 14))
.foregroundStyle(Tj.Palette.text)
.lineSpacing(6)
.padding(.bottom, 12)
TjDashedDivider().padding(.bottom, 10)
Text("仅供参考,不构成医疗建议")
.font(.system(size: 11))
.italic()
.foregroundStyle(Tj.Palette.text3)
}
.padding(.leading, 20)
.padding(.trailing, 20)
.padding(.vertical, 20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
Tj.Palette.paper
.overlay(alignment: .leading) {
Tj.Palette.brick.frame(width: 3)
}
)
.clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous))
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06), radius: 0, x: 0, y: 1)
}
private var normalCollapsed: some View {
Button { withAnimation { normalsExpanded.toggle() } } label: {
HStack(spacing: 10) {
TjBadge(text: "\(normalCount)", style: .leaf)
Text("谷丙转氨酶、空腹血糖、糖化血红蛋白…")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
.lineLimit(1)
Spacer()
Image(systemName: normalsExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.tjCard(bordered: true)
}
.buttonStyle(.plain)
}
}
private struct Stat: View {
let n: String
let label: String
var tone: Tone = .ink
enum Tone { case ink, brick, amber, leaf }
var color: Color {
switch tone {
case .ink: return Tj.Palette.text
case .brick: return Tj.Palette.brick
case .amber: return Color(red: 0.59, green: 0.45, blue: 0.27)
case .leaf: return Tj.Palette.leaf
}
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(n)
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(color)
Text(label)
.font(.system(size: 10))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct SectionLabel: View {
let title: String
let count: Int
let accent: AccentKind
enum AccentKind { case brick, leaf }
init(_ title: String, count: Int, accent: AccentKind) {
self.title = title
self.count = count
self.accent = accent
}
var body: some View {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(accent == .brick ? Tj.Palette.brick : Tj.Palette.leaf)
.frame(width: 4, height: 14)
Text(title).font(.system(size: 13, weight: .semibold)).foregroundStyle(Tj.Palette.text)
Text("· \(count)").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
}
}
}
private struct IndicatorRow: View {
let item: B5IndicatorData
let expanded: Bool
let onTap: () -> Void
var statusBadge: TjBadgeStyle {
switch item.status {
case .high: return .brick
case .low: return .amber
case .normal: return .leaf
}
}
var statusWord: String {
switch item.status {
case .high: return "偏高"
case .low: return "偏低"
case .normal: return "正常"
}
}
var valueColor: Color {
switch item.status {
case .high: return Tj.Palette.brick
case .low: return Color(red: 0.55, green: 0.45, blue: 0.32)
case .normal: return Tj.Palette.text
}
}
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(item.name)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
TjBadge(text: statusWord, style: statusBadge)
}
Text("范围 \(item.range) \(item.unit)")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(item.value)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(valueColor)
Text(item.unit)
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
if expanded, let note = item.note {
TjDashedDivider()
Text(note)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text2)
.lineSpacing(5)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(
item.status != .normal
? Color(red: 0.78, green: 0.68, blue: 0.48).opacity(0.5)
: Tj.Palette.lineSoft,
lineWidth: 1
)
)
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,190 @@
import SwiftUI
import SwiftData
/// Markdown + / /
struct HealthExportDetailView: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
let export: HealthExport
@State private var copiedFlash: Bool = false
@State private var showDeleteConfirm = false
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 16) {
metaBar
promptBlock
MarkdownView(text: export.content)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
actionRow
}
.background(Tj.Palette.sand.ignoresSafeArea())
.alert("永久删除这份导出?", isPresented: $showDeleteConfirm) {
Button("删除", role: .destructive) {
ctx.delete(export)
try? ctx.save()
dismiss()
}
Button("取消", role: .cancel) {}
} message: {
Text("删除后无法恢复。源记录(指标、症状等)不受影响。")
}
}
private var header: some View {
HStack(alignment: .center, spacing: 12) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
VStack(alignment: .leading, spacing: 2) {
Text("身体档案 · 历史导出")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text(Self.absoluteDate(export.createdAt))
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
TjLockChip()
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Tj.Palette.sand)
.overlay(alignment: .bottom) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
private var metaBar: some View {
HStack(spacing: 10) {
TjBadge(text: export.modelTag, style: .neutral)
if export.decodeRate > 0 {
Text(String(format: "%.1f tok/s", export.decodeRate))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
Spacer()
if let from = export.inferredTimeFromDate, let to = export.inferredTimeToDate {
Text("\(Self.shortDate(from))\(Self.shortDate(to))")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
}
private var promptBlock: some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "quote.opening")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Text(export.prompt)
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
)
}
private var actionRow: some View {
HStack(spacing: 10) {
Button { copy() } label: {
Label(copiedFlash ? "已复制" : "复制", systemImage: copiedFlash ? "checkmark" : "doc.on.doc")
}
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
ShareLink(item: export.content) {
Label("分享", systemImage: "square.and.arrow.up")
.font(.system(size: 13, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 14)
.frame(height: 44)
.background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1))
.contentShape(Capsule()) // :
}
Spacer()
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Image(systemName: "trash")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Tj.Palette.brick)
.frame(width: 44, height: 44)
.background(Circle().strokeBorder(Tj.Palette.brick.opacity(0.4), lineWidth: 1))
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Tj.Palette.paper)
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
private func copy() {
UIPasteboard.general.string = export.content
copiedFlash = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
copiedFlash = false
}
}
private static func absoluteDate(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day().hour().minute())
}
private static func shortDate(_ d: Date) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "MM-dd"
return f.string(from: d)
}
}
#Preview {
let exp = HealthExport(
prompt: "我感冒3天了,把最近一个月的健康情况给医生看",
content: """
# 就诊摘要 — 感冒就诊
## 主诉
患者男,38 岁,感冒 3 天未愈。
## 患者背景
- 高血压 2 年
- 在服药:缬沙坦 80mg qd
""",
inferredTimeFromDate: Calendar.current.date(byAdding: .day, value: -30, to: .now),
inferredTimeToDate: .now,
inferredIntent: "cold_consult",
decodeRate: 24.3
)
return HealthExportDetailView(export: exp)
}

View File

@@ -0,0 +1,137 @@
import SwiftUI
import SwiftData
/// ArchiveListView strip
struct HealthExportListView: View {
@Environment(\.modelContext) private var ctx
@Query(sort: \HealthExport.createdAt, order: .reverse)
private var exports: [HealthExport]
@State private var selected: HealthExport?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 14)
if exports.isEmpty {
empty
} else {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(exports) { exp in
Button {
selected = exp
} label: {
HealthExportRow(export: exp)
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
delete(exp)
} label: {
Label("删除", systemImage: "trash")
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("我的导出")
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $selected) { exp in
HealthExportDetailView(export: exp)
}
}
private var header: some View {
HStack(alignment: .lastTextBaseline) {
Text("我的导出")
.font(.tjTitle(24))
.foregroundStyle(Tj.Palette.text)
Text(exports.isEmpty ? "" : String(appLoc: "\(exports.count)"))
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
TjLockChip()
}
}
private var empty: some View {
VStack(spacing: 12) {
Spacer()
TjPlaceholder(label: String(appLoc: "还没有导出过\n回到记录页右上角生成一份"))
.frame(width: 240, height: 140)
Spacer()
}
.frame(maxWidth: .infinity)
}
private func delete(_ exp: HealthExport) {
ctx.delete(exp)
try? ctx.save()
}
}
///
struct HealthExportRow: View {
let export: HealthExport
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
Text(export.promptPreview)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(2)
.multilineTextAlignment(.leading)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
HStack(spacing: 8) {
Text(Self.relativeDate(export.createdAt))
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
if export.decodeRate > 0 {
Text(String(format: "%.1f tok/s", export.decodeRate))
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
Spacer()
if let label = export.inferredLabelCN ?? export.inferredIntent {
TjBadge(text: label, style: .neutral)
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.tjCard()
}
static func relativeDate(_ d: Date) -> String {
let f = RelativeDateTimeFormatter()
f.locale = Locale.current
f.unitsStyle = .full
return f.localizedString(for: d, relativeTo: .now)
}
}
#Preview {
NavigationStack {
HealthExportListView()
}
.modelContainer(for: [
Indicator.self, Report.self, DiaryEntry.self, Asset.self,
ChatTurn.self, Symptom.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self, HealthExport.self
], inMemory: true)
}

View File

@@ -0,0 +1,555 @@
import SwiftUI
import SwiftData
/// sheet
/// :idle running(extractingIntent retrieving generating) completed / failed
struct HealthExportSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
/// :(,W3 )
let initialPrompt: String
@State private var prompt: String = ""
@State private var phase: HealthExportService.Phase?
@State private var content: String = ""
@State private var rate: Double = 0
@State private var task: Task<Void, Never>?
@State private var error: Error?
@State private var completed: Bool = false
@State private var copiedFlash: Bool = false
@FocusState private var promptFocused: Bool
init(initialPrompt: String = "") {
self.initialPrompt = initialPrompt
}
private var isRunning: Bool { phase != nil && !completed && error == nil }
private var isInputMode: Bool { phase == nil && !completed && error == nil }
var body: some View {
VStack(spacing: 0) {
header
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if isInputMode {
inputSection
} else {
promptEcho
if isRunning { phaseIndicator }
if !content.isEmpty {
MarkdownView(text: content)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
if let err = error { errorRow(err) }
// ,
Color.clear.frame(height: 1).id("bottom")
}
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
.onChange(of: content) { _, _ in
withAnimation(.easeOut(duration: 0.12)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
if completed { actionRow }
}
.background(Tj.Palette.sand.ignoresSafeArea())
.onAppear {
if prompt.isEmpty { prompt = initialPrompt }
if isInputMode { promptFocused = true }
}
.onDisappear { task?.cancel() }
}
// MARK: - Header
private var header: some View {
HStack(alignment: .center, spacing: 12) {
Button { close() } label: {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
VStack(alignment: .leading, spacing: 2) {
Text("导出身体档案")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("给医生看的就诊摘要")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
TjLockChip()
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Tj.Palette.sand)
.overlay(alignment: .bottom) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
// MARK: - Input section (idle)
private var inputSection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("说说你想给医生看什么")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
VStack(alignment: .leading, spacing: 6) {
Text("例:我感冒3天了,把最近一个月的健康情况给医生看")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Text("例:最近血糖好像不稳,把过去三个月的化验单整理一下")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
ZStack(alignment: .topLeading) {
if prompt.isEmpty {
Text("在这里输入主诉……")
.font(.system(size: 15))
.foregroundStyle(Tj.Palette.text3)
.padding(.horizontal, 14)
.padding(.vertical, 14)
.allowsHitTesting(false)
}
TextEditor(text: $prompt)
.font(.system(size: 15))
.foregroundStyle(Tj.Palette.text)
.scrollContentBackground(.hidden)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(minHeight: 130)
.focused($promptFocused)
}
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
HStack {
Text("本地 RAG · Qwen3 1.7B · 不上传任何数据")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Spacer()
Button { start() } label: {
Text("生成报告")
}
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
.disabled(prompt.trimmingCharacters(in: .whitespaces).isEmpty)
.opacity(prompt.trimmingCharacters(in: .whitespaces).isEmpty ? 0.5 : 1)
}
}
}
// MARK: - Prompt echo (after start)
private var promptEcho: some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "quote.opening")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Text(prompt)
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
.lineLimit(3)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
)
}
// MARK: - Phase indicator
private var phaseIndicator: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
phasePill(.extractingIntent)
arrow
phasePill(.retrieving)
arrow
phasePill(.generating)
}
if phase == .generating && rate > 0 {
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
} else {
Text(phase?.label ?? "")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
}
}
private func phasePill(_ p: HealthExportService.Phase) -> some View {
let active = (p == phase)
let done = phaseOrder(p) < phaseOrder(phase ?? .extractingIntent)
let fill = active ? Tj.Palette.ink : (done ? Tj.Palette.leaf : Tj.Palette.sand2)
let fg = (active || done) ? Tj.Palette.paper : Tj.Palette.text3
return Text(p.label)
.font(.system(size: 11, weight: active ? .semibold : .regular))
.foregroundStyle(fg)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().fill(fill))
}
private var arrow: some View {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
private func phaseOrder(_ p: HealthExportService.Phase) -> Int {
switch p {
case .extractingIntent: return 0
case .retrieving: return 1
case .generating: return 2
case .completed: return 3
}
}
// MARK: - Error
private func errorRow(_ err: Error) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.brick)
Text(err.localizedDescription)
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text)
}
Button { reset() } label: { Text("返回修改") }
.buttonStyle(TjGhostButton(height: 40, fontSize: 13))
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.6))
)
}
// MARK: - Action row (completed)
private var actionRow: some View {
HStack(spacing: 10) {
Button { copy() } label: {
Label(copiedFlash ? "已复制" : "复制", systemImage: copiedFlash ? "checkmark" : "doc.on.doc")
}
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
ShareLink(item: content) {
Label("分享", systemImage: "square.and.arrow.up")
.font(.system(size: 13, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 14)
.frame(height: 44)
.background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1))
.contentShape(Capsule()) // :
}
Spacer()
Button { regenerate() } label: {
Label("重新生成", systemImage: "arrow.clockwise")
}
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 13, horizontalPadding: 16))
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Tj.Palette.paper)
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
// MARK: - Actions
private func start() {
let p = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !p.isEmpty else { return }
promptFocused = false
content = ""
rate = 0 // , tok/s
error = nil
completed = false
phase = .extractingIntent
let stream = HealthExportService.shared.export(prompt: p, in: ctx)
task = Task { @MainActor in
do {
for try await event in stream {
switch event {
case .phaseChanged(let ph):
phase = ph
case .token(let chunk):
content += chunk.text
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
case .completed:
completed = true
}
}
} catch {
self.error = error
self.phase = nil
}
}
}
private func regenerate() {
completed = false
start()
}
private func reset() {
task?.cancel()
task = nil
phase = nil
content = ""
rate = 0
error = nil
completed = false
promptFocused = true
}
private func copy() {
UIPasteboard.general.string = content
copiedFlash = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
copiedFlash = false
}
}
private func close() {
task?.cancel()
dismiss()
}
}
// MARK: - Markdown ()
/// Markdown ,
/// : `# ``## ``-` `****`( AttributedString inline )
/// prompt LLM
struct MarkdownView: View {
let text: String
var body: some View {
let blocks = Self.parse(text)
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
renderBlock(block)
}
}
}
@ViewBuilder
private func renderBlock(_ block: Block) -> some View {
switch block {
case .h1(let s):
VStack(alignment: .leading, spacing: 8) {
Text(inline(s))
.font(.system(size: 22, weight: .bold))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
Rectangle()
.fill(Tj.Palette.ink)
.frame(height: 1)
.frame(maxWidth: .infinity)
}
.padding(.top, 2)
.padding(.bottom, 4)
case .h2(let s):
HStack(alignment: .center, spacing: 8) {
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(Tj.Palette.brick)
.frame(width: 3, height: 16)
Text(inline(s))
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
}
.padding(.top, 10)
.padding(.bottom, 2)
case .bullet(let s):
if let abnormalText = Self.extractAbnormal(s) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.brick)
Text(inline(abnormalText))
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.55))
)
.overlay(alignment: .leading) {
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(Tj.Palette.brick)
.frame(width: 3)
}
} else {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Circle()
.fill(Tj.Palette.text3)
.frame(width: 4, height: 4)
.padding(.top, 6)
Text(inline(s))
.font(.system(size: 14))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.leading, 2)
}
case .body(let s):
Text(inline(s))
.font(.system(size: 14))
.lineSpacing(3)
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
case .gap:
Spacer().frame(height: 4)
}
}
/// bullet , strip
/// nil()
private static func extractAbnormal(_ s: String) -> String? {
let trimmed = s.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("⚠️") {
return trimmed.replacingOccurrences(of: "⚠️", with: "")
.trimmingCharacters(in: .whitespaces)
}
// ,(),
// : 4
let negations = ["", "", ""]
let abnormalSignals = ["偏高", "偏低", "异常", "过高", "过低"]
for sig in abnormalSignals {
guard let r = trimmed.range(of: sig) else { continue }
let window = String(trimmed[..<r.lowerBound].suffix(4))
if negations.contains(where: { window.contains($0) }) { continue }
return trimmed
}
return nil
}
private func inline(_ s: String) -> AttributedString {
// **bold** / *italic* / [text](url) AttributedString markdown
if let attr = try? AttributedString(
markdown: s,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
) {
return attr
}
return AttributedString(s)
}
// MARK: -
enum Block {
case h1(String)
case h2(String)
case bullet(String)
case body(String)
case gap
}
static func parse(_ raw: String) -> [Block] {
var out: [Block] = []
let lines = raw.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")
for line in lines {
let t = line.trimmingCharacters(in: .whitespaces)
if t.isEmpty {
// gap
if case .gap = out.last { continue }
out.append(.gap)
continue
}
if t.hasPrefix("# ") {
out.append(.h1(String(t.dropFirst(2))))
} else if t.hasPrefix("## ") {
out.append(.h2(String(t.dropFirst(3))))
} else if t.hasPrefix("### ") {
out.append(.h2(String(t.dropFirst(4))))
} else if t.hasPrefix("- ") || t.hasPrefix("* ") {
out.append(.bullet(String(t.dropFirst(2))))
} else {
out.append(.body(t))
}
}
return out
}
}
#Preview("HealthExportSheet · 空状态") {
HealthExportSheet()
.modelContainer(for: [
Indicator.self, Report.self, DiaryEntry.self, Asset.self,
ChatTurn.self, Symptom.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self, HealthExport.self
], inMemory: true)
}
#Preview("MarkdownView · 演示") {
ScrollView {
MarkdownView(text: """
# 就诊摘要 — 感冒就诊
## 主诉
患者男,38 岁,感冒 3 天未愈,主诉鼻塞、咳嗽、低烧。
## 患者背景
- 高血压 2 年
- 在服药:**缬沙坦 80mg qd**
- 过敏:青霉素
## 近期症状
- 2026-05-24 感冒(进行中,severity 2):鼻塞、低烧
- 2026-05-20 头痛(已结束)
## 关键指标
- ⚠️ 收缩压 142 mmHg (参考 <140) — 2026-05-26
- 体温 37.2 ℃ (参考 36-37) — 2026-05-25
""")
.padding()
}
.background(Tj.Palette.sand)
}

View File

@@ -10,6 +10,8 @@ struct CaptureReviewForm: View {
let warning: String?
let onSave: (ParsedReport) -> Void
let onCancel: () -> Void
/// assets () nil,banner
var onReanalyze: (() -> Void)? = nil
var body: some View {
ScrollView {
@@ -36,10 +38,22 @@ struct CaptureReviewForm: View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.amber)
Text(text)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
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)
@@ -53,7 +67,7 @@ struct CaptureReviewForm: View {
private var pageThumbnails: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("已保存 \(assets.count) 页(端侧加密)")
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
@@ -78,13 +92,13 @@ struct CaptureReviewForm: View {
private var metaSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionLabel("基本信息")
sectionLabel(String(appLoc: "基本信息"))
VStack(spacing: 10) {
labeledField("标题") {
labeledField(String(appLoc: "标题")) {
TextField("如:春季年度体检", text: $parsed.title)
.textFieldStyle(.plain)
}
labeledField("类型") {
labeledField(String(appLoc: "类型")) {
Picker("", selection: $parsed.typeRaw) {
ForEach(ReportType.allCases, id: \.rawValue) { t in
Text(t.label).tag(t.rawValue)
@@ -92,18 +106,18 @@ struct CaptureReviewForm: View {
}
.pickerStyle(.segmented)
}
labeledField("报告日期") {
labeledField(String(appLoc: "报告日期")) {
DatePicker("", selection: $parsed.reportDate,
in: ...Date.now,
displayedComponents: .date)
.datePickerStyle(.compact)
.labelsHidden()
.environment(\.locale, Locale(identifier: "zh_CN"))
.environment(\.locale, Locale.current)
}
labeledField("机构(可选)") {
labeledField(String(appLoc: "机构(可选)")) {
TextField("如:协和医院", text: $parsed.institution)
}
labeledField("摘要(可选)") {
labeledField(String(appLoc: "摘要(可选)")) {
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
.lineLimit(1...3)
}
@@ -128,7 +142,7 @@ struct CaptureReviewForm: View {
private var indicatorSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
sectionLabel("指标(\(parsed.indicators.count) 项)")
sectionLabel(String(appLoc: "指标(\(parsed.indicators.count) 项)"))
Spacer()
Button {
parsed.indicators.append(
@@ -148,22 +162,22 @@ struct CaptureReviewForm: View {
.padding(.vertical, 8)
} else {
VStack(spacing: 10) {
ForEach(parsed.indicators.indices, id: \.self) { idx in
indicatorRow(idx)
ForEach($parsed.indicators) { $indicator in
indicatorRow($indicator)
}
}
}
}
}
private func indicatorRow(_ idx: Int) -> some View {
let binding = $parsed.indicators[idx]
private func indicatorRow(_ binding: Binding<ParsedReport.ParsedIndicator>) -> some View {
let id = binding.wrappedValue.id
return VStack(spacing: 8) {
HStack(spacing: 8) {
TextField("指标名", text: binding.name)
.font(.system(size: 14, weight: .medium))
Button(role: .destructive) {
parsed.indicators.remove(at: idx)
parsed.indicators.removeAll { $0.id == id }
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(Tj.Palette.text3)

View File

@@ -1,6 +1,7 @@
import SwiftUI
import SwiftData
import UIKit
import Combine
/// VL ( + )
/// , A1-A3 / B1-B5 mockup
@@ -16,11 +17,17 @@ struct UnifiedCaptureFlow: View {
@Environment(\.modelContext) private var ctx
let onClose: () -> Void
@AppStorage("hasSeenCaptureTip") private var hasSeenCaptureTip: Bool = false
@State private var phase: Phase = .idle
@State private var analyzeTask: Task<Void, Never>? = nil
@State private var showTip: Bool = false
/// VL (); cancel ,UI 退
private let analyzeTimeoutSeconds: Int = 30
enum Phase {
case idle
case analyzing(images: [UIImage])
case analyzing(images: [UIImage], assets: [FileVault.SavedAsset]?)
case editing(parsed: ParsedReport,
assets: [FileVault.SavedAsset],
warning: String?)
@@ -32,20 +39,30 @@ struct UnifiedCaptureFlow: View {
.background(Tj.Palette.sand.ignoresSafeArea())
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { onClose() }
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 "拍摄报告"
case .analyzing: return "本地识别中…"
case .editing: return "核对识别结果"
case .idle: return String(appLoc: "拍摄报告")
case .analyzing: return String(appLoc: "本地识别中…")
case .editing: return String(appLoc: "核对识别结果")
}
}
@@ -54,21 +71,57 @@ struct UnifiedCaptureFlow: View {
switch phase {
case .idle:
captureEntry
case .analyzing(let images):
AnalyzingView(images: images)
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: onClose
onCancel: cancelAll,
onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) }
)
}
}
// MARK: -
/// + SwiftData Vault , sheet
/// (),
/// (§6), Vault
/// .analyzing/.editing assets;.idle ,
private func cancelAll() {
analyzeTask?.cancel()
analyzeTask = nil
switch phase {
case .idle:
break
case .analyzing(_, let maybeAssets):
if let assets = maybeAssets { removeOrphans(assets) }
case .editing(_, let assets, _):
removeOrphans(assets)
}
onClose()
}
private func removeOrphans(_ assets: [FileVault.SavedAsset]) {
for a in assets {
try? FileVault.shared.remove(relativePath: a.relativePath)
}
}
// MARK: - : /
@ViewBuilder
private var captureEntry: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
@@ -95,54 +148,124 @@ struct UnifiedCaptureFlow: View {
private func startAnalyze(images: [UIImage]) {
guard !images.isEmpty else { onClose(); return }
phase = .analyzing(images: images)
Task {
do {
let result = try await CaptureService.shared.analyze(images: images)
await MainActor.run {
phase = .editing(
parsed: result.parsed,
assets: result.assets,
warning: result.parsed.isEmpty
? "识别没有读出指标,请手动补充"
: nil
)
}
} catch let CaptureError.parseFailed(msg) {
// :, indicators ,assets
await fallbackToManual(images: images, msg: "VL 输出无法解析:\(msg)")
} catch let CaptureError.inferenceFailed(msg) {
await fallbackToManual(images: images, msg: "推理失败:\(msg)")
} catch let CaptureError.modelNotReady {
await fallbackToManual(images: images, msg: "VL 模型未就绪,先手动录入")
} catch CaptureError.writeAssetFailed {
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 dismisscancelAll
// 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: "图片保存失败,手动录入并保留文本"
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 fallbackToManual(images: images, msg: "未知错误:\(error.localizedDescription)")
await editingFallback(assets: assets,
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
}
}
}
private func fallbackToManual(images: [UIImage], msg: String) async {
// 便 VL , Vault( CaptureService.analyze 1 )
// writeAsset (modelNotReady / inferenceFailed),
// ,
var assets: [FileVault.SavedAsset] = []
for img in images {
if let a = try? FileVault.shared.writeJPEG(img) { assets.append(a) }
/// : 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
)
phase = .editing(parsed: .empty(), assets: assets, warning: msg)
}
}
@@ -151,7 +274,7 @@ struct UnifiedCaptureFlow: View {
private func saveAll(parsed final: ParsedReport,
assets: [FileVault.SavedAsset]) {
let report = Report(
title: final.title.isEmpty ? "拍摄识别" : final.title,
title: final.title.isEmpty ? String(appLoc: "拍摄识别") : final.title,
type: ReportType(rawValue: final.typeRaw) ?? .other,
reportDate: final.reportDate,
institution: final.institution.isEmpty ? nil : final.institution,
@@ -190,6 +313,11 @@ struct UnifiedCaptureFlow: View {
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) {
@@ -216,13 +344,72 @@ private struct AnalyzingView: View {
Text("本地识别中")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(images.count) 页 · 100% 本地推理")
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()
}
}
}

View File

@@ -1,6 +1,10 @@
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
@@ -8,9 +12,41 @@ struct DiaryQuickSheet: View {
@State private var content: String = ""
@State private var createdAt: Date = .now
private var canSubmit: Bool {
/// AI
enum AssistPhase {
case idle //
case loading // LLM
case ready // , / /
case failed(Error) //
}
@State private var phase: AssistPhase = .idle
@State private var questions: [DiaryAssistService.Question] = []
@State private var lastRate: Double = 0
@State private var currentRound: Int = 0
/// (question.dim), prompt
@State private var coveredDims: Set<String> = []
@State private var suggestTask: Task<Void, Never>?
/// question id;nil =
@State private var fillingId: UUID?
/// , =
@State private var fillValues: [String] = []
/// () true,
@State private var exhaustedNote = false
/// sheet detent large,
/// medium,()
@State private var detent: PresentationDetent = .large
@FocusState private var contentFocused: Bool
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) {
@@ -21,44 +57,71 @@ struct DiaryQuickSheet: View {
.padding(.bottom, 14)
HStack {
Text("写日记")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
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, 16)
.padding(.bottom, 14)
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("内容")
TextField("今天怎么样?", text: $content, axis: .vertical)
.lineLimit(4...10)
.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)
)
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)
}
VStack(alignment: .leading, spacing: 8) {
sectionLabel("时间")
DatePicker("", selection: $createdAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
.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)
}
}
}
}
.padding(.horizontal, 20)
Spacer(minLength: 12)
HStack(spacing: 12) {
Button("取消") { dismiss() }
@@ -76,12 +139,281 @@ struct DiaryQuickSheet: View {
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.medium, .large])
.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))
@@ -89,6 +421,141 @@ struct DiaryQuickSheet: View {
.foregroundStyle(Tj.Palette.text2)
}
/// AI (coveredDims) LLM,
/// ,
private func requestSuggestions() {
suggestTask?.cancel()
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
let covered = Array(coveredDims)
// 1.
contentFocused = false
// 2. sheet large( medium AI)
if detent != .large {
withAnimation(.snappy(duration: 0.25)) {
detent = .large
}
}
exhaustedNote = false
phase = .loading
suggestTask = Task { @MainActor in
do {
let result = try await DiaryAssistService.shared.suggest(
content: snapshotContent,
coveredDimensions: covered
)
if Task.isCancelled { return }
// ( 1.7B ):
// ; ;
let coveredSnapshot = coveredDims
var acceptedNorms = questions.map { Self.normalize($0.q) }
var batchDims = Set<String>()
let nextRound = currentRound + 1
let fresh = result.questions.compactMap { q -> DiaryAssistService.Question? in
let dim = q.dim.trimmingCharacters(in: .whitespacesAndNewlines)
let norm = Self.normalize(q.q)
if !dim.isEmpty, coveredSnapshot.contains(dim) { return nil }
if !dim.isEmpty, batchDims.contains(dim) { return nil }
if acceptedNorms.contains(where: { Self.isSimilar($0, norm) }) { return nil }
if !dim.isEmpty { batchDims.insert(dim) }
acceptedNorms.append(norm)
var stamped = q
stamped.round = nextRound
return stamped
}
withAnimation(.snappy(duration: 0.2)) {
if fresh.isEmpty {
exhaustedNote = true //
} else {
questions.append(contentsOf: fresh)
for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) }
currentRound = nextRound
exhaustedNote = false
}
lastRate = result.decodeRate
phase = .ready
}
} catch is CancellationError {
if !Task.isCancelled {
phase = hasQuestions ? .ready : .idle
}
} catch {
if !Task.isCancelled {
phase = .failed(error)
}
}
}
}
/// : + ,
private static func normalize(_ s: String) -> String {
s.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: " ", with: "")
.replacingOccurrences(of: "?", with: "?")
}
/// :, Jaccard 0.8(/)
private static func isSimilar(_ a: String, _ b: String) -> Bool {
if a == b { return true }
let sa = Set(a), sb = Set(b)
guard !sa.isEmpty, !sb.isEmpty else { return false }
let inter = sa.intersection(sb).count
let union = sa.union(sb).count
return union > 0 && Double(inter) / Double(union) >= 0.8
}
private func cancelSuggestions() {
suggestTask?.cancel()
phase = hasQuestions ? .ready : .idle
}
/// : `[]` ;( adopted)
/// q ; coveredDims, prompt
private func adopt(_ question: DiaryAssistService.Question) {
guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else {
// :( fill 退)
commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill)
return
}
withAnimation(.snappy(duration: 0.18)) {
fillingId = question.id
fillValues = Array(repeating: "", count: DiaryFillTemplate.slotCount(question.fill))
}
}
/// ()
private func closeFill() {
withAnimation(.snappy(duration: 0.18)) {
fillingId = nil
fillValues = []
}
}
/// :(), adopted,
private func commitAdoption(_ question: DiaryAssistService.Question, text: String) {
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
withAnimation(.snappy(duration: 0.18)) {
questions[idx].adopted = true
}
}
appendToContent(text)
fillingId = nil
fillValues = []
}
/// (,)
private func appendToContent(_ text: String) {
let toAppend = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !toAppend.isEmpty else { return }
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
content = toAppend
} else if content.hasSuffix("\n") {
content += toAppend
} else {
content += "\n" + toAppend
}
}
private func submit() {
guard canSubmit else { return }
let entry = DiaryEntry(
@@ -100,3 +567,7 @@ struct DiaryQuickSheet: View {
dismiss()
}
}
#Preview {
DiaryQuickSheet()
}

View File

@@ -0,0 +1,235 @@
import SwiftUI
/// AI ( [] ,):
enum FillSegment: Equatable {
case literal(String)
/// `label` ( "" / "/");
/// `options` (`/` ,)
case slot(label: String, options: [String])
}
/// `fill` ,便
enum DiaryFillTemplate {
/// `.literal`
static func parse(_ template: String) -> [FillSegment] {
let chars = Array(template)
var segs: [FillSegment] = []
var i = 0
var literalStart = 0
func flushLiteral(upTo end: Int) {
if end > literalStart { segs.append(.literal(String(chars[literalStart..<end]))) }
}
while i < chars.count {
if chars[i] == "[",
let close = (i + 1 ..< chars.count).first(where: { chars[$0] == "]" }) {
flushLiteral(upTo: i)
let inner = String(chars[(i + 1)..<close])
segs.append(.slot(label: inner, options: options(from: inner)))
i = close + 1
literalStart = i
} else {
i += 1
}
}
flushLiteral(upTo: chars.count)
return segs
}
/// `/` (5 ) 2 ,
private static func options(from inner: String) -> [String] {
let tokens = inner.split(separator: "/")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
guard tokens.count >= 2, tokens.allSatisfy({ $0.count <= 5 }) else { return [] }
return tokens
}
///
static func slotCount(_ template: String) -> Int {
parse(template).reduce(0) { acc, seg in
if case .slot = seg { return acc + 1 }
return acc
}
}
/// `values` :,退(,)
static func assemble(_ template: String, values: [String]) -> String {
var out = ""
var idx = 0
for seg in parse(template) {
switch seg {
case .literal(let t):
out += t
case .slot(let label, _):
let v = idx < values.count
? values[idx].trimmingCharacters(in: .whitespacesAndNewlines) : ""
out += v.isEmpty ? label : v
idx += 1
}
}
return out
}
}
/// : `[]` + chip,,
/// / ****
struct QuestionFillPanel: View {
let template: String
@Binding var values: [String]
let onCommit: (String) -> Void
let onCancel: () -> Void
private var segments: [FillSegment] { DiaryFillTemplate.parse(template) }
/// + values
private var slots: [(index: Int, label: String, options: [String])] {
var result: [(Int, String, [String])] = []
var i = 0
for seg in segments {
if case let .slot(label, options) = seg {
result.append((i, label, options))
i += 1
}
}
return result
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
// :,线
previewText
.font(.system(size: 13))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
)
ForEach(slots, id: \.index) { slot in
slotEditor(index: slot.index, label: slot.label, options: slot.options)
}
HStack(spacing: 8) {
Button(action: onCancel) {
Text("取消")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
// :.plain ,
// contentShape
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Button {
onCommit(DiaryFillTemplate.assemble(template, values: values))
} label: {
HStack(spacing: 5) {
Image(systemName: "text.append")
.font(.system(size: 12, weight: .semibold))
Text("加入记录")
.font(.system(size: 13, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.ink)
)
}
.buttonStyle(.plain)
}
}
.padding(.leading, 22)
.padding(.top, 2)
}
// MARK: -
/// :literal , brick ,线
private var previewText: Text {
var result = Text("")
var idx = 0
for seg in segments {
switch seg {
case .literal(let t):
result = result + Text(t).foregroundStyle(Tj.Palette.text)
case .slot(let label, _):
let v = idx < values.count
? values[idx].trimmingCharacters(in: .whitespacesAndNewlines) : ""
if v.isEmpty {
result = result + Text(label).foregroundStyle(Tj.Palette.text3).underline()
} else {
result = result + Text(v).foregroundStyle(Tj.Palette.brick).fontWeight(.semibold)
}
idx += 1
}
}
return result
}
private func slotEditor(index: Int, label: String, options: [String]) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
if !options.isEmpty {
HStack(spacing: 6) {
ForEach(options, id: \.self) { opt in
let picked = bindingValue(index) == opt
Button { values[index] = opt } label: {
Text(opt)
.font(.system(size: 12, weight: picked ? .semibold : .regular))
.foregroundStyle(picked ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
Capsule().fill(picked ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
Capsule().strokeBorder(Tj.Palette.line,
lineWidth: picked ? 0 : 1)
)
}
.buttonStyle(.plain)
}
Spacer(minLength: 0)
}
}
TextField(String(appLoc: "填写\(label)"), text: binding(index))
.font(.system(size: 13))
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
}
private func bindingValue(_ i: Int) -> String {
i < values.count ? values[i] : ""
}
private func binding(_ i: Int) -> Binding<String> {
Binding(
get: { i < values.count ? values[i] : "" },
set: { if i < values.count { values[i] = $0 } }
)
}
}

View File

@@ -16,6 +16,9 @@ struct HomeView: View {
@Query(sort: \Symptom.startedAt, order: .reverse)
private var symptoms: [Symptom]
/// sheet( C1 )
@State private var selectedEntry: TimelineEntry?
@MainActor
private var recentEntries: [TimelineEntry] {
let all =
@@ -37,6 +40,8 @@ struct HomeView: View {
.padding(.top, 4)
.padding(.bottom, 18)
TodayRemindersCard()
OngoingSymptomsCard()
.padding(.bottom, 18)
@@ -49,6 +54,15 @@ struct HomeView: View {
.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 {
@@ -69,17 +83,17 @@ struct HomeView: View {
}
private var todayLine: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "M 月 d 日 · EEE"
return f.string(from: Date())
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 "早安"
case 12..<18: return "下午好"
default: return "晚上好"
case 5..<12: return String(appLoc: "早安")
case 12..<18: return String(appLoc: "下午好")
default: return String(appLoc: "晚上好")
}
}
@@ -108,7 +122,18 @@ struct HomeView: View {
.foregroundStyle(Tj.Palette.text3)
VStack(spacing: 10) {
ForEach(group.items) { entry in
TimelineRow(entry: entry)
Button {
if TimelineDetail.resolve(
for: entry,
indicators: indicators, reports: reports,
diaries: diaries, symptoms: symptoms
) != nil {
selectedEntry = entry
}
} label: {
TimelineRow(entry: entry)
}
.buttonStyle(.plain)
}
}
}
@@ -136,7 +161,7 @@ struct HomeView: View {
Button(action: onTapArchive) {
HStack(spacing: 14) {
TjPlaceholder(label: "档案 · \(reports.count)")
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
.frame(width: 56, height: 56)
VStack(alignment: .leading, spacing: 2) {
Text("我的报告档案")

View File

@@ -0,0 +1,118 @@
import SwiftUI
import SwiftData
import Combine
/// :(CustomReminder)+ (MetricReminder),
/// ;(,)
/// ( EmptyView,)
/// ; (RemindersListView)
struct TodayRemindersCard: View {
@Query(sort: \CustomReminder.updatedAt, order: .reverse)
private var customReminders: [CustomReminder]
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
private var metricReminders: [MetricReminder]
@State private var showingCenter = false
/// ,( OngoingSymptomsCard )
@State private var tick: Date = .now
private let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
/// , + ,
private var items: [TodayItem] {
let cal = Calendar.current
var arr: [TodayItem] = []
for r in customReminders where r.occurs(on: tick, calendar: cal) {
arr.append(TodayItem(id: "c-\(r.id.uuidString)",
hour: r.hour, minute: r.minute, title: r.title))
}
for r in metricReminders where r.occurs(on: tick, calendar: cal) {
arr.append(TodayItem(id: "m-\(r.metricId)",
hour: r.hour, minute: r.minute, title: r.displayName))
}
return arr.sorted { ($0.hour, $0.minute) < ($1.hour, $1.minute) }
}
var body: some View {
let rows = items
if rows.isEmpty {
EmptyView()
} else {
VStack(alignment: .leading, spacing: 10) {
header(count: rows.count)
VStack(spacing: 8) {
ForEach(rows) { row($0) }
}
}
.padding(.bottom, 18)
.onReceive(timer) { now in tick = now }
.sheet(isPresented: $showingCenter) {
// NavigationStack ;sheet
NavigationStack { RemindersListView(presentedAsSheet: true) }
}
}
}
private func header(count: Int) -> some View {
HStack(spacing: 8) {
Circle()
.fill(Tj.Palette.amber)
.frame(width: 7, height: 7)
Text("今日提醒")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(count)")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
Button { showingCenter = true } label: {
Text("全部 ")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
private func row(_ item: TodayItem) -> some View {
let isPast = item.isPast(now: tick)
return HStack(spacing: 12) {
Text(item.timeLabel)
.font(.system(size: 14, weight: .semibold).monospacedDigit())
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.ink)
.frame(width: 46, alignment: .leading)
Image(systemName: "bell.fill")
.font(.system(size: 12))
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.amber)
Text(item.title)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.text)
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04),
radius: 2, x: 0, y: 1)
}
}
/// ()
private struct TodayItem: Identifiable {
let id: String
let hour: Int
let minute: Int
let title: String
var timeLabel: String { String(format: "%02d:%02d", hour, minute) }
/// ()
func isPast(now: Date) -> Bool {
let c = Calendar.current.dateComponents([.hour, .minute], from: now)
let nowMinutes = (c.hour ?? 0) * 60 + (c.minute ?? 0)
return hour * 60 + minute < nowMinutes
}
}

View File

@@ -21,8 +21,8 @@ enum CustomMetricNameConflict: Equatable {
var warningText: String {
switch self {
case .none: return ""
case .builtin(let n): return "\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块"
case .existingCustom(let n):return "已经有一个叫「\(n)」的自定义指标"
case .builtin(let n): return String(appLoc: "\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块")
case .existingCustom(let n):return String(appLoc: "已经有一个叫「\(n)」的自定义指标")
}
}
}
@@ -133,7 +133,7 @@ struct CustomMetricEditor: View {
private var nameSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("名称")
sectionLabel(String(appLoc: "名称"))
TextField("例如:腰围 / 步数 / 睡眠时长", text: $name)
.padding(.horizontal, 14).padding(.vertical, 12)
.background(fieldBg)
@@ -161,7 +161,7 @@ struct CustomMetricEditor: View {
private var unitSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("单位(可选)")
sectionLabel(String(appLoc: "单位(可选)"))
TextField("例如:cm / 步 / 小时", text: $unit)
.autocorrectionDisabled()
.padding(.horizontal, 14).padding(.vertical, 12)
@@ -172,16 +172,16 @@ struct CustomMetricEditor: View {
private var rangeRow: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel("参考范围(可选)")
sectionLabel(String(appLoc: "参考范围(可选)"))
Spacer()
Text("用于自动判定 正常/偏高/偏低")
.font(.system(size: 10))
.foregroundStyle(Tj.Palette.text3)
}
HStack(spacing: 12) {
rangeField(label: "下限", value: $lower, placeholder: "70")
rangeField(label: String(appLoc: "下限"), value: $lower, placeholder: "70")
Text("").foregroundStyle(Tj.Palette.text3)
rangeField(label: "上限", value: $upper, placeholder: "90")
rangeField(label: String(appLoc: "上限"), value: $upper, placeholder: "90")
}
}
}
@@ -199,7 +199,7 @@ struct CustomMetricEditor: View {
private var iconSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("图标")
sectionLabel(String(appLoc: "图标"))
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4),
spacing: 8) {
ForEach(customMetricIconChoices, id: \.self) { sf in

View File

@@ -171,7 +171,7 @@ struct IndicatorQuickSheet: View {
private var monitorGridSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel("长期监测(进趋势)")
sectionLabel(String(appLoc: "长期监测(进趋势)"))
Spacer()
if !hiddenSet.isEmpty {
hiddenCountChip
@@ -246,13 +246,10 @@ struct IndicatorQuickSheet: View {
}
.buttonStyle(.plain)
.contextMenu {
// :()
// action , trash/,,
Button { editingCustom = CustomMetricEditTarget(metric: cm) } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) {
editingCustom = CustomMetricEditTarget(metric: cm)
} label: {
Label("编辑/删除", systemImage: "trash")
Label("编辑 / 删除", systemImage: "pencil")
}
}
}
@@ -329,7 +326,7 @@ struct IndicatorQuickSheet: View {
private var labPresetSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("化验项快捷(不进趋势)")
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(labPresets) { p in
@@ -345,14 +342,14 @@ struct IndicatorQuickSheet: View {
private var bpFieldSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
sectionLabel("收缩 / 舒张")
sectionLabel(String(appLoc: "收缩 / 舒张"))
Spacer()
bpRangeHint
}
HStack(spacing: 12) {
bpField(label: "收缩压", value: $systolic, placeholder: "120")
bpField(label: String(appLoc: "收缩压"), value: $systolic, placeholder: "120")
Text("/").font(.system(size: 22, weight: .light)).foregroundStyle(Tj.Palette.text3)
bpField(label: "舒张压", value: $diastolic, placeholder: "80")
bpField(label: String(appLoc: "舒张压"), value: $diastolic, placeholder: "80")
Text("mmHg").foregroundStyle(Tj.Palette.text3)
}
bpStatusChips
@@ -396,10 +393,10 @@ struct IndicatorQuickSheet: View {
private var bpStatusChips: some View {
HStack(spacing: 8) {
if let s = computedBPStatus(.systolic) {
statusBadge("收缩 " + s.label, color: s.color)
statusBadge(String(appLoc: "收缩 ") + s.label, color: s.color)
}
if let s = computedBPStatus(.diastolic) {
statusBadge("舒张 " + s.label, color: s.color)
statusBadge(String(appLoc: "舒张 ") + s.label, color: s.color)
}
Spacer()
}
@@ -407,7 +404,7 @@ struct IndicatorQuickSheet: View {
private var nameSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("指标名")
sectionLabel(String(appLoc: "指标名"))
TextField("例如:血红蛋白", text: $name)
.textInputAutocapitalization(.never)
.padding(.horizontal, 14)
@@ -427,7 +424,7 @@ struct IndicatorQuickSheet: View {
private var valueRow: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("数值")
sectionLabel(String(appLoc: "数值"))
TextField(monitorFieldPlaceholder, text: $value)
.keyboardType(.decimalPad)
.font(.system(size: 18, weight: .semibold, design: .monospaced))
@@ -437,7 +434,7 @@ struct IndicatorQuickSheet: View {
.overlay(fieldBorder)
}
VStack(alignment: .leading, spacing: 8) {
sectionLabel("单位")
sectionLabel(String(appLoc: "单位"))
TextField("mmol/L", text: $unit)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
@@ -455,7 +452,7 @@ struct IndicatorQuickSheet: View {
private var rangeSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel("参考范围")
sectionLabel(String(appLoc: "参考范围"))
Spacer()
if let m = selectedMonitor, m != .bloodPressure {
monitorRangeHint(m)
@@ -486,11 +483,11 @@ struct IndicatorQuickSheet: View {
private var statusSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("状态")
sectionLabel(String(appLoc: "状态"))
HStack(spacing: 8) {
statusChip(.normal, label: "正常", color: Tj.Palette.leaf)
statusChip(.high, label: "偏高 ↑", color: Tj.Palette.brick)
statusChip(.low, label: "偏低 ↓", color: Tj.Palette.amber)
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)
}
}
}
@@ -498,7 +495,7 @@ struct IndicatorQuickSheet: View {
private var autoStatusHint: some View {
let auto = computedSingleStatus
return HStack(spacing: 8) {
sectionLabel("状态(按数值自动判)")
sectionLabel(String(appLoc: "状态(按数值自动判)"))
if let s = auto {
statusBadge(s.label, color: s.color)
} else {
@@ -511,7 +508,7 @@ struct IndicatorQuickSheet: View {
private var timeSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("测量时间")
sectionLabel(String(appLoc: "测量时间"))
DatePicker("", selection: $capturedAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
@@ -520,7 +517,7 @@ struct IndicatorQuickSheet: View {
private var noteSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("备注(可选)")
sectionLabel(String(appLoc: "备注(可选)"))
TextField("例如:空腹采血", text: $note, axis: .vertical)
.lineLimit(1...3)
.padding(.horizontal, 14)
@@ -535,7 +532,7 @@ struct IndicatorQuickSheet: View {
private var reminderSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
sectionLabel("周期提醒")
sectionLabel(String(appLoc: "周期提醒"))
Spacer()
Toggle("", isOn: $reminderEnabled)
.labelsHidden()
@@ -570,13 +567,13 @@ struct IndicatorQuickSheet: View {
}
weekdayPickerRow
HStack(spacing: 8) {
quickFreqChip("每天") {
quickFreqChip(String(appLoc: "每天")) {
reminderWeekdays = Set(1...7)
}
quickFreqChip("工作日") {
quickFreqChip(String(appLoc: "工作日")) {
reminderWeekdays = Set([2, 3, 4, 5, 6])
}
quickFreqChip("周末") {
quickFreqChip(String(appLoc: "周末")) {
reminderWeekdays = Set([1, 7])
}
}
@@ -600,15 +597,23 @@ struct IndicatorQuickSheet: View {
}
private var reminderFrequencyLabel: String {
if reminderWeekdays.count == 7 { return "每天" }
if reminderWeekdays.isEmpty { return "未选" }
let names = ["", "", "", "", "", "", ""]
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 "每周 " + sorted.map { names[$0 - 1] }.joined()
return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined()
}
private var weekdayPickerRow: some View {
let names = ["", "", "", "", "", "", ""]
let names = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""),
]
let weekdayValues = [2, 3, 4, 5, 6, 7, 1] // (Apple Calendar )
return HStack(spacing: 6) {
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
@@ -1074,9 +1079,9 @@ struct IndicatorQuickSheet: View {
private extension IndicatorStatus {
var label: String {
switch self {
case .normal: return "正常"
case .high: return "偏高 ↑"
case .low: return "偏低 ↓"
case .normal: return String(appLoc: "正常")
case .high: return String(appLoc: "偏高 ↑")
case .low: return String(appLoc: "偏低 ↓")
}
}

View File

@@ -2,6 +2,7 @@ import SwiftUI
/// · 使
/// , Service / AIRuntime, DesignSystem token
/// App Store :/
struct AboutView: View {
/// Bundle ,
private var versionText: String {
@@ -19,45 +20,53 @@ struct AboutView: View {
VStack(spacing: 16) {
header
section(icon: "sparkles", title: "这是什么") {
section(icon: "sparkles", title: String(appLoc: "这是什么")) {
paragraph(
"康康是一款以本地优先为设计原则的个人健康影像档案工具。" +
"你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;" +
"设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。"
String(appLoc: "康康是一款以本地优先为设计原则的个人健康影像档案工具。") +
String(appLoc: "你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;") +
String(appLoc: "设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。")
)
}
section(icon: "checklist", title: "主要功能") {
bullet("拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档")
bullet("通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明")
bullet("长期趋势:关注的指标可生成折线图和简要解读")
bullet("本地问答:基于你自己的档案问答,引用可点击回链到原记录")
bullet("隐私优先:健康数据不上传、无需注册账号")
section(icon: "checklist", title: String(appLoc: "主要功能")) {
bullet(String(appLoc: "拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档"))
bullet(String(appLoc: "通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明"))
bullet(String(appLoc: "长期趋势:关注的指标可生成折线图和简要解读"))
bullet(String(appLoc: "本地问答:基于你自己的档案问答,引用可点击回链到原记录"))
bullet(String(appLoc: "隐私优先:健康数据不上传、无需注册账号"))
}
section(icon: "lock.shield", title: "隐私保护") {
bullet("AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据")
bullet("原图与数据库采用系统级文件加密,随设备锁屏受到保护。")
bullet("支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份")
bullet("可选开启 Face ID 启动锁,进一步保护隐私。")
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: "exclamationmark.triangle", title: "使用注意", tint: Tj.Palette.amber) {
bullet("本地 AI 模型体积较大(约 3GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;" +
"模型未就绪时 App 仍可使用,AI 功能会提示前往下载")
bullet("AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对," +
"并以原始报告 / 化验单为准")
bullet("AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。")
bullet("数据保存在本设备:卸载 App 或删除数据后可能无法恢复,重要资料请自行留存原件。")
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: "hand.raised", title: "免责声明", tint: Tj.Palette.brick) {
bullet("康康是一款健康信息记录与参考工具,并非医疗器械,不提供医疗诊断、用药或剂量建议、急诊判断等医疗服务。")
bullet("App 内所有 AI 生成的解读、趋势分析与问答内容仅供信息参考," +
"不构成医疗建议,也不能替代执业医师、药师或其他专业人员的面诊、检查与意见。")
bullet("任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。")
bullet("如出现身体不适或紧急情况,请及时就医或拨打当地急救电话,请勿依赖本 App 进行判断")
bullet("在适用法律允许的范围内,因使用本 App 或依赖其中内容所产生的后果,由使用者自行承担")
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)")
@@ -65,6 +74,12 @@ struct AboutView: View {
.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)

View File

@@ -72,7 +72,7 @@ struct CustomMetricsListView: View {
private var emptyState: some View {
VStack(spacing: 14) {
Spacer(minLength: 40)
TjPlaceholder(label: "还没有自定义指标")
TjPlaceholder(label: String(appLoc: "还没有自定义指标"))
.frame(width: 220, height: 130)
Text("右上角 + 新建一个")
.font(.system(size: 12))
@@ -118,7 +118,7 @@ struct CustomMetricsListView: View {
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(count == 0 ? "未使用" : "\(count)")
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")

View File

@@ -0,0 +1,300 @@
import SwiftUI
import SwiftData
/// /
/// `reminder == nil` ;()
/// @State , SwiftData + ;
struct CustomReminderEditSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
/// nil =
let reminder: CustomReminder?
@State private var title = ""
@State private var note = ""
@State private var pickedTime: Date = .now
@State private var frequency: CustomReminder.Frequency = .daily
@State private var weekdays: Set<Int> = Set(1...7)
@State private var dayOfMonth = 1
@State private var month = 1
@State private var hydrated = false
@State private var showAuthDeniedAlert = false
/// (, ): / / /
private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)]
init(reminder: CustomReminder? = nil) {
self.reminder = reminder
}
private var isEditing: Bool { reminder != nil }
private var trimmedTitle: String {
title.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSave: Bool {
guard !trimmedTitle.isEmpty else { return false }
if frequency == .weekly { return !weekdays.isEmpty }
return true
}
var body: some View {
NavigationStack {
Form {
Section {
TextField(String(appLoc: "做点什么?例:跑步5公里 / 吃2片护肝片"),
text: $title, axis: .vertical)
.lineLimit(1...3)
TextField(String(appLoc: "备注(可选)"), text: $note, axis: .vertical)
.lineLimit(1...3)
.foregroundStyle(Tj.Palette.text2)
}
Section {
Picker(String(appLoc: "重复"), selection: $frequency) {
Text(String(appLoc: "每日")).tag(CustomReminder.Frequency.daily)
Text(String(appLoc: "每周")).tag(CustomReminder.Frequency.weekly)
Text(String(appLoc: "每月")).tag(CustomReminder.Frequency.monthly)
Text(String(appLoc: "每年")).tag(CustomReminder.Frequency.yearly)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
frequencyDetail
} header: {
Text("重复")
}
Section {
timePresetRow
DatePicker(String(appLoc: "时间"), selection: $pickedTime,
displayedComponents: .hourAndMinute)
} header: {
Text("时间")
}
if isEditing {
Section {
Button(role: .destructive) { deleteReminder() } label: {
Label(String(appLoc: "删除提醒"), systemImage: "trash")
}
}
}
}
.scrollContentBackground(.hidden)
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle(isEditing ? String(appLoc: "编辑提醒") : String(appLoc: "新建提醒"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(String(appLoc: "取消")) { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button(String(appLoc: "保存")) { save() }
.fontWeight(.semibold)
.disabled(!canSave)
}
}
.onAppear(perform: hydrate)
.onChange(of: month) { _, newMonth in
// ,(231)
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)
}

View File

@@ -0,0 +1,79 @@
import SwiftUI
/// · ( App ,)
struct LanguageSettingsView: View {
@State private var lang = LanguageManager.shared
var body: some View {
ScrollView {
VStack(spacing: 10) {
ForEach(AppLanguage.allCases) { option in
row(option)
}
Text("切换后整个 App 立即生效,无需重启。")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 4)
.padding(.top, 6)
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("语言")
.navigationBarTitleDisplayMode(.inline)
}
private func row(_ option: AppLanguage) -> some View {
let selected = lang.current == option
return Button {
// .id ,
lang.set(option)
} label: {
HStack(spacing: 12) {
ZStack {
Circle().fill(selected ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
icon(option, selected: selected)
}
.frame(width: 40, height: 40)
Text(option.displayName)
.font(.system(size: 15, weight: selected ? .semibold : .regular))
.foregroundStyle(Tj.Palette.text)
Spacer()
if selected {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
}
.padding(14)
.tjCard()
}
.buttonStyle(.plain)
}
/// :(/A//),
@ViewBuilder
private func icon(_ option: AppLanguage, selected: Bool) -> some View {
let fg = selected ? Tj.Palette.ink : Tj.Palette.text2
switch option.pickerIcon {
case .symbol(let name):
Image(systemName: name)
.font(.system(size: 16))
.foregroundStyle(fg)
case .glyph(let g):
Text(verbatim: g)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(fg)
}
}
}
#Preview {
NavigationStack { LanguageSettingsView() }
}

View File

@@ -4,13 +4,15 @@ import SwiftData
struct MeView: View {
@Environment(\.modelContext) private var ctx
@Query private var profiles: [UserProfile]
@Query private var reminders: [MetricReminder]
@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 }
private var enabledReminderCount: Int { reminders.filter(\.enabled).count }
/// Bundle ,
private var appVersionText: String {
@@ -22,17 +24,24 @@ struct MeView: View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
HStack {
Text("我的")
.font(.tjTitle())
.foregroundStyle(Tj.Palette.text)
Spacer()
}
.padding(.top, 4)
.padding(.bottom, 6)
profileCard
remindersCard
customMetricsCard
modelManagementCard
settingsCard(title: "Face ID 启动锁",
detail: "关闭",
icon: "faceid")
languageCard
faceIDCard
NavigationLink {
AboutView()
} label: {
settingsCard(title: "关于",
settingsCard(title: String(appLoc: "关于"),
detail: appVersionText,
icon: "info.circle")
}
@@ -42,13 +51,14 @@ struct MeView: View {
.padding(.vertical, 20)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("我的")
.navigationBarTitleDisplayMode(.large)
// ( // ), .navigationTitle:
// , App
.onAppear {
if profiles.isEmpty {
_ = UserProfileStore.loadOrCreate(in: ctx)
}
downloadService.refreshStates()
appLock.refreshAvailability()
}
}
}
@@ -89,46 +99,6 @@ struct MeView: View {
.buttonStyle(.plain)
}
private var remindersCard: some View {
NavigationLink {
RemindersListView()
} label: {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(enabledReminderCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "bell.fill")
.font(.system(size: 18))
.foregroundStyle(enabledReminderCount > 0 ? Tj.Palette.ink : Tj.Palette.text2)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("记录提醒")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(reminderLine)
.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 reminderLine: String {
if reminders.isEmpty { return "尚未设置" }
if enabledReminderCount == 0 { return "全部已关闭(\(reminders.count) 条)" }
return "\(enabledReminderCount) 项启用"
}
private var customMetricsCard: some View {
NavigationLink {
CustomMetricsListView()
@@ -164,25 +134,84 @@ struct MeView: View {
}
private var customMetricsLine: String {
if customMetrics.isEmpty { return "添加你自己的长期监测项" }
return "\(customMetrics.count)"
if customMetrics.isEmpty { return String(appLoc: "添加你自己的长期监测项") }
return String(appLoc: "\(customMetrics.count)")
}
private var modelManagementCard: some View {
NavigationLink {
ModelManagementView()
} label: {
settingsCard(title: "模型管理", detail: modelDetail, icon: "cpu")
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 "已就绪" }
if downloadService.isAnyDownloading { return "下载中…" }
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 ? "未下载" : "\(readyCount)/\(ModelKind.allCases.count) 就绪"
return readyCount == 0 ? String(appLoc: "未下载") : String(appLoc: "\(readyCount)/\(ModelKind.allCases.count) 就绪")
}
private var languageCard: some View {
NavigationLink {
LanguageSettingsView()
} label: {
settingsCard(title: String(appLoc: "语言"),
detail: lang.current.displayName,
icon: "character.bubble")
}
.buttonStyle(.plain)
}
// MARK: - Face ID ( Toggle )
private var faceIDCard: some View {
HStack(spacing: 12) {
ZStack {
Circle().fill(lockEnabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "faceid")
.font(.system(size: 18))
.foregroundStyle(lockEnabled ? Tj.Palette.ink : Tj.Palette.text2)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("Face ID 启动锁")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Tj.Palette.text)
Text(faceIDLine)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Toggle("", isOn: faceIDBinding)
.labelsHidden()
.disabled(!appLock.biometryAvailable)
}
.padding(14)
.tjCard()
}
private var faceIDLine: String {
if !appLock.biometryAvailable { return String(appLoc: "本设备未设置 Face ID 或密码") }
return lockEnabled ? String(appLoc: "已开启 · \(appLock.biometryLabel)") : String(appLoc: "关闭")
}
/// , enabled();
private var faceIDBinding: Binding<Bool> {
Binding(
get: { lockEnabled },
set: { newValue in
if newValue {
Task { await appLock.enableWithAuth() }
} else {
appLock.disable()
}
}
)
}
private func settingsCard(title: String, detail: String, icon: String) -> some View {
@@ -212,7 +241,7 @@ struct MeView: View {
private var profileLine: String {
guard let p = profile, p.hasAnyBasics else {
return "点这里完善你的资料"
return String(appLoc: "点这里完善你的资料")
}
return p.summaryLine
}

View File

@@ -132,11 +132,11 @@ struct ModelManagementView: View {
private func statusBadge(_ phase: DownloadPhase) -> some View {
switch phase {
case .idle: return TjBadge(text: "待下载", style: .neutral)
case .downloading: return TjBadge(text: "下载中", style: .amber)
case .verifying: return TjBadge(text: "校验中", style: .amber)
case .ready: return TjBadge(text: "已就绪", style: .leaf)
case .failed: return TjBadge(text: "失败 · 重试", style: .brick)
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)
}
}
@@ -199,13 +199,14 @@ struct ModelManagementView: View {
let name = folder.lastPathComponent
guard let kind = ModelKind.allCases.first(where: { $0.rawValue == name }) else {
importError = "请选择名为 Qwen3-1.7B-4bit 或 Qwen2.5-VL-3B-Instruct-4bit 的文件夹"
let names = ModelKind.allCases.map(\.rawValue).joined(separator: "")
importError = String(appLoc: "请选择名为 \(names) 的文件夹")
return
}
try service.importModel(kind, from: folder)
importError = nil
} catch {
importError = "导入失败:\(error.localizedDescription)"
importError = String(appLoc: "导入失败:\(error.localizedDescription)")
}
}
@@ -217,8 +218,8 @@ struct ModelManagementView: View {
private func subtitle(_ kind: ModelKind) -> String {
switch kind {
case .llm: return "文本解读 · 趋势 / 问答"
case .vl: return "拍照识别报告 → 结构化指标"
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答")
case .vl: return String(appLoc: "拍照识别报告 → 结构化指标")
}
}

View File

@@ -12,11 +12,11 @@ struct ModelSelfTestView: View {
var label: String {
switch self {
case .idle: return "未开始"
case .loading: return "加载模型…"
case .running: return "推理中…"
case .done: return "完成 ✓"
case .failed(let m): return "失败:\(m)"
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)")
}
}
}

View File

@@ -3,27 +3,51 @@ 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
if reminders.isEmpty {
createButton
if isEmpty {
emptyState
} else {
ForEach(reminders) { r in
ReminderRow(
ForEach(customReminders) { r in
CustomReminderRow(
reminder: r,
isEditing: editingId == r.metricId,
onTapEdit: { toggleEdit(r.metricId) },
onChange: { Task { await sync(r) } },
onDelete: { delete(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)
@@ -31,33 +55,65 @@ struct RemindersListView: View {
.padding(.bottom, 32)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("记录提醒")
.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 {
VStack(alignment: .leading, spacing: 4) {
Text("\(enabledCount) / \(reminders.count) 项启用")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Text("提醒在录入「指标记录 · 长期监测」时开启")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
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)
}
.frame(maxWidth: .infinity, alignment: .leading)
.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: "还没有记录提醒\n去「+ 指标记录」录入时打开")
TjPlaceholder(label: String(appLoc: "还没有提醒,点上方新建"))
.frame(width: 240, height: 140)
Spacer()
}
.frame(maxWidth: .infinity)
}
private var enabledCount: Int { reminders.filter(\.enabled).count }
// 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
@@ -76,6 +132,63 @@ struct RemindersListView: View {
}
}
/// : 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
@@ -182,7 +295,11 @@ private struct ReminderRow: View {
}
private var weekdayRow: some View {
let names = ["", "", "", "", "", "", ""]
let names = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""),
]
let weekdayValues = [2, 3, 4, 5, 6, 7, 1]
return HStack(spacing: 6) {
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
@@ -217,5 +334,5 @@ private struct ReminderRow: View {
NavigationStack {
RemindersListView()
}
.modelContainer(for: [MetricReminder.self], inMemory: true)
.modelContainer(for: [MetricReminder.self, CustomReminder.self], inMemory: true)
}

View File

@@ -19,12 +19,12 @@ enum MonitorMetric: String, CaseIterable, Identifiable {
var displayName: String {
switch self {
case .bloodPressure: return "血压"
case .fastingGlucose: return "空腹血糖"
case .postprandialGlucose: return "餐后血糖"
case .temperature: return "体温"
case .heartRate: return "心率"
case .spo2: return "血氧"
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: "血氧")
}
}
@@ -45,43 +45,43 @@ enum MonitorMetric: String, CaseIterable, Identifiable {
case .bloodPressure:
return [
Field(seriesKey: "bp.systolic",
label: "收缩压",
label: String(appLoc: "收缩压"),
unit: "mmHg",
placeholder: "120",
baseRange: 90...140),
Field(seriesKey: "bp.diastolic",
label: "舒张压",
label: String(appLoc: "舒张压"),
unit: "mmHg",
placeholder: "80",
baseRange: 60...90),
]
case .fastingGlucose:
return [Field(seriesKey: "glucose.fasting",
label: "空腹血糖",
label: String(appLoc: "空腹血糖"),
unit: "mmol/L",
placeholder: "5.0",
baseRange: 3.9...6.1)]
case .postprandialGlucose:
return [Field(seriesKey: "glucose.postprandial",
label: "餐后 2h",
label: String(appLoc: "餐后 2h"),
unit: "mmol/L",
placeholder: "6.5",
baseRange: 0...7.8)]
case .temperature:
return [Field(seriesKey: "temperature",
label: "体温",
label: String(appLoc: "体温"),
unit: "°C",
placeholder: "36.5",
baseRange: 36.0...37.2)]
case .heartRate:
return [Field(seriesKey: "heart_rate",
label: "心率",
label: String(appLoc: "心率"),
unit: "bpm",
placeholder: "72",
baseRange: 60...100)]
case .spo2:
return [Field(seriesKey: "spo2",
label: "血氧",
label: String(appLoc: "血氧"),
unit: "%",
placeholder: "98",
baseRange: 95...100)]
@@ -101,7 +101,7 @@ extension MonitorMetric {
/// IndicatorRecordSheet 90-140 mmHg
func rangeText(_ range: ClosedRange<Double>?) -> String {
guard let r = range else { return "无参考范围" }
guard let r = range else { return String(appLoc: "无参考范围") }
let lower = format(r.lowerBound)
let upper = format(r.upperBound)
// baseRange 0...7.8,<7.8

View File

@@ -17,28 +17,47 @@ struct ProfileEditView: View {
}
}
/// `@Bindable` SwiftData @Model `$profile.xxx`
///
///
/// ( 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
@State private var newAllergy = ""
@State private var newFamilyEntry = ""
@State private var newMedication = ""
@State private var newCustomCondition = ""
private static let chronicPresets = [
"高血压", "糖尿病", "冠心病", "高血脂",
"甲状腺疾病", "哮喘", "慢性肾病", "抑郁/焦虑",
]
var body: some View {
Form {
basicsSection
chronicSection
allergySection
familySection
medicationSection
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)
@@ -49,48 +68,75 @@ private struct ProfileEditForm: View {
try? ctx.save()
}
}
}
// MARK: -
// MARK: - :(,)
private var basicsSection: some View {
Section {
birthYearPicker
sexPicker
heightRow
weightRow
bloodTypePicker
} header: {
Text("基本")
} footer: {
if let bmi = profile.bmi {
Text("BMI: \(String(format: "%.1f", bmi)) \(bmiLabel(bmi))")
.font(.system(size: 11))
}
}
/// : `.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)
}
private func bmiLabel(_ bmi: Double) -> String {
switch bmi {
case ..<18.5: return "(偏瘦)"
case ..<24: return "(正常)"
case ..<28: return "(超重)"
default: return "(肥胖)"
}
/// birthYear / expanded ,;
/// `years` (body )
private var years: [Int] {
Array((1900...currentYear).reversed())
}
private var birthYearPicker: some View {
Picker("出生年份", selection: Binding(
private var selectedLabel: String {
if let y = profile.birthYear {
let age = currentYear - y
return age >= 0 ? "\(y)(\(age)\(String(appLoc: "")))" : String(y)
}
return String(appLoc: "未设置")
}
private var yearBinding: Binding<Int> {
Binding(
get: { profile.birthYear ?? 0 },
set: { profile.birthYear = $0 == 0 ? nil : $0 }
)) {
Text("未设置").tag(0)
ForEach((1900...currentYear).reversed(), id: \.self) { year in
Text(String(year)).tag(year)
}
}
)
}
private var sexPicker: some View {
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 }
@@ -101,8 +147,15 @@ private struct ProfileEditForm: View {
}
.pickerStyle(.segmented)
}
}
private var heightRow: some View {
/// :, 80pt
/// ,,
private struct HeightRow: View {
@Bindable var profile: UserProfile
@FocusState private var focused: Bool
var body: some View {
HStack {
Text("身高")
Spacer()
@@ -110,11 +163,19 @@ private struct ProfileEditForm: View {
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.frame(width: 80)
.focused($focused)
Text("cm").foregroundStyle(Tj.Palette.text3)
}
.contentShape(Rectangle())
.onTapGesture { focused = true }
}
}
private var weightRow: some View {
private struct WeightRow: View {
@Bindable var profile: UserProfile
@FocusState private var focused: Bool
var body: some View {
HStack {
Text("体重")
Spacer()
@@ -122,11 +183,18 @@ private struct ProfileEditForm: View {
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 80)
.focused($focused)
Text("kg").foregroundStyle(Tj.Palette.text3)
}
.contentShape(Rectangle())
.onTapGesture { focused = true }
}
}
private var bloodTypePicker: some View {
private struct BloodTypeRow: View {
@Bindable var profile: UserProfile
var body: some View {
Picker("血型", selection: $profile.bloodTypeRaw) {
Text("不知道").tag("")
Text("A 型").tag("A")
@@ -135,19 +203,51 @@ private struct ProfileEditForm: View {
Text("O 型").tag("O")
}
}
}
// MARK: -
/// BMI : heightCM + weightKG,
private struct BMIFooter: View {
@Bindable var profile: UserProfile
private var chronicSection: some View {
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(Self.chronicPresets, id: \.self) { name in
ForEach(presets, id: \.self) { name in
chip(label: name,
selected: profile.chronicConditions.contains(name)) {
toggleCondition(name)
toggle(name)
}
}
ForEach(profile.chronicConditions.filter { !Self.chronicPresets.contains($0) },
ForEach(profile.chronicConditions.filter { !presets.contains($0) },
id: \.self) { name in
chip(label: name, selected: true) {
profile.chronicConditions.removeAll { $0 == name }
@@ -171,56 +271,14 @@ private struct ProfileEditForm: View {
}
}
// MARK: - / /
private var allergySection: some View {
listSection(title: "过敏史", placeholder: "如:青霉素",
items: $profile.allergies, newInput: $newAllergy)
}
private var familySection: some View {
listSection(title: "家族史", placeholder: "如:母亲 高血压",
items: $profile.familyHistory, newInput: $newFamilyEntry)
}
private var medicationSection: some View {
listSection(title: "当前用药", placeholder: "如:缬沙坦 80mg qd",
items: $profile.currentMedications, newInput: $newMedication)
}
private func listSection(title: String, placeholder: String,
items: Binding<[String]>,
newInput: Binding<String>) -> some View {
Section(title) {
ForEach(items.wrappedValue, id: \.self) { item in
HStack {
Text(item)
Spacer()
Button(role: .destructive) {
items.wrappedValue.removeAll { $0 == item }
} label: {
Image(systemName: "minus.circle")
.foregroundStyle(Tj.Palette.brick)
}
.buttonStyle(.borderless)
}
}
HStack {
TextField(placeholder, text: newInput)
Button("") {
let trimmed = newInput.wrappedValue.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty,
!items.wrappedValue.contains(trimmed) else { return }
items.wrappedValue.append(trimmed)
newInput.wrappedValue = ""
}
.disabled(newInput.wrappedValue.trimmingCharacters(in: .whitespaces).isEmpty)
}
private func toggle(_ name: String) {
if profile.chronicConditions.contains(name) {
profile.chronicConditions.removeAll { $0 == name }
} else {
profile.chronicConditions.append(name)
}
}
// MARK: - helpers
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
@@ -233,21 +291,47 @@ private struct ProfileEditForm: View {
}
.buttonStyle(.plain)
}
}
private func toggleCondition(_ name: String) {
if profile.chronicConditions.contains(name) {
profile.chronicConditions.removeAll { $0 == name }
} else {
profile.chronicConditions.append(name)
// 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)
}
}
}
private var currentYear: Int {
Calendar.current.component(.year, from: .now)
}
}
/// chip SwiftUI Wrap, Layout
// MARK: - chip (SwiftUI Wrap, Layout )
struct FlexibleChipGrid<Content: View>: View {
@ViewBuilder let content: () -> Content

View File

@@ -1,159 +0,0 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct A1ViewfinderView: View {
var onShoot: () -> Void
var onClose: () -> Void
@State private var dotPulse = false
var body: some View {
GeometryReader { geometry in
ZStack {
Color(red: 0.04, green: 0.047, blue: 0.04).ignoresSafeArea()
mockCameraPreview(screenHeight: geometry.size.height)
VStack {
HStack {
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white)
.frame(width: 36, height: 36)
}
Spacer()
}
.padding(.horizontal, 6)
.padding(.top, 50)
topHint
Spacer()
}
SmartFramer()
.allowsHitTesting(false)
.ignoresSafeArea()
identifiedPill
.padding(.top, geometry.size.height * 0.62 - 20)
VStack {
Spacer()
bottomControls
}
}
}
#if os(iOS)
.statusBarHidden(false)
#endif
.preferredColorScheme(.dark)
}
private func mockCameraPreview(screenHeight: CGFloat) -> some View {
RadialGradient(
colors: [Color.white.opacity(0.05), Color.clear],
center: .init(x: 0.5, y: 0.3),
startRadius: 20,
endRadius: 400
)
.overlay(alignment: .center) {
VStack(alignment: .leading, spacing: 6) {
Text("总胆固醇 TC 5.42 mmol/L").opacity(0.65)
Text("甘油三酯 TG 1.78 mmol/L").opacity(0.65)
Text("低密度脂蛋白 3.84 mmol/L ↑").fontWeight(.semibold).opacity(1)
Text("高密度脂蛋白 1.21 mmol/L").opacity(0.65)
Text("载脂蛋白 A1 1.42 g/L").opacity(0.45)
Text("载脂蛋白 B 1.04 g/L").opacity(0.45)
}
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text)
.padding(.vertical, 20)
.padding(.horizontal, 18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.92))
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
.rotationEffect(.degrees(-1.2))
.shadow(color: .black.opacity(0.45), radius: 15, x: 0, y: 12)
.padding(.horizontal, 24)
.padding(.vertical, screenHeight * 0.20)
}
}
private var topHint: some View {
Text("对准异常的那一行就好 · 不用拍整张")
.font(.system(size: 12))
.tracking(0.5)
.foregroundStyle(Color.white.opacity(0.92))
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(Capsule().fill(Color(red: 0.08, green: 0.11, blue: 0.094).opacity(0.7)))
.padding(.top, 6)
}
private var identifiedPill: some View {
HStack(spacing: 6) {
Circle()
.fill(Tj.Palette.paper)
.frame(width: 6, height: 6)
.opacity(dotPulse ? 1 : 0.35)
Text("AI 已识别到 1 项指标")
.font(.system(size: 11))
.tracking(0.5)
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Capsule().fill(Color(red: 0.37, green: 0.47, blue: 0.31).opacity(0.85)))
.onAppear {
withAnimation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true)) {
dotPulse.toggle()
}
}
}
private var bottomControls: some View {
HStack {
CircleIconButton(icon: "bolt.fill", size: 44) { }
Spacer()
Button(action: onShoot) {
ZStack {
Circle().fill(Tj.Palette.ink)
Circle().strokeBorder(Tj.Palette.paper, lineWidth: 4)
}
.frame(width: 72, height: 72)
.overlay(
Circle().strokeBorder(Color.white.opacity(0.2), lineWidth: 1)
.frame(width: 76, height: 76)
)
}
.buttonStyle(.plain)
Spacer()
CircleIconButton(icon: "photo.on.rectangle", size: 44) { }
}
.padding(.horizontal, 32)
.padding(.bottom, 40)
}
}
private struct CircleIconButton: View {
let icon: String
let size: CGFloat
let action: () -> Void
var body: some View {
Button(action: action) {
ZStack {
Circle().fill(Color.white.opacity(0.12))
Image(systemName: icon)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(Tj.Palette.paper)
}
.frame(width: size, height: size)
}
.buttonStyle(.plain)
}
}

View File

@@ -1,180 +0,0 @@
import SwiftUI
struct A2ConfirmView: View {
var onSave: () -> Void
var onNext: () -> Void
var onBack: () -> Void
@State private var expanded = false
var body: some View {
VStack(spacing: 0) {
header
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
croppedPhoto.padding(.bottom, 14)
resultCard.padding(.bottom, 16)
actions
}
.padding(.horizontal, 18)
.padding(.bottom, 18)
}
}
.background(Tj.Palette.sand.ignoresSafeArea())
}
private var header: some View {
HStack(spacing: 6) {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
}
Text("核对识别结果")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("识别用时 0.4s · 本地")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.sand2))
}
.padding(.horizontal, 12)
.padding(.top, 4)
.padding(.bottom, 8)
}
private var croppedPhoto: some View {
ZStack(alignment: .topTrailing) {
Text("低密度脂蛋白 3.84 mmol/L ↑")
.font(.system(size: 13, design: .monospaced))
.fontWeight(.semibold)
.tracking(0.3)
.foregroundStyle(Tj.Palette.text)
.padding(.vertical, 14)
.padding(.horizontal, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.92))
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06),
radius: 2, x: 0, y: 1)
Text("已裁剪")
.font(.system(size: 9))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
.padding(.top, 8)
.padding(.trailing, 10)
}
}
private var resultCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("指标名 · 可编辑")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Text("低密度脂蛋白胆固醇")
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("LDL-C")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
TjBadge(text: "偏高", style: .brick)
}
HStack(spacing: 12) {
FieldBox(label: "数值") {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("3.84")
.font(.system(size: 30, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text("mmol/L")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
FieldBox(label: "参考范围") {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("< 3.40")
.font(.system(size: 14, design: .monospaced))
.foregroundStyle(Tj.Palette.text2)
Text("mmol/L")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
}
Button { withAnimation { expanded.toggle() } } label: {
HStack(alignment: .top, spacing: 10) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Tj.Palette.brick)
.frame(width: 4)
Text(expanded
? "超过参考上限 0.44属轻度偏高。建议关注饮食结构减少动物脂肪摄入3 个月内复查。若家族有心血管病史,可与医生沟通是否需要药物干预。"
: "超过参考上限 0.44,属轻度偏高。点击展开详细解读 ")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text2)
.lineSpacing(5)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand)
)
}
.buttonStyle(.plain)
}
.padding(18)
.tjCard()
}
private var actions: some View {
VStack(spacing: 10) {
Button(action: onSave) {
Text("保存到记录")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
Button(action: onNext) {
HStack(spacing: 8) {
Image(systemName: "camera.fill").font(.system(size: 14))
Text("继续拍下一项")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
}
}
}
private struct FieldBox<Content: View>: View {
let label: String
@ViewBuilder var content: Content
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.system(size: 10))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
content
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
.padding(.horizontal, 12)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
}

View File

@@ -1,124 +0,0 @@
import SwiftUI
struct A3BatchItem {
let name: String
let value: String
let unit: String
let range: String
let status: IndicatorStatus
}
struct A3BatchView: View {
var onAddMore: () -> Void
var onFinish: () -> Void
var onBack: () -> Void
let items: [A3BatchItem] = [
.init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high),
.init(name: "甘油三酯 TG", value: "1.78", unit: "mmol/L", range: "< 1.70", status: .high),
.init(name: "空腹血糖 GLU", value: "5.4", unit: "mmol/L", range: "3.96.1", status: .normal),
]
var body: some View {
VStack(spacing: 0) {
header
ScrollView(showsIndicators: false) {
VStack(spacing: 10) {
ForEach(Array(items.enumerated()), id: \.offset) { idx, it in
BatchRow(index: idx + 1, item: it)
}
addRow
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
HStack(spacing: 10) {
Button {
onFinish()
} label: {
Text("全部保存(\(items.count)").frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
}
.padding(.horizontal, 16)
.padding(.bottom, 14)
}
.background(Tj.Palette.sand.ignoresSafeArea())
}
private var header: some View {
HStack(spacing: 6) {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
}
VStack(alignment: .leading, spacing: 2) {
Text("本次已记录 \(items.count)")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("核对后一次保存")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Text("· · ·")
.font(.system(size: 14, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
.padding(.trailing, 12)
}
.padding(.horizontal, 12)
.padding(.top, 4)
.padding(.bottom, 12)
}
private var addRow: some View {
Button(action: onAddMore) {
HStack(spacing: 8) {
Image(systemName: "camera").font(.system(size: 14))
Text("再拍一项")
.font(.system(size: 13))
}
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.line, style: StrokeStyle(lineWidth: 1.5, dash: [4, 4]))
)
}
.buttonStyle(.plain)
}
}
private struct BatchRow: View {
let index: Int
let item: A3BatchItem
var body: some View {
HStack(spacing: 12) {
TjPlaceholder(label: "#\(index)")
.frame(width: 60, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text(item.name)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Text("范围 \(item.range) \(item.unit)")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(item.value)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(item.status == .high ? Tj.Palette.brick : Tj.Palette.text)
TjBadge(text: item.status == .high ? "偏高" : "正常",
style: item.status == .high ? .brick : .leaf)
}
}
.padding(12)
.tjCard()
}
}

View File

@@ -1,60 +0,0 @@
import SwiftUI
private enum QuickStep: Hashable {
case viewfinder
case confirm
case batch
}
struct QuickCaptureFlow: View {
var onClose: () -> Void
@State private var step: QuickStep = .viewfinder
@State private var snapCount = 0
var body: some View {
ZStack {
switch step {
case .viewfinder:
A1ViewfinderView(
onShoot: {
snapCount += 1
withAnimation(.easeInOut(duration: 0.25)) { step = .confirm }
},
onClose: onClose
)
.transition(.opacity)
case .confirm:
A2ConfirmView(
onSave: {
if snapCount >= 2 {
withAnimation { step = .batch }
} else {
onClose()
}
},
onNext: {
withAnimation { step = .viewfinder }
},
onBack: {
withAnimation { step = .viewfinder }
}
)
.transition(.opacity)
case .batch:
A3BatchView(
onAddMore: {
withAnimation { step = .viewfinder }
},
onFinish: onClose,
onBack: {
withAnimation { step = .confirm }
}
)
.transition(.opacity)
}
}
}
}

View File

@@ -0,0 +1,254 @@
import SwiftUI
import SwiftData
import UIKit
import Combine
/// ·
/// VL ( indicators) Indicator( Report)
///
/// :
/// ```
/// idle(/) analyzing(croppedImage) confirm(items)
/// /
/// confirm( + warning)
/// confirm save dismiss · confirm idle
/// ```
struct QuickRegionCaptureFlow: View {
@Environment(\.modelContext) private var ctx
let onClose: () -> Void
@State private var phase: Phase = .idle
@State private var analyzeTask: Task<Void, Never>? = nil
/// VL (); cancel ,UI
private let analyzeTimeoutSeconds: Int = 30
enum Phase {
case idle
case analyzing(image: UIImage)
case confirm(image: UIImage?, items: [QuickRegionItem], warning: String?)
}
var body: some View {
content
.background(Tj.Palette.sand.ignoresSafeArea())
}
@ViewBuilder
private var content: some View {
switch phase {
case .idle:
captureEntry
.ignoresSafeArea()
case .analyzing(let image):
NavigationStack {
AnalyzingRegionView(
image: image,
timeoutSeconds: analyzeTimeoutSeconds,
onCancel: {
analyzeTask?.cancel()
analyzeTask = nil
// (,)
phase = .confirm(image: image, items: [],
warning: String(appLoc: "已取消识别,手动补充或重拍"))
}
)
.navigationTitle(String(appLoc: "本地识别中…"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { cancelAll() }
.foregroundStyle(Tj.Palette.text)
}
}
}
case .confirm(let image, let items, let warning):
NavigationStack {
QuickRegionConfirmView(
image: image,
items: items,
warning: warning,
onSave: { finalItems, capturedAt in save(items: finalItems, capturedAt: capturedAt) },
onCancel: cancelAll,
onRetake: { phase = .idle }
)
.navigationTitle(String(appLoc: "核对异常项"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { cancelAll() }
.foregroundStyle(Tj.Palette.text)
}
}
}
}
}
// MARK: - :()/ ()
@ViewBuilder
private var captureEntry: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
onFinish: { imgs in if let first = imgs.first { startAnalyze(image: first) } },
onCancel: onClose
)
#else
RegionCameraView(
onCapture: { startAnalyze(image: $0) },
onCancel: onClose
)
#endif
}
// MARK: -
private func startAnalyze(image: UIImage) {
analyzeTask?.cancel()
phase = .analyzing(image: image)
let timeout = analyzeTimeoutSeconds
// MainActor ,Task{} , phase 线,
analyzeTask = Task {
guard let data = image.jpegData(compressionQuality: 0.9) else {
phase = .confirm(image: image, items: [],
warning: String(appLoc: "图片编码失败,手动补充或重拍"))
return
}
let watchdog = Task {
try? await Task.sleep(for: .seconds(timeout))
analyzeTask?.cancel()
}
defer { watchdog.cancel() }
do {
let parsed = try await CaptureService.shared.recognizeRegion(imageData: data)
if Task.isCancelled {
phase = .confirm(image: image, items: [],
warning: String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍"))
return
}
let items = Self.buildItems(from: parsed)
phase = .confirm(
image: image,
items: items,
warning: items.isEmpty ? String(appLoc: "没读出指标,手动补充或重拍") : nil
)
} catch CaptureError.modelNotReady {
phase = .confirm(image: image, items: [],
warning: String(appLoc: "VL 模型未就绪,手动补充"))
} catch let CaptureError.parseFailed(msg) {
phase = .confirm(image: image, items: [],
warning: String(appLoc: "VL 输出无法解析:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
phase = .confirm(image: image, items: [],
warning: Task.isCancelled
? String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍")
: String(appLoc: "推理失败:\(msg)"))
} catch {
phase = .confirm(image: image, items: [],
warning: String(appLoc: "未知错误:\(error.localizedDescription)"))
}
}
}
/// VL ,(high/low)
private static func buildItems(from parsed: [ParsedReport.ParsedIndicator]) -> [QuickRegionItem] {
let mapped = parsed.map {
QuickRegionItem(name: $0.name, value: $0.value, unit: $0.unit,
range: $0.range, status: $0.status, include: true)
}
// (stable):high/low ,normal
return mapped.enumerated().sorted { a, b in
let aAbn = a.element.status != .normal
let bAbn = b.element.status != .normal
if aAbn != bAbn { return aAbn && !bAbn }
return a.offset < b.offset
}.map { $0.element }
}
// MARK: - /
private func cancelAll() {
analyzeTask?.cancel()
analyzeTask = nil
onClose()
}
/// Indicator(): Report Asset seriesKey
private func save(items: [QuickRegionItem], capturedAt: Date) {
let selected = items.filter {
$0.include
&& !$0.name.trimmingCharacters(in: .whitespaces).isEmpty
&& !$0.value.trimmingCharacters(in: .whitespaces).isEmpty
}
for item in selected {
let indicator = Indicator(
name: item.name.trimmingCharacters(in: .whitespaces),
value: item.value.trimmingCharacters(in: .whitespaces),
unit: item.unit.trimmingCharacters(in: .whitespaces),
range: item.range.trimmingCharacters(in: .whitespaces),
status: item.status,
capturedAt: capturedAt
)
ctx.insert(indicator)
}
try? ctx.save()
onClose()
}
}
// MARK: -
private struct AnalyzingRegionView: View {
let image: UIImage
let timeoutSeconds: Int
let onCancel: () -> Void
@State private var elapsed: Int = 0
private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack(spacing: 20) {
Spacer()
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 200)
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(ProgressView().tint(Tj.Palette.ink).scaleEffect(1.3))
)
VStack(spacing: 6) {
Text("识别框内指标")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("100% 本地推理 · 已用 \(elapsed)s")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
if elapsed >= timeoutSeconds - 5 {
Text("快超时了,>\(timeoutSeconds)s 会自动转手动录入")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.amber)
}
}
Button("取消识别 · 改为手动录入", action: onCancel)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
.padding(.top, 4)
Spacer()
}
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Tj.Palette.sand)
.onReceive(tick) { _ in elapsed += 1 }
}
}

View File

@@ -0,0 +1,305 @@
import SwiftUI
import UIKit
/// · VL + ,()
/// = Indicator
struct QuickRegionConfirmView: View {
let image: UIImage?
let warning: String?
let onSave: ([QuickRegionItem], Date) -> Void
let onCancel: () -> Void
let onRetake: () -> Void
@State private var items: [QuickRegionItem]
@State private var capturedAt: Date
init(image: UIImage?,
items: [QuickRegionItem],
warning: String?,
capturedAt: Date = .now,
onSave: @escaping ([QuickRegionItem], Date) -> Void,
onCancel: @escaping () -> Void,
onRetake: @escaping () -> Void) {
self.image = image
self.warning = warning
self.onSave = onSave
self.onCancel = onCancel
self.onRetake = onRetake
_items = State(initialValue: items)
_capturedAt = State(initialValue: capturedAt)
}
private var selectedCount: Int {
items.filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty
&& !$0.value.trimmingCharacters(in: .whitespaces).isEmpty }.count
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if let warning { warningBanner(warning) }
if let image { thumbnailCard(image) }
timeCard
itemsCard
}
.padding(20)
}
.safeAreaInset(edge: .bottom) { bottomBar }
.background(Tj.Palette.sand.ignoresSafeArea())
}
// MARK: -
private func warningBanner(_ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.amber)
Text(text)
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.amber.opacity(0.12))
)
}
private func thumbnailCard(_ image: UIImage) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("拍到的局部")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Text("仅核对用 · 不保存照片")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
.frame(maxHeight: 180)
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
Button {
onRetake()
} label: {
Label("重拍", systemImage: "camera.rotate")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
}
}
.padding(16)
.tjCard()
}
private var timeCard: some View {
VStack(alignment: .leading, spacing: 10) {
Text("测量时间")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
DatePicker("", selection: $capturedAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
}
.padding(16)
.tjCard()
}
private var itemsCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("识别到的指标 (\(items.count))")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Button {
items.append(QuickRegionItem(name: "", value: "", unit: "", range: "",
status: .high, include: true))
} label: {
Label("加一项", systemImage: "plus.circle.fill")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
}
}
if items.isEmpty {
Text("没有识别到指标,点「加一项」手动补充,或返回重拍")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 20)
} else {
ForEach($items) { $item in
itemRow($item)
}
}
}
.padding(16)
.tjCard()
}
private func itemRow(_ item: Binding<QuickRegionItem>) -> some View {
let abnormal = item.wrappedValue.status != .normal
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Button {
item.wrappedValue.include.toggle()
} label: {
Image(systemName: item.wrappedValue.include ? "checkmark.circle.fill" : "circle")
.font(.system(size: 20))
.foregroundStyle(item.wrappedValue.include ? Tj.Palette.ink : Tj.Palette.text3)
}
.buttonStyle(.plain)
TextField(String(appLoc: "指标名"), text: item.name)
.font(.system(size: 15, weight: .medium))
if abnormal {
Text(statusLabel(item.wrappedValue.status))
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(statusColor(item.wrappedValue.status))
.padding(.horizontal, 7).padding(.vertical, 3)
.background(Capsule().fill(statusColor(item.wrappedValue.status).opacity(0.16)))
}
Button {
if let idx = items.firstIndex(where: { $0.id == item.wrappedValue.id }) {
items.remove(at: idx)
}
} label: {
Image(systemName: "trash")
.font(.system(size: 14))
.foregroundStyle(Tj.Palette.brick)
}
}
HStack(spacing: 10) {
fieldCol(String(appLoc: "数值"), item.value, width: 80, mono: true)
fieldCol(String(appLoc: "单位"), item.unit, width: 80)
fieldCol(String(appLoc: "范围"), item.range)
}
statusPicker(item)
}
.padding(12)
.opacity(item.wrappedValue.include ? 1 : 0.5)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(abnormal ? statusColor(item.wrappedValue.status).opacity(0.6) : Tj.Palette.line,
lineWidth: abnormal ? 1.5 : 1)
)
}
private func fieldCol(_ label: String, _ text: Binding<String>, width: CGFloat? = nil,
mono: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
TextField("", text: text)
.font(.system(size: 14, weight: mono ? .semibold : .regular,
design: mono ? .monospaced : .default))
.keyboardType(mono ? .decimalPad : .default)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
.frame(width: width)
}
.frame(maxWidth: width == nil ? .infinity : nil, alignment: .leading)
}
private func statusPicker(_ item: Binding<QuickRegionItem>) -> some View {
HStack(spacing: 8) {
ForEach(IndicatorStatus.allCases, id: \.self) { st in
let selected = item.wrappedValue.status == st
Button {
item.wrappedValue.status = st
} label: {
Text(statusLabel(st))
.font(.system(size: 12, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text2)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Capsule().fill(selected ? statusColor(st) : Tj.Palette.paper))
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1))
}
.buttonStyle(.plain)
}
Spacer()
}
}
private func statusLabel(_ s: IndicatorStatus) -> String {
switch s {
case .normal: return String(appLoc: "正常")
case .high: return String(appLoc: "偏高 ↑")
case .low: return String(appLoc: "偏低 ↓")
}
}
private func statusColor(_ s: IndicatorStatus) -> Color {
switch s {
case .normal: return Tj.Palette.leaf
case .high: return Tj.Palette.brick
case .low: return Tj.Palette.amber
}
}
private var bottomBar: some View {
HStack(spacing: 12) {
Button(action: onCancel) {
Text("取消")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
Button {
onSave(items, capturedAt)
} label: {
Text(selectedCount > 0 ? "\(String(appLoc: "保存到记录"))(\(selectedCount))"
: String(appLoc: "保存到记录"))
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
.disabled(selectedCount == 0)
.opacity(selectedCount == 0 ? 0.4 : 1)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(
Tj.Palette.sand
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
)
}
}
/// `include` (,)
struct QuickRegionItem: Identifiable {
let id = UUID()
var name: String
var value: String
var unit: String
var range: String
var status: IndicatorStatus
var include: Bool
}

View File

@@ -0,0 +1,372 @@
import SwiftUI
import AVFoundation
import UIKit
import Combine
/// ·
/// + + **** UIImage
/// (,QuickRegionCaptureFlow 退 PhotoPicker)
///
/// : bake `.up`(), aspect-fill
/// (view ) rect( `RegionImageCropper`)
/// `metadataOutputRectConverted` ,
struct RegionCameraView: View {
let onCapture: (UIImage) -> Void
let onCancel: () -> Void
@StateObject private var controller = RegionCameraController()
@State private var authState: AuthState = .checking
@State private var isCapturing = false
@State private var flash = false
enum AuthState { case checking, authorized, denied }
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
switch authState {
case .checking:
ProgressView().tint(.white)
case .denied:
deniedView
case .authorized:
cameraStack
}
if flash {
Color.white.ignoresSafeArea().transition(.opacity)
}
}
.task { await resolveAuth() }
}
// MARK: - + +
private var cameraStack: some View {
GeometryReader { proxy in
let box = RegionFraming.box(in: proxy.size)
ZStack {
RegionCameraPreview(controller: controller)
.ignoresSafeArea()
// (even-odd ),
Canvas { ctx, size in
var path = Path(CGRect(origin: .zero, size: size))
path.addPath(Path(roundedRect: box, cornerRadius: Tj.Radius.md))
ctx.fill(path, with: .color(.black.opacity(0.5)), style: FillStyle(eoFill: true))
}
.ignoresSafeArea()
.allowsHitTesting(false)
//
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Color.white.opacity(0.95),
style: StrokeStyle(lineWidth: 2, dash: [8, 6]))
.frame(width: box.width, height: box.height)
.position(x: box.midX, y: box.midY)
.allowsHitTesting(false)
//
Text("把异常项放进框里 · 对准一两行")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Capsule().fill(.black.opacity(0.4)))
.position(x: box.midX, y: box.minY - 22)
.allowsHitTesting(false)
controlsOverlay
}
}
}
private var controlsOverlay: some View {
VStack {
HStack {
Button {
onCancel()
} label: {
Text("取消")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Capsule().fill(.black.opacity(0.35)))
}
Spacer()
}
.padding(.horizontal, 18)
.padding(.top, 8)
Spacer()
shutterButton
.padding(.bottom, 36)
}
}
private var shutterButton: some View {
Button {
capture()
} label: {
ZStack {
Circle().fill(.white).frame(width: 72, height: 72)
Circle().strokeBorder(.white.opacity(0.6), lineWidth: 3).frame(width: 84, height: 84)
if isCapturing {
ProgressView().tint(.black)
}
}
}
.disabled(isCapturing)
.accessibilityLabel("拍摄异常项")
}
private var deniedView: some View {
VStack(spacing: 16) {
Image(systemName: "camera.fill")
.font(.system(size: 40))
.foregroundStyle(.white.opacity(0.8))
Text("相机权限未开启")
.font(.tjH2())
.foregroundStyle(.white)
Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。")
.font(.system(size: 13))
.foregroundStyle(.white.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 36)
HStack(spacing: 12) {
Button("取消") { onCancel() }
.font(.system(size: 15))
.foregroundStyle(.white)
.padding(.horizontal, 18).padding(.vertical, 10)
.background(Capsule().strokeBorder(.white.opacity(0.5), lineWidth: 1))
Button("去设置") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.black)
.padding(.horizontal, 18).padding(.vertical, 10)
.background(Capsule().fill(.white))
}
}
}
// MARK: -
private func capture() {
guard !isCapturing else { return }
isCapturing = true
withAnimation(.easeOut(duration: 0.08)) { flash = true }
controller.capture { image in
withAnimation(.easeIn(duration: 0.15)) { flash = false }
isCapturing = false
guard let image else { return }
onCapture(image)
}
}
private func resolveAuth() async {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
authState = .authorized
case .notDetermined:
let granted = await AVCaptureDevice.requestAccess(for: .video)
authState = granted ? .authorized : .denied
default:
authState = .denied
}
}
}
// MARK: - (UIView SwiftUI ,)
enum RegionFraming {
/// 84% , 160 28%
static func box(in size: CGSize) -> CGRect {
guard size.width > 0, size.height > 0 else { return .zero }
let w = size.width * 0.84
let h = min(160, size.height * 0.28)
let x = (size.width - w) / 2
let y = (size.height - h) / 2 - size.height * 0.06
return CGRect(x: x, y: y, width: w, height: h)
}
}
// MARK: -
enum RegionImageCropper {
/// (view ) `.resizeAspectFill` `.up` rect
/// : aspect-fill viewSize,
/// `metadataOutputRectConverted`(****,
/// x/y ,, RegionImageCropperTests)
static func cropRect(photoPixelSize p: CGSize, box: CGRect, in viewSize: CGSize) -> CGRect {
guard p.width > 0, p.height > 0, viewSize.width > 0, viewSize.height > 0 else { return .zero }
// aspect-fill:,
let scale = max(viewSize.width / p.width, viewSize.height / p.height)
let scaledW = p.width * scale
let scaledH = p.height * scale
// ,
let ox = (viewSize.width - scaledW) / 2
let oy = (viewSize.height - scaledH) / 2
// :,
var x = (box.minX - ox) / scale
var y = (box.minY - oy) / scale
var w = box.width / scale
var h = box.height / scale
//
x = max(0, min(p.width, x))
y = max(0, min(p.height, y))
w = max(0, min(p.width - x, w))
h = max(0, min(p.height - y, h))
return CGRect(x: x, y: y, width: w, height: h).integral
}
/// `.up` (`box` / `viewSize` view );退
static func crop(_ image: UIImage, box: CGRect, viewSize: CGSize) -> UIImage {
guard let cg = image.cgImage else { return image }
let rect = cropRect(photoPixelSize: CGSize(width: cg.width, height: cg.height),
box: box, in: viewSize)
guard rect.width >= 1, rect.height >= 1, let cropped = cg.cropping(to: rect) else { return image }
return UIImage(cgImage: cropped, scale: image.scale, orientation: .up)
}
}
extension UIImage {
/// EXIF bake , `.up` ,便 rect CGImage
func normalizedUp() -> UIImage {
if imageOrientation == .up { return self }
let format = UIGraphicsImageRendererFormat.default()
format.scale = scale
let renderer = UIGraphicsImageRenderer(size: size, format: format)
return renderer.image { _ in draw(in: CGRect(origin: .zero, size: size)) }
}
}
// MARK: - AVFoundation
/// SwiftUI ,(weak UIView)
final class RegionCameraController: ObservableObject {
weak var view: RegionPreviewUIView?
func capture(_ completion: @escaping (UIImage?) -> Void) {
guard let view else { completion(nil); return }
view.capture(completion: completion)
}
}
struct RegionCameraPreview: UIViewRepresentable {
let controller: RegionCameraController
func makeUIView(context: Context) -> RegionPreviewUIView {
let v = RegionPreviewUIView()
controller.view = v
return v
}
func updateUIView(_ uiView: RegionPreviewUIView, context: Context) {}
static func dismantleUIView(_ uiView: RegionPreviewUIView, coordinator: ()) {
uiView.stop()
}
}
/// + ,
final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
private let session = AVCaptureSession()
private let output = AVCapturePhotoOutput()
private var previewLayer: AVCaptureVideoPreviewLayer?
private var setupDone = false
private var captureCompletion: ((UIImage?) -> Void)?
override func didMoveToWindow() {
super.didMoveToWindow()
guard !setupDone, window != nil else { return }
setupDone = true
configure()
}
private func configure() {
session.beginConfiguration()
session.sessionPreset = .photo
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
session.commitConfiguration()
return
}
session.addInput(input)
if session.canAddOutput(output) { session.addOutput(output) }
session.commitConfiguration()
let preview = AVCaptureVideoPreviewLayer(session: session)
preview.videoGravity = .resizeAspectFill
preview.frame = bounds
layer.addSublayer(preview)
self.previewLayer = preview
applyPortrait(preview.connection)
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.session.startRunning()
}
}
/// (iOS 17+ videoRotationAngle, videoOrientation )
private func applyPortrait(_ connection: AVCaptureConnection?) {
guard let connection else { return }
if connection.isVideoRotationAngleSupported(90) {
connection.videoRotationAngle = 90
}
}
override func layoutSubviews() {
super.layoutSubviews()
previewLayer?.frame = bounds
}
func capture(completion: @escaping (UIImage?) -> Void) {
guard session.isRunning else { completion(nil); return }
captureCompletion = completion
applyPortrait(output.connection(with: .video))
output.capturePhoto(with: AVCapturePhotoSettings(), delegate: self)
}
func stop() {
guard session.isRunning else { return }
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.session.stopRunning()
}
}
func photoOutput(_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?) {
let completion = captureCompletion
captureCompletion = nil
// AVFoundation ,SwiftUI 线
let deliver: (UIImage?) -> Void = { result in
DispatchQueue.main.async { completion?(result) }
}
guard error == nil,
let data = photo.fileDataRepresentation(),
let image = UIImage(data: data) else {
deliver(nil)
return
}
let upright = image.normalizedUp()
guard previewLayer != nil else {
deliver(upright)
return
}
// : .resizeAspectFill bounds,,
// aspect-fill rect bounds 线
DispatchQueue.main.async {
let viewSize = self.bounds.size
let box = RegionFraming.box(in: viewSize)
let cropped = RegionImageCropper.crop(upright, box: box, viewSize: viewSize)
completion?(cropped)
}
}
}

View File

@@ -1,100 +0,0 @@
import SwiftUI
struct SmartFramer: View {
var radius: CGFloat = 10
var height: CGFloat = 56
@State private var breathing = false
var body: some View {
GeometryReader { geo in
ZStack {
Color.black.opacity(0.32)
.mask(
Rectangle()
.overlay(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.frame(height: height)
.padding(.horizontal, geo.size.width * 0.08)
.blendMode(.destinationOut)
)
.compositingGroup()
)
RoundedRectangle(cornerRadius: radius + 4, style: .continuous)
.stroke(Color(red: 0.95, green: 0.78, blue: 0.45), lineWidth: 1.5)
.shadow(color: Color(red: 0.95, green: 0.78, blue: 0.45).opacity(0.5), radius: 8)
.frame(height: height + 8)
.padding(.horizontal, geo.size.width * 0.08 - 4)
.opacity(breathing ? 1 : 0.35)
cornerMarks(in: geo.size)
}
.frame(width: geo.size.width, height: geo.size.height)
.onAppear {
withAnimation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true)) {
breathing.toggle()
}
}
}
}
private func cornerMarks(in size: CGSize) -> some View {
let inset = size.width * 0.08
return ZStack {
ForEach(Corner.allCases, id: \.self) { corner in
CornerMark(corner: corner, radius: radius)
.frame(width: 18, height: 18)
.position(corner.position(in: size, inset: inset, frameHeight: height))
}
}
}
}
private enum Corner: CaseIterable {
case tl, tr, bl, br
func position(in size: CGSize, inset: CGFloat, frameHeight: CGFloat) -> CGPoint {
let centerY = size.height / 2
let top = centerY - frameHeight / 2
let bottom = centerY + frameHeight / 2
switch self {
case .tl: return CGPoint(x: inset, y: top)
case .tr: return CGPoint(x: size.width - inset, y: top)
case .bl: return CGPoint(x: inset, y: bottom)
case .br: return CGPoint(x: size.width - inset, y: bottom)
}
}
}
private struct CornerMark: View {
let corner: Corner
let radius: CGFloat
var body: some View {
Path { p in
let r = min(radius, 8)
switch corner {
case .tl:
p.move(to: CGPoint(x: 0, y: 18))
p.addLine(to: CGPoint(x: 0, y: r))
p.addQuadCurve(to: CGPoint(x: r, y: 0), control: CGPoint(x: 0, y: 0))
p.addLine(to: CGPoint(x: 18, y: 0))
case .tr:
p.move(to: CGPoint(x: 0, y: 0))
p.addLine(to: CGPoint(x: 18 - r, y: 0))
p.addQuadCurve(to: CGPoint(x: 18, y: r), control: CGPoint(x: 18, y: 0))
p.addLine(to: CGPoint(x: 18, y: 18))
case .bl:
p.move(to: CGPoint(x: 0, y: 0))
p.addLine(to: CGPoint(x: 0, y: 18 - r))
p.addQuadCurve(to: CGPoint(x: r, y: 18), control: CGPoint(x: 0, y: 18))
p.addLine(to: CGPoint(x: 18, y: 18))
case .br:
p.move(to: CGPoint(x: 0, y: 18))
p.addLine(to: CGPoint(x: 18 - r, y: 18))
p.addQuadCurve(to: CGPoint(x: 18, y: 18 - r), control: CGPoint(x: 18, y: 18))
p.addLine(to: CGPoint(x: 18, y: 0))
}
}
.stroke(Tj.Palette.paper, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
}
}

View File

@@ -1,25 +1,30 @@
import SwiftUI
enum RecordKind: String, Identifiable, CaseIterable {
case quick, indicator, archive, diary, symptom
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 "异常项快拍"
case .indicator: return "指标记录"
case .archive: return "关键报告归档"
case .diary: return "文字日记"
case .symptom: return "症状开始"
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 "拍一张化验单,VL 自动识别"
case .indicator: return "手动填一项指标(免拍照)"
case .archive: return "完整保存整份报告(可多页)"
case .diary: return "记录心情、用药、其他"
case .symptom: return "开始一个持续症状,结束时再点结束"
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 {
@@ -27,8 +32,9 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .quick: return "camera.fill"
case .indicator: return "number.square.fill"
case .archive: return "doc.fill"
case .diary: return "pencil"
case .diary: return "heart.text.square"
case .symptom: return "waveform.path.ecg"
case .reminder: return "bell.badge"
}
}
var accent: Color {
@@ -38,6 +44,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .archive: return Tj.Palette.ink
case .diary: return Tj.Palette.leaf
case .symptom: return Tj.Palette.amber
case .reminder: return Tj.Palette.leaf
}
}
}
@@ -64,8 +71,10 @@ struct RecordSheet: View {
}
.padding(.bottom, 14)
VStack(spacing: 10) {
ForEach(RecordKind.allCases) { kind in
// ScrollView :6 detent ,
ScrollView {
VStack(spacing: 10) {
ForEach(RecordKind.displayOrder) { kind in
Button {
onPick(kind)
} label: {
@@ -97,8 +106,10 @@ struct RecordSheet: View {
}
.buttonStyle(.plain)
}
}
.padding(.bottom, 22)
}
.padding(.bottom, 22)
.scrollIndicators(.hidden)
}
.padding(.horizontal, 18)
.background(

View File

@@ -1,10 +1,11 @@
import SwiftUI
import SwiftData
private let symptomPresets: [String] = [
"头痛", "咳嗽", "腹痛", "发烧",
"恶心", "失眠", "疲劳", "关节痛"
]
/// :,( 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
@@ -77,10 +78,10 @@ struct SymptomStartSheet: View {
private var presetSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("常见症状")
sectionLabel(String(appLoc: "常见症状"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(symptomPresets, id: \.self) { item in
ForEach(symptomPresets(), id: \.self) { item in
chip(item, selected: name == item) {
name = item
customName = ""
@@ -93,7 +94,7 @@ struct SymptomStartSheet: View {
private var customSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("或者自己写")
sectionLabel(String(appLoc: "或者自己写"))
TextField("例如:眼皮跳", text: $customName)
.textInputAutocapitalization(.never)
.padding(.horizontal, 14)
@@ -116,7 +117,7 @@ struct SymptomStartSheet: View {
private var timeSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("开始时间")
sectionLabel(String(appLoc: "开始时间"))
DatePicker("", selection: $startedAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
@@ -126,7 +127,7 @@ struct SymptomStartSheet: View {
private var severitySection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel("强度")
sectionLabel(String(appLoc: "强度"))
Spacer()
Text("\(Int(severity)) / 5")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
@@ -144,7 +145,7 @@ struct SymptomStartSheet: View {
private var noteSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("备注(可选)")
sectionLabel(String(appLoc: "备注(可选)"))
TextField("位置、可能诱因…", text: $note, axis: .vertical)
.lineLimit(2...4)
.padding(.horizontal, 14)

View File

@@ -9,11 +9,11 @@ nonisolated enum DateSection: Hashable {
var label: String {
switch self {
case .today: return "今天"
case .yesterday: return "昨天"
case .thisWeek: return "本周"
case .thisMonth: return "本月"
case .year(let y): return "\(y)"
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)")
}
}
@@ -68,10 +68,10 @@ func formatDuration(_ interval: TimeInterval) -> String {
let hours = (totalMinutes % (60 * 24)) / 60
let minutes = totalMinutes % 60
if days > 0 && hours > 0 { return "\(days)\(hours) 小时" }
if days > 0 { return "\(days)" }
if hours > 0 && minutes > 0 { return "\(hours) 小时 \(minutes)" }
if hours > 0 { return "\(hours) 小时" }
if minutes > 0 { return "\(minutes) 分钟" }
return "刚刚"
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: "刚刚")
}

View File

@@ -8,10 +8,10 @@ enum TimelineKind: String, CaseIterable, Identifiable {
var label: String {
switch self {
case .indicator: return "指标"
case .report: return "报告"
case .symptom: return "症状"
case .diary: return "日记"
case .indicator: return String(appLoc: "指标")
case .report: return String(appLoc: "报告")
case .symptom: return String(appLoc: "症状")
case .diary: return String(appLoc: "日记")
}
}
@@ -86,39 +86,59 @@ struct TimelineEntry: Identifiable, Hashable {
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: "血压",
subtitle: "长期监测",
trailing: "\(sys.value)/\(dia.value) mmHg" + (abnormal ? "" : ""),
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 abnormal = r.indicators.filter { $0.status != .normal }.count
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) · 共 \(r.pageCount)",
trailing: abnormal > 0 ? "\(abnormal) 项偏高" : nil,
trailingIsAlert: abnormal > 0,
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: "文字日记",
subtitle: String(appLoc: "文字日记"),
trailing: nil,
trailingIsAlert: false,
isOngoing: false
@@ -131,11 +151,11 @@ struct TimelineEntry: Identifiable, Hashable {
let subtitle: String
let trailing: String?
if ongoing {
subtitle = "症状 · 持续中"
trailing = "持续 \(formatDuration(s.duration))"
subtitle = String(appLoc: "症状 · 持续中")
trailing = String(appLoc: "持续 \(formatDuration(s.duration))")
} else {
subtitle = "症状 · 已结束"
trailing = "持续 \(formatDuration(s.duration))"
subtitle = String(appLoc: "症状 · 已结束")
trailing = String(appLoc: "持续 \(formatDuration(s.duration))")
}
return TimelineEntry(
id: "symptom-\(s.persistentModelID)",
@@ -151,9 +171,9 @@ struct TimelineEntry: Identifiable, Hashable {
private static func typeSubtitle(for i: Indicator) -> String {
if let report = i.report {
return "指标 · \(report.title)"
return String(appLoc: "指标 · \(report.title)")
}
return "异常项快拍"
return String(appLoc: "异常项快拍")
}
private static func indicatorValue(_ i: Indicator) -> String {
@@ -175,6 +195,6 @@ private extension String {
let s = String(line)
return s.count > 40 ? String(s.prefix(40)) + "" : s
}
return trimmed.isEmpty ? "(空日记)" : trimmed
return trimmed.isEmpty ? String(appLoc: "(空日记)") : trimmed
}
}

View File

@@ -0,0 +1,389 @@
import SwiftUI
import SwiftData
/// 线, sheet
/// : W2 ;W4 C2 `ReportDetailView`( Tab + ),
/// 线 C2 ,
enum TimelineDetail {
case indicator(Indicator)
case bloodPressure(sys: Indicator, dia: Indicator?)
case report(Report)
case diary(DiaryEntry)
case symptom(Symptom)
/// 线(id `<kind>-<persistentModelID>` / `bp-<sysID>-<diaID>`)
/// C1 , nil
static func resolve(for entry: TimelineEntry,
indicators: [Indicator],
reports: [Report],
diaries: [DiaryEntry],
symptoms: [Symptom]) -> TimelineDetail? {
switch entry.kind {
case .report:
return reports.first { "report-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.report)
case .diary:
return diaries.first { "diary-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.diary)
case .symptom:
return symptoms.first { "symptom-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.symptom)
case .indicator:
if let i = indicators.first(where: { "indicator-\($0.persistentModelID)" == entry.id }) {
return .indicator(i)
}
// :bp-<sysID>-<diaID>
if entry.id.hasPrefix("bp-"),
let sys = indicators.first(where: { entry.id.hasPrefix("bp-\($0.persistentModelID)-") }) {
// id diaID , ±5s
//()
let dia = indicators.first { entry.id.hasSuffix("-\($0.persistentModelID)") }
return .bloodPressure(sys: sys, dia: dia)
}
return nil
}
}
}
/// 线:,
struct TimelineEntryDetailView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var ctx
let detail: TimelineDetail
@State private var showDeleteConfirm = false
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 16) {
bodyContent
deleteButton
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.background(Tj.Palette.sand.ignoresSafeArea())
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.alert(String(appLoc: "永久删除这条记录?"), isPresented: $showDeleteConfirm) {
Button(String(appLoc: "删除"), role: .destructive) { performDelete() }
Button(String(appLoc: "取消"), role: .cancel) { }
} message: {
Text("删除后无法恢复。")
}
}
// MARK: - (:SwiftData + Vault unlink, CLAUDE.md §6)
private var deleteButton: some View {
Button(role: .destructive) { showDeleteConfirm = true } label: {
Label(String(appLoc: "永久删除"), systemImage: "trash")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Tj.Palette.brick.opacity(0.8))
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.brick.opacity(0.3), lineWidth: 1)
)
// : contentShape ()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.top, 8)
}
private func performDelete() {
switch detail {
case .indicator(let i):
deleteIndicator(i)
case .bloodPressure(let sys, let dia):
deleteIndicator(sys)
if let dia { deleteIndicator(dia) }
case .report(let r):
// cascade Asset/Indicator ,Vault JPEG unlink
var paths = Set(r.assets.map(\.relativePath))
paths.formUnion(r.indicators.compactMap { $0.asset?.relativePath })
for p in paths { try? FileVault.shared.remove(relativePath: p) }
ctx.delete(r)
case .diary(let d):
ctx.delete(d)
case .symptom(let s):
ctx.delete(s)
}
try? ctx.save()
dismiss()
}
/// : unlink + Asset ( nullify,),
private func deleteIndicator(_ i: Indicator) {
if let asset = i.asset {
try? FileVault.shared.remove(relativePath: asset.relativePath)
ctx.delete(asset)
}
ctx.delete(i)
}
// MARK: - Header
private var header: some View {
HStack(spacing: 12) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
Text(titleText)
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
TjLockChip()
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Tj.Palette.sand)
.overlay(alignment: .bottom) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
private var titleText: String {
switch detail {
case .indicator: return String(appLoc: "指标详情")
case .bloodPressure: return String(appLoc: "血压详情")
case .report: return String(appLoc: "报告详情")
case .diary: return String(appLoc: "日记详情")
case .symptom: return String(appLoc: "症状详情")
}
}
@ViewBuilder
private var bodyContent: some View {
switch detail {
case .indicator(let i): indicatorBody(i)
case .bloodPressure(let s, let d): bpBody(sys: s, dia: d)
case .report(let r): reportBody(r)
case .diary(let d): diaryBody(d)
case .symptom(let s): symptomBody(s)
}
}
// MARK: -
private func indicatorBody(_ i: Indicator) -> some View {
card {
HStack(alignment: .firstTextBaseline) {
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
statusChip(i.status)
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(i.value)
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
if !i.unit.isEmpty {
Text(i.unit).font(.system(size: 14)).foregroundStyle(Tj.Palette.text3)
}
}
divider
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
field(String(appLoc: "来源"), i.report?.title ?? String(appLoc: "异常项快拍"))
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
}
}
// MARK: - ()
private func bpBody(sys: Indicator, dia: Indicator?) -> some View {
let combined: IndicatorStatus = sys.status != .normal
? sys.status
: (dia?.status ?? .normal)
return card {
HStack(alignment: .firstTextBaseline) {
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
statusChip(combined)
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(sys.value)/\(dia?.value ?? "")")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
Text("mmHg").font(.system(size: 14)).foregroundStyle(Tj.Palette.text3)
}
divider
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
}
}
// MARK: -
private func reportBody(_ r: Report) -> some View {
let sorted = r.indicators.sorted {
($0.status == .normal ? 1 : 0) < ($1.status == .normal ? 1 : 0)
}
return VStack(alignment: .leading, spacing: 16) {
card {
Text(r.title).font(.tjH2()).foregroundStyle(Tj.Palette.text)
HStack(spacing: 8) {
TjBadge(text: r.type.label, style: .neutral)
Text(Self.dateText(r.reportDate))
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
if !r.assets.isEmpty {
Text(String(appLoc: "原图\(r.assets.count)"))
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
}
}
if let inst = r.institution, !inst.isEmpty {
field(String(appLoc: "机构"), inst)
}
}
if let sum = r.summary, !sum.isEmpty {
card {
Text(String(appLoc: "摘要"))
.font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
Text(sum).font(.system(size: 14)).foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
}
if !r.indicators.isEmpty {
card {
Text(String(appLoc: "指标"))
.font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
ForEach(sorted) { ind in
HStack {
Text(ind.name).font(.system(size: 14)).foregroundStyle(Tj.Palette.text)
Spacer(minLength: 8)
Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)")
.font(.system(size: 13, design: .monospaced))
.foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
statusChip(ind.status)
}
}
}
}
if let note = r.note, !note.isEmpty {
card { field(String(appLoc: "备注"), note) }
}
}
}
// MARK: -
private func diaryBody(_ d: DiaryEntry) -> some View {
VStack(alignment: .leading, spacing: 16) {
card {
Text(Self.dateTimeText(d.createdAt))
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
Text(d.content)
.font(.system(size: 15))
.foregroundStyle(Tj.Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
if !d.tags.isEmpty {
field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " "))
}
}
}
}
// MARK: -
private func symptomBody(_ s: Symptom) -> some View {
card {
HStack(alignment: .firstTextBaseline) {
Text(s.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
if s.isOngoing {
Text(String(appLoc: "进行中"))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.padding(.horizontal, 8).padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.brick.opacity(0.14)))
}
}
divider
field(String(appLoc: "程度"), "\(s.severity) / 5")
field(String(appLoc: "开始"), Self.dateTimeText(s.startedAt))
field(String(appLoc: "结束"), s.endedAt.map(Self.dateTimeText) ?? String(appLoc: "进行中"))
field(String(appLoc: "持续"), formatDuration(s.duration))
if let note = s.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
if !s.tags.isEmpty {
field(String(appLoc: "标签"), s.tags.map { "#\($0)" }.joined(separator: " "))
}
}
}
// MARK: -
@ViewBuilder
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 10) { content() }
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
private func field(_ label: String, _ value: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 12)
Text(value)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.multilineTextAlignment(.trailing)
.fixedSize(horizontal: false, vertical: true)
}
}
private var divider: some View {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
private func statusChip(_ s: IndicatorStatus) -> some View {
let text: String
let color: Color
let arrow: String
switch s {
case .high: text = String(appLoc: "偏高"); color = Tj.Palette.brick; arrow = ""
case .low: text = String(appLoc: "偏低"); color = Tj.Palette.brick; arrow = ""
case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = ""
}
return HStack(spacing: 3) {
if !arrow.isEmpty { Text(arrow).font(.system(size: 11, weight: .bold)) }
Text(text).font(.system(size: 12, weight: .semibold))
}
.foregroundStyle(color)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(color.opacity(0.14)))
}
private nonisolated static func dateTimeText(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day().hour().minute())
}
private nonisolated static func dateText(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day())
}
}

View File

@@ -56,18 +56,12 @@ extension Date {
return self.formatted(date: .omitted, time: .shortened)
}
if cal.isDateInYesterday(self) {
return "昨天 " + self.formatted(date: .omitted, time: .shortened)
return String(appLoc: "昨天") + " " + self.formatted(date: .omitted, time: .shortened)
}
let now = Date.now
if cal.isDate(self, equalTo: now, toGranularity: .year) {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "M 月 d 日"
return f.string(from: self)
return self.formatted(.dateTime.month().day())
}
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "yyyy 年 M 月 d 日"
return f.string(from: self)
return self.formatted(.dateTime.year().month().day())
}
}

View File

@@ -3,16 +3,31 @@ 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(identifier: "zh_CN")
c.locale = Locale.current
return c
}()
private let weekdayLabels = ["", "", "", "", "", "", ""]
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] {
@@ -64,6 +79,9 @@ struct CalendarMonthGrid: View {
ranges: data.ranges(touching: cell.date, calendar: calendar),
marks: data.marks(for: cell.date, calendar: calendar),
isToday: calendar.isDateInToday(cell.date),
isSelected: selectedDate.map {
calendar.isDate(cell.date, inSameDayAs: $0)
} ?? false,
calendar: calendar
)
.onTapGesture { onTapDay(cell.date) }
@@ -84,6 +102,7 @@ private struct DayCellView: View {
let ranges: [SymptomRange]
let marks: DayMarks
let isToday: Bool
let isSelected: Bool
let calendar: Calendar
private var dayNumber: Int {
@@ -92,14 +111,20 @@ private struct DayCellView: View {
var body: some View {
ZStack(alignment: .top) {
// :
// :selected > today
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(isToday ? Tj.Palette.sand2 : Color.clear)
.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 ? .bold : .regular,
weight: (isToday || isSelected) ? .bold : .regular,
design: .default))
.foregroundStyle(textColor)
.padding(.top, 4)
@@ -145,10 +170,17 @@ private struct DayCellView: View {
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

View File

@@ -8,7 +8,7 @@ struct CalendarYearGrid: View {
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.firstWeekday = 2
c.locale = Locale(identifier: "zh_CN")
c.locale = Locale.current
return c
}()
@@ -42,15 +42,13 @@ private struct MiniMonth: View {
let calendar: Calendar
private var monthLabel: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "M 月"
return f.string(from: anchor)
anchor.formatted(.dateTime.month())
}
private var days: [Date] {
guard let interval = calendar.dateInterval(of: .month, for: anchor) else { return [] }
let count = calendar.dateComponents([.day], from: interval.start, to: interval.end).day ?? 30
// range(of:.day,in:.month) , DST 1
let count = calendar.range(of: .day, in: .month, for: anchor)?.count ?? 30
return (0..<count).compactMap { calendar.date(byAdding: .day, value: $0, to: interval.start) }
}

View File

@@ -6,38 +6,37 @@ struct SelectedDay: Identifiable, Hashable {
var id: TimeInterval { date.timeIntervalSince1970 }
}
struct DayDetailSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
// 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(identifier: "zh_CN")
c.locale = Locale.current
return c
}()
// MARK: -
// 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)
@@ -52,90 +51,54 @@ struct DayDetailSheet: View {
return (s, state)
}
}
private var totalCount: Int {
dayIndicators.count + dayReports.count + dayDiaries.count + daySymptoms.count
}
// MARK: - body
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, 12)
VStack(alignment: .leading, spacing: 14) {
if showHeader { header }
if totalCount == 0 {
emptyState
} else {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
if !daySymptoms.isEmpty {
section("症状", count: daySymptoms.count) {
VStack(spacing: 8) {
ForEach(daySymptoms, id: \.symptom.id) { item in
symptomRow(item.symptom, state: item.state)
}
}
}
}
if !dayIndicators.isEmpty {
section("指标", count: dayIndicators.count) {
VStack(spacing: 8) {
ForEach(dayIndicators) { i in
indicatorRow(i)
}
}
}
}
if !dayReports.isEmpty {
section("报告", count: dayReports.count) {
VStack(spacing: 8) {
ForEach(dayReports) { r in
reportRow(r)
}
}
}
}
if !dayDiaries.isEmpty {
section("日记", count: dayDiaries.count) {
VStack(spacing: 8) {
ForEach(dayDiaries) { d in
diaryRow(d)
}
}
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)
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
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) }
}
}
}
}
}
.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)
.sheet(item: $endingSymptom) { sym in
SymptomEndSheet(symptom: sym)
}
}
// MARK: - header
// MARK: header
private var header: some View {
HStack(alignment: .firstTextBaseline) {
@@ -145,7 +108,7 @@ struct DayDetailSheet: View {
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
Text(dayLabel)
.font(.tjTitle(28))
.font(.tjTitle(22))
.foregroundStyle(Tj.Palette.text)
}
Spacer()
@@ -158,27 +121,18 @@ struct DayDetailSheet: View {
}
private var dateLine: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "yyyy 年"
return f.string(from: date) + " · " + weekdayLabel
date.formatted(.dateTime.year()) + " · " + weekdayLabel
}
private var dayLabel: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "M 月 d 日"
return f.string(from: date)
date.formatted(.dateTime.month().day())
}
private var weekdayLabel: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "EEEE"
return f.string(from: date)
date.formatted(.dateTime.weekday(.wide))
}
// MARK: - section
// MARK: section helper
private func section<Content: View>(_ title: String,
count: Int,
@@ -198,27 +152,30 @@ struct DayDetailSheet: View {
}
}
// MARK: - rows
// 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)
stateBadge(state, isOngoing: s.isOngoing)
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
@@ -237,15 +194,6 @@ struct DayDetailSheet: View {
.tjCard(bordered: true)
}
private func stateBadge(_ state: SymptomDayState, isOngoing: Bool) -> some View {
Text(state.badge)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(state.badgeFg)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(state.badgeBg))
}
private func indicatorRow(_ i: Indicator) -> some View {
HStack(spacing: 12) {
ZStack {
@@ -256,7 +204,6 @@ struct DayDetailSheet: View {
.foregroundStyle(indicatorAccent(i))
}
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text(i.name)
.font(.system(size: 14, weight: .medium))
@@ -269,7 +216,6 @@ struct DayDetailSheet: View {
}
}
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)
@@ -281,7 +227,9 @@ struct DayDetailSheet: View {
}
private func reportRow(_ r: Report) -> some View {
let abnormal = r.indicators.filter { $0.status != .normal }.count
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)
@@ -291,7 +239,6 @@ struct DayDetailSheet: View {
.foregroundStyle(Tj.Palette.ink2)
}
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text(r.title)
.font(.system(size: 14, weight: .medium))
@@ -302,8 +249,8 @@ struct DayDetailSheet: View {
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 6)
if abnormal > 0 {
Text("\(abnormal) 项偏高")
if let summary {
Text(summary)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundStyle(Tj.Palette.brick)
}
@@ -331,23 +278,19 @@ struct DayDetailSheet: View {
.tjCard(bordered: true)
}
// MARK: - empty
private var emptyState: some View {
VStack(spacing: 12) {
Spacer(minLength: 16)
TjPlaceholder(label: "这一天还没有记录")
.frame(width: 220, height: 120)
VStack(spacing: 8) {
TjPlaceholder(label: String(appLoc: "这一天还没有记录"))
.frame(height: 90)
.frame(maxWidth: 240)
Text("点底部 + 号可以补一条")
.font(.system(size: 12))
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
}
// MARK: - utils
private func severityColor(_ value: Int) -> Color {
switch value {
case 1, 2: return Tj.Palette.leaf
@@ -369,22 +312,65 @@ struct DayDetailSheet: View {
}
}
// 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 "今天开始"
case .ongoing: return "进行中"
case .endedToday: return "今天结束"
case .startedToday: return String(appLoc: "今天开始")
case .ongoing: return String(appLoc: "进行中")
case .endedToday: return String(appLoc: "今天结束")
}
}
var badge: String {
switch self {
case .startedToday: return "开始"
case .ongoing: return "持续"
case .endedToday: return "结束"
case .startedToday: return String(appLoc: "开始")
case .ongoing: return String(appLoc: "持续")
case .endedToday: return String(appLoc: "结束")
}
}

View File

@@ -46,8 +46,7 @@ extension SeriesBucket {
//
let bpKeys: Set<String> = ["bp.systolic", "bp.diastolic"]
let bpIndicators = bpKeys.flatMap { buckets[$0] ?? [] }
let bpHasEnoughPoints = bpIndicators.filter { $0.seriesKey == "bp.systolic" }.count >= minPoints
let bpHasEnoughPoints = (buckets["bp.systolic"]?.count ?? 0) >= minPoints
var results: [SeriesBucket] = []
@@ -123,7 +122,7 @@ extension SeriesBucket {
let sysLine = SeriesLine(
id: "bp.systolic",
seriesKey: "bp.systolic",
label: "收缩",
label: String(appLoc: "收缩"),
color: Tj.Palette.brick,
points: sysItems.compactMap { point(from: $0) },
referenceRange: m.effectiveRange(for: sysField, profile: profile)
@@ -131,7 +130,7 @@ extension SeriesBucket {
let diaLine = SeriesLine(
id: "bp.diastolic",
seriesKey: "bp.diastolic",
label: "舒张",
label: String(appLoc: "舒张"),
color: Tj.Palette.leaf,
points: diaItems.compactMap { point(from: $0) },
referenceRange: m.effectiveRange(for: diaField, profile: profile)
@@ -142,11 +141,13 @@ extension SeriesBucket {
diaItems.last?.capturedAt ?? .distantPast
)
// 线:(/)线 +
let lines = [sysLine, diaLine].filter { !$0.points.isEmpty }
return SeriesBucket(
id: "bp",
title: "血压",
title: String(appLoc: "血压"),
unit: "mmHg",
lines: [sysLine, diaLine],
lines: lines,
latestDate: latest
)
}

View File

@@ -34,8 +34,11 @@ struct SeriesChartCard: View {
hi = max(hi, r.upperBound)
}
}
guard lo < hi else { return nil }
let pad = max(1, (hi - lo) * 0.12)
// 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)
}
@@ -165,10 +168,10 @@ struct SeriesChartCard: View {
let days = Calendar.current.dateComponents([.day],
from: dom.lowerBound,
to: dom.upperBound).day ?? 0
if days <= 0 { return "今天" }
if days < 30 { return "\(days)" }
if days < 365 { return "\(days / 30) 个月" }
return "\(days / 365)"
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 {

View File

@@ -6,8 +6,8 @@ enum CalendarMode: String, CaseIterable, Identifiable {
var id: String { rawValue }
var label: String {
switch self {
case .month: return ""
case .year: return ""
case .month: return String(appLoc: "")
case .year: return String(appLoc: "")
}
}
}
@@ -31,7 +31,8 @@ struct TrendsView: View {
@State private var mode: CalendarMode = .month
@State private var anchor: Date = .now
@State private var selectedDay: SelectedDay?
/// , inline
@State private var selectedDate: Date = .now
private var profile: UserProfile? { profiles.first }
@@ -44,7 +45,7 @@ struct TrendsView: View {
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.firstWeekday = 2
c.locale = Locale(identifier: "zh_CN")
c.locale = Locale.current
return c
}()
@@ -66,6 +67,9 @@ struct TrendsView: View {
anchorBar
calendarBody
legend
if mode == .month {
dayDetailInline
}
seriesSection
}
.padding(.horizontal, 20)
@@ -73,15 +77,31 @@ struct TrendsView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
.sheet(item: $selectedDay) { sel in
DayDetailSheet(
date: sel.date,
}
/// 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
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 {
@@ -91,7 +111,10 @@ struct TrendsView: View {
.foregroundStyle(Tj.Palette.text)
Spacer()
Button {
anchor = .now
withAnimation(.snappy(duration: 0.2)) {
anchor = .now
selectedDate = .now
}
} label: {
Text("回到今天")
.font(.system(size: 12))
@@ -164,18 +187,20 @@ struct TrendsView: View {
}
private var anchorTitle: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = mode == .month ? "yyyy 年 M 月" : "yyyy 年"
return f.string(from: anchor)
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) { day in
selectedDay = SelectedDay(date: day)
CalendarMonthGrid(monthAnchor: anchor, data: data, selectedDate: selectedDate) { day in
withAnimation(.snappy(duration: 0.2)) {
selectedDate = day
}
}
.padding(14)
.background(
@@ -231,10 +256,10 @@ struct TrendsView: View {
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
HStack(spacing: 14) {
legendItem(color: Tj.Palette.brick, label: "指标异常")
legendItem(color: Tj.Palette.amber, label: "症状持续中")
legendItem(color: Tj.Palette.ink2, label: "报告归档")
legendItem(color: Tj.Palette.leaf, label: "正常")
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)
@@ -268,6 +293,14 @@ struct TrendsView: View {
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
}
}
}
}
}

12210
康康/Localizable.xcstrings Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -10,11 +10,11 @@ enum ReportType: String, Codable, CaseIterable {
var label: String {
switch self {
case .checkup: return "体检报告"
case .lab: return "化验单"
case .imaging: return "影像报告"
case .prescription: return "处方"
case .other: return "其他"
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: "其他")
}
}
}
@@ -250,17 +250,117 @@ final class MetricReminder {
var isEveryDay: Bool { Set(weekdays) == Set(1...7) }
var frequencyLabel: String {
if !enabled { return "已关闭" }
if isEveryDay { return "每天" }
if weekdays.isEmpty { return "未选日" }
let names = ["", "", "", "", "", "", ""]
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 "每周 " + sorted.map { names[$0 - 1] }.joined()
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 / 315
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

View File

@@ -57,9 +57,9 @@ extension UserProfile {
var label: String {
switch self {
case .male: return ""
case .female: return ""
case .undisclosed: return "不愿透露"
case .male: return String(appLoc: "")
case .female: return String(appLoc: "")
case .undisclosed: return String(appLoc: "不愿透露")
}
}
}
@@ -78,7 +78,7 @@ extension UserProfile {
/// ProfileCard :"38 · · 175cm · 68kg · A"
var summaryLine: String {
var parts: [String] = []
if let age { parts.append("\(age)") }
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 {
@@ -87,7 +87,7 @@ extension UserProfile {
: String(format: "%.1fkg", w)
parts.append(s)
}
if !bloodTypeRaw.isEmpty { parts.append("\(bloodTypeRaw)") }
if !bloodTypeRaw.isEmpty { parts.append(String(appLoc: "\(bloodTypeRaw)")) }
return parts.joined(separator: " · ")
}

View File

@@ -90,11 +90,17 @@ final class FileVault: @unchecked Sendable {
}
}
/// Vault (/),;
/// /
nonisolated func wipe() throws {
let fm = FileManager.default
let contents = try fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)
let contents = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? []
for url in contents {
try fm.removeItem(at: url)
try? fm.removeItem(at: url)
}
let remaining = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? []
if !remaining.isEmpty {
throw FileVaultError.removeFailed
}
}
}

View File

@@ -4,10 +4,10 @@ enum TjTab: String, Hashable, CaseIterable {
case home, records, trend, me
var label: String {
switch self {
case .home: return "主页"
case .records: return "记录"
case .trend: return "趋势"
case .me: return "我的"
case .home: return String(appLoc: "主页")
case .records: return String(appLoc: "记录")
case .trend: return String(appLoc: "趋势")
case .me: return String(appLoc: "我的")
}
}
var icon: String {
@@ -18,6 +18,15 @@ enum TjTab: String, Hashable, CaseIterable {
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 {
@@ -27,26 +36,39 @@ enum ActiveFlow: Identifiable {
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: { tab = .records })
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: { tab = $0 },
onTap: { select($0) },
onTapRecord: { showRecordSheet = true })
}
.background(Tj.Palette.sand.ignoresSafeArea())
@@ -60,6 +82,7 @@ struct RootView: View {
case .symptom: showSymptomStart = true
case .diary: showDiary = true
case .indicator: showIndicator = true
case .reminder: showReminders = true
}
}
}
@@ -73,11 +96,15 @@ struct RootView: View {
.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:
UnifiedCaptureFlow(onClose: { activeFlow = nil })
QuickRegionCaptureFlow(onClose: { activeFlow = nil })
case .archive:
UnifiedCaptureFlow(onClose: { activeFlow = nil })
}
@@ -86,7 +113,7 @@ struct RootView: View {
.sheet(item: $activeFlow) { flow in
switch flow {
case .quick:
UnifiedCaptureFlow(onClose: { activeFlow = nil })
QuickRegionCaptureFlow(onClose: { activeFlow = nil })
case .archive:
UnifiedCaptureFlow(onClose: { activeFlow = nil })
}
@@ -100,6 +127,8 @@ private struct TabBar: View {
let onTap: (TjTab) -> Void
let onTapRecord: () -> Void
@Namespace private var indicatorNS
private let cornerRadius: CGFloat = 22
private let slotHeight: CGFloat = 34
@@ -115,6 +144,7 @@ private struct TabBar: View {
.padding(.top, 10)
.padding(.bottom, 6)
.background(barBackground)
.animation(.spring(response: 0.35, dampingFraction: 0.75), value: active)
}
private var barBackground: some View {
@@ -143,6 +173,7 @@ private struct TabBar: View {
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))
@@ -188,7 +219,7 @@ private struct TabBar: View {
.buttonStyle(TabPressStyle())
}
}
//
private struct TabPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label

View File

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

View File

@@ -0,0 +1,31 @@
import SwiftUI
/// `RootView` : scenePhase, /
/// RootView (线 §10.7 Tab )
///
/// (KangkangApp):`AppLockContainer { RootView() }`
struct AppLockContainer<Content: View>: 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)
}
}
}

View File

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

View File

@@ -1,5 +1,6 @@
import Foundation
import UIKit
import SwiftData
/// VL (, SwiftData )
/// Indicator/Report prompt schema
@@ -12,7 +13,9 @@ struct ParsedReport: Sendable {
var pageCount: Int
var indicators: [ParsedIndicator]
struct ParsedIndicator: Sendable {
struct ParsedIndicator: Sendable, Identifiable {
// : ForEach , indices id
let id = UUID()
var name: String
var value: String
var unit: String
@@ -40,16 +43,14 @@ struct ParsedReport: Sendable {
/// CaptureService UI (退 vs )
enum CaptureError: Error, LocalizedError {
case modelNotReady
case writeAssetFailed
case inferenceFailed(String)
case parseFailed(String)
var errorDescription: String? {
switch self {
case .modelNotReady: return "VL 模型尚未就绪"
case .writeAssetFailed: return "图片保存失败"
case .inferenceFailed(let m): return "识别失败:\(m)"
case .parseFailed(let m): return "结构化失败:\(m)"
case .modelNotReady: return String(appLoc: "VL 模型尚未就绪")
case .inferenceFailed(let m): return String(appLoc: "识别失败:\(m)")
case .parseFailed(let m): return String(appLoc: "结构化失败:\(m)")
}
}
}
@@ -60,38 +61,78 @@ actor CaptureService {
static let shared = CaptureService()
private init() {}
/// + VL + ParsedReport
/// , CaptureError;UI
/// - Returns: (ParsedReport, [FileVault.SavedAsset]) ,
/// SavedAsset Asset @Model
func analyze(images: [UIImage]) async throws
-> (parsed: ParsedReport, assets: [FileVault.SavedAsset]) {
/// 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)
}
// 1. Vault()
let assets: [FileVault.SavedAsset]
/// :****(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 {
assets = try images.map { try FileVault.shared.writeJPEG($0) }
try await AIRuntime.shared.prepareVL()
} catch {
throw CaptureError.writeAssetFailed
throw CaptureError.modelNotReady
}
// 2. VL
try await AIRuntime.shared.prepareVL()
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
prompt: VLPrompts.reportExtraction()
)
} catch {
throw CaptureError.inferenceFailed("\(error)")
}
// 3. JSON (: / )
do {
let parsed = try CaptureService.parseReportJSON(raw, pageCount: assets.count)
return (parsed, assets)
return try CaptureService.parseReportJSON(raw, pageCount: assets.count)
} catch let CaptureError.parseFailed(msg) {
throw CaptureError.parseFailed(msg)
} catch {
@@ -109,7 +150,10 @@ actor CaptureService {
/// indicator , ParsedReport.isEmpty = true,
/// UI
static func parseReportJSON(_ raw: String, pageCount: Int = 1) throws -> ParsedReport {
let jsonString = extractJSONObject(from: raw)
// extractBalancedJSON( {} extractJSONObject):VL
// [{...},{...}], { , indicator
// indicators
let jsonString = extractBalancedJSON(from: raw)
guard let data = jsonString.data(using: .utf8) else {
throw CaptureError.parseFailed("非 UTF-8 输出")
}
@@ -119,8 +163,13 @@ actor CaptureService {
} catch {
throw CaptureError.parseFailed("JSON 不合法:\(error.localizedDescription)")
}
guard let dict = obj as? [String: Any] else {
throw CaptureError.parseFailed("根节点不是对象")
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) ?? ""
@@ -136,7 +185,7 @@ actor CaptureService {
}
return ParsedReport(
title: title.isEmpty ? "拍摄识别" : title,
title: title.isEmpty ? String(appLoc: "拍摄识别") : title,
typeRaw: typeRaw,
reportDate: reportDate,
institution: institution,
@@ -146,6 +195,32 @@ actor CaptureService {
)
}
/// :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 {
@@ -189,6 +264,56 @@ actor CaptureService {
return String(s[start...])
}
/// JSON ,`{...}` `[...]`
/// ( `{"indicators":[...]}` `[...]`)
/// ( JSONSerialization )
static func extractBalancedJSON(from raw: String) -> 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[..<endRange.lowerBound])
}
s = s.trimmingCharacters(in: .whitespacesAndNewlines)
}
let firstBrace = s.firstIndex(of: "{")
let firstBracket = s.firstIndex(of: "[")
let start: String.Index
let open: Character
let close: Character
switch (firstBrace, firstBracket) {
case let (b?, k?):
if b < k { start = b; open = "{"; close = "}" }
else { start = k; open = "["; close = "]" }
case let (b?, nil): start = b; open = "{"; close = "}"
case let (nil, k?): start = k; open = "["; close = "]"
default: return s
}
var depth = 0
var inString = false
var escape = false
var idx = start
while idx < s.endIndex {
let ch = s[idx]
if escape { escape = false }
else if ch == "\\" { escape = true }
else if ch == "\"" { inString.toggle() }
else if !inString {
if ch == open { depth += 1 }
else if ch == close {
depth -= 1
if depth == 0 { return String(s[start...idx]) }
}
}
idx = s.index(after: idx)
}
return String(s[start...])
}
private static func parseReportType(_ raw: String?) -> String {
guard let raw = raw?.lowercased() else { return ReportType.other.rawValue }
return ReportType(rawValue: raw)?.rawValue ?? ReportType.other.rawValue
@@ -198,8 +323,15 @@ actor CaptureService {
guard let s = raw?.trimmingCharacters(in: .whitespaces), !s.isEmpty else { return nil }
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
return f.date(from: s)
// 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? {
@@ -216,3 +348,59 @@ actor CaptureService {
return .init(name: name, value: value, unit: unit, range: range, status: status)
}
}
// MARK: - Report CaptureService (MainActor )
//
// CaptureService actor, Report(@Model Sendable)
// C2UI :
// ```
// 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()
}
}

View File

@@ -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. <think>...</think>( 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)
}
}

View File

@@ -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<Event, Error> {
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
)
// <think>...</think>
// Prompt Qwen3 `/no_think`, thinking
// + chunk + diff yield:
// - thinking ,UI generated
// - </think> ,
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<Indicator>(
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<Symptom>(
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<Report>(
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<DiaryEntry>(
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: - <think>
/// ,
/// + + diff yield,:
/// - `<think>...</think>` ( think )
/// - `<think>...`() ,
/// - Qwen3 `</think>` thinking
/// - trim, `## `
static func stripThinkBlocks(_ raw: String) -> String {
var s = raw
// 1. <think>...</think>( think )
while let openR = s.range(of: "<think>"),
let closeR = s.range(of: "</think>", range: openR.upperBound..<s.endIndex) {
s.removeSubrange(openR.lowerBound..<closeR.upperBound)
}
// 2. :,
if let openR = s.range(of: "<think>") {
s = String(s[..<openR.lowerBound])
}
// 3. (Qwen3 ):
if let closeR = s.range(of: "</think>") {
s = String(s[closeR.upperBound...])
}
// 4. trim
while let first = s.first, first.isWhitespace {
s.removeFirst()
}
return s
}
}

View File

@@ -93,8 +93,9 @@ final class ModelDownloadService {
to: destination,
expectedBytes: file.bytes,
onProgress: { [weak self] received in
guard let self else { return }
Task { @MainActor in
self?.applyProgress(kind, currentTotal: base + received)
self.applyProgress(kind, currentTotal: base + received)
}
}
)
@@ -132,7 +133,7 @@ final class ModelDownloadService {
states[kind] = DownloadState(phase: .ready, receivedBytes: total,
totalBytes: total, bytesPerSecond: 0)
} else {
states[kind] = DownloadState(phase: .failed(message ?? "下载失败"),
states[kind] = DownloadState(phase: .failed(message ?? String(appLoc: "下载失败")),
receivedBytes: completedBytes(for: kind),
totalBytes: total, bytesPerSecond: 0)
}

Some files were not shown because too many files have changed in this diff Show More