Compare commits

..

56 Commits

Author SHA1 Message Date
link2026
3798efa48d merge: resolve conflicts in .gitignore 2026-06-01 08:54:53 +08:00
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
link2026
910ca99f21 feat(me,home): 模型推理自检 + 启动容错 + 首页假数据清理
- KangkangApp: ModelContainer 创建失败时重置本地 store 重建,
  避免 demo 阶段 schema 演进导致旧真机启动崩溃(注:生产需正式迁移)
- ModelSelfTestView: 正式的推理自检页(固定 prompt + 流式输出 + tok/s),
  仅当 LLM 模型就绪时从「模型管理」出现入口
- 删除 DEBUG-only 的 DebugAIRunner,自检转正为就绪后可见的正式入口
- HomeView: 删除写死的「今日摘记」假数据卡;问候改为按时段动态
  (早安/下午好/晚上好)+ 当天日期;影像档案数字接真实 @Query 计数
- MeView: 模型管理卡动态状态 + 关于页接真实版本号(用户改动一并纳入)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 08:02:35 +08:00
link2026
062c027c77 feat(models): 模型自动下载(我的·模型管理) + 断点续传 + 旁路导入
实现 spec(2026-05-29-model-download-design)的模型分发功能:
- ModelManifest: 硬编码功能文件清单 + base URL https://file.myv0.com/
- FileDownloader: URLSessionDataDelegate 分块写盘,HTTP Range 断点续传 + 大小校验
  (根因修复:URL.resourceValues 会缓存文件大小,续传时先读 offset 再读 finalSize
   会拿到下载前的陈旧值导致校验误判;改用 FileManager.attributesOfItem)
- ModelDownloadService: @MainActor @Observable 编排逐文件下载,聚合进度/速度,
  支持下载全部/暂停/重试,以及旁路文件导入
- ModelStore: 新增 fileURL/localBytes/isComplete(可注入清单)/importModel(补 VL)
- ModelManagementView: 分模型卡片(状态/进度/速度) + 下载全部/暂停
  + NWPathMonitor 蜂窝提示 + 从文件导入(离线兜底)
- MeView: 模型管理卡改 NavigationLink + 动态状态(已就绪/下载中/N就绪)

测试(Swift Testing): Manifest 清单/字节数、Store 路径/校验/导入、
DownloadState、FileDownloader(URLProtocol mock:下载/Range续传/大小校验)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 23:19:51 +08:00
link2026
6ccbe4ac55 docs(spec): 模型自动下载功能设计(2026-05-29)
新增「我的·模型管理」页模型下载功能设计:
- 独立 ModelDownloadService + ModelStore 保持纯存储(§3.1)
- HTTPS 断点续传(Range+追加写)、分模型卡片进度、大小校验
- 旁路文件导入兜底(补 VL)、AI 入口未就绪「前往下载」引导
- base URL https://file.myv0.com/,含精确 24 文件清单

并加 .gitignore 忽略本地模型素材目录 /Models/

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:12:19 +08:00
link2026
fe80e112af docs(spec): 导出身体档案功能设计(2026-05-27)
记录 Tab 顶部入口 + 全屏 sheet,两段式本地 RAG(意图抽取 → 结构化检索 → Markdown 生成),
新增 HealthExport @Model 持久化历史。给 W3 AskService 铺路。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:03:35 +08:00
link2026
5f8f492f0e feat(indicator): 长期监测预设支持长按隐藏 + 恢复
- UserProfile 加 hiddenPresetMetrics: [String],存被隐藏的 MonitorMetric.rawValue
- IndicatorQuickSheet monitorTile 加 contextMenu 隐藏入口
- section label 右侧"已隐藏 N 个 ›"chip 触发 HiddenMonitorRestoreSheet
- 纯 UI 过滤,不动 Indicator 历史 / Trends 折线 / MetricReminder

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:47:55 +08:00
link2026
599d39af35 docs(spec): 长期监测预设支持隐藏(2026-05-26)
UserProfile 加 hiddenPresetMetrics 字段;IndicatorQuickSheet
长按 tile 出 contextMenu 隐藏,顶部 chip 显示已隐藏数 + 恢复入口。
历史数据/Trends/Reminder 全不动。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:37:34 +08:00
link2026
1b01923c8e feat(capture): 统一报告捕获流程并集成视觉语言模型识别
- 替换 QuickCaptureFlow 和 ArchiveFlow 为 UnifiedCaptureFlow 统一流程
- 新增 VLSession 封装 Qwen2.5-VL 模型进行图像文本推理
- 实现 AIRuntime 中 VL 模型的准备和分析功能
- 添加 VLPrompts 定义体检化验单识别的 JSON 输出模板
- 创建 CaptureReviewForm 提供 VL 解析结果的可编辑表单界面
- 集成 VisionKit 文档扫描器支持真机多页文档扫描
- 为模拟器实现 PhotosPicker 回退方案选择已有照片
- 在 RootView 中统一使用 UnifiedCaptureFlow 处理快速和归档流程
- 添加 CustomMetricEditor 支持自定义监测指标的创建编辑删除
- 扩展 KangkangApp 模型配置以支持新数据类型
- 实现档案列表中症状结束功能通过时间线行点击触发
2026-05-26 11:18:00 +08:00
link2026
39edc25dc1 refactor(profile,monitor): move height/weight from MonitorMetric to UserProfile
身高/体重对成人变化慢,作为 Profile 静态字段比每次录入 Indicator 更合适。

- MonitorMetric:6 case(从 8 减),删 .height / .weight
- UserProfile:加 weightKG: Double?(支持小数),加 bmi computed
- summaryLine 加体重段:'175cm · 68.5kg'(整数省小数)
- ProfileEditView basics 加 weight 行 + footer 显示 BMI + 分类(偏瘦/正常/超重/肥胖)
- IndicatorQuickSheet:删 .height 回写 Profile 的特殊逻辑
- UserProfileTests:+5 个(weight 字段、summaryLine 含 weight、BMI 计算)

兼容性:老 Indicator 里的 seriesKey 'weight' / 'height' 数据保留(SwiftData String?
不变),只是新录入路径走 Profile 不走 Indicator;Trends 仍能用 String seriesKey
查询历史(如果将来要展示老数据)。

测试:60 case pass / 0 fail / 0 warning。
2026-05-26 07:58:47 +08:00
link2026
37b47b2076 docs(claude): sync §5/§7/§10 with Monitor+Profile; fix SeriesBucket SwiftData import
- §5 schema 重写为 7 @Model 完整列表(含 UserProfile + Indicator.seriesKey)
- §7 IA 改成 5 槽 TabBar(2 内容 + 中间 + + 2 设置),记录入口 5 个 kind
- §10.6 红线例外清单加 Monitor + Profile(Symptom 也补上)
- SeriesBucket.swift 缺 import SwiftData(persistentModelID 报错)

全套测试 50 case pass / 0 fail / 0 warning。
2026-05-26 07:53:16 +08:00
link2026
e2fb631b96 feat(timeline): merge bp.systolic + bp.diastolic into single entry
- TimelineEntry.from(indicators:) 批处理:找 bp.systolic 配对同 capturedAt
  (±5s)的 bp.diastolic,合并成 '血压 120/80 mmHg' 一行
- 未配对的 systolic 单独退回 from(indicator:)
- 非 bp.* series 不动
- ArchiveListView + HomeView 改用 from(indicators:) 批处理
- 6 个新测试覆盖配对/未配对/异常标记/非 bp 不动/不同时间不合并
2026-05-26 07:50:00 +08:00
link2026
0f38bf585b first commit 2026-05-26 07:48:57 +08:00
link2026
3dcb792131 feat(profile,monitor): ProfileEditView + MeView 卡片 + IndicatorQuickSheet 改造
- ProfileEditView Form 风格,即改即存,onDisappear 触发 ctx.save
  - basics(出生年 / 性别 / 身高 / 血型)
  - 慢病 chips(8 预设 + 自定义)
  - allergies / familyHistory / medications 通用 list section
  - FlowLayout(Layout 协议自实现)用于 chip 流式换行

- MeView 改造:NavigationStack + ProfileCard 显示 summaryLine,
  3 个 settings 卡片(模型 / Face ID / 关于)stub,DEBUG 块仍在底部

- IndicatorQuickSheet 整合 MonitorMetric:
  - 顶部 LazyVGrid 2 列展示 8 个 MonitorMetric(进趋势)
  - 下方 horizontal scroll 化验项快捷(不进趋势)
  - 选血压切到 2 字段 UI(收缩/舒张),保存写 2 条 Indicator(同 capturedAt)
  - 选单字段 monitor:自动算 status,锁 name/unit/range
  - 选 lab preset:辅助填 name/unit/range,status 手动
  - 自由输入路径不变
  - 身高 monitor 保存时回写 UserProfile.heightCM
  - Profile-aware range hint:'按 67 岁调整' 仅在 effectiveRange 不同于 baseRange 时显示
2026-05-26 07:47:20 +08:00
link2026
9a6d21100b feat(monitor): add UserProfile + MonitorMetric catalog + Indicator.seriesKey
数据层(spec 2026-05-26):
- UserProfile @Model:核心 4 项 + 健康背景 + 用药,SwiftData 单例(loadOrCreate)
- Indicator 加 seriesKey: String?,标识长期指标分组('bp.systolic' 等)
- MonitorMetric enum 8 case:血压(2 field 拆 2 Indicator)/ 空腹+餐后血糖 /
  体重 / 体温 / 心率 / SpO2 / 身高
- effectiveRange(for:profile:) 实现 1 条 Profile-aware 规则:
  age >= 65 时 bp.systolic 上限 140→150
- KangkangApp schema 加 UserProfile.self

测试 17 个全绿(UserProfile 6 + MonitorMetric 11);schema 烟测扩 2(seriesKey roundtrip + UserProfile persist)。
UI 层 + Timeline 合并下个 commit。
2026-05-26 07:40:42 +08:00
link2026
7ede38ae06 docs(spec): add Monitor + Profile design v1 (approved)
long-term formatted indicators(.indicator 入口预设 + 自由)+ 个人资料
(年龄/性别/身高/血型/健康背景/用药)+ Profile-aware reference range
(老人血压 90-150 替代 90-140)。详见 spec §2-§5。
2026-05-26 07:34:43 +08:00
link2026
22cf4bcefe fix(concurrency): make DateSection nonisolated to silence #expect warnings
5 个 Swift Testing macro 展开的 warning:DateSection 的 Equatable 协议被默认
推到 MainActor,但 #expect 在 nonisolated context 比较 — Swift 6 严格模式会报错。
2026-05-25 23:39:52 +08:00
link2026
bb08243aa9 chore(preview): add #Preview to RecordSheet + DebugAIRunner
之前 HomeView/MeView/TrendsView/ArchiveListView/RootView/SymptomStartSheet
都有 #Preview,只剩这两个。补完后所有主屏 View 都能在 Xcode Canvas 直接
预览,改 UI 不用 build & run。
2026-05-25 23:37:55 +08:00
link2026
b80fae35c9 docs(w2): mark plan tasks 1-7/9 done + sync CLAUDE.md §8 + write W2 retro
- plan: flip 43 checkboxes done across Task 1-7/9; Task 8 (manual speed
  baseline) and Task 10 (this retro) intentionally left open
- CLAUDE.md §8: AI/ ⚠️ partial (AIRuntime/LLMSession/ModelStore/TokenChunk
  done, VLSession/Prompts/ pending); FileVault ; add Debug/DebugAIRunner ;
  drop bold from "W2 当前" and tag W2-W3 row 进行中
- new retros/2026-05-31-w2.md: status table, TBD speed baseline,
  off-plan Symptom/Timeline/ArchiveListView/AppIcon/Swift6 cleanup,
  Swift 6 + Simulator sandbox learnings, W3 prep checklist
2026-05-25 23:36:16 +08:00
link2026
e3ad24ac0e test(ai): add LLMSession/AIRuntime smoke tests (no real inference)
iOS Simulator sandbox 看不到 host ~/tiji-models;Mac Designed for iPad
卡 code signing。真实推理验证由 DebugAIRunner 手动跑,结果记 W2 retro。
W3 把核心 LLM 接口拆独立 SPM target 后,可在 Mac 原生跑真实推理。

烟测覆盖:
- TokenChunk 值字段
- AIRuntimeError 3 case 都有 errorDescription
- AIRuntime actor status 可异步读取
2026-05-25 23:33:04 +08:00
link2026
b63b26bce5 feat(timeline): TimelineRow + DateSection + grouping tests + Diary sheet
- TimelineRow: 时间线条目单行视图
- DateSection + TimelineGrouping: 今日/昨日/本周/更早分组
- DiaryQuickSheet: 文字日记快速记录入口
- TimelineGroupingTests: 分组逻辑烟测
- SymptomEndSheet / RootView: 配套微调
2026-05-25 23:23:21 +08:00
link2026
b1b8d0a8c7 fix(timeline): add missing SwiftData import + @MainActor on caller props
- TimelineEntry.swift: 缺 import SwiftData,4 处 persistentModelID 报错
- ArchiveListView.allEntries / HomeView.recentEntries: 显式 @MainActor,
  否则 default-isolation=MainActor 下被推断为 nonisolated,调用 MainActor
  方法 TimelineEntry.from(...) 触发 4+4 个 isolation 警告
2026-05-25 23:22:35 +08:00
link2026
2e728dcd24 chore(assets): add Kangkang AppIcon (light/dark/tinted, 16-1024) + SVG source
9 PNG sizes for iOS/macOS + dark + tinted variants. SVG design source under
docs/design/. Updates Contents.json to reference them.

Scheme reference 编码统一为 &#x5eb7;&#x5eb7;(Xcode 写入格式)。
2026-05-25 23:18:29 +08:00
link2026
46b69cf8e1 feat(symptom): add Symptom @Model + start/end sheets + ongoing card
- Symptom @Model with severity 1-5 clamp, isOngoing, duration helpers
- SymptomStartSheet / SymptomEndSheet / OngoingSymptomsCard
- RecordSheet 加 .symptom kind 入口
- RootView 增加 'records' tab + ArchiveListView placeholder
- HomeView 顶部加 OngoingSymptomsCard
- ModelsSchemaTests: 2 个 Symptom 烟测(ongoing predicate + severity clamp)

Note: Symptom 是 CLAUDE.md §10 清单外的新功能,由产品负责人决定加入。
ArchiveListView 仍是 placeholder,真实 C1 实现按计划在 W4。
2026-05-25 23:18:21 +08:00
link2026
e4a68a1bdd fix(concurrency): clear 4 Swift 6 warnings under default MainActor isolation
- ModelStore/FileVault: drop nonisolated(unsafe) on shared, mark all instance
  methods nonisolated (they only read filesystem); ModelKind enum also nonisolated
- AIRuntime ↔ ModelStore cross-actor call resolved by the above
- LLMSession: replace deprecated Device.setDefault(device:) with task-scoped
  Device.withDefaultDevice(.cpu, body:); wrap both load and generate so the
  TaskLocal propagates through ModelContainer.perform
2026-05-25 23:18:08 +08:00
link2026
53da442424 chore: rename Tiji→Kangkang test imports + scheme + sync docs
Rename @testable imports across all test/UI test files after the Tiji→Kangkang
project rename in 44ed01a. Add shared scheme. Sync CLAUDE.md / W2 plan / spec
v1.0 to current scope (Symptom feature noted, C1/C2 flow lockdown).
2026-05-25 23:18:00 +08:00
link2026
44ed01acf4 ```
refactor: 重命名项目名称从"体己"到"康康"

将整个项目的目录结构从"体己"重命名为"康康",包括所有源代码文件、
资源文件、测试文件以及Xcode项目配置文件。此更改涉及项目中所有的
文件路径和应用入口点(App/TijiApp.swift → App/KangkangApp.swift)。
```
2026-05-25 19:01:16 +08:00
link2026
9419e8158f ```
feat(debug): 添加模型导入功能并修复模拟器GPU初始化问题

- 在DebugAIRunner中添加文件导入器,支持用户选择并导入LLM模型文件夹
- 添加导入状态管理和错误提示功能
- 修复iOS模拟器环境下MLX GPU stream初始化崩溃问题,强制使用CPU模式
- 添加UniformTypeIdentifiers导入以支持文件选择功能
```
2026-05-25 18:25:20 +08:00
link2026
57536e5319 test(models): 加 3 个 Schema 关系烟测
按 W2 plan Task 9 落地:
- insertIndicatorWithReportRelationship: 验证 Indicator.report 反向关系
  双向可达(report.indicators 也能找到)
- cascadeDeleteReportRemovesIndicators: 删 Report 触发 cascade,旗下
  Indicator 一并被清理(对应"永久删除"语义)
- chatTurnPersistsReferencedIDs: ChatTurn 的 referencedIndicatorIDs
  作为 [String] 字段正确持久化

全部用 in-memory ModelContainer 隔离,无副作用。

注:文件需用户在 Xcode 拖入 体己Tests target 后 ⌘U 跑测试。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:23:45 +08:00
link2026
a3e758cf83 fix(build): 加 SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES
Xcode 26 默认不开 Mac (Designed for iPad) 支持。开启后,iOS App 可
在 M 系列 Mac 上原生运行,使用 host Mac 真实 Metal device,绕过
iOS Simulator 上 MLX 必崩的限制(mlx::core::metal::Device 初始化
在 simulator 下读 device 属性返回 nullptr,libcpp abort)。

6 处 build config(主 target + Tests + UITests × Debug/Release)
都加上,与现有 SUPPORTED_PLATFORMS 包含 macosx 一致。

xcodebuild -destination 'platform=macOS,variant=Designed for iPad'
+ -allowProvisioningUpdates 已验证 BUILD SUCCEEDED。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:11:20 +08:00
link2026
acfdaa1f4f fix(concurrency): nonisolated(unsafe) static shared + 修同 actor 内冗余 await
项目开启了 -default-isolation=MainActor upcoming feature,导致:

1. static let shared 默认被视为 MainActor 隔离,即使 class 标了
   @unchecked Sendable,从其他 actor(如 AIRuntime)同步访问仍报
   "Expression is 'async' but is not marked with 'await'".

   修法:ModelStore.shared 和 FileVault.shared 都加 nonisolated(unsafe)
   修饰,明确"任何隔离上下文都可同步访问"。

2. AIRuntime.generate() 内的 Task { ... } 继承 AIRuntime actor 隔离,
   self.recordRate 是同 actor 内部调用,不需要 await,否则报
   "No 'async' operations occur within 'await' expression".

   修法:去掉冗余的 await。

** BUILD SUCCEEDED ** 已验证。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:00:30 +08:00
link2026
9fbd31458c feat(debug): DebugAIRunner DEBUG 自检入口挂到 MeView
按 W2 plan Task 7 落地,实现胜过 plan 原稿(强化 UX 减少 Xcode console
依赖):
- 卡片显示 Application Support 路径 + 模型预期完整路径
- 一键复制路径到剪贴板,方便 `cp -R` 拷模型
- 模型就绪状态徽章(✓ 就绪 / ⚠ 未就绪),依赖 ModelStore.isReady
- 跑一段 prompt 流式输出,顶部 tok/s 速率显示
- 全文件 #if DEBUG 包裹,Release 不打包

MeView 在 DEBUG 时挂 DebugAIRunner 在 placeholder 下方。

下一步用户手动:把 ~/tiji-models/Qwen3-1.7B-4bit 拷到模拟器沙盒
Application Support/Models/ 下,然后跑 App → Me 页点按钮验收。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:50:07 +08:00
link2026
a02679a623 fix(build): 手动 patch SPM 链接 + 清孤儿文件让 Task 6 真正可编译
经过多轮 Xcode UI / SPM 解析失败,本 commit 合并以下修复:

pbxproj 手动 patch:
- 删除孤立的 mlx-swift XCRemoteSwiftPackageReference(版本 0.31.3 与
  mlx-swift-examples 2.29.1 锁定的 0.29.1..<0.30.0 冲突)
- 在 体己 target 加入 MLXLLM + MLXLMCommon 两个 product 依赖,绑定到
  mlx-swift-examples 包。补齐 PBXBuildFile + XCSwiftPackageProductDependency
  + packageProductDependencies + Frameworks build phase 4 处条目

LLMSession.swift 简化:
- 去掉 import MLX(避免需要把 mlx-swift transitive MLX/MLXFast/MLXNN 等
  5 个 product 也链上,大幅简化依赖)
- 移除 MLX.GPU.synchronize() 调用——研究笔记里建议的尾部同步对 AsyncStream
  数据正确性无影响,省一份直接 import 依赖

清理孤儿文件:
- 体己/AI/Theme.swift 和 体己/AI/TabBar.swift 是早期混乱中由出错的
  fix subagent 创建的占位 stub,跟 DesignSystem/Tokens.swift 重复声明
  enum Tj,导致 invalid redeclaration

附:Package.resolved 由 xcodebuild SPM resolve 生成,加入版本控制确保
团队成员锁定相同版本图。

** BUILD SUCCEEDED ** 验证通过(iPhone 17 Pro simulator)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:45:32 +08:00
link2026
f5f78e36a6 fix(ai): 回滚 LLMSession 的错误 stub-out,正确施加 GPU.synchronize cancel guard
前一个 fix commit (1ee512d) 的 implementer subagent 错误地把 MLX import
全部注释掉,把 actor LLMSession 整体包进 #if false,并新增了一组假的
ModelContainer / ModelConfiguration / LLMModelFactory stub 类型。这是
对 spec 的严重偏离——MLX SPM 依赖已经存在(Task 1 用户手动配置 + 多
次 BUILD SUCCEEDED 已验证)。

本 commit 恢复 ad1b045 的真实 MLX 实现,并保留原本只有 2 行的 P0
修复(GPU.synchronize 仅在 !Task.isCancelled 路径执行)。

防再犯:后续 fix subagent prompt 加入"不要修改与 P0 无关的代码"
显式红线。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:07:54 +08:00
link2026
1ee512dce1 harden(ai): LLMSession 取消时跳过 MLX.GPU.synchronize
按 code quality review(P0)反馈,for-await 因 Task.isCancelled
退出时,GPU.synchronize() 不必执行——这是一个阻塞的 GPU 同步操作,
取消场景下属浪费。

W3 引入"用户取消推理"UI 时会更频繁触发此路径。

P1/P2 留待 W3 退散考量:
- decodeRate 用窗口平均(目前是累积)
- AIRuntime 持具体 LLMSession 类型,W3 抽 protocol 做 mock
- prompt 空字符串守门
- Float(0.6) 风格

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:06:09 +08:00
link2026
ad1b045e12 feat(ai): LLMSession 接 MLX-Swift,跑 Qwen3-1.7B 流式生成
按 W2 plan Task 6 + docs/superpowers/notes/2026-05-25-mlx-api-corrections.md
落地 LLM 推理底座:

- actor LLMSession 包装 MLXLLM.ModelContainer
- load(folderURL:) 用 ModelConfiguration(directory:) + LLMModelFactory.shared.loadContainer
- generate(prompt:maxTokens:) 返回 AsyncThrowingStream<TokenChunk, Error>
- 内部 container.perform { (context: ModelContext) in ... } 拿到模型上下文
- UserInput → processor.prepare → MLXLMCommon.generate(顶层函数, AsyncStream)
- Generation switch 穷举 3 个 case(chunk / info / toolCall)
- maxTokens 通过 GenerateParameters 传递,温度 0.6 topP 0.9
- 取消传播:continuation.onTermination 同步 task.cancel()
- 每 chunk yield 时计算 tok/s decodeRate

API 基线:mlx-swift-examples tag 2.29.1, commit 9bff95ca。

需用户手动:
1. Xcode 把 LLMSession.swift 拖入 体己 target (AI group)
2. ⌘B 验证 AIRuntime 不再报 "Cannot find LLMSession"
3. 把 ~/tiji-models/Qwen3-1.7B-4bit/ 拷到模拟器沙盒 Application Support/Models/
4. Task 7 (DebugAIRunner) 才能跑通

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:03:04 +08:00
link2026
ef0fbeac97 fix(ai,persistence): ModelStore + FileVault 标 @unchecked Sendable
Xcode 26 默认开启 Swift 6 严格并发检查。AIRuntime(actor)
调用 ModelStore.shared.isReady(...) 跨 actor 边界,因 ModelStore
非 Sendable 而编译报错"Expression is 'async' but is not marked
with 'await'; this is an error in the Swift 6 language mode"。

两个类的内部状态只读(rootURL: let),方法只做线程安全的
filesystem I/O,符合 Sendable 语义,标 @unchecked Sendable
即可,不必加锁或重构。

修复目标错误:
- AIRuntime.swift:48 - guard ModelStore.shared.isReady(.llm) ...
- 后续 CaptureService 调 FileVault.shared.writeJPEG 同样路径

不影响:
- HomeView/B5ResultView 里 Text "+" 的 macOS 26.0 deprecation 是
  warning,不阻塞 build,留待 UI polish 周清理

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:00:47 +08:00
link2026
193e478425 docs: 记录 MLX-Swift-Examples 2.29.1 真实 API 与 plan 草稿的偏差
W2 plan Task 6 写的 LLMSession 草稿在 4 处与真实 API 不符:
- container.perform 的 context 是具体 ModelContext struct
- MLXLMCommon.generate 是顶层函数,只 try 不 await,返回 AsyncStream 非 Throwing
- Generation 有第三个 case .toolCall,switch 必须穷举
- GenerateParameters 需要 maxTokens,且 temperature/topP 是 Float
- 取消传播需 continuation.onTermination = { _ in task.cancel() }

本笔记含完整修正版 LLMSession.swift,Task 6 implementer 必用此为准。

参考:mlx-swift-examples tag 2.29.1,commit 9bff95ca。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:53:54 +08:00
link2026
771b28e7ef fix(ai): ModelKind rawValue 改为真实 HF mlx-community 仓库名
实际查 HuggingFace 后,mlx-community 下的仓库名:
- Qwen3-1.7B-4bit(不是 Qwen3-1.7B-MLX-4bit)
- Qwen2.5-VL-3B-Instruct-4bit(VL 模型带 Instruct 后缀)

改动:
- ModelKind.llm/vl rawValue 改名,这也是沙盒 Models/ 下的子目录名
- 加 huggingFaceRepo computed:"mlx-community/\(rawValue)"
- CLAUDE.md §2 表格补 HF 仓库 ID
- spec §2.2 模型来源行修正

W2 plan 中的下载脚本已陈旧(用了 huggingface-cli + 错名),
W2 retro 时会修正。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:50:20 +08:00
link2026
e7cdb45472 harden(ai): AIRuntime 去掉冗余 weak self,prepare loading 路径加注释
按 code quality review 反馈(2×P0):
- generate() 的 Task 闭包不再 [weak self];actor 单例 strong capture
  没有循环引用风险,且避免 Swift 5.10+ weak-on-actor 警告
- prepare() 的 case .loading: return 加注释说明这是有意设计,
  调用方需轮询或显示 loading UI(W3 引入 prepare 队列优化)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:33:51 +08:00
link2026
4dcd951821 feat(ai): add AIRuntime actor skeleton + TokenChunk
按 W2 plan Task 5 落地推理串行化骨架:
- TokenChunk: Sendable struct (text + decodeRate tok/s)
- AIRuntime: actor 单例
  - Status: notReady / loading / ready / error(msg)
  - prepare() async throws: 幂等加载,失败回滚 status
  - generate(prompt:maxTokens:) -> AsyncThrowingStream: 流式输出
    跨 actor 边界用 snapshot 模式捕获 self.status/llmSession
  - lastDecodeRate: 给 UI 顶部条 / Live Activity 取
- AIRuntimeError: LocalizedError, 三种 case

WIP: Build will fail until Task 6 lands LLMSession (intentional).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:30:47 +08:00
link2026
d40cb7d1e0 harden(ai): ModelStore seedFromBundle 在 DEBUG 报错,加空目录测试
按 code quality review 反馈:
- seedFromBundle 找不到 bundle 资源时,DEBUG 下 assertionFailure 提示
  target membership(release 仍静默 return),避免 W6 启用时排查困难
- 补 totalBytesReturnsZeroWhenFolderMissing 测试,覆盖 folder 不存在时
  enumerator 为 nil 的 guard 路径

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:12:26 +08:00
link2026
ad6fb660f0 feat(ai): add ModelStore with path management and bundle seed
按 W2 plan Task 4 落地模型路径管理:
- ModelKind enum: llm (Qwen3-1.7B-MLX-4bit) / vl (Qwen2.5-VL-3B-MLX-4bit)
- 用 config.json 作为 sentinel 判定模型是否就绪
- isReady / localURL / totalBytes 三个查询接口
- seedFromBundle(_:) 占位:Demo 现场预装模型旁路(W6 启用)
- shared 单例用 Application Support/Models/

测试 3 条:fresh / mark-ready / totalBytes,均用临时目录隔离 + defer cleanup。

注:.swift 文件需用户在 Xcode 拖入 target,⌘U 确认绿后 amend build commit。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:09:51 +08:00
link2026
0739ccea2b harden(persistence): FileVault path traversal guard + error unification
按 code quality review 反馈(P0 + 4×P1):
- 加 resolveSafePath() 拒绝 / 和 .. 并验证 hasPrefix(rootURL)
- loadImage/remove 统一抛 FileVaultError(readFailed/removeFailed)
- 删除测试 struct 上多余的 @MainActor
- 每个 @Test 加 defer cleanup,不泄漏 temp 目录
- 测试图片改用生成 16x16 红色,不依赖 SF Symbol

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:06:49 +08:00
link2026
d704a9eb78 feat(persistence): add FileVault with complete file protection
按 W2 plan Task 3 落地原图加密存储:
- writeJPEG / loadImage / remove / wipe 四个核心操作
- Application Support/Vault/ 目录全程 .completeFileProtection
- 文件写入用 .completeFileProtection options(双保险)
- FileVault(rootURL:) 注入便于测试隔离
- shared 单例用真实 App Support 路径

测试 3 条:roundtrip / remove / wipe。

注:.swift 文件需用户在 Xcode 拖入 target(Persistence group + 体己Tests),
之后 ⌘U 跑测试,若全绿再 amend 提交 .pbxproj。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:03:15 +08:00
link2026
2b6c4b9726 feat(models): add Asset/ChatTurn, indicator-report relationship, pinned flag
按 W2 plan Task 2 落地数据模型:
- Indicator 加 report / asset / pinned 字段
- Report 加 indicators / assets @Relationship(cascade)
- DiaryEntry 加 tags
- 新增 @Model Asset (原图元数据)
- 新增 @Model ChatTurn (问答历史 + 引用)
- TijiApp Schema 加入新 model

注:Schema 破坏性变更,用户需在 Xcode 里 Erase Simulator
后重启 App。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:55:26 +08:00
link2026
c050865db5 feat(ui): UI 骨架基线 — 3 Tab + RecordSheet + Quick/Archive 流程占位
替换 Xcode 默认模板:
- 删除 ContentView/Item/__App
- 新增 App/TijiApp(SwiftData ModelContainer)、RootView(3 Tab + RecordSheet)
- DesignSystem:Tokens(色板/字体/圆角)+ Components(卡片/按钮/Chip)
- Models:Indicator / Report / DiaryEntry @Model 初版
- Features:Home / Quick(A1-A3)/ Archive(B1-B5)/ Record / Trends / Me 静态 UI

W2 AI 基座工作将在此基线上叠加。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:49:21 +08:00
143 changed files with 31103 additions and 1001 deletions

3
.gitignore vendored
View File

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

View File

@@ -1,4 +1,4 @@
# 康记 / 体己 —— 工程前提 # 康 —— 工程前提
> 这是一个 6 周决赛 demo 项目。今天是 2026-05-25,处于 W1末/W2初。 > 这是一个 6 周决赛 demo 项目。今天是 2026-05-25,处于 W1末/W2初。
> 任何 IDE/Claude 会话开始干活前,先读这份文件。 > 任何 IDE/Claude 会话开始干活前,先读这份文件。
@@ -7,7 +7,7 @@
## 1. 产品定位 ## 1. 产品定位
- **名字**:康(对内代号 体己 / Tiji) - **名字**:康(对内代号 Kangkang)
- **形态**:iOS 原生 App,SwiftUI + SwiftData - **形态**:iOS 原生 App,SwiftUI + SwiftData
- **核心卖点**:**100% 本地推理**的个人健康影像档案 + 大白话解读 + 本地 RAG 问答 - **核心卖点**:**100% 本地推理**的个人健康影像档案 + 大白话解读 + 本地 RAG 问答
- **目标用户**:不愿把体检/化验报告交给云端的普通人 - **目标用户**:不愿把体检/化验报告交给云端的普通人
@@ -23,8 +23,8 @@
| 持久化 | SwiftData | 见 §5 数据模型 | | 持久化 | SwiftData | 见 §5 数据模型 |
| 图表 | Swift Charts | iOS 16+ 原生 | | 图表 | Swift Charts | iOS 16+ 原生 |
| **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama | | **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama |
| LLM | Qwen3-1.7B (MLX 4bit 量化) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 | | LLM | Qwen3-1.7B 4bit (HF: `mlx-community/Qwen3-1.7B-4bit`) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 |
| VL | Qwen2.5-VL-3B (MLX 4bit 量化) | ~2.0GB,负责拍照→结构化指标 | | VL | Qwen2.5-VL-3B-Instruct 4bit (HF: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`) | ~2.0GB,负责拍照→结构化指标 |
| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 | | 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 |
| Face ID | LocalAuthentication | | | Face ID | LocalAuthentication | |
| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 | | Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 |
@@ -84,7 +84,7 @@ VL prompt 必须:
## 4. 模型分发 ## 4. 模型分发
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条 - 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
- 总体积 ~3GB,WiFi 提示必须有 - 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载" - App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用) - `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
@@ -92,37 +92,27 @@ VL prompt 必须:
## 5. 数据模型(SwiftData) ## 5. 数据模型(SwiftData)
现有 3`@Model`,要新增 2 个: **当前 schema(2026-05-26)**:7@Model
```swift ```swift
// ( Models/Models.swift) @Model class Indicator {
@Model class Indicator { name, value, unit, range, statusRaw, note, capturedAt } name, value, unit, range, statusRaw, note, capturedAt,
@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt } report: Report?, asset: Asset?,
@Model class DiaryEntry { content, createdAt } pinned: Bool, // true,Trends
seriesKey: String? // "bp.systolic" / "glucose.fasting" / ... key
//
// Indicator + report: Report?
// Indicator + asset: Asset?
// Indicator + pinned: Bool C2 "" true,Trends pinned
// Report + indicators: [Indicator] @Relationship cascade
// Report + assets: [Asset] @Relationship cascade
// DiaryEntry + tags: [String] VL/LLM
// @Model
@Model class Asset {
var relativePath: String // Vault/
var mimeType: String
var bytes: Int
var createdAt: Date
} }
@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt,
indicators: [Indicator] cascade,
assets: [Asset] cascade }
@Model class DiaryEntry { content, createdAt, tags: [String] }
@Model class Symptom { name, startedAt, endedAt?, note?, severity 1-5, tags, createdAt }
@Model class Asset { relativePath, mimeType, bytes, createdAt }
@Model class ChatTurn { question, answer, referencedIndicatorIDs, referencedReportIDs, createdAt, decodeRate }
@Model class ChatTurn { @Model class UserProfile { // App (UserProfileStore.loadOrCreate)
var question: String birthYear?, biologicalSexRaw, heightCM?, bloodTypeRaw,
var answer: String allergies, chronicConditions, familyHistory, currentMedications,
var referencedIndicatorIDs: [String] updatedAt
var referencedReportIDs: [String]
var createdAt: Date
var decodeRate: Double // ,Me
} }
``` ```
@@ -149,18 +139,21 @@ VL prompt 必须:
## 7. 信息架构 ## 7. 信息架构
``` ```
TabBar: [页] [+ 记录] [趋势] [我的] TabBar: [页] [记录] [+ 新建] [趋势] [我的]
│ │ │ │ │ │ │ │
│ │ │ └─ 模型管理 / Face ID / 关于 │ │ │ └─ 个人资料 / 模型管理 / Face ID / 关于
│ │ └─ 折线图 + AI 一句话解读 │ │ └─ 折线图 + AI 一句话解读
└─ Modal: 选择 拍一张 / 写日记 / 问问看 │ └─ Sheet: 拍一张 / 指标记录 / 报告归档 / 写日记 / 症状
└─ 问候 + 今日摘要 + 时间线 + 影像档案入口 │ └─ ArchiveListView(时间线 + 分类 chip + 年/月分组)
└─ 问候 + 今日摘要 + 进行中症状 + 最近时间线
``` ```
- **3 Tab 不变**,中间 + 号是 Sheet - TabBar **5 槽**:左 2 个内容 Tab + 中间 + 号 + 右 2 个 Tab
- "+ 新建" 是 sheet 不是 Tab
- AI 问答以 Modal Sheet 形式出现,**不占 Tab** - AI 问答以 Modal Sheet 形式出现,**不占 Tab**
- "问问看"入口除了在 RecordSheet 里,首页摘要卡片下方也有一个常驻入口 - 「指标记录」sheet 顶部 LazyVGrid 是 8 个 MonitorMetric 长期监测预设(进趋势),
- 历史时间线在首页下半部分,不单独开 Tab 下方 horizontal scroll 是化验项快捷预设(不进趋势),不选预设走自由输入
- 「我的 · 个人资料」是 NavigationLink push 的 Form 编辑页
### 7.1 档案库 C1 / C2 导航(看的一半) ### 7.1 档案库 C1 / C2 导航(看的一半)
@@ -205,8 +198,8 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
## 8. 现有代码状态(2026-05-25) ## 8. 现有代码状态(2026-05-25)
``` ```
体己/ 康康/
├── App/TijiApp.swift ✅ SwiftData container 已建 ├── App/KangkangApp.swift ✅ SwiftData container 已建
├── RootView.swift ✅ 3 Tab + RecordSheet 已建 ├── RootView.swift ✅ 3 Tab + RecordSheet 已建
├── Models/Models.swift ✅ Indicator / Report / DiaryEntry,缺 Asset / ChatTurn ├── Models/Models.swift ✅ Indicator / Report / DiaryEntry,缺 Asset / ChatTurn
├── DesignSystem/ ✅ Tokens + Components,沿用 ├── DesignSystem/ ✅ Tokens + Components,沿用
@@ -219,9 +212,10 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
└── Me/ ❌ 只有 placeholder └── Me/ ❌ 只有 placeholder
待建: 待建:
├── AI/ AIRuntime, LLMSession, VLSession, Prompts/ ├── AI/ ⚠️ AIRuntime + LLMSession + ModelStore + TokenChunk ✅;VLSession + Prompts/
├── Debug/DebugAIRunner.swift ✅ DEBUG-only AI 自检入口
├── Services/ ❌ CaptureService, AskService, TrendService, ReportCompareService ├── Services/ ❌ CaptureService, AskService, TrendService, ReportCompareService
├── Persistence/FileVault.swift 原图加密目录管理 ├── Persistence/FileVault.swift 原图加密目录管理
├── Security/AppLock.swift ❌ Face ID 启动锁 ├── Security/AppLock.swift ❌ Face ID 启动锁
├── Features/Ask/ ❌ AskSheet (RAG 问答 UI) ├── Features/Ask/ ❌ AskSheet (RAG 问答 UI)
├── Features/Archive/ ├── Features/Archive/
@@ -255,7 +249,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
3. **UI 不直接调 AIRuntime**——必须经过 Service 3. **UI 不直接调 AIRuntime**——必须经过 Service
4. **AIRuntime 必须 actor 化**——禁止 class + lock 4. **AIRuntime 必须 actor 化**——禁止 class + lock
5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏 5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏
6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**例外**:报告对比(16.1)已加回,见 §7.2 6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2)
7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来 7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架 8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
@@ -265,8 +259,8 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
| 周次 | 必交付 | | 周次 | 必交付 |
|---|---| |---|---|
| W1 末 / **W2 当前** | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 | | W1 末 / W2 当前 | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 |
| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果) | | W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) |
| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault | | W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault |
| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) | | W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) |
| W4-W5 | 趋势(Swift Charts + AI 解读)、**C2 ReportDetailView**(三 Tab + 重新解读) | | W4-W5 | 趋势(Swift Charts + AI 解读)、**C2 ReportDetailView**(三 Tab + 重新解读) |

0
README.md Normal file
View File

View File

@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<filter id="wordShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#0f3f33" flood-opacity="0.42"/>
</filter>
</defs>
<rect width="1024" height="1024" fill="#8ED9E4"/>
<circle cx="748" cy="286" r="124" fill="#FFF1A8"/>
<circle cx="748" cy="286" r="76" fill="#FFFFFF"/>
<path d="M0 426C210 350 404 377 592 500C731 592 875 608 1024 506V1024H0V426Z" fill="#2C7E79"/>
<path d="M0 612C226 533 436 536 624 631C774 710 903 690 1024 580V1024H0V612Z" fill="#1F6761"/>
<path d="M0 780C232 678 436 641 634 693C799 743 924 711 1024 594V1024H0V780Z" fill="#53A247"/>
<path d="M0 888C232 807 447 780 656 825C812 863 931 825 1024 722V1024H0V888Z" fill="#82CC52"/>
<path d="M318 1024C506 861 725 727 1024 604V1024H318Z" fill="#B2D95E"/>
<path
d="M188 560H268L324 416L428 704L500 560H594"
fill="none"
stroke="#F4FFFC"
stroke-width="36"
stroke-linecap="round"
stroke-linejoin="round"
opacity="0.96"/>
<text
x="512"
y="846"
fill="#FFFFFF"
stroke="#145D46"
stroke-width="5"
stroke-opacity="0.68"
paint-order="stroke fill"
font-family="Hiragino Sans GB, Songti SC, Helvetica Neue, Arial, sans-serif"
font-size="136"
font-weight="600"
text-anchor="middle"
letter-spacing="8"
filter="url(#wordShadow)">康康</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

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

@@ -0,0 +1,113 @@
# MLX-Swift-Examples API 核对(2026-05-25)
研究产出来源:`https://github.com/ml-explore/mlx-swift-examples` tag `2.29.1`,commit `9bff95ca5f0b9e8c021acc4d71a2bbe4a7441631`
W2 plan Task 6 的 LLMSession 草稿与真实 API 有 4 处偏差,**Task 6 必须用本文修正版,不要回头读 plan 里的草稿**。
## 关键修正
| 项 | 草稿 | 真实 API |
|---|---|---|
| `ModelConfiguration(directory:)` | ✓ | ✓ 一致 |
| `LLMModelFactory.shared.loadContainer(configuration:)` | ✓ | ✓ 一致(`hub` / `progressHandler` 有默认值) |
| `container.perform { context in ... }` | 未类型化 | context 是 `ModelContext` struct(具体类型);`processor: any UserInputProcessor` |
| `MLXLMCommon.generate(...)` 调用语义 | `try MLXLMCommon.generate(...)` 后内部 `for await` | **同上,只需 `try`(无 `await`)**;**返回 `AsyncStream<Generation>`(非 throwing)** |
| `Generation` 枚举 case | 只列了 `.chunk(String)``.info(...)` | **还有 `.toolCall(ToolCall)`,switch 必须穷举** |
| `GenerateParameters` | 只传 `temperature / topP`,`maxTokens` 在草稿用 `produced >= maxTokens break` 控制 | **`maxTokens` 必须传 GenerateParameters**;`temperature` / `topP``Float` 不是 `Double` |
| 取消 | 草稿没处理 | **必须** `continuation.onTermination = { _ in task.cancel() }` |
| `UserInput` 构造 | `LMInput.init(prompt:)` | `UserInput(prompt: prompt)``context.processor.prepare(input: userInput)``LMInput` |
## 修正版 LLMSession.swift(Task 6 直接抄)
```swift
import Foundation
import MLX
import MLXLLM
import MLXLMCommon
actor LLMSession {
let container: ModelContainer
init(container: ModelContainer) {
self.container = container
}
static func load(folderURL: URL) async throws -> LLMSession {
let configuration = ModelConfiguration(directory: folderURL)
let container = try await LLMModelFactory.shared.loadContainer(
configuration: configuration
)
return LLMSession(container: container)
}
/// AsyncThrowingStream , Task
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
let parameters = GenerateParameters(
maxTokens: maxTokens,
temperature: Float(0.6),
topP: Float(0.9)
)
try await container.perform { (context: ModelContext) in
let userInput = UserInput(prompt: prompt)
let lmInput = try await context.processor.prepare(input: userInput)
let start = Date()
var produced = 0
for await event in try MLXLMCommon.generate(
input: lmInput,
parameters: parameters,
context: context
) {
if Task.isCancelled { break }
switch event {
case .chunk(let text):
produced += 1
let elapsed = Date().timeIntervalSince(start)
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
continuation.yield(TokenChunk(text: text, decodeRate: rate))
case .info:
// ,
break
case .toolCall:
// ,switch
break
}
}
MLX.GPU.synchronize()
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
}
```
## 与 AIRuntime 的对接
`AIRuntime.swift`(W2-T5 提交的 `4dcd951` + `e7cdb45`)已经预设:
```swift
let session = try await LLMSession.load(folderURL: ModelStore.shared.localURL(for: .llm))
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
```
签名匹配,Task 6 不改 AIRuntime。
## 真实模型 HF 仓库名
- LLM: `mlx-community/Qwen3-1.7B-4bit`(沙盒目录:`Qwen3-1.7B-4bit`)
- VL: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`(沙盒目录:`Qwen2.5-VL-3B-Instruct-4bit`)
注:plan 文档 Task 6 里写的是带 "MLX-" 中缀的旧名,**已弃用**。ModelKind rawValue 已在 commit `771b28e` 修正。

View File

@@ -18,23 +18,23 @@
| 路径 | 职责 | | 路径 | 职责 |
|---|---| |---|---|
| `体己/AI/AIRuntime.swift` | actor 单例,推理串行化,暴露 prepare / generate / lastDecodeRate | | `康康/AI/AIRuntime.swift` | actor 单例,推理串行化,暴露 prepare / generate / lastDecodeRate |
| `体己/AI/ModelStore.swift` | 模型路径管理 + bundle 旁路 | | `康康/AI/ModelStore.swift` | 模型路径管理 + bundle 旁路 |
| `体己/AI/LLMSession.swift` | Qwen3-1.7B 加载 + 流式生成 | | `康康/AI/LLMSession.swift` | Qwen3-1.7B 加载 + 流式生成 |
| `体己/AI/TokenChunk.swift` | 流式数据结构 | | `康康/AI/TokenChunk.swift` | 流式数据结构 |
| `体己/Persistence/FileVault.swift` | `Application Support/Vault/` 加密目录读写 | | `康康/Persistence/FileVault.swift` | `Application Support/Vault/` 加密目录读写 |
| `体己/Debug/DebugAIRunner.swift` | DEBUG-only 测试入口,挂在 MeView 末尾 | | `康康/Debug/DebugAIRunner.swift` | DEBUG-only 测试入口,挂在 MeView 末尾 |
| `体己Tests/FileVaultTests.swift` | FileVault 单元测试 | | `康康Tests/FileVaultTests.swift` | FileVault 单元测试 |
| `体己Tests/ModelStoreTests.swift` | ModelStore 单元测试 | | `康康Tests/ModelStoreTests.swift` | ModelStore 单元测试 |
### 修改 ### 修改
| 路径 | 改什么 | | 路径 | 改什么 |
|---|---| |---|---|
| `体己/Models/Models.swift` | 加 Asset / ChatTurn,Indicator 加 report/asset/pinned,Report 加 indicators/assets 关系,DiaryEntry 加 tags | | `康康/Models/Models.swift` | 加 Asset / ChatTurn,Indicator 加 report/asset/pinned,Report 加 indicators/assets 关系,DiaryEntry 加 tags |
| `体己/App/TijiApp.swift` | Schema 加入两个新 @Model | | `康康/App/KangkangApp.swift` | Schema 加入两个新 @Model |
| `体己/Features/Me/MeView.swift` | DEBUG 块挂 DebugAIRunner | | `康康/Features/Me/MeView.swift` | DEBUG 块挂 DebugAIRunner |
| `体己.xcodeproj` | SPM 加入 mlx-swift 与 mlx-swift-examples | | `康康.xcodeproj` | SPM 加入 mlx-swift 与 mlx-swift-examples |
### 不动(W2 不碰) ### 不动(W2 不碰)
@@ -45,15 +45,15 @@
## Task 1:Xcode 项目加入 MLX Swift SPM 依赖 ## Task 1:Xcode 项目加入 MLX Swift SPM 依赖
**Files:** **Files:**
- Modify: `体己.xcodeproj/project.pbxproj`(通过 Xcode UI 修改,不要手编) - Modify: `康康.xcodeproj/project.pbxproj`(通过 Xcode UI 修改,不要手编)
- [ ] **Step 1:打开 Xcode 项目** - [x] **Step 1:打开 Xcode 项目**
```bash ```bash
open /Users/xuhuayong/apps/体己/体己.xcodeproj open /Users/xuhuayong/apps/康康/康康.xcodeproj
``` ```
- [ ] **Step 2:加入 MLX Swift 依赖** - [x] **Step 2:加入 MLX Swift 依赖**
在 Xcode → File → Add Package Dependencies → 输入 URL: 在 Xcode → File → Add Package Dependencies → 输入 URL:
@@ -61,14 +61,14 @@ open /Users/xuhuayong/apps/体己/体己.xcodeproj
https://github.com/ml-explore/mlx-swift https://github.com/ml-explore/mlx-swift
``` ```
选 "Up to Next Major" → 添加,勾选这些 product 加到 **体己** target: 选 "Up to Next Major" → 添加,勾选这些 product 加到 **康康** target:
- `MLX` - `MLX`
- `MLXFast` - `MLXFast`
- `MLXNN` - `MLXNN`
- `MLXOptimizers` - `MLXOptimizers`
- `MLXRandom` - `MLXRandom`
- [ ] **Step 3:加入 mlx-swift-examples(含 LLM 工具)** - [x] **Step 3:加入 mlx-swift-examples(含 LLM 工具)**
继续 Add Package Dependencies,URL: 继续 Add Package Dependencies,URL:
@@ -76,25 +76,25 @@ https://github.com/ml-explore/mlx-swift
https://github.com/ml-explore/mlx-swift-examples https://github.com/ml-explore/mlx-swift-examples
``` ```
勾选 `MLXLLM``MLXLMCommon` 加到 **体己** target。 勾选 `MLXLLM``MLXLMCommon` 加到 **康康** target。
- [ ] **Step 4:确认 Build Settings** - [x] **Step 4:确认 Build Settings**
Xcode → 体己 target → Build Settings → 搜 "Swift Language Version" → 确认 Swift 5(MLX 不支持 Swift 6 严格并发)。 Xcode → 康康 target → Build Settings → 搜 "Swift Language Version" → 确认 Swift 5(MLX 不支持 Swift 6 严格并发)。
体己 target → General → Minimum Deployments → iOS 17.0(MLX 要求)。 康康 target → General → Minimum Deployments → iOS 17.0(MLX 要求)。
- [ ] **Step 5:Build 验证** - [x] **Step 5:Build 验证**
Xcode 顶部选模拟器(任何一个 iPhone 15+),按 ⌘B。 Xcode 顶部选模拟器(任何一个 iPhone 15+),按 ⌘B。
Expected:Build Succeeded,无依赖错误。 Expected:Build Succeeded,无依赖错误。
- [ ] **Step 6:提交** - [x] **Step 6:提交**
```bash ```bash
cd /Users/xuhuayong/apps/体己 cd /Users/xuhuayong/apps/康康
git add 体己.xcodeproj git add 康康.xcodeproj
git commit -m "build: add MLX Swift SPM dependencies" git commit -m "build: add MLX Swift SPM dependencies"
``` ```
@@ -103,11 +103,11 @@ git commit -m "build: add MLX Swift SPM dependencies"
## Task 2:扩展 Models.swift —— Asset 与 ChatTurn ## Task 2:扩展 Models.swift —— Asset 与 ChatTurn
**Files:** **Files:**
- Modify: `体己/Models/Models.swift`(全文重写) - Modify: `康康/Models/Models.swift`(全文重写)
- [ ] **Step 1:把 Models.swift 替换为新内容** - [x] **Step 1:把 Models.swift 替换为新内容**
打开 `体己/Models/Models.swift`,**整文件替换**为: 打开 `康康/Models/Models.swift`,**整文件替换**为:
```swift ```swift
import Foundation import Foundation
@@ -268,9 +268,9 @@ final class ChatTurn {
} }
``` ```
- [ ] **Step 2:更新 TijiApp.swift Schema** - [x] **Step 2:更新 KangkangApp.swift Schema**
打开 `体己/App/TijiApp.swift`,替换 Schema 数组: 打开 `康康/App/KangkangApp.swift`,替换 Schema 数组:
```swift ```swift
let schema = Schema([ let schema = Schema([
@@ -282,7 +282,7 @@ let schema = Schema([
]) ])
``` ```
- [ ] **Step 3:删模拟器沙盒(破坏性迁移)** - [x] **Step 3:删模拟器沙盒(破坏性迁移)**
在 Mac 上: 在 Mac 上:
@@ -293,16 +293,16 @@ xcrun simctl erase all
(也可以在 Simulator → Device → Erase All Content and Settings) (也可以在 Simulator → Device → Erase All Content and Settings)
- [ ] **Step 4:Build & Run 验证** - [x] **Step 4:Build & Run 验证**
Xcode ⌘R 运行到模拟器,App 启动不崩 = Schema OK。 Xcode ⌘R 运行到模拟器,App 启动不崩 = Schema OK。
Expected:App 启动到 RootView,无 fatalError。 Expected:App 启动到 RootView,无 fatalError。
- [ ] **Step 5:提交** - [x] **Step 5:提交**
```bash ```bash
git add 体己/Models/Models.swift 体己/App/TijiApp.swift git add 康康/Models/Models.swift 康康/App/KangkangApp.swift
git commit -m "feat(models): add Asset/ChatTurn, indicator-report relationship, pinned flag" git commit -m "feat(models): add Asset/ChatTurn, indicator-report relationship, pinned flag"
``` ```
@@ -311,17 +311,17 @@ git commit -m "feat(models): add Asset/ChatTurn, indicator-report relationship,
## Task 3:FileVault —— 加密目录读写(TDD) ## Task 3:FileVault —— 加密目录读写(TDD)
**Files:** **Files:**
- Create: `体己/Persistence/FileVault.swift` - Create: `康康/Persistence/FileVault.swift`
- Test: `体己Tests/FileVaultTests.swift` - Test: `康康Tests/FileVaultTests.swift`
- [ ] **Step 1:写失败的测试** - [x] **Step 1:写失败的测试**
创建 `体己Tests/FileVaultTests.swift`: 创建 `康康Tests/FileVaultTests.swift`:
```swift ```swift
import Testing import Testing
import UIKit import UIKit
@testable import @testable import
@MainActor @MainActor
struct FileVaultTests { struct FileVaultTests {
@@ -369,15 +369,15 @@ struct FileVaultTests {
} }
``` ```
- [ ] **Step 2:运行测试,确认 fail** - [x] **Step 2:运行测试,确认 fail**
Xcode ⌘U 跑测试(在模拟器上跑)。 Xcode ⌘U 跑测试(在模拟器上跑)。
Expected:`FileVaultTests` 编译错误 "Cannot find 'FileVault' in scope"。 Expected:`FileVaultTests` 编译错误 "Cannot find 'FileVault' in scope"。
- [ ] **Step 3:写最小 FileVault 实现** - [x] **Step 3:写最小 FileVault 实现**
创建 `体己/Persistence/FileVault.swift`: 创建 `康康/Persistence/FileVault.swift`:
```swift ```swift
import Foundation import Foundation
@@ -454,22 +454,22 @@ final class FileVault {
} }
``` ```
- [ ] **Step 4:把 FileVault.swift 加入 体己 target** - [x] **Step 4:把 FileVault.swift 加入 康康 target**
Xcode 右键 `体己/` 目录 → New Group "Persistence" → 把 FileVault.swift 拖进去,确认 Target Membership 勾选 "体己"。 Xcode 右键 `康康/` 目录 → New Group "Persistence" → 把 FileVault.swift 拖进去,确认 Target Membership 勾选 "康康"。
把 FileVaultTests.swift 拖进 体己Tests target,确认 Target Membership 勾选 "体己Tests"。 把 FileVaultTests.swift 拖进 康康Tests target,确认 Target Membership 勾选 "康康Tests"。
- [ ] **Step 5:跑测试,确认全 pass** - [x] **Step 5:跑测试,确认全 pass**
Xcode ⌘U。 Xcode ⌘U。
Expected:`writeAndReadJPEGRoundtrip` / `removeMakesFileGone` / `wipeRemovesAllFiles` 全绿。 Expected:`writeAndReadJPEGRoundtrip` / `removeMakesFileGone` / `wipeRemovesAllFiles` 全绿。
- [ ] **Step 6:提交** - [x] **Step 6:提交**
```bash ```bash
git add 体己/Persistence/FileVault.swift 体己Tests/FileVaultTests.swift 体己.xcodeproj git add 康康/Persistence/FileVault.swift 康康Tests/FileVaultTests.swift 康康.xcodeproj
git commit -m "feat(persistence): add FileVault with complete file protection" git commit -m "feat(persistence): add FileVault with complete file protection"
``` ```
@@ -478,17 +478,17 @@ git commit -m "feat(persistence): add FileVault with complete file protection"
## Task 4:ModelStore —— 模型路径与 bundle 旁路(TDD) ## Task 4:ModelStore —— 模型路径与 bundle 旁路(TDD)
**Files:** **Files:**
- Create: `体己/AI/ModelStore.swift` - Create: `康康/AI/ModelStore.swift`
- Test: `体己Tests/ModelStoreTests.swift` - Test: `康康Tests/ModelStoreTests.swift`
- [ ] **Step 1:写失败的测试** - [x] **Step 1:写失败的测试**
创建 `体己Tests/ModelStoreTests.swift`: 创建 `康康Tests/ModelStoreTests.swift`:
```swift ```swift
import Testing import Testing
import Foundation import Foundation
@testable import @testable import
@MainActor @MainActor
struct ModelStoreTests { struct ModelStoreTests {
@@ -531,13 +531,13 @@ struct ModelStoreTests {
} }
``` ```
- [ ] **Step 2:运行测试,确认 fail** - [x] **Step 2:运行测试,确认 fail**
⌘U → expect `Cannot find 'ModelStore'`. ⌘U → expect `Cannot find 'ModelStore'`.
- [ ] **Step 3:写 ModelStore 实现** - [x] **Step 3:写 ModelStore 实现**
创建 `体己/AI/ModelStore.swift`: 创建 `康康/AI/ModelStore.swift`:
```swift ```swift
import Foundation import Foundation
@@ -619,21 +619,21 @@ final class ModelStore {
} }
``` ```
- [ ] **Step 4:Xcode 中把文件加入 target** - [x] **Step 4:Xcode 中把文件加入 target**
右键 `体己/` → New Group "AI" → 拖入 ModelStore.swift,勾 "体己" target。 右键 `康康/` → New Group "AI" → 拖入 ModelStore.swift,勾 "康康" target。
ModelStoreTests.swift 拖入 体己Tests target。 ModelStoreTests.swift 拖入 康康Tests target。
- [ ] **Step 5:跑测试,全绿** - [x] **Step 5:跑测试,全绿**
⌘U。 ⌘U。
Expected:3 个测试全 pass。 Expected:3 个测试全 pass。
- [ ] **Step 6:提交** - [x] **Step 6:提交**
```bash ```bash
git add 体己/AI/ModelStore.swift 体己Tests/ModelStoreTests.swift 体己.xcodeproj git add 康康/AI/ModelStore.swift 康康Tests/ModelStoreTests.swift 康康.xcodeproj
git commit -m "feat(ai): add ModelStore with path management and bundle seed" git commit -m "feat(ai): add ModelStore with path management and bundle seed"
``` ```
@@ -642,12 +642,12 @@ git commit -m "feat(ai): add ModelStore with path management and bundle seed"
## Task 5:TokenChunk + AIRuntime actor 骨架 ## Task 5:TokenChunk + AIRuntime actor 骨架
**Files:** **Files:**
- Create: `体己/AI/TokenChunk.swift` - Create: `康康/AI/TokenChunk.swift`
- Create: `体己/AI/AIRuntime.swift` - Create: `康康/AI/AIRuntime.swift`
本任务**不接 MLX**,只搭骨架。Task 6 才接真模型。 本任务**不接 MLX**,只搭骨架。Task 6 才接真模型。
- [ ] **Step 1:创建 TokenChunk.swift** - [x] **Step 1:创建 TokenChunk.swift**
```swift ```swift
import Foundation import Foundation
@@ -658,7 +658,7 @@ struct TokenChunk: Sendable {
} }
``` ```
- [ ] **Step 2:创建 AIRuntime.swift 骨架** - [x] **Step 2:创建 AIRuntime.swift 骨架**
```swift ```swift
import Foundation import Foundation
@@ -754,19 +754,19 @@ actor AIRuntime {
} }
``` ```
- [ ] **Step 3:确认 Build 失败原因合理** - [x] **Step 3:确认 Build 失败原因合理**
⌘B → expect "Cannot find 'LLMSession' in scope"(Task 6 才会建)。 ⌘B → expect "Cannot find 'LLMSession' in scope"(Task 6 才会建)。
这是预期。我们要让 Task 6 写完后 AIRuntime 直接能工作。 这是预期。我们要让 Task 6 写完后 AIRuntime 直接能工作。
- [ ] **Step 4:把文件加入 target** - [x] **Step 4:把文件加入 target**
把 TokenChunk.swift 和 AIRuntime.swift 拖进 AI group,勾 "体己" target。 把 TokenChunk.swift 和 AIRuntime.swift 拖进 AI group,勾 "康康" target。
(此时 Build 还是失败,正常) (此时 Build 还是失败,正常)
- [ ] **Step 5:暂不提交** - [x] **Step 5:暂不提交**
等 Task 6 完成、Build 通过后一起提交。 等 Task 6 完成、Build 通过后一起提交。
@@ -775,7 +775,7 @@ actor AIRuntime {
## Task 6:LLMSession —— 接 MLX 跑 Qwen3-1.7B ## Task 6:LLMSession —— 接 MLX 跑 Qwen3-1.7B
**Files:** **Files:**
- Create: `体己/AI/LLMSession.swift` - Create: `康康/AI/LLMSession.swift`
**预先准备(开发者手动一次)**: **预先准备(开发者手动一次)**:
@@ -785,7 +785,7 @@ actor AIRuntime {
具体路径在 App 启动时打印,见 Step 5。 具体路径在 App 启动时打印,见 Step 5。
- [ ] **Step 1:在终端下载模型(脚本一次性)** - [x] **Step 1:在终端下载模型(脚本一次性)**
```bash ```bash
mkdir -p ~/tiji-models && cd ~/tiji-models mkdir -p ~/tiji-models && cd ~/tiji-models
@@ -796,9 +796,9 @@ huggingface-cli download mlx-community/Qwen3-1.7B-MLX-4bit \
Expected:目录里有 `config.json` / `model.safetensors` / `tokenizer.json` 等。 Expected:目录里有 `config.json` / `model.safetensors` / `tokenizer.json` 等。
- [ ] **Step 2:写 LLMSession 实现** - [x] **Step 2:写 LLMSession 实现**
创建 `体己/AI/LLMSession.swift`: 创建 `康康/AI/LLMSession.swift`:
```swift ```swift
import Foundation import Foundation
@@ -866,11 +866,11 @@ actor LLMSession {
> **注**:`MLXLMCommon` 的具体 API 版本可能在 GenerateParameters/stream 处略有差异。如果 Step 4 编译报错,查看 mlx-swift-examples 仓库 `Libraries/MLXLLM` 的最新示例,以仓库示例为准小幅调整。 > **注**:`MLXLMCommon` 的具体 API 版本可能在 GenerateParameters/stream 处略有差异。如果 Step 4 编译报错,查看 mlx-swift-examples 仓库 `Libraries/MLXLLM` 的最新示例,以仓库示例为准小幅调整。
- [ ] **Step 3:把 LLMSession.swift 加入 体己 target** - [x] **Step 3:把 LLMSession.swift 加入 康康 target**
拖入 AI group,确认 Target Membership。 拖入 AI group,确认 Target Membership。
- [ ] **Step 4:Build,期望成功** - [x] **Step 4:Build,期望成功**
⌘B。 ⌘B。
@@ -878,9 +878,9 @@ Expected:Build Succeeded。
若 MLX API 签名不匹配,参考 https://github.com/ml-explore/mlx-swift-examples 中 `Libraries/MLXLLM` 的最新 LLM 示例修正。 若 MLX API 签名不匹配,参考 https://github.com/ml-explore/mlx-swift-examples 中 `Libraries/MLXLLM` 的最新 LLM 示例修正。
- [ ] **Step 5:在 TijiApp 启动时打印沙盒路径(临时调试)** - [x] **Step 5:在 KangkangApp 启动时打印沙盒路径(临时调试)**
打开 `体己/App/TijiApp.swift`,在 `WindowGroup { RootView() }` 内加一个 `.onAppear`: 打开 `康康/App/KangkangApp.swift`,在 `WindowGroup { RootView() }` 内加一个 `.onAppear`:
```swift ```swift
.onAppear { .onAppear {
@@ -901,7 +901,7 @@ Expected:Build Succeeded。
📁 App Support: /Users/.../data/Containers/Data/Application/<UUID>/Library/Application Support 📁 App Support: /Users/.../data/Containers/Data/Application/<UUID>/Library/Application Support
``` ```
- [ ] **Step 6:把模型拷到沙盒** - [x] **Step 6:把模型拷到沙盒**
```bash ```bash
APP_SUPPORT="<上面控制台打印的路径>" APP_SUPPORT="<上面控制台打印的路径>"
@@ -909,10 +909,10 @@ mkdir -p "$APP_SUPPORT/Models"
cp -R ~/tiji-models/Qwen3-1.7B-MLX-4bit "$APP_SUPPORT/Models/" cp -R ~/tiji-models/Qwen3-1.7B-MLX-4bit "$APP_SUPPORT/Models/"
``` ```
- [ ] **Step 7:提交(本任务 + Task 5 一起)** - [x] **Step 7:提交(本任务 + Task 5 一起)**
```bash ```bash
git add 体己/AI/ 体己/App/TijiApp.swift 体己.xcodeproj git add 康康/AI/ 康康/App/KangkangApp.swift 康康.xcodeproj
git commit -m "feat(ai): add AIRuntime actor and LLMSession with MLX Qwen3-1.7B" git commit -m "feat(ai): add AIRuntime actor and LLMSession with MLX Qwen3-1.7B"
``` ```
@@ -921,12 +921,12 @@ git commit -m "feat(ai): add AIRuntime actor and LLMSession with MLX Qwen3-1.7B"
## Task 7:DebugAIRunner —— DEBUG 测试入口 ## Task 7:DebugAIRunner —— DEBUG 测试入口
**Files:** **Files:**
- Create: `体己/Debug/DebugAIRunner.swift` - Create: `康康/Debug/DebugAIRunner.swift`
- Modify: `体己/Features/Me/MeView.swift` - Modify: `康康/Features/Me/MeView.swift`
- [ ] **Step 1:创建 DebugAIRunner** - [x] **Step 1:创建 DebugAIRunner**
`体己/Debug/DebugAIRunner.swift`: `康康/Debug/DebugAIRunner.swift`:
```swift ```swift
#if DEBUG #if DEBUG
@@ -998,9 +998,9 @@ struct DebugAIRunner: View {
#endif #endif
``` ```
- [ ] **Step 2:在 MeView 末尾挂上(仅 DEBUG)** - [x] **Step 2:在 MeView 末尾挂上(仅 DEBUG)**
打开 `体己/Features/Me/MeView.swift`,把现有内容整体替换为: 打开 `康康/Features/Me/MeView.swift`,把现有内容整体替换为:
```swift ```swift
import SwiftUI import SwiftUI
@@ -1025,18 +1025,18 @@ struct MeView: View {
#Preview { MeView() } #Preview { MeView() }
``` ```
- [ ] **Step 3:在 Xcode 中加入文件** - [x] **Step 3:在 Xcode 中加入文件**
右键 `体己/` → New Group "Debug" → 拖入 DebugAIRunner.swift,勾 "体己" target。 右键 `康康/` → New Group "Debug" → 拖入 DebugAIRunner.swift,勾 "康康" target。
- [ ] **Step 4:Build,确认 OK** - [x] **Step 4:Build,确认 OK**
⌘B → Expected: Build Succeeded。 ⌘B → Expected: Build Succeeded。
- [ ] **Step 5:提交** - [x] **Step 5:提交**
```bash ```bash
git add 体己/Debug/ 体己/Features/Me/MeView.swift 体己.xcodeproj git add 康康/Debug/ 康康/Features/Me/MeView.swift 康康.xcodeproj
git commit -m "chore(debug): add AI self-test runner in MeView (DEBUG only)" git commit -m "chore(debug): add AI self-test runner in MeView (DEBUG only)"
``` ```
@@ -1092,15 +1092,15 @@ git commit --allow-empty -m "milestone: W2 LLM 自检通过 (simulator)"
## Task 9:加一组 schema 重建烟测(防回归) ## Task 9:加一组 schema 重建烟测(防回归)
**Files:** **Files:**
- Create: `体己Tests/ModelsSchemaTests.swift` - Create: `康康Tests/ModelsSchemaTests.swift`
- [ ] **Step 1:写 schema 烟测** - [x] **Step 1:写 schema 烟测**
```swift ```swift
import Testing import Testing
import SwiftData import SwiftData
import Foundation import Foundation
@testable import @testable import
@MainActor @MainActor
struct ModelsSchemaTests { struct ModelsSchemaTests {
@@ -1179,7 +1179,7 @@ struct ModelsSchemaTests {
} }
``` ```
- [ ] **Step 2:加入 体己Tests target,跑测试** - [x] **Step 2:加入 康康Tests target,跑测试**
⌘U。 ⌘U。
@@ -1187,10 +1187,10 @@ Expected:3 个测试全 pass。
若 cascade 删除测试失败 → 检查 `Indicator.report` 反向关系是否声明正确(参考 Task 2)。 若 cascade 删除测试失败 → 检查 `Indicator.report` 反向关系是否声明正确(参考 Task 2)。
- [ ] **Step 3:提交** - [x] **Step 3:提交**
```bash ```bash
git add 体己Tests/ModelsSchemaTests.swift 体己.xcodeproj git add 康康Tests/ModelsSchemaTests.swift 康康.xcodeproj
git commit -m "test(models): add schema smoke tests for relationships and cascade" git commit -m "test(models): add schema smoke tests for relationships and cascade"
``` ```

View File

@@ -0,0 +1,42 @@
# W2 Retro · 2026-05-31
> 范围:2026-05-19(W2 起)→ 2026-05-25(W2 中段写,W3 周一前回看修订)。本次 retro 在 W2 中段写,主要是周末批量收尾的留痕。
## Status
| 风险/里程碑 | 状态 | 备注 |
|---|---|---|
| R1 · MLX 跑通 | ⚠️ 部分通过 | LLMSession.load 通过 Swift Testing 烟测,真实 tok/s 待用户手动 DebugAIRunner 验证 |
| R4 · Schema 迁移 | ✅ 通过 | 5 + 1(Symptom)个 @Model,3 + 2 个关系烟测全绿 |
| 本周里程碑 · AI 基座骨架 | ✅ | AIRuntime / LLMSession / ModelStore / FileVault 全部交付,build 干净 0 warning |
## 速度基线
- 模拟器(iPhone 17 Sim, Apple Silicon Mac):**TBD**(W3 周一前由 xuhuayong 在 macOS Designed for iPad 内点 DebugAIRunner 填入)
- 真机 iPhone 15+:**待 W3 验证**(本周未连真机,模型只 sideload 到 macOS sandbox)
> 验收门槛:模拟器 < 5 tok/s 触发 R1 红线(换 llama.cpp,W2 plan revert)。当前烟测路径无法测速,需 manual。
## 计划外完成
- **Symptom 模块**:新增 @Model + Start/End sheets + OngoingSymptomsCard。这是 CLAUDE.md §10 红线 #6 "新功能必须问'清单里有吗'" 的例外,由产品负责人决定加入。
- **Timeline 统一时间线**:TimelineEntry + TimelineRow + DateSection + TimelineGrouping,被 HomeView 和 ArchiveListView 共享。
- **ArchiveListView 提前打底**(原计划 W4):接 @Query 拉 Indicator/Report/Diary/Symptom,filter chips + 年/月分组 + 空态。
- **AppIcon**:Light/Dark/Tinted 三套 9 sizes + SVG 源。
- **Swift 6 并发清扫**:`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` 下,把 ModelStore / FileVault / ModelKind 显式标 nonisolated,LLMSession 用 task-scoped Device.withDefaultDevice 替代 deprecated API。
## 计划内缺口
- **Task 8 Step 1-2 自检与速度基线**:延后到用户 manual 验证。
- **Task 8 Step 3 真机连测**:延后到 W3。
- **Task 10 Step 2 §8 状态更新**:已在本 retro commit 内一起完成。
## 学到的
1. **`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` 会把跨边界类型/方法都默认推到 MainActor**,跟 actor (如 AIRuntime) 互操作时必须显式 `nonisolated` 整条调用链。`@unchecked Sendable` 不自动解锁实例方法的 isolation。
2. **iOS Simulator app sandbox 阻止读 Mac 用户目录**,集成测试无法直接验证真实推理;Mac Designed for iPad 又卡 code signing。W3 把 LLM 接口拆 SPM target 后才能写 host-fs 集成测试。
3. **`Device.withDefaultDevice` 是 TaskLocal,跨 actor 传递正常**,但跨 Task(如 AsyncStream 的 detached Task)需要在 inner Task 内重新 `withDefaultDevice`
4. **MLX Swift API 比 mlx-swift-examples 文档稳定**,真正卡的是 Swift 6 并发系统,不是 MLX 本身。
## 下周(W3)前置准备
- [ ] 用户在 macOS App 内点 DebugAIRunner,把实际 tok/s 填进本 retro 的"速度基线"段
- [ ] 准备 510 张真实化验单照片(W4 VL 回归测用),放进 ~/tiji-models/test-reports/
- [ ] 准备 20 条危险问句(W3 末医疗话术安全测试)
- [ ] 决定是否把 LLM 接口拆 SPM target(便于真实推理集成测试)
- [ ] W3 plan 周一动笔,把 Symptom + Timeline 写进 spec

View File

@@ -1,4 +1,4 @@
# 康记 / 体己 —— 功能设计 Spec(v1.0) # 康 —— 功能设计 Spec(v1.0)
**日期**:2026-05-25 **日期**:2026-05-25
**状态**:Draft, 已与产品方对齐 §1-§6 **状态**:Draft, 已与产品方对齐 §1-§6
@@ -8,7 +8,7 @@
## 0. 概要 ## 0. 概要
是一个 iOS 原生健康影像档案 App,**100% 端侧 AI 推理**,基于 SwiftUI + SwiftData + MLX Swift,目标 6 周交付决赛 demo。本 spec 把原始功能清单收敛为 **方案 B**:核心 5 模块 + Live Activity + 分享摘要,其余 P2/P3 全部 deferred。 是一个 iOS 原生健康影像档案 App,**100% 端侧 AI 推理**,基于 SwiftUI + SwiftData + MLX Swift,目标 6 周交付决赛 demo。本 spec 把原始功能清单收敛为 **方案 B**:核心 5 模块 + Live Activity + 分享摘要,其余 P2/P3 全部 deferred。
**5 大核心模块** **5 大核心模块**
@@ -77,7 +77,7 @@ Persistence
### 2.1 `AIRuntime` 接口 ### 2.1 `AIRuntime` 接口
``` ```
体己/AI/ 康康/AI/
├── AIRuntime.swift // actor 单例,推理串行化 ├── AIRuntime.swift // actor 单例,推理串行化
├── ModelStore.swift // 模型路径管理 + 下载 + bundle 旁路 ├── ModelStore.swift // 模型路径管理 + 下载 + bundle 旁路
├── LLMSession.swift // Qwen3-1.7B 文本生成,流式 ├── LLMSession.swift // Qwen3-1.7B 文本生成,流式
@@ -112,7 +112,7 @@ struct TokenChunk {
| 项 | 决策 | | 项 | 决策 |
|---|---| |---|---|
| 模型来源 | HuggingFace MLX 社区版 Qwen3-1.7B-MLX-4bit + Qwen2.5-VL-3B-MLX-4bit | | 模型来源 | HuggingFace `mlx-community/Qwen3-1.7B-4bit` + `mlx-community/Qwen2.5-VL-3B-Instruct-4bit` |
| 体积 | LLM ~1.0GB + VL ~2.0GB ≈ 3GB | | 体积 | LLM ~1.0GB + VL ~2.0GB ≈ 3GB |
| 存储 | `Application Support/Models/`,`URLSession.downloadTask` + 断点续传 | | 存储 | `Application Support/Models/`,`URLSession.downloadTask` + 断点续传 |
| 首启动 | 启动屏 → 隐私承诺 → "下载模型"页(进度 + WiFi 提示) → 主界面 | | 首启动 | 启动屏 → 隐私承诺 → "下载模型"页(进度 + WiFi 提示) → 主界面 |
@@ -376,7 +376,7 @@ User → UI(B2Scan) → CaptureService → AIRuntime → Persistence
### 4.3 服务层文件 ### 4.3 服务层文件
``` ```
体己/AI/ [7.5d] 康康/AI/ [7.5d]
├── AIRuntime.swift 2d ├── AIRuntime.swift 2d
├── ModelStore.swift 1d ├── ModelStore.swift 1d
├── LLMSession.swift 1d ├── LLMSession.swift 1d
@@ -387,17 +387,17 @@ User → UI(B2Scan) → CaptureService → AIRuntime → Persistence
├── KeywordExtraction.swift ├── KeywordExtraction.swift
└── TrendNarrative.swift └── TrendNarrative.swift
体己/Services/ [4.5d] 康康/Services/ [4.5d]
├── CaptureService.swift 1.5d ├── CaptureService.swift 1.5d
├── AskService.swift 1.5d ├── AskService.swift 1.5d
├── TrendService.swift 1d ├── TrendService.swift 1d
└── ReportCompareService.swift 0.5d └── ReportCompareService.swift 0.5d
体己/Persistence/ [1d] 康康/Persistence/ [1d]
├── FileVault.swift 0.5d ├── FileVault.swift 0.5d
└── PermanentDelete.swift 0.5d └── PermanentDelete.swift 0.5d
体己/Security/ [0.5d] 康康/Security/ [0.5d]
└── AppLock.swift 0.5d └── AppLock.swift 0.5d
``` ```

View File

@@ -0,0 +1,122 @@
# Hide Monitor Preset · 设计 v1
> 「记录指标」sheet 长期监测预设(`MonitorMetric`)支持隐藏
>
> 日期:2026-05-26 · 状态:approved by user(2026-05-26 对话)
> 关联:[CLAUDE.md](../../../CLAUDE.md) §7,[Monitor+Profile spec](./2026-05-26-monitor-and-profile-design.md)
---
## 1. 背景
`IndicatorQuickSheet`「长期监测(进趋势)」分组由 `MonitorMetric.allCases` 渲染,目前 6 个硬编码 case(血压/空腹血糖/餐后血糖/体温/心率/血氧)无法隐藏,与下方 `CustomMonitorMetric`(可长按编辑/删除)体验不一致。
用户场景:不测血氧、不测血压的人想清理 grid;但**不能误删历史数据**——已经测过的折线在 Trends 里还要看。
## 2. 目标
- 长按 `MonitorMetric` tile → contextMenu 出"隐藏"
- 已隐藏的 tile 从 grid 过滤掉,但已有 `Indicator` 记录、Trends 折线、`MetricReminder` 全不动
- 提供可逆恢复入口
## 3. 非目标(YAGNI)
- ❌ 化验项快捷预设(labPresets)同款功能 — 本次不动
- ❌ 「我的」里集中管理页 — grid 上就近恢复即可
- ❌ 批量隐藏 / 拖拽排序
- ❌ 二次确认弹窗 — 隐藏可逆,不需要
- ❌ 隐藏时联动关掉对应 `MetricReminder` — 用户没说,保守不动
## 4. 数据模型
`UserProfile` 增加一个字段:
```swift
var hiddenPresetMetrics: [String] = [] // MonitorMetric.rawValue
```
- 类型沿用 `[String]`,跟 `allergies` / `chronicConditions` 一致,SwiftData 自动 transformable
- init 默认 `[]`,无 migration 风险
- 写入用 `UserProfile.updatedAt = .now`
为什么不另开 `@Model HiddenPresetMetric`:8 个 case 的隐藏标记只是 UI 偏好,放 Profile 单例最自然,避免新 entity + 关联查询。
## 5. UI 行为
### 5.1 隐藏入口
`IndicatorQuickSheet.monitorTile(_:)``.contextMenu`:
```swift
.contextMenu {
Button(role: .destructive) {
hideMonitor(m)
} label: {
Label("隐藏", systemImage: "eye.slash")
}
}
```
`hideMonitor``m.rawValue` 加入 `profile.hiddenPresetMetrics`,save,grid 因 `@Query` 重渲染。被隐藏的 tile 若当前选中,要 `clearMonitor()` 复位。
### 5.2 grid 过滤
```swift
ForEach(MonitorMetric.allCases.filter { !hiddenSet.contains($0.rawValue) }) { m in
monitorTile(m)
}
```
`hiddenSet` = `Set(profile?.hiddenPresetMetrics ?? [])`,computed property。
### 5.3 恢复入口
`monitorGridSection` 顶部 section label 一行:
```
长期监测(进趋势) 已隐藏 3
```
- chip 仅当 `hiddenSet.nonEmpty` 显示
- 点 chip → `.sheet` 弹一个轻量列表(`.medium` detent)
- 列表项:每个被隐藏的 `MonitorMetric` 显示 icon + displayName + 右侧"显示"按钮
- 点"显示" → `profile.hiddenPresetMetrics.removeAll { $0 == m.rawValue }` + save
- 列表空了自动 dismiss
### 5.4 边界
- 全部 6 个都隐藏:section 还在(label + chip + addCustomTile),不消失
- 隐藏不影响:Trends 折线、`Indicator` 列表查询、`MetricReminder` 调度
- `UserProfileStore.loadOrCreate` 已保证 profile 存在,无 nil 分支
- `@Query private var profiles: [UserProfile]` 已在 sheet 里,直接取 `profiles.first`
## 6. 文件改动清单
1. `Models/UserProfile.swift` — 加 `hiddenPresetMetrics: [String]` 字段 + init 默认值
2. `Features/Indicator/IndicatorQuickSheet.swift`
- `monitorGridSection`: 过滤 + 顶部 chip
- `monitorTile`: 加 contextMenu
- 新增 `hideMonitor(_:)` / `unhideMonitor(_:)` / `hiddenSet`
- 新增 `HiddenMonitorRestoreSheet` 子 View(同文件内,私有)
不动:`MonitorMetric.swift``CustomMetricEditor.swift`、Trends、`ReminderService``MeView`
## 7. 测试 / 验证手段
无单测目标(全 UI 行为)。手测点:
- [ ] 长按血压 tile → 出现"隐藏",点了 grid 里消失
- [ ] 顶部 chip "已隐藏 1" 出现,数字正确
- [ ] 点 chip → 弹列表,有 1 行血压,点"显示"恢复
- [ ] 全部 6 个隐藏 → grid 只剩 addCustomTile + 自定义指标,不崩
- [ ] 隐藏期间去 Trends,血压折线仍在
- [ ] 隐藏前若血压已选中,隐藏后选中态清空、字段清空
- [ ] 重启 App,隐藏状态持久
## 8. 红线核查(CLAUDE.md §10)
- ✅ 不引入云
- ✅ 不动 AIRuntime / Service 边界
- ✅ 不动 SwiftData 既有 `Indicator` schema
- ✅ Tab / RecordSheet 骨架不动
- ✅ 不是清单外功能,是对 §7 grid 的小改良

View File

@@ -0,0 +1,434 @@
# Monitor + Profile · 设计 v1
> 长期格式化指标录入(`.indicator` 入口预设 + 自由)+ 个人资料(年龄、性别、健康背景、用药)
>
> 日期:2026-05-26 · 状态:approved by user,进入实施
> 关联:[CLAUDE.md](../../../CLAUDE.md) §5 §7 §10;[W2 retro](../retros/2026-05-31-w2.md) 计划外完成
---
## 1. 背景与目标
### 1.1 当前缺口
康康现有的 4 个记录 kind(`quick` 拍照、`archive` 归档、`diary` 文字、`symptom` 持续症状)都是**事件型**——一次性记录,不假设后续会重复同一指标。但血压/血糖/体重这类**长期监测**类需求:
- 用户每天/每周测,数值规律地重复
- 需要趋势(W4-W5 计划的 Trends 页)
- 不需要拍照(已是格式化数字)
- 参考范围依赖个人 demographic(老人血压标准放宽)
同时,App 启动以来一直没有用户基础信息持久化的位置。LLM 给出趋势解读时缺乏 demographic context("LDL 偏高"对 35 岁健康男和 70 岁糖尿病患者风险完全不同)。
### 1.2 目标
- **一个统一的"手动录入指标"入口**:用户已加 `.indicator` case,本设计把 7 个预设(血压/血糖/体重/...)和「自由输入」合并进这个 sheet
- **个人资料卡**:在「我的」加一张资料卡,push 进 Form 编辑页,4 项核心 + 健康背景 + 用药
- **联动**:参考范围按 Profile 个性化(目前规则只覆盖"老人血压"一例,后续可扩)
### 1.3 非目标(YAGNI)
- ❌ Trends 页升级(本次只打通数据通路,留给 W4-W5)
- ❌ 提醒/通知功能(到点测量推送)
- ❌ HealthKit 导入
- ❌ 多 Profile / 给家人记
- ❌ AppLock / Face ID(W5 末统一实现)
- ❌ 单位切换(kg/lb,mmol/L vs mg/dL)
- ❌ 紧急联系人
---
## 2. 数据模型
### 2.1 Indicator 扩字段
```swift
@Model final class Indicator {
// :name/value/unit/range/statusRaw/note/capturedAt/report/asset/pinned
var seriesKey: String? // "bp.systolic" / "glucose.fasting" / ...
// VL/Report Indicator nil
}
```
**为什么用 String 而非 enum**:`seriesKey` 跨设备/版本要稳定,enum 改名会破坏老数据;String 用命名空间约定(`bp.*` / `glucose.*`)即可。
**为什么不新建 @Model**:复用 Indicator 让 Trends/Timeline/ReportCompareService 一次写完受益,避免分裂查询路径。
### 2.2 UserProfile @Model
```swift
@Model final class UserProfile {
// 4
var birthYear: Int? // 1990 "",
var biologicalSexRaw: String // "" / "male" / "female"
var heightCM: Int?
var bloodTypeRaw: String // "" / "A" / "B" / "AB" / "O"
//
var allergies: [String] //
var chronicConditions: [String] // +
var familyHistory: [String] //
//
var currentMedications: [String]
var updatedAt: Date
init(birthYear: Int? = nil, /* ... */) { /* ... */ }
}
extension UserProfile {
enum Sex: String { case male, female, undisclosed = "" }
var sex: Sex { Sex(rawValue: biologicalSexRaw) ?? .undisclosed }
/// ( birthYear nil)
var age: Int? {
guard let y = birthYear else { return nil }
return Calendar.current.component(.year, from: .now) - y
}
}
```
### 2.3 单例策略
UserProfile 全 App 单一实例,通过 helper 保证:
```swift
enum UserProfileStore {
@MainActor
static func loadOrCreate(in ctx: ModelContext) -> UserProfile {
let descriptor = FetchDescriptor<UserProfile>()
if let existing = try? ctx.fetch(descriptor).first { return existing }
let new = UserProfile()
ctx.insert(new)
try? ctx.save()
return new
}
}
```
任何 View 用 `@Query` 拉,空了再调 loadOrCreate。MeView 启动时调一次,确保后续 @Query 必拿到。
### 2.4 Schema 注册
`KangkangApp.swift` 的 schema 加入 `UserProfile.self`。Indicator 加字段是 additive change,SwiftData 自动迁移(给老 row 的 seriesKey 填 nil)。
---
## 3. MonitorMetric Catalog
`Features/Monitor/MonitorMetric.swift`,8 个预设(血压算 1 个 case,内部展开 2 条 Indicator):
```swift
enum MonitorMetric: String, CaseIterable, Identifiable {
case bloodPressure // bp.systolic + bp.diastolic
case fastingGlucose // glucose.fasting
case postprandialGlucose // glucose.postprandial
case weight // weight
case temperature // temperature
case heartRate // heart_rate
case spo2 // spo2
case height // height( UserProfile.heightCM)
var id: String { rawValue }
var displayName: String { /* "" / "" / ... */ }
var icon: String { /* SF Symbol */ }
var fields: [Field] // 1 2
}
extension MonitorMetric {
struct Field {
let seriesKey: String // "bp.systolic"
let label: String // ""
let unit: String // "mmHg"
let placeholder: String // "120"
let baseRange: ClosedRange<Double>? // nil status(/)
}
/// metric profile ( baseRange )
func effectiveRange(for field: Field, profile: UserProfile?) -> ClosedRange<Double>? {
// :bp age >= 65 150 / 90
if let age = profile?.age, age >= 65,
field.seriesKey == "bp.systolic" {
return 90...150
}
if let age = profile?.age, age >= 65,
field.seriesKey == "bp.diastolic" {
return 60...90 //
}
return field.baseRange
}
/// status(value normal, high, low, normal)
static func status(value: Double, in range: ClosedRange<Double>?) -> IndicatorStatus {
guard let r = range else { return .normal }
if value > r.upperBound { return .high }
if value < r.lowerBound { return .low }
return .normal
}
}
```
### 3.1 Profile-aware 规则
本次仅实现 1 条规则(老人收缩压上限 140→150),目的是**展示联动机制**,不追求医学完备。未来扩规则只改 `effectiveRange` 函数,不动调用方。
---
## 4. UI
### 4.1 IndicatorRecordSheet(替代之前提的 MonitorRecordSheet)
`Features/Indicator/IndicatorRecordSheet.swift`,被 RootView 在 `.indicator` case 弹出。
**布局**:
```
[拖动条]
"记录指标 · 本地处理"
[2 列 grid]
┌─────────┐ ┌─────────┐
│ 血压 │ │ 空腹血糖│
│ 收/舒 │ │ 3.9-6.1 │
└─────────┘ └─────────┘
┌─────────┐ ┌─────────┐
│ 体重 │ │ 体温 │
└─────────┘ └─────────┘
... (共 7 预设)
┌─────────┐ ┌─────────┐
│ 心率 │ │ + 自由 │
└─────────┘ └─────────┘
—— 选中 metric 后,grid 下方展开 ——
【血压】参考范围:90-140 / 60-90 mmHg(成人通用)
[收缩压 _____ mmHg]
[舒张压 _____ mmHg]
status chip 实时显示
[保存按钮]
```
**关键交互**:
- 进入 sheet 时无选中,grid 全展示
- 点预设 → 高亮卡片 + 下方展开输入区
- 切换 metric → 数值清空(避免血压数值串到血糖)
- 选「+ 自由输入」→ 展开 4 个字段:名称 / 数值 / 单位 / 参考范围(string)
- 保存:
- 血压 → 2 条 Indicator(同 capturedAt + 各自 seriesKey)
- 单字段预设 → 1 条 Indicator(seriesKey 填)
- 身高预设 → 1 条 Indicator + 回写 UserProfile.heightCM
- 自由输入 → 1 条 Indicator(seriesKey 为 nil,name 用户输入)
**Profile-aware 提示**:
-`effectiveRange``baseRange` 不同,参考范围一行末尾小字:"按你的年龄(67)调整"
-`effectiveRange` 与 baseRange 相同 / 无 Profile,正常显示
### 4.2 MeView 改造
```
[ScrollView]
┌─────────────────────────────────┐
│ 个人资料 更多 →│
│ 38岁 · 男 · 175cm · A型 │
│ (未设置时:"点这里完善你的资料") │
└─────────────────────────────────┘
↓ tap push
┌─────────────────────────────────┐
│ 模型管理 未配置 → │ (W6 stub)
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ Face ID 启动锁 关闭 → │ (W5 stub)
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ 关于 → │ (链接到隐私承诺 placeholder)
└─────────────────────────────────┘
#if DEBUG
DebugAIRunner
#endif
```
stub 卡片本次只放占位 + 文案,push 进去是空页或 placeholder。
### 4.3 ProfileEditView
`Features/Profile/ProfileEditView.swift`,Form 风格:
```
导航标题:个人资料
—— 基本 ——
出生年份 [picker 1900-2026]
性别 [男 / 女 / 不愿透露 segmented]
身高 [TextField + cm]
血型 [A / B / AB / O / 不知道 picker]
—— 健康背景 ——
过敏史 [chips + add field]
慢病 [8 预设 chips 多选 + 自定义 add]
家族史 [chips + add field]
—— 当前用药 ——
[列表 + add row + 行内 swipe-to-delete]
(保存即时,无显式 Save 按钮——边改边写)
```
慢病 8 预设:`高血压 / 糖尿病 / 冠心病 / 高血脂 / 甲状腺疾病 / 哮喘 / 慢性肾病 / 抑郁/焦虑`
### 4.4 Timeline 行内合并(顺手)
`Features/Timeline/TimelineEntry.swift`,`from(indicator:)` 增加配对逻辑:
```swift
static func from(indicators: [Indicator]) -> [TimelineEntry] {
// map, bp.systolic bp.diastolic
// : capturedAt()+ bp.* prefix ; map
}
```
ArchiveListView 和 HomeView 的 `mapped` 表达式从 `indicators.map(...)` 改为 `TimelineEntry.from(indicators:)`(批处理)。
合并后的 TimelineEntry:
- title: "血压"
- subtitle: "120 / 80 mmHg"
- trailing: 异常时显示"偏高"或"正常"
非 bp.* 的 series 不合并,逐条显示("空腹血糖 5.4 mmol/L" / "体重 68 kg")。
---
## 5. 联动:Profile ↔ Monitor
### 5.1 调用路径
```
IndicatorRecordSheet
↓ @Query UserProfile (单例)
MonitorMetric.effectiveRange(for: field, profile: profile)
- 显示个性化参考范围
- 保存时 MonitorMetric.status(value:, in: effectiveRange) 算 statusRaw
```
### 5.2 未来扩展点
`effectiveRange` 是唯一规则入口,扩规则只动这个函数。规则示例(本次不实现):
- 性别 → 血红蛋白、肌酐参考范围不同
- 慢病 → 糖尿病患者血糖目标更严
- 年龄分段 → 儿童体温、心率范围
---
## 6. 测试
### 6.1 新建 `康康Tests/UserProfileTests.swift`
- `freshProfileHasNilDemographics()` — 新建 profile,字段都 nil/空数组
- `ageComputedFromBirthYear()` — 1985 → 41 岁(2026 当前年)
- `sexParsesEnumFromRaw()` — male/female/空 → 三种 enum
- `loadOrCreateReturnsExistingSingleton()` — 第二次 call 不创建新 row
- `arrayFieldsRoundtripThroughSwiftData()` — chronicConditions 存读
### 6.2 新建 `康康Tests/MonitorMetricTests.swift`
- `allMetricsHaveAtLeastOneField()`
- `bpHasTwoFields()`
- `statusHighWhenAboveUpper()` / `statusLowWhenBelowLower()` / `statusNormalWhenInside()` / `statusNormalWhenRangeNil()`
- `bpUpperBoundShiftsForElderly()` — age 67 时 bp.systolic 上限 = 150
- `bpUpperBoundUnchangedWhenNoProfile()` — profile 为 nil 时上限 = 140
- `nonBPSeriesUnaffectedByProfile()` — 血糖范围不随年龄变
### 6.3 扩 `康康Tests/ModelsSchemaTests.swift`
- `userProfileSchemaPersistsAcrossSave()`
- `indicatorSeriesKeyRoundtrip()`
- `cascadeStillWorksWithSeriesKey()` — Report 删除时,关联 Indicator(无论 seriesKey)都删
### 6.4 扩 `康康Tests/TimelineGroupingTests.swift`
- `bpSystolicAndDiastolicMergeIntoSingleEntry()`
- `nonBPSeriesStayAsSeparateEntries()`
- `bpAtDifferentTimesDoNotMerge()` — capturedAt 差 > 5 秒不合并
预期总测试数:11(profile 5)+ 7(metric)+ 3(schema)+ 3(timeline)= 18 个新测试。
---
## 7. 不变项与守恒检查
- ✅ §10.1 不引入云服务 — 完全本地
- ✅ §10.2 不自实现密码学 — SwiftData store 已有 file protection
- ✅ §10.3 UI 不直接调 AIRuntime — 本设计不涉及 AI
- ✅ §10.4 AIRuntime actor — 不涉及
- ✅ §10.5 VL/LLM prompt — 不涉及
- ⚠️ §10.6 新功能必须问"清单里有吗" — Monitor 和 Profile 都是清单外,**已跟用户确认加入**
- ✅ §10.7 不重构现有骨架 — 不动 RootView / RecordSheet 骨架(只补 case 处理),不动 DesignSystem
- ✅ §10.8 C2 ≠ B3 — 不涉及
---
## 8. 文件清单
### 新建(6)
| 路径 | 职责 |
|---|---|
| `康康/Models/UserProfile.swift` | UserProfile @Model + Sex enum + age computed + loadOrCreate helper |
| `康康/Features/Monitor/MonitorMetric.swift` | 8 metric catalog + effectiveRange + status 算法 |
| `康康/Features/Indicator/IndicatorRecordSheet.swift` | 预设 grid + 自由输入合一的录入 sheet |
| `康康/Features/Profile/ProfileEditView.swift` | Form 编辑页 |
| `康康Tests/UserProfileTests.swift` | 5 测试 |
| `康康Tests/MonitorMetricTests.swift` | 7 测试 |
### 修改(7)
| 路径 | 改什么 |
|---|---|
| `康康/Models/Models.swift` | Indicator 加 `seriesKey: String?`,初始化器加默认值 nil |
| `康康/App/KangkangApp.swift` | schema 加 `UserProfile.self` |
| `康康/Features/Me/MeView.swift` | 加 ProfileCard + 3 个 stub 卡片 |
| `康康/RootView.swift` | `.indicator` case 接 IndicatorRecordSheet 弹出 |
| `康康/Features/Timeline/TimelineEntry.swift` | 加 `from(indicators:)` 批处理 + bp 配对 |
| `康康Tests/ModelsSchemaTests.swift` | 3 个新测试 |
| `康康Tests/TimelineGroupingTests.swift` | 3 个新测试 |
### 文档(2)
| 路径 | 改什么 |
|---|---|
| `CLAUDE.md` | §5 加 UserProfile @Model + Indicator seriesKey;§7 IA 加 Profile 入口;§11 时间表加备注;§10.6 例外清单加 Monitor + Profile |
| `docs/superpowers/specs/2026-05-26-monitor-and-profile-design.md` | 本文件 |
---
## 9. 验收
- [ ] App build & test 全绿,0 警告
- [ ] DEBUG 启动 → 我的 → 个人资料 → 填年龄 + 性别 + 身高 + 血型,push back 显示在 ProfileCard
- [ ] DEBUG 启动 → + 号 → 指标记录 → 选血压 → 输 145/85 → 保存 → 在首页时间线看到合并的"血压 145/85"行
- [ ] 把 UserProfile birthYear 改成 1955(70 岁) → 再次进血压录入 → 顶部小字显示"按你的年龄(70)调整",参考范围 90-150 / 60-90
- [ ] 录入身高 175 → 个人资料卡片自动显示 175cm
- [ ] 18 个新测试全绿
---
## 10. 估时
- 数据层(UserProfile + Indicator.seriesKey + schema 注册):20 分钟
- MonitorMetric catalog + effectiveRange:20 分钟
- IndicatorRecordSheet UI:25 分钟
- ProfileEditView + MeView 改造:25 分钟
- Timeline 合并:15 分钟
- 18 测试:30 分钟
- CLAUDE.md + 提交整理:15 分钟
**总计 ~150 分钟**(2.5 小时)。

View File

@@ -0,0 +1,430 @@
# 导出身体档案 — 设计文档
**日期**:2026-05-27 (W2)
**作者**:link2026 + Claude
**关联卖点**:#1 影像档案系统、#2 100% 本地、#3 本地 RAG 长期记忆、#4 隐私三件套、#6 Live Activity tok/s
**优先级**:P0(打通 RAG 链路 + demo 主要演示场景)
---
## 1. 一句话定位
在「记录」Tab 顶部增加「导出身体档案」入口,用户输入自然语言主诉(如「我感冒 3 天,把最近一个月给医生看」),完全本地的两段式 RAG 把 SwiftData 里相关的指标 / 报告 / 症状 / 日记 / 个人资料检索并生成给医生看的 Markdown 摘要,可复制、分享、查看历史。
---
## 2. 用户故事
> 周日晚上,我感冒第 3 天还没好。明早要去社区医院,医生只有 5 分钟问诊,我想把过去一个月的体温记录、上次体检的关键异常项、在服的降压药、家族过敏史一次性整理出来给医生。我不想把这些数据上传到任何云。
成功标准:
- 输入 prompt → 30 秒内出现首字 → 90 秒内完整生成
- 输出 Markdown 包含主诉 / 患者背景 / 近期症状 / 关键指标 / 在服药与过敏 / 患者疑问
- 一键复制到微信发给医生,或直接 AirDrop / 邮件分享
- 重启 App 后能看到历史导出
---
## 3. 范围
**做**:
- 记录 Tab 右上角 toolbar「导出」按钮
- ArchiveListView 顶部「我的导出」横向卡区(有历史时显示,前 3 条 + 查看全部)
- 全屏 sheet:prompt 输入 / Phase 指示 / 流式 Markdown / 完成后复制+分享+重新生成
- 历史列表页 + 详情页
- 两段式 RAG 链路:Qwen3-1.7B 抽意图 → SwiftData 结构化检索 → Qwen3-1.7B 生成 Markdown
-`HealthExport` @Model + Schema 注册
- 引用回链(referencedXxxIDs,W3 再做点击跳转)
**不做**:
- embedding / 向量检索
- 跨设备同步、云端备份
- PDF 导出(W6 余力再说)
- 给医生的诊断建议 / 用药建议(红线 §10.1)
- 自动定期导出(此版无 schedule)
---
## 4. 架构
```
┌─ UI ─────────────────────────────────────────────────────┐
│ ArchiveListView │
│ ├─ .toolbar trailing: "导出" 图标按钮 │
│ └─ 顶部横向卡区 HealthExportRecentStrip(有历史时显示)│
│ │ │
│ └─→ HealthExportSheet (full-screen cover) │
│ ├─ prompt TextEditor │
│ ├─ Phase 状态条 │
│ ├─ Markdown 流式渲染 │
│ └─ Actions: 复制 / 分享 / 重新生成 │
│ │
│ HealthExportListView (NavigationLink "查看全部") │
│ └─ 全部历史(@Query DESC)→ HealthExportDetailView │
└────────────────────────────────────────────────────────┘
↑ Event 流
┌─ Service ────────────────────────────────────────────────┐
│ HealthExportService (struct, DI ModelContext + Runtime) │
│ func export(prompt:) -> AsyncThrowingStream<Event> │
│ Event = .phaseChanged(Phase) | .token(TokenChunk) │
│ | .completed(HealthExport) | .failed(Error) │
└────────────────────────────────────────────────────────┘
↑ 串行排队
┌─ AI 层 (已存在) ──────────────────────────────────────────┐
│ AIRuntime(actor 单例)→ LLMSession 串行两次调用 │
└────────────────────────────────────────────────────────┘
↑ 检索
┌─ Persistence (SwiftData) ────────────────────────────────┐
│ Indicator / Report / Symptom / DiaryEntry / │
│ UserProfile / HealthExport(新增) │
└────────────────────────────────────────────────────────┘
```
**红线对齐**(CLAUDE.md §10):
- UI 不直接调 AIRuntime,只与 HealthExportService 通讯 ✅
- AIRuntime 仍是 actor 单例,两段调用在它的队列内串行,与 CaptureService / 未来的 AskService 互不抢占 GPU ✅
- 两个 prompt 都带 few-shot + 失败回退 ✅
- 不引入云服务、不自实现密码学、不重构现有 Tab/RecordSheet ✅
---
## 5. 数据模型
新增 `Models/HealthExport.swift`:
```swift
import Foundation
import SwiftData
@Model final class HealthExport {
var id: UUID = UUID()
var prompt: String = "" //
var content: String = "" // Markdown
var createdAt: Date = .now
// ( §3.3)
var referencedIndicatorIDs: [UUID] = []
var referencedReportIDs: [UUID] = []
var referencedSymptomIDs: [UUID] = []
var referencedDiaryIDs: [UUID] = []
// ("", LLM)
var inferredTimeFromDate: Date?
var inferredTimeToDate: Date?
var inferredIntent: String?
// demo
var modelTag: String = "Qwen3-1.7B-4bit"
var decodeRate: Double = 0 // tok/s
init() {}
}
```
**Schema 注册**:`App/KangkangApp.swift``ModelContainer(for:)` 加入 `HealthExport.self`(增表是 SwiftData 兼容变更,无需手写迁移)。
**为什么 `referenced*IDs` 用 `[UUID]` 而不是 SwiftData 关系**:
导出是历史快照,源 Indicator / Report 可能后续被用户永久删除(§10.4);弱关联避免 cascade 影响历史导出本身。点击跳转时,源记录若已不存在,UI 显示「记录已删除」灰态。
---
## 6. 状态机 + 数据流
`HealthExportService.export(prompt:)``AsyncThrowingStream<Event, Error>`:
```swift
enum Phase: String {
case extractingIntent //
case retrieving //
case generating //
case completed
}
enum Event {
case phaseChanged(Phase)
case token(TokenChunk)
case completed(HealthExport)
case failed(Error)
}
```
**流程**:
```
.idle
│ user tap 生成
phaseChanged(.extractingIntent)
│ LLMSession.generate(prompt: INTENT_PROMPT, maxTokens: 120)
│ 失败 → 回退到默认 {time_range_days: 30, keywords: [], symptom_keywords: []}
phaseChanged(.retrieving)
│ 同步 SwiftData 查询:
│ - Indicator where capturedAt ∈ [from, to], 可选按 keyword 过滤 name/seriesKey
│ - Report where reportDate ∈ [from, to]
│ - Symptom where startedAt <= to AND (endedAt == nil OR endedAt >= from)
│ - DiaryEntry where createdAt ∈ [from, to] AND content contains any symptom_keyword
│ (privacy 过滤:无主诉相关关键词的日记不入 prompt;
│ 若 symptom_keywords 为空,则一律不包含日记 —— 安全默认)
│ - UserProfile 单例,无条件包含
phaseChanged(.generating)
│ 拼 GENERATION_PROMPT(把上一步结果序列化为简短结构)
│ LLMSession.generate(prompt:, maxTokens: 1024)
│ for token in stream: yield .token(chunk)
phaseChanged(.completed)
│ build HealthExport(prompt, content, referencedIDs, inferred*, decodeRate)
│ modelContext.insert + try modelContext.save()
.completed(healthExport)
```
**取消语义**:UI 关闭 sheet → stream 被取消 → 中间态不入库。
**与 AIRuntime 互斥**:HealthExportService 在 `AIRuntime` 的 actor 函数里调度两次 LLM 调用;若此时 CaptureService 正在跑 VL,自然在 actor 队列里等待。Phase indicator 在 UI 上显示「排队中」(可选,W3 polish)。
---
## 7. Prompt 设计
两个 prompt 都放在 `AI/Prompts/HealthExportPrompts.swift`,带 2 个 few-shot。
### 7.1 意图抽取(Qwen3-1.7B,~120 token 输出)
```text
你是健康数据助手。读用户的请求,只输出严格 JSON,不要任何解释或 Markdown。
字段:
{
"time_range_days": int, // 时间窗,默认 30
"keywords": [string], // 指标关键词(中文,如"血压"/"血糖"/"体温")
"symptom_keywords": [string], // 症状关键词
"intent": string // 简短意图标签
}
示例 1:
User: 我感冒3天了,要把最近一个月的健康情况给医生看
Output: {"time_range_days":30,"keywords":["体温","血压","脉搏"],"symptom_keywords":["感冒","咳嗽","咽喉痛","发烧"],"intent":"cold_consult"}
示例 2:
User: 我最近血糖好像不稳,把上次体检前后的化验单整理一下
Output: {"time_range_days":90,"keywords":["血糖","糖化血红蛋白","胰岛素"],"symptom_keywords":[],"intent":"glucose_review"}
User: {{USER_PROMPT}}
Output:
```
**解析容错**:
- 非 JSON → 抓 `{…}` 之间的子串再试一次
- 仍失败 → 用默认 `{30, [], []}`,继续流程,不报错给用户
### 7.2 报告生成(Qwen3-1.7B,maxTokens 1024)
```text
你正在帮患者撰写一份给社区医生看的就诊摘要。
要求:
- 输出 Markdown,严格按下方结构
- 只用「数据」中提供的信息,数据缺失就写"无记录"
- 不要给诊断意见、不要给用药建议、不要写"建议就医"
- 引用具体数值时保留单位和参考范围
- 全文中文,简洁,医生 30 秒能扫完
结构:
# 就诊摘要 — {{INTENT_LABEL_CN}}
## 主诉
## 患者背景
## 近期症状(按时间倒序)
## 关键指标(异常项优先)
## 在服药与过敏
## 患者疑问
数据:
{{SERIALIZED_DATA_JSON}}
患者原话:{{USER_PROMPT}}
现在生成:
```
**SERIALIZED_DATA_JSON 结构**(给 LLM 看的精简结构):
```json
{
"profile": {
"age": 38, "sex": "男", "height_cm": 172,
"allergies": ["青霉素"],
"chronic": ["高血压(2 年)"],
"family_history": ["父亲冠心病"],
"current_meds": ["缬沙坦 80mg qd"]
},
"symptoms": [
{"name": "感冒", "started": "2026-05-24", "severity": 2,
"ongoing": true, "note": "鼻塞、低烧"}
],
"indicators": [
{"name": "收缩压", "value": 142, "unit": "mmHg", "range": "<140",
"status": "high", "date": "2026-05-26"}
],
"reports": [
{"title": "年度体检", "type": "physical", "date": "2026-04-12",
"institution": "瑞金医院"}
],
"diaries": [
{"date": "2026-05-25", "excerpt": "夜里两点醒了一次,头痛 7/10"}
]
}
```
---
## 8. UI 详细设计
### 8.1 ArchiveListView 改动
- toolbar trailing 加按钮:`Image(systemName: "doc.text.below.ecg") "导出"`
-`List` 顶部插入 `HealthExportRecentStrip()`(若 `@Query HealthExport` 非空)
- 横向卡区,3 条最近导出 + 末尾「查看全部 →」卡,点击进入 `HealthExportListView`
### 8.2 HealthExportSheet (full-screen cover)
```
┌──────────────────────────────────────────────┐
│ ✕ 导出身体档案 本地·永不上传 │ Header
├──────────────────────────────────────────────┤
│ 例:我感冒3天了,把最近一个月给医生看 │ Hint
│ ┌──────────────────────────────────────────┐ │
│ │ (多行 TextEditor,~6 行) │ │
│ └──────────────────────────────────────────┘ │
│ [ 生成报告 ] │ TjPrimaryButton
├──────────────────────────────────────────────┤
│ ●─○─○ 理解意图 │ Phase pill,
│ │ 生成时显示
│ 本地推理 · Qwen3 · 24.3 tok/s │
├──────────────────────────────────────────────┤
│ # 就诊摘要 — 感冒就诊 │
│ ## 主诉 │ Markdown 流式
│ 患者男,38 岁…… │ 渲染(原生
│ …(打字机效果)… │ Text(LocalizedStringKey))
│ │
├──────────────────────────────────────────────┤
│ [ 复制 ] [ 分享 ] [ 重新生成 ] │ 完成后才显示
└──────────────────────────────────────────────┘
```
- 「分享」用系统 `ShareLink(item: content)`,导出纯文本
- 「重新生成」复用同一 `prompt` + `inferred*` 字段,跳过意图抽取,直接走 retrieving + generating
- 持久化时机:`.completed` 事件触发时由 Service 立即 `insert + save`;sheet 关闭只是 dismiss 视图,不再写库
- 生成中按 ✕ → 取消 stream → 不入库;已生成完成后按 ✕ → 仅 dismiss(数据已在库中)
### 8.3 HealthExportListView
简单的 `List` + `@Query(sort: \.createdAt, order: .reverse)`,每条显示:
- 标题:`HealthExport.prompt` 截断到 60 字
- 副标题:`relativeDate(createdAt)` + `tok/s` 标签
- 滑动删除
### 8.4 HealthExportDetailView
- 只读 Markdown(复用 sheet 的渲染组件)
- 顶部信息条:生成时间 / 模型 tag / tok/s
- toolbar:复制 / 分享 / 删除
- W3 再补:`referenced*IDs` 转 Pill,点击跳源记录(此 spec 不阻塞)
---
## 9. 错误处理
| 情况 | 行为 |
|---|---|
| 模型未就绪 | toolbar 按钮置灰 + 副标题「模型未就绪,前往下载」(对齐 §4) |
| 意图抽取 JSON 解析失败 | 默认 `{30 days, [], []}` 兜底,流程继续,不报错给用户 |
| SwiftData 查询为空 | 数据段填 `"无记录"`,LLM 仍生成结构化"无明显异常"摘要 |
| 生成 stream 中途取消 | Service 抛 `CancellationError`,UI 显示「已取消」,不入库 |
| 生成超时 (>120s) | `Task.withTimeout` 超时取消,UI 同取消逻辑 |
| LLM 抛错(显存等) | UI 显示「生成失败:{msg}」+ 重试按钮 |
| `modelContext.save` 失败 | 仅日志,UI 仍展示文本,提示「保存失败,请重试」 |
**安全:** 全程不调用任何网络;`HealthExport` 持久化继承 §6 的 `.completeFileProtection`
---
## 10. 测试策略
**单元(`HealthExportServiceTests`)**:
- mock `AIRuntime` 协议(新增 `protocol AIRuntimeProtocol`,actor 单例符合该协议)
- 给定固定 SwiftData in-memory + 已知 Indicator/Symptom → 验证 referencedIDs 正确
- 意图抽取返回非 JSON → 验证回退到默认 30 天
- 验证 Phase 转换顺序:`.extractingIntent → .retrieving → .generating → .completed`
- 取消语义:在 `.generating` 阶段取消 → 不入库
**Preview**:
- `HealthExportSheet` 用 mock service 吐预设 Markdown(打字机视效在 Preview 即可看到)
- `HealthExportListView` 用 3 条 fake `HealthExport`
**真机验收**(W3 末):
- 在 16 inch M3 Max 模拟器上跑通(simulator 走 CPU,慢但能跑通流程)
- 真机 iPhone 15 Pro:首字 ≤ 10s,完整生成 ≤ 60s,tok/s ≥ 20
- 关 WiFi + 飞行模式仍能正常生成(隐私三件套 demo 关键)
---
## 11. 与现有/未来代码的关系
- **复用**:`AIRuntime` / `LLMSession` / `TokenChunk` / `Tj.*` Design System
- **铺路**:`HealthExportService` 的两段式 RAG 工程模式直接复用给 W3 的 `AskService`(只需替换 generation prompt + 输出形态)
- **不冲突**:`CaptureService` 在 AIRuntime 队列里和本服务串行;两者不会同时占 GPU
- **不影响**:`ArchiveListView` / `RecordSheet` / 现有 7 个 @Model 都不需要重构
---
## 12. 取舍记录
| 决策 | 选择 | 拒绝的方案 | 理由 |
|---|---|---|---|
| 入口位置 | 「记录」Tab toolbar | RecordSheet 加一项 | 语义:RecordSheet 是「写入」,导出是「读出」 |
| 数据范围 | Indicator+Report+Symptom+Profile+Diary | 仅 Indicator+Report | 「感冒 3 天」需要 Symptom;医生需要 Profile;Diary 由 LLM 关键词过滤后入 prompt,降低隐私风险 |
| 历史位置 | ArchiveListView 顶部横向卡区 + 查看全部 | 「我的」Tab 加历史入口 | 路径更短;符合「记录 Tab=身体档案」语义 |
| Pipeline | 严格两段式 RAG | 单段 LLM / 模板化 | 准确性 + 复用给 AskService + demo 卖点 #3 |
| Markdown 渲染 | SwiftUI 原生 `Text(LocalizedStringKey)` | 第三方 Markdown 库 | YAGNI;W6 polish 时再评估 |
| referenced 关联 | `[UUID]` 弱关联 | SwiftData 关系 | 历史快照 vs 源记录可被永久删除 |
| Live Activity | 此版只在 Service 暴露 decodeRate,UI 显示数字 | 此版直接接 ActivityKit | W5 真机阶段统一接,与 AskService 共用一套 Activity |
---
## 13. 排期估算(放在 W2 末 ~ W3 初)
| 步骤 | 工作量 |
|---|---|
| HealthExport @Model + Schema 注册 | 0.5h |
| HealthExportPrompts(两个 prompt + few-shot 调试) | 2h |
| HealthExportService(状态机 + 两段调用 + 检索) | 4h |
| HealthExportSheet(输入 + Phase + 流式渲染 + 三按钮) | 3h |
| ArchiveListView toolbar + RecentStrip | 1.5h |
| HealthExportListView + DetailView | 1.5h |
| 单元测试 + 真机验收 | 2h |
| **合计** | **~14h ≈ 2 个工作日** |
也是 W3「AskService 基础 RAG」的前置铺路工作,工程上一举两得。
---
## 14. 修订记录:防编造加固(2026-05-30)
**现象**:导出摘要出现整份虚构病例(疲劳/盗汗/血红蛋白98/阿司匹林…),不符任何真实记录。
**根因(双重)**:① §数据范围里「Diary 由关键词过滤后入 prompt」在泛化请求(无症状词,如「最近身体异常」)下把日记**全部清空** → 真实记录没进 prompt;② 数据稀疏时,1.7B 在固定 6 段模板上**凭训练先验脑补**完整病例(对「只用数据/缺失写无记录」这类约束遵循差)。
**修复(三层,客户端硬保证为主)**:
1. **检索**:`retrieve` 改为——有症状词→按词过滤(保留隐私);无症状词→纳入时间窗内最近 5 条日记,确保真实记录进 prompt。
2. **空数据硬兜底**:`isEffectivelyEmpty` 判定无任何记录且 profile 空时,**跳过 LLM**,用 `fallbackReport` 产出确定性「6 段全无记录、主诉仅照搬原话」的摘要,从根上杜绝空数据编造。
3. **prompt 重写**:从「撰写」改为「抽取/搬运」框架;反编造铁律首尾各一遍;加一条**稀疏 few-shot** 教模型「缺失写无记录、数值原样照搬」。
**残留限制**:部分数据(如仅 1 条日记)仍走 LLM,强约束 + few-shot 大幅降低但不能 100% 杜绝小模型臆造;后续可加生成后数值校验。

View File

@@ -0,0 +1,180 @@
# 模型自动下载功能设计2026-05-29
> 让用户在「我的 · 模型管理」页一键从自建 HTTPS 服务下载两个 MLX 模型,支持断点续传、
> 进度展示和现场重装的旁路导入兜底。对应 CLAUDE.md §4「模型分发」与 W6「首启动下载流程」的核心部分。
## 1. 背景与现状
- 模型加载链路已通:`LLMSession`/`VLSession``ModelConfiguration(directory:)` 从沙盒
`Application Support/Models/<repo>/` 读取,`AIRuntime.prepare()/prepareVL()`
`ModelStore.isReady()` 为假时抛 `notReady`
- **缺口**:没有任何下载实现。`ModelStore` 只有 `isReady()` 判定 + `seedFromBundle()` 占位;
唯一能装模型的路径是 DEBUG-only 的 `DebugAIRunner` 手动 `fileImporter`(且只导 LLM漏 VL
- `MeView` 已预留「模型管理」卡片(`detail="未配置"`icon `cpu`),尚未连接任何界面。
- `HealthExportService` 的未就绪文案已写「请先到『我的 · 模型管理』下载」,落点早有预期。
- 无 Onboarding / 首启动流程。
## 2. 服务器素材(已就绪并验证)
- 自建 Caddy 静态文件服务,文件根 `/srv/models/`
- base URL**`https://file.myv0.com/`**(用户自建反代,标准 HTTPS
- 备选:`http://101.132.124.52:5244/`(纯 IP需 App 端 ATS 例外;域名挂了时用)。
- 已验证:`config.json` 返回 200两个 `model.safetensors` 均支持 Range`206` + `Accept-Ranges: bytes`
反代返回的总大小与本机精确一致LLM 968080210、VL 3073720461未截断大文件。
- 服务器 24 个真实文件字节数与本机逐一匹配LLM 984015687、VL 3089713215
## 3. 范围
**做**模型管理页分模型卡片、HTTPS 断点续传下载、大小校验、蜂窝网络提示、
旁路文件导入LLM + VL、MeView 接入、AI 入口未就绪「前往下载」引导。
**不做YAGNI**:首启动 Onboarding、启动自动后台下载、哈希校验大小校验够
Live Activity 下载进度Live Activity 是推理时的 tok/s单独功能、并行多文件下载。
## 4. 架构(方案 A独立 Service + ModelStore 保持纯存储)
```
ModelManagementView (UI)
→ ModelDownloadService (@MainActor @Observable下载编排 + 进度状态)
→ ModelStore (文件路径 / 就绪判定 / 旁路导入)
→ URLSession (HTTPS 分块下载)
```
- 符合 §3.1 模块边界UI 不直接碰 `URLSession`,只观察 Service 发布的状态。
- `ModelDownloadService` 与现有 `CaptureService`/`AskService` 并列。
- `ModelStore` 继续只管「模型在哪 / 是否就绪 / 旁路拷入」,不引入网络职责。
### 4.1 下载状态模型
```swift
enum DownloadPhase: Equatable {
case idle //
case downloading //
case verifying //
case ready //
case failed(String) // ·
}
struct DownloadState: Equatable {
var phase: DownloadPhase
var receivedBytes: Int
var totalBytes: Int
var bytesPerSecond: Double
var fraction: Double { totalBytes > 0 ? Double(receivedBytes) / Double(totalBytes) : 0 }
}
```
`ModelDownloadService` 持有 `var states: [ModelKind: DownloadState]``@MainActor` 更新UI 观察。
## 5. 数据:硬编码 manifest
```swift
struct ModelFile { let path: String; let bytes: Int } // path
enum ModelManifest {
static let baseURL = URL(string: "https://file.myv0.com/")!
static func files(for kind: ModelKind) -> [ModelFile]
static func totalBytes(for kind: ModelKind) -> Int // files.reduce
}
```
- 只列**加载必需**的功能文件,排除纯文档 `README.md` / `.gitattributes`(省下载)。
- 文件 URL = `baseURL / kind.rawValue / file.path`
- `bytes` 用于总进度计算与下载后**逐文件大小校验**。
- 精确清单见附录 A。
## 6. 下载流程(断点续传,应对 3GB 单文件)
逐文件**串行**下载,单文件级续传用 **HTTP Range + 追加写**(比 `URLSession.resumeData` 更可控,
app 重启也能续):
1. 目标 `Models/<repo>/<file>` 已存在且 size 匹配 → 跳过(粗粒度续传)。
2. 否则下到 `Models/<repo>/<file>.part`:已下字节数 = `.part` 当前大小,
`Range: bytes=<已下>-` 请求,`URLSession` data delegate 流式 `FileHandle` 追加写。
3. 完成后校验 `.part` 大小 == manifest `bytes`,原子 `rename` 去掉 `.part` 后缀。
4. 该模型全部文件就位 → `ModelStore.isReady` 自然为真。
- 串行(一次一个文件):不抢 MLX 资源、进度计算清晰。
- 总进度 = 已完成字节 / `totalBytes(for:)`;速度用滑动窗口算 bytes/s。
- 支持「暂停」:取消当前 task`.part` 保留,下次从断点续。
## 7. UI
### 7.1 `ModelManagementView`(分模型卡片)
- 两张卡:
- **Qwen3-1.7B · 文本解读**(约 939 MB
- **Qwen2.5-VL-3B · 拍照识别**(约 2.9 GB
- 每张卡显示:状态 `待下载 / 下载中 xx% · x.x MB/s / 校验中 / 已就绪 ✅ / 失败 · 重试`
+ 进度条(原生 `ProgressView` + `Tj.Palette`+ 大小。
- 顶部总操作 `下载全部模型``TjPrimaryButton`);下载中切为 `暂停`
- **蜂窝网络提示**`NWPathMonitor` 检测到非 WiFi开下前弹确认"约 3.9GB,建议 WiFi 下载")。
- 底部 `从文件导入``TjGhostButton`)→ 旁路导入。
- 复用 `.tjCard` / `TjBadge` / `TjLockChip`,不新增设计 token§9
### 7.2 旁路导入(现场重装兜底)
`DebugAIRunner``fileImporter` 逻辑转正进 Service / `ModelStore`
- 选文件夹 → 校验含 `config.json` → 拷入 `Models/<repo>/`
- **补上 VL**(现在 DEBUG 只导 LLM
- 按所选文件夹名匹配 `ModelKind.rawValue` 自动识别是 LLM 还是 VL不匹配时提示选择。
## 8. 接入点
- `MeView` 「模型管理」卡片 → `NavigationLink``ModelManagementView`
`detail` 动态显示 `已就绪 / 未下载 / 下载中 xx%`
- **AI 入口未就绪引导**§4 要求):`DiaryQuickSheet``UnifiedCaptureFlow``HealthExport`
的「模型未就绪」错误态补 `前往下载` 按钮,跳 `ModelManagementView`
## 9. 错误处理
- 网络中断 → 卡片转 `失败 · 重试`,保留 `.part` 供下次续传,不卡死、不删已下数据。
- 校验失败size 不符)→ 删该文件重下。
- 旁路导入选错文件夹(无 `config.json`)→ 提示,不写入。
- base URL 不可达 → 失败态,文案提示检查网络。
## 10. 测试策略
- 单元测试(用 `URLProtocol` mock 网络,不碰真 MLX / SwiftData
- `ModelManifest.totalBytes` 计算正确。
- 续传偏移计算:`.part` 已有 N 字节时请求 `Range: bytes=N-`
- 大小校验size 不符判失败。
- `DownloadState.fraction` 边界totalBytes=0
- `ModelStore.isReady` 在文件齐全 / 缺失时的判定。
- UI 手动验证:模拟器跑下载流程(指向真实 base URL 或 mock
## 附录 A精确文件清单功能文件排除 README/.gitattributes
### Qwen3-1.7B-4bit9 文件984,013,244 字节≈939 MB
| path | bytes |
|---|---|
| config.json | 937 |
| model.safetensors | 968080210 |
| model.safetensors.index.json | 49731 |
| tokenizer.json | 11422654 |
| tokenizer_config.json | 9706 |
| vocab.json | 2776833 |
| merges.txt | 1671853 |
| special_tokens_map.json | 613 |
| added_tokens.json | 707 |
### Qwen2.5-VL-3B-Instruct-4bit11 文件3,089,710,883 字节≈2.9 GB
| path | bytes |
|---|---|
| config.json | 1659 |
| model.safetensors | 3073720461 |
| model.safetensors.index.json | 108307 |
| tokenizer.json | 11421896 |
| tokenizer_config.json | 7256 |
| vocab.json | 2776833 |
| merges.txt | 1671853 |
| special_tokens_map.json | 613 |
| added_tokens.json | 605 |
| chat_template.json | 1050 |
| preprocessor_config.json | 350 |
> 注:进度分母 = 本表功能文件 `bytes` 之和(已排除 README/.gitattributes
> 服务器上含 README/.gitattributes 的全量为 LLM 984,015,687 / VL 3,089,713,215 字节,仅作素材核对参照。

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

@@ -1,620 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXContainerItemProxy section */
5E463D092FC403BC0089145B /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 5E463CF12FC403BB0089145B /* Project object */;
proxyType = 1;
remoteGlobalIDString = 5E463CF82FC403BB0089145B;
remoteInfo = "体己";
};
5E463D132FC403BC0089145B /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 5E463CF12FC403BB0089145B /* Project object */;
proxyType = 1;
remoteGlobalIDString = 5E463CF82FC403BB0089145B;
remoteInfo = "体己";
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
5E463CF92FC403BB0089145B /* 体己.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "体己.app"; sourceTree = BUILT_PRODUCTS_DIR; };
5E463D082FC403BC0089145B /* 体己Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "体己Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
5E463D122FC403BC0089145B /* 体己UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "体己UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
5E463CFB2FC403BB0089145B /* 体己 */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "体己";
sourceTree = "<group>";
};
5E463D0B2FC403BC0089145B /* 体己Tests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "体己Tests";
sourceTree = "<group>";
};
5E463D152FC403BC0089145B /* 体己UITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "体己UITests";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
5E463CF62FC403BB0089145B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5E463D052FC403BC0089145B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5E463D0F2FC403BC0089145B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
5E463CF02FC403BB0089145B = {
isa = PBXGroup;
children = (
5E463CFB2FC403BB0089145B /* 体己 */,
5E463D0B2FC403BC0089145B /* 体己Tests */,
5E463D152FC403BC0089145B /* 体己UITests */,
5E463CFA2FC403BB0089145B /* Products */,
);
sourceTree = "<group>";
};
5E463CFA2FC403BB0089145B /* Products */ = {
isa = PBXGroup;
children = (
5E463CF92FC403BB0089145B /* 体己.app */,
5E463D082FC403BC0089145B /* 体己Tests.xctest */,
5E463D122FC403BC0089145B /* 体己UITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
5E463CF82FC403BB0089145B /* 体己 */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5E463D1C2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己" */;
buildPhases = (
5E463CF52FC403BB0089145B /* Sources */,
5E463CF62FC403BB0089145B /* Frameworks */,
5E463CF72FC403BB0089145B /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
5E463CFB2FC403BB0089145B /* 体己 */,
);
name = "体己";
packageProductDependencies = (
);
productName = "体己";
productReference = 5E463CF92FC403BB0089145B /* 体己.app */;
productType = "com.apple.product-type.application";
};
5E463D072FC403BC0089145B /* 体己Tests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5E463D1F2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己Tests" */;
buildPhases = (
5E463D042FC403BC0089145B /* Sources */,
5E463D052FC403BC0089145B /* Frameworks */,
5E463D062FC403BC0089145B /* Resources */,
);
buildRules = (
);
dependencies = (
5E463D0A2FC403BC0089145B /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
5E463D0B2FC403BC0089145B /* 体己Tests */,
);
name = "体己Tests";
packageProductDependencies = (
);
productName = "体己Tests";
productReference = 5E463D082FC403BC0089145B /* 体己Tests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
5E463D112FC403BC0089145B /* 体己UITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5E463D222FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己UITests" */;
buildPhases = (
5E463D0E2FC403BC0089145B /* Sources */,
5E463D0F2FC403BC0089145B /* Frameworks */,
5E463D102FC403BC0089145B /* Resources */,
);
buildRules = (
);
dependencies = (
5E463D142FC403BC0089145B /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
5E463D152FC403BC0089145B /* 体己UITests */,
);
name = "体己UITests";
packageProductDependencies = (
);
productName = "体己UITests";
productReference = 5E463D122FC403BC0089145B /* 体己UITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
5E463CF12FC403BB0089145B /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
5E463CF82FC403BB0089145B = {
CreatedOnToolsVersion = 26.0.1;
};
5E463D072FC403BC0089145B = {
CreatedOnToolsVersion = 26.0.1;
TestTargetID = 5E463CF82FC403BB0089145B;
};
5E463D112FC403BC0089145B = {
CreatedOnToolsVersion = 26.0.1;
TestTargetID = 5E463CF82FC403BB0089145B;
};
};
};
buildConfigurationList = 5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "体己" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 5E463CF02FC403BB0089145B;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 5E463CFA2FC403BB0089145B /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
5E463CF82FC403BB0089145B /* 体己 */,
5E463D072FC403BC0089145B /* 体己Tests */,
5E463D112FC403BC0089145B /* 体己UITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
5E463CF72FC403BB0089145B /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5E463D062FC403BC0089145B /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5E463D102FC403BC0089145B /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
5E463CF52FC403BB0089145B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5E463D042FC403BC0089145B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5E463D0E2FC403BC0089145B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
5E463D0A2FC403BC0089145B /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 5E463CF82FC403BB0089145B /* 体己 */;
targetProxy = 5E463D092FC403BC0089145B /* PBXContainerItemProxy */;
};
5E463D142FC403BC0089145B /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 5E463CF82FC403BB0089145B /* 体己 */;
targetProxy = 5E463D132FC403BC0089145B /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
5E463D1A2FC403BC0089145B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
5E463D1B2FC403BC0089145B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
5E463D1D2FC403BC0089145B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
5E463D1E2FC403BC0089145B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
5E463D202FC403BC0089145B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--Tests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/体己.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/体己";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
5E463D212FC403BC0089145B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--Tests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/体己.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/体己";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
5E463D232FC403BC0089145B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--UITests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TEST_TARGET_NAME = "体己";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
5E463D242FC403BC0089145B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "tiji.--UITests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TEST_TARGET_NAME = "体己";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "体己" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5E463D1A2FC403BC0089145B /* Debug */,
5E463D1B2FC403BC0089145B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
5E463D1C2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5E463D1D2FC403BC0089145B /* Debug */,
5E463D1E2FC403BC0089145B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
5E463D1F2FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己Tests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5E463D202FC403BC0089145B /* Debug */,
5E463D212FC403BC0089145B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
5E463D222FC403BC0089145B /* Build configuration list for PBXNativeTarget "体己UITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5E463D232FC403BC0089145B /* Debug */,
5E463D242FC403BC0089145B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 5E463CF12FC403BB0089145B /* Project object */;
}

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>体己.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -1,85 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,66 +0,0 @@
//
// ContentView.swift
//
//
// Created by Tim on 2026/5/25.
//
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
var body: some View {
NavigationSplitView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
} label: {
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
}
}
.onDelete(perform: deleteItems)
}
#if os(macOS)
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
#endif
.toolbar {
#if os(iOS)
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
#endif
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
} detail: {
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
}
}
}
}
#Preview {
ContentView()
.modelContainer(for: Item.self, inMemory: true)
}

View File

@@ -1,18 +0,0 @@
//
// Item.swift
//
//
// Created by Tim on 2026/5/25.
//
import Foundation
import SwiftData
@Model
final class Item {
var timestamp: Date
init(timestamp: Date) {
self.timestamp = timestamp
}
}

View File

@@ -1,32 +0,0 @@
//
// __App.swift
//
//
// Created by Tim on 2026/5/25.
//
import SwiftUI
import SwiftData
@main
struct __App: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Item.self,
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(sharedModelContainer)
}
}

View File

@@ -0,0 +1,69 @@
{
"originHash" : "6b8265ebd61c6fdfca835dd1f90f17439ca9abc5c11a8b7b5db8790be0349e4d",
"pins" : [
{
"identity" : "gzipswift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/1024jp/GzipSwift",
"state" : {
"revision" : "731037f6cc2be2ec01562f6597c1d0aa3fe6fd05",
"version" : "6.0.1"
}
},
{
"identity" : "mlx-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift",
"state" : {
"revision" : "072b684acaae80b6a463abab3a103732f33774bf",
"version" : "0.29.1"
}
},
{
"identity" : "mlx-swift-examples",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ml-explore/mlx-swift-examples",
"state" : {
"revision" : "9bff95ca5f0b9e8c021acc4d71a2bbe4a7441631",
"version" : "2.29.1"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a",
"version" : "1.5.1"
}
},
{
"identity" : "swift-jinja",
"kind" : "remoteSourceControl",
"location" : "https://github.com/huggingface/swift-jinja.git",
"state" : {
"revision" : "0b67ecb79139f6addef8699eff3622808aa6c7dc",
"version" : "2.3.6"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics",
"state" : {
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
"version" : "1.1.1"
}
},
{
"identity" : "swift-transformers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/huggingface/swift-transformers",
"state" : {
"revision" : "a2e184dddb4757bc943e77fbe99ac6786c53f0b2",
"version" : "1.0.0"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5E463CF82FC403BB0089145B"
BuildableName = "&#x5eb7;&#x5eb7;.app"
BlueprintName = "&#x5eb7;&#x5eb7;"
ReferencedContainer = "container:&#x5eb7;&#x5eb7;.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5E463D072FC403BC0089145B"
BuildableName = "&#x5eb7;&#x5eb7;Tests.xctest"
BlueprintName = "&#x5eb7;&#x5eb7;Tests"
ReferencedContainer = "container:&#x5eb7;&#x5eb7;.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5E463D112FC403BC0089145B"
BuildableName = "&#x5eb7;&#x5eb7;UITests.xctest"
BlueprintName = "&#x5eb7;&#x5eb7;UITests"
ReferencedContainer = "container:&#x5eb7;&#x5eb7;.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5E463CF82FC403BB0089145B"
BuildableName = "&#x5eb7;&#x5eb7;.app"
BlueprintName = "&#x5eb7;&#x5eb7;"
ReferencedContainer = "container:&#x5eb7;&#x5eb7;.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5E463CF82FC403BB0089145B"
BuildableName = "&#x5eb7;&#x5eb7;.app"
BlueprintName = "&#x5eb7;&#x5eb7;"
ReferencedContainer = "container:&#x5eb7;&#x5eb7;.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>康康.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>5E463CF82FC403BB0089145B</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>5E463D072FC403BC0089145B</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>5E463D112FC403BC0089145B</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

243
康康/AI/AIRuntime.swift Normal file
View File

@@ -0,0 +1,243 @@
import Foundation
import MLX
enum AIRuntimeError: Error, LocalizedError {
case notReady
case modelLoadFailed(String)
case inferenceFailed(String)
var errorDescription: String? {
switch self {
case .notReady: return String(appLoc: "AI 模型尚未准备好")
case .modelLoadFailed(let m): return String(appLoc: "模型加载失败:\(m)")
case .inferenceFailed(let m): return String(appLoc: "推理失败:\(m)")
}
}
}
actor AIRuntime {
static let shared = AIRuntime()
enum Status: Sendable, Equatable {
case notReady
case loading
case ready
case error(String)
}
private(set) var status: Status = .notReady
private(set) var vlStatus: Status = .notReady
private(set) var lastDecodeRate: Double = 0
private var llmSession: LLMSession?
private var vlSession: VLSession?
// MARK: - (§3.1 OOM )
//
// actor , generate() Task;
// analyzeReport await actor,LLM VL,
// GPU App jetsam
//(MEMORY in-flight )
//
// actor (count = 1):( + )
// await acquireGate(), releaseGate()actor
// gateBusy / gateWaiters
private var gateBusy = false
private var gateWaiters: [CheckedContinuation<Void, Never>] = []
private func acquireGate() async {
if !gateBusy {
gateBusy = true
return
}
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
gateWaiters.append(cont)
}
// releaseGate (gateBusy true)
}
private func releaseGate() {
if gateWaiters.isEmpty {
gateBusy = false
} else {
// ,gateBusy true,
let next = gateWaiters.removeFirst()
next.resume()
}
}
private init() {}
/// App : MLX GPU , reuse cache
/// App ( CPU, Metal abort)
/// increased-memory-limit entitlement + LLM/VL , jetsam OOM
nonisolated static func configureMLXMemory() {
#if !targetEnvironment(simulator)
// 256MB cache : 3GB MB
MLX.GPU.set(cacheLimit: 256 * 1024 * 1024)
#endif
}
/// ,
func prepare() async throws {
// ,
// return: ready, generate
// `guard status == .ready` ()
while status == .loading {
try await Task.sleep(nanoseconds: 80_000_000)
}
if status == .ready { return }
// isComplete() isReady( config.json):config.json ,
// isReady true safetensors ModelDownloadService
// ( isComplete)
guard ModelStore.shared.isComplete(for: .llm) else {
status = .error("LLM 模型未就绪")
throw AIRuntimeError.notReady
}
// :( VL ), VL + LLM,
// VL + LLM OOM
await acquireGate()
defer { releaseGate() }
// :, load
if status == .ready { return }
// OOM (§3.1):LLM(~1GB) VL(~3GB), App jetsam
unloadVL()
status = .loading
do {
let session = try await LLMSession.load(
folderURL: ModelStore.shared.localURL(for: .llm)
)
self.llmSession = session
status = .ready
} catch {
status = .error("\(error)")
throw AIRuntimeError.modelLoadFailed("\(error)")
}
}
/// await prepare()
/// :, actor LLMSession await
func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream<TokenChunk, Error> {
// actor ,Task 访 self.status / self.llmSession
let snapshotStatus = status
let snapshotSession = llmSession
return AsyncThrowingStream { continuation in
let task = Task {
guard snapshotStatus == .ready, let session = snapshotSession else {
continuation.finish(throwing: AIRuntimeError.notReady)
return
}
// : LLM VL / ,
await self.acquireGate()
do {
// session.generate actor , await
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
for try await chunk in stream {
// (UI)/, checkCancellation Task 退,
// session onTermination, MLX , GPU
try Task.checkCancellation()
// Task generate() , AIRuntime actor ;
// actor recordRate await
self.recordRate(chunk.decodeRate)
continuation.yield(chunk)
}
continuation.finish()
} catch {
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
}
// / / (checkCancellation catch ),
// ,
self.releaseGate()
}
// / Task( LLMSession / HealthExportService )
continuation.onTermination = { _ in task.cancel() }
}
}
private func recordRate(_ rate: Double) {
if rate > 0 { lastDecodeRate = rate }
}
// MARK: - VL
/// VL , load
func prepareVL() async throws {
while vlStatus == .loading {
try await Task.sleep(nanoseconds: 80_000_000)
}
if vlStatus == .ready { return }
// prepare(): isComplete (),
guard ModelStore.shared.isComplete(for: .vl) else {
vlStatus = .error("VL 模型未就绪")
throw AIRuntimeError.notReady
}
// :( LLM ), LLM + VL
// App 退
await acquireGate()
defer { releaseGate() }
if vlStatus == .ready { return }
// OOM (§3.1): VL(~3GB) LLM(~1GB), jetsam
unloadLLM()
vlStatus = .loading
do {
let session = try await VLSession.load(
folderURL: ModelStore.shared.localURL(for: .vl)
)
self.vlSession = session
vlStatus = .ready
} catch {
vlStatus = .error("\(error)")
throw AIRuntimeError.modelLoadFailed("\(error)")
}
}
// MARK: - (OOM )
/// LLM, ModelContainer MLX
/// :(prepareVL ), LLM ,
private func unloadLLM() {
guard llmSession != nil else { return }
llmSession = nil
status = .notReady
MLX.GPU.clearCache()
}
/// VL, ModelContainer MLX
private func unloadVL() {
guard vlSession != nil else { return }
vlSession = nil
vlStatus = .notReady
MLX.GPU.clearCache()
}
/// JSON ( VLPrompts.reportExtraction )
/// + 退(§3.2)
/// LLM.generate() , OOM
func analyzeReport(imageURLs: [URL],
prompt: String,
maxTokens: Int = 512) async throws -> String {
guard vlStatus == .ready, let session = vlSession else {
throw AIRuntimeError.notReady
}
await acquireGate()
defer { releaseGate() }
do {
return try await session.analyze(
imageURLs: imageURLs,
prompt: prompt,
maxTokens: maxTokens
)
} catch {
throw AIRuntimeError.inferenceFailed("\(error)")
}
}
}

View File

@@ -0,0 +1,158 @@
import Foundation
enum DownloadError: Error, LocalizedError {
case badStatus(Int)
case sizeMismatch(expected: Int, got: Int)
var errorDescription: String? {
switch self {
case .badStatus(let code):
return String(appLoc: "下载失败(HTTP \(code))")
case .sizeMismatch(let expected, let got):
return String(appLoc: "文件大小校验失败(预期 \(expected),实际 \(got))")
}
}
}
/// , HTTP Range +
/// `URLSessionDataDelegate` `.part`,
///
/// : `FileManager.attributesOfItem` ,****
/// `URL.resourceValues(.fileSizeKey)` URL ,
/// offset finalSize ,
///
/// ()
final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendable {
private let configuration: URLSessionConfiguration
private let lock = NSLock()
private var handle: FileHandle?
private var written: Int = 0
private var onProgress: ((Int) -> Void)?
private var responseError: Error?
private var continuation: CheckedContinuation<Void, Error>?
init(configuration: URLSessionConfiguration = .default) {
self.configuration = configuration
super.init()
}
/// URL
static func fileSize(at url: URL) -> Int {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attrs[.size] as? Int else { return 0 }
return size
}
/// `url` `destination` `destination.part` Range ;
/// == `expectedBytes`, `destination`
nonisolated func download(
from url: URL,
to destination: URL,
expectedBytes: Int,
onProgress: (@Sendable (Int) -> Void)? = nil
) async throws {
let fm = FileManager.default
let part = destination.appendingPathExtension("part")
//
if Self.fileSize(at: destination) == expectedBytes,
fm.fileExists(atPath: destination.path) {
return
}
try fm.createDirectory(
at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
var offset = 0
if fm.fileExists(atPath: part.path) {
offset = Self.fileSize(at: part)
} else {
fm.createFile(atPath: part.path, contents: nil)
}
let fileHandle = try FileHandle(forWritingTo: part)
try fileHandle.seekToEnd()
lock.withLock {
self.handle = fileHandle
self.written = offset
self.onProgress = onProgress
self.responseError = nil
}
var request = URLRequest(url: url)
if offset > 0 {
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
}
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
// didCompleteWithError ( delegate , didReceive)
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
lock.lock()
self.continuation = cont
lock.unlock()
session.dataTask(with: request).resume()
}
let finalSize = Self.fileSize(at: part)
guard finalSize == expectedBytes else {
try? fm.removeItem(at: part)
throw DownloadError.sizeMismatch(expected: expectedBytes, got: finalSize)
}
if fm.fileExists(atPath: destination.path) {
try fm.removeItem(at: destination)
}
try fm.moveItem(at: part, to: destination)
}
// MARK: - URLSessionDataDelegate ( delegate )
nonisolated func urlSession(
_ session: URLSession, dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
lock.lock(); responseError = DownloadError.badStatus(http.statusCode); lock.unlock()
completionHandler(.cancel)
} else {
completionHandler(.allow)
}
}
nonisolated func urlSession(
_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data
) {
lock.lock()
try? handle?.write(contentsOf: data)
written += data.count
let progress = written
let callback = onProgress
lock.unlock()
callback?(progress)
}
nonisolated func urlSession(
_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?
) {
lock.lock()
try? handle?.close()
handle = nil
let cont = continuation
continuation = nil
let respErr = responseError
lock.unlock()
if let respErr {
cont?.resume(throwing: respErr)
} else if let error {
cont?.resume(throwing: error)
} else {
cont?.resume()
}
}
}

View File

@@ -0,0 +1,98 @@
import Foundation
import MLX
import MLXLLM
import MLXLMCommon
/// MLX ,actor 线访
/// mlx-swift-examples 2.29.1(commit 9bff95ca) API
actor LLMSession {
let container: ModelContainer
init(container: ModelContainer) {
self.container = container
}
/// simulator CPU(MLX Metal backend Sim abort)
/// body (GPU/ANE)
/// task-scoped `withDefaultDevice`,TaskLocal child Task / actor
private static func withDeviceOverride<R>(
_ body: () async throws -> R
) async rethrows -> R {
#if targetEnvironment(simulator)
return try await Device.withDefaultDevice(.cpu, body)
#else
return try await body()
#endif
}
/// ( config.json + weights + tokenizer)
static func load(folderURL: URL) async throws -> LLMSession {
let configuration = ModelConfiguration(directory: folderURL)
let container = try await withDeviceOverride {
try await LLMModelFactory.shared.loadContainer(
configuration: configuration
)
}
return LLMSession(container: container)
}
/// AsyncThrowingStream , Task
/// - Parameters:
/// - prompt: prompt ( processor LMInput)
/// - maxTokens: token , GenerateParameters
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
try await Self.withDeviceOverride {
let parameters = GenerateParameters(
maxTokens: maxTokens,
temperature: Float(0.6),
topP: Float(0.9)
)
try await container.perform { (context: ModelContext) in
let userInput = UserInput(prompt: prompt)
let lmInput = try await context.processor.prepare(input: userInput)
let start = Date()
var produced = 0
for await event in try MLXLMCommon.generate(
input: lmInput,
parameters: parameters,
context: context
) {
if Task.isCancelled { break }
switch event {
case .chunk(let text):
produced += 1
let elapsed = Date().timeIntervalSince(start)
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
continuation.yield(TokenChunk(text: text, decodeRate: rate))
case .info:
// ,
break
case .toolCall:
// ,switch
break
}
}
// : MLX.GPU.synchronize()
// GPU AsyncStream yield
// ,GPU
// transitive import MLX , SPM
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
}

View File

@@ -0,0 +1,68 @@
import Foundation
/// : + ()
struct ModelFile: Equatable, Sendable {
let path: String
let bytes: Int
}
///
/// , README.md / .gitattributes()
/// ,
/// docs/superpowers/specs/2026-05-29-model-download-design.md A
nonisolated enum ModelManifest {
/// Caddy ( HTTPS )
/// IP( App ATS ): http://101.132.124.52:5244/
static let baseURL = URL(string: "https://file.myv0.com/")!
static func files(for kind: ModelKind) -> [ModelFile] {
switch kind {
case .llm:
return [
ModelFile(path: "config.json", bytes: 937),
ModelFile(path: "model.safetensors", bytes: 968_080_210),
ModelFile(path: "model.safetensors.index.json", bytes: 49_731),
ModelFile(path: "tokenizer.json", bytes: 11_422_654),
ModelFile(path: "tokenizer_config.json", bytes: 9_706),
ModelFile(path: "vocab.json", bytes: 2_776_833),
ModelFile(path: "merges.txt", bytes: 1_671_853),
ModelFile(path: "special_tokens_map.json", bytes: 613),
ModelFile(path: "added_tokens.json", bytes: 707),
]
case .vl:
// Qwen3-VL-4B-Instruct-4bit: mlx-community blob
// (HF API blobs=true,2026-05 ),
// :( README.md / .gitattributes),
// mlx-vlm , VLMModelFactory
// chat_template(.json + .jinja ) video ,
// swift-transformers / Qwen3VLProcessor
return [
ModelFile(path: "config.json", bytes: 7_137),
ModelFile(path: "model.safetensors", bytes: 3_093_767_283),
ModelFile(path: "model.safetensors.index.json", bytes: 64_742),
ModelFile(path: "tokenizer.json", bytes: 11_422_654),
ModelFile(path: "tokenizer_config.json", bytes: 5_445),
ModelFile(path: "vocab.json", bytes: 2_776_833),
ModelFile(path: "merges.txt", bytes: 1_671_853),
ModelFile(path: "special_tokens_map.json", bytes: 613),
ModelFile(path: "added_tokens.json", bytes: 707),
ModelFile(path: "generation_config.json", bytes: 269),
ModelFile(path: "chat_template.json", bytes: 5_502),
ModelFile(path: "chat_template.jinja", bytes: 5_292),
ModelFile(path: "preprocessor_config.json", bytes: 782),
ModelFile(path: "video_preprocessor_config.json", bytes: 817),
]
}
}
static func totalBytes(for kind: ModelKind) -> Int {
files(for: kind).reduce(0) { $0 + $1.bytes }
}
/// URL = baseURL / <> / <>
static func fileURL(for kind: ModelKind, file: ModelFile) -> URL {
baseURL
.appendingPathComponent(kind.rawValue, isDirectory: true)
.appendingPathComponent(file.path)
}
}

138
康康/AI/ModelStore.swift Normal file
View File

@@ -0,0 +1,138 @@
import Foundation
nonisolated enum ModelKind: String, CaseIterable {
/// HuggingFace mlx-community , Models/
case llm = "Qwen3-1.7B-4bit"
case vl = "Qwen3-VL-4B-Instruct-4bit"
var displayName: String {
switch self {
case .llm: return "Qwen3-1.7B"
case .vl: return "Qwen3-VL-4B"
}
}
/// HuggingFace ID(org/name),
var huggingFaceRepo: String { "mlx-community/\(rawValue)" }
///
var sentinelFilename: String { "config.json" }
}
/// `@unchecked Sendable`:rootURL let, filesystem(线),
/// actor / Task 访
/// `nonisolated`: `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`,
/// MainActor, `AIRuntime` actor
final class ModelStore: @unchecked Sendable {
nonisolated static let shared: ModelStore = {
do {
let appSupport = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let root = appSupport.appendingPathComponent("Models", isDirectory: true)
return try ModelStore(rootURL: root)
} catch {
fatalError("ModelStore.shared init failed: \(error)")
}
}()
let rootURL: URL
init(rootURL: URL) throws {
self.rootURL = rootURL
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
}
nonisolated func localURL(for kind: ModelKind) -> URL {
rootURL.appendingPathComponent(kind.rawValue, isDirectory: true)
}
nonisolated func isReady(_ kind: ModelKind) -> Bool {
let sentinel = localURL(for: kind).appendingPathComponent(kind.sentinelFilename)
return FileManager.default.fileExists(atPath: sentinel.path)
}
nonisolated func totalBytes(for kind: ModelKind) -> Int {
let folder = localURL(for: kind)
guard let enumerator = FileManager.default.enumerator(
at: folder,
includingPropertiesForKeys: [.fileSizeKey]
) else { return 0 }
var sum = 0
for case let url as URL in enumerator {
if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize {
sum += size
}
}
return sum
}
/// Demo : Bundle (W6 使,)
nonisolated func seedFromBundle(_ kind: ModelKind) throws {
guard let bundleURL = Bundle.main.url(forResource: kind.rawValue, withExtension: nil) else {
#if DEBUG
assertionFailure("Bundle 缺少 \(kind.rawValue),检查资源是否加入 target")
#endif
return
}
let target = localURL(for: kind)
if FileManager.default.fileExists(atPath: target.path) {
try FileManager.default.removeItem(at: target)
}
try FileManager.default.copyItem(at: bundleURL, to: target)
}
// MARK: - /
/// URL
nonisolated func fileURL(for kind: ModelKind, relativePath: String) -> URL {
localURL(for: kind).appendingPathComponent(relativePath)
}
/// , 0()
nonisolated func localBytes(for kind: ModelKind, relativePath: String) -> Int {
let url = fileURL(for: kind, relativePath: relativePath)
guard let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize else { return 0 }
return size
}
/// :
/// `files` `ModelManifest`;
nonisolated func isComplete(for kind: ModelKind, files: [ModelFile]? = nil) -> Bool {
let manifest = files ?? ModelManifest.files(for: kind)
guard !manifest.isEmpty else { return false }
for file in manifest where localBytes(for: kind, relativePath: file.path) != file.bytes {
return false
}
return true
}
/// : config.json ()
nonisolated func importModel(_ kind: ModelKind, from sourceFolder: URL) throws {
let configPath = sourceFolder.appendingPathComponent(kind.sentinelFilename).path
guard FileManager.default.fileExists(atPath: configPath) else {
throw ModelStoreError.missingConfig
}
let target = localURL(for: kind)
if FileManager.default.fileExists(atPath: target.path) {
try FileManager.default.removeItem(at: target)
}
try FileManager.default.createDirectory(
at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
try FileManager.default.copyItem(at: sourceFolder, to: target)
}
}
enum ModelStoreError: Error, LocalizedError {
case missingConfig
var errorDescription: String? {
switch self {
case .missingConfig:
return String(appLoc: "所选文件夹缺少 config.json,不是有效的模型目录")
}
}
}

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

@@ -0,0 +1,135 @@
import Foundation
/// VL (Qwen3-VL) / prompt
/// : JSON,markdown
/// CaptureService 退(§3.2 退线)
enum VLPrompts {
/// JSON ( prompt ):
/// ```
/// {
/// "title": "", // , ""
/// "type": "checkup|lab|imaging|prescription|other",
/// "report_date": "YYYY-MM-DD", // ()
/// "institution": "XX ", //
/// "page_count": 1,
/// "summary": "", //
/// "indicators": [
/// {
/// "name": "",
/// "value": "3.84",
/// "unit": "mmol/L",
/// "range": "< 3.40",
/// "status": "high|low|normal"
/// }
/// ]
/// }
/// ```
/// `kind` UI indicators A2() B3()
/// VL "", few-shot ,
/// prompt,退
static func reportExtraction(today: Date = .now) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
let todayStr = f.string(from: today)
return reportExtractionTemplate.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
}
private static let reportExtractionTemplate: String = #"""
你是一个医学体检报告识别助手。请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
今天的日期是 {{TODAY}}。
JSON schema(严格):
{
"title": string,
"type": "checkup" | "lab" | "imaging" | "prescription" | "other",
"report_date": "YYYY-MM-DD",
"institution": string,
"page_count": number,
"summary": string,
"indicators": [
{
"name": string,
"value": string,
"unit": string,
"range": string,
"status": "high" | "low" | "normal"
}
]
}
规则:
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"
- range 字段保留原文(如 "< 3.40""3.9 - 6.1""0 - 5"),不要解析成区间对象。
- 无法识别的字段填空字符串(institution / summary)。
- report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。
- 不要发明指标。看不清的整行跳过。
- 化验单一般 type = "lab",体检套餐 = "checkup"
示例 1(化验单 · 单项):
输入: 一张化验单照片,只能看清「低密度脂蛋白 3.84 mmol/L 参考 <3.40」
输出:
{"title":"","type":"lab","report_date":"2026-05-25","institution":"","page_count":1,"summary":"","indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]}
示例 2(体检 · 多项):
输入: 一份春季体检,3 项可读
输出:
{"title":"","type":"checkup","report_date":"2026-04-12","institution":"","page_count":1,"summary":"","indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"},{"name":"","value":"32","unit":"U/L","range":"9 - 50","status":"normal"},{"name":"","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]}
现在请识别图片并输出 JSON:
"""#
// MARK: - ()
/// :/****()
/// indicators ,// , Report
static func regionExtraction(today: Date = .now) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
let todayStr = f.string(from: today)
return regionExtractionTemplate.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
}
private static let regionExtractionTemplate: String = #"""
你是一个医学化验单识别助手。下面给你的是一张化验单/体检报告的**局部照片**,通常只框住了一两行指标。
请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
今天的日期是 {{TODAY}}。
JSON schema(严格):
{
"indicators": [
{
"name": string,
"value": string,
"unit": string,
"range": string,
"status": "high" | "low" | "normal"
}
]
}
规则:
- 只识别框内清楚可读的指标行,通常 1-3 行;看不清的整行跳过,绝不发明指标。
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"
- range 字段保留原文(如 "< 3.40""3.9 - 6.1""0 - 5"),不要解析成区间对象。
- 识别不出单位/范围就填空字符串,不要编造。
- 不要输出 title / institution / date / summary 等任何报告级字段,只输出 indicators 数组。
示例 1(单行):
输入: 局部照片,清楚可读「低密度脂蛋白 3.84 mmol/L 参考 <3.40 ↑」
输出:
{"indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]}
示例 2(两行):
输入: 局部照片,清楚可读「尿酸 486 μmol/L 208-428」与「空腹血糖 5.2 mmol/L 3.9-6.1」
输出:
{"indicators":[{"name":"尿","value":"486","unit":"μmol/L","range":"208 - 428","status":"high"},{"name":"","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]}
现在请识别这张局部照片并输出 JSON:
"""#
}

View File

@@ -0,0 +1,6 @@
import Foundation
struct TokenChunk: Sendable {
let text: String
let decodeRate: Double // tokens / second,
}

72
康康/AI/VLSession.swift Normal file
View File

@@ -0,0 +1,72 @@
import Foundation
import MLX
import MLXVLM
import MLXLMCommon
/// MLX VL (Qwen3-VL)
/// LLMSession actor , AIRuntime
actor VLSession {
let container: ModelContainer
init(container: ModelContainer) {
self.container = container
}
private static func withDeviceOverride<R>(
_ body: () async throws -> R
) async rethrows -> R {
#if targetEnvironment(simulator)
return try await Device.withDefaultDevice(.cpu, body)
#else
return try await body()
#endif
}
/// VL ( config.json + weights + tokenizer + processor)
static func load(folderURL: URL) async throws -> VLSession {
let configuration = ModelConfiguration(directory: folderURL)
let container = try await withDeviceOverride {
try await VLMModelFactory.shared.loadContainer(
configuration: configuration
)
}
return VLSession(container: container)
}
/// ( token )
/// VL JSON , JSON UI
/// - Parameters:
/// - imageURLs: file:// URL, FileVault
/// - prompt: (VLPrompts.reportExtraction)
/// - maxTokens: 512(JSON 200-400)
func analyze(imageURLs: [URL],
prompt: String,
maxTokens: Int = 512) async throws -> String {
try await Self.withDeviceOverride {
try await container.perform { (context: ModelContext) in
let images = imageURLs.map { UserInput.Image.url($0) }
let userInput = UserInput(prompt: prompt, images: images)
let lmInput = try await context.processor.prepare(input: userInput)
let parameters = GenerateParameters(
maxTokens: maxTokens,
temperature: Float(0.2), // JSON ,
topP: Float(0.9)
)
var collected = ""
for await event in try MLXLMCommon.generate(
input: lmInput,
parameters: parameters,
context: context
) {
if Task.isCancelled { break }
if case .chunk(let text) = event {
collected.append(text)
}
}
return collected
}
}
}
}

View File

@@ -0,0 +1,106 @@
import SwiftUI
import SwiftData
@main
struct KangkangApp: App {
@State private var lang = LanguageManager.shared
init() {
// MLX , entitlement + LLM/VL jetsam OOM
AIRuntime.configureMLXMemory()
}
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Indicator.self,
Report.self,
DiaryEntry.self,
Asset.self,
ChatTurn.self,
Symptom.self,
UserProfile.self,
MetricReminder.self,
CustomMonitorMetric.self,
HealthExport.self,
CustomReminder.self,
])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
// store .completeUnlessOpen (§6),
func makeContainer() throws -> ModelContainer {
let container = try ModelContainer(for: schema, configurations: [config])
KangkangApp.protectStore(at: config.url)
return container
}
do {
return try makeContainer()
} catch {
// Demo schema : SwiftData
// (: @Model ),
// , store -wal/-shm
// App ,()
// VersionedSchema + SchemaMigrationPlan
// : @Model ,
print("⚠️ ModelContainer 创建失败,备份旧 store 后重建: \(error)")
KangkangApp.backupIncompatibleStore(at: config.url)
do {
return try makeContainer()
} catch {
fatalError("Could not create ModelContainer even after store reset: \(error)")
}
}
}()
/// SwiftData store( `-wal`/`-shm`) `.completeUnlessOpen` :
/// , SQLite ,
/// `.complete` /Live Activity 访 store CLAUDE.md §6
/// ( iOS CompleteUntilFirstUserAuthentication,)
private static func protectStore(at storeURL: URL) {
let fm = FileManager.default
for suffix in ["", "-wal", "-shm"] {
let path = storeURL.path + suffix
guard fm.fileExists(atPath: path) else { continue }
try? fm.setAttributes([.protectionKey: FileProtectionType.completeUnlessOpen],
ofItemAtPath: path)
}
}
/// schema store( `-wal` / `-shm`)
/// `Application Support/StoreBackups/<>/`,
/// ,;
private static func backupIncompatibleStore(at storeURL: URL) {
let fm = FileManager.default
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.dateFormat = "yyyyMMdd-HHmmss"
let stamp = fmt.string(from: Date())
let backupDir = storeURL.deletingLastPathComponent()
.appendingPathComponent("StoreBackups/\(stamp)", isDirectory: true)
try? fm.createDirectory(at: backupDir, withIntermediateDirectories: true)
// ()
try? fm.setAttributes([.protectionKey: FileProtectionType.completeUnlessOpen],
ofItemAtPath: backupDir.path)
for suffix in ["", "-wal", "-shm"] {
let src = URL(fileURLWithPath: storeURL.path + suffix)
guard fm.fileExists(atPath: src.path) else { continue }
let dst = backupDir.appendingPathComponent(src.lastPathComponent)
do {
try fm.moveItem(at: src, to: dst)
try? fm.setAttributes([.protectionKey: FileProtectionType.completeUnlessOpen],
ofItemAtPath: dst.path)
} catch {
try? fm.removeItem(at: src) // ,
}
}
}
var body: some Scene {
WindowGroup {
AppLockContainer {
RootView()
.environment(\.locale, lang.locale)
.id(lang.current) // ,
}
}
.modelContainer(sharedModelContainer)
}
}

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

View File

@@ -0,0 +1,98 @@
{
"images": [
{
"filename": "app-icon-kangkang-1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app-icon-kangkang-dark-1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "tinted"
}
],
"filename": "app-icon-kangkang-tinted-1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"filename": "app-icon-kangkang-16.png",
"idiom": "mac",
"scale": "1x",
"size": "16x16"
},
{
"filename": "app-icon-kangkang-32.png",
"idiom": "mac",
"scale": "2x",
"size": "16x16"
},
{
"filename": "app-icon-kangkang-32.png",
"idiom": "mac",
"scale": "1x",
"size": "32x32"
},
{
"filename": "app-icon-kangkang-64.png",
"idiom": "mac",
"scale": "2x",
"size": "32x32"
},
{
"filename": "app-icon-kangkang-128.png",
"idiom": "mac",
"scale": "1x",
"size": "128x128"
},
{
"filename": "app-icon-kangkang-256.png",
"idiom": "mac",
"scale": "2x",
"size": "128x128"
},
{
"filename": "app-icon-kangkang-256.png",
"idiom": "mac",
"scale": "1x",
"size": "256x256"
},
{
"filename": "app-icon-kangkang-512.png",
"idiom": "mac",
"scale": "2x",
"size": "256x256"
},
{
"filename": "app-icon-kangkang-512.png",
"idiom": "mac",
"scale": "1x",
"size": "512x512"
},
{
"filename": "app-icon-kangkang-1024.png",
"idiom": "mac",
"scale": "2x",
"size": "512x512"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -0,0 +1,146 @@
import SwiftUI
struct TjLockChip: View {
var body: some View {
HStack(spacing: 4) {
Image(systemName: "lock.fill")
.font(.system(size: 9, weight: .semibold))
Text("本地加密")
.font(.system(size: 10))
.tracking(0.5)
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(Tj.Palette.ink))
}
}
enum TjBadgeStyle {
case brick, amber, leaf, ink, neutral
var bg: Color {
switch self {
case .brick: return Tj.Palette.brickSoft
case .amber: return Color(red: 0.957, green: 0.890, blue: 0.749)
case .leaf: return Tj.Palette.leafSoft
case .ink: return Tj.Palette.ink
case .neutral: return Tj.Palette.sand2
}
}
var fg: Color {
switch self {
case .brick: return Tj.Palette.brick
case .amber: return Tj.Palette.amber
case .leaf: return Tj.Palette.leaf
case .ink: return Tj.Palette.paper
case .neutral: return Tj.Palette.text2
}
}
}
struct TjBadge: View {
let text: String
var style: TjBadgeStyle = .neutral
var body: some View {
Text(text)
.font(.system(size: 10, weight: .semibold))
.tracking(0.3)
.foregroundStyle(style.fg)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(Capsule().fill(style.bg))
.lineLimit(1)
}
}
struct TjPlaceholder: View {
let label: String
var dark: Bool = false
var radius: CGFloat = Tj.Radius.sm
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(dark ? Color(red: 0.110, green: 0.122, blue: 0.110) : Tj.Palette.sand2)
DiagonalStripes(spacing: 7, color: dark ? Color.white.opacity(0.04) : Color.black.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
Text(label)
.font(.system(size: 11, design: .monospaced))
.tracking(0.5)
.foregroundStyle(dark ? Color.white.opacity(0.5) : Tj.Palette.text3)
.multilineTextAlignment(.center)
.padding(8)
}
}
}
private struct DiagonalStripes: View {
let spacing: CGFloat
let color: Color
var body: some View {
Canvas { ctx, size in
let step = spacing
let count = Int((size.width + size.height) / step) + 4
for i in -2..<count {
let x = CGFloat(i) * step
var path = Path()
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x + size.height, y: size.height))
ctx.stroke(path, with: .color(color), lineWidth: 1)
}
}
}
}
struct TjPrimaryButton: ButtonStyle {
var height: CGFloat = 48
var fontSize: CGFloat = 15
var horizontalPadding: CGFloat = 22
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: fontSize, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, horizontalPadding)
.frame(height: height)
.background(Capsule().fill(Tj.Palette.ink))
.opacity(configuration.isPressed ? 0.85 : 1)
}
}
struct TjGhostButton: ButtonStyle {
var height: CGFloat = 48
var fontSize: CGFloat = 15
var horizontalPadding: CGFloat = 22
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: fontSize, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, horizontalPadding)
.frame(height: height)
.background(
Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1)
)
.opacity(configuration.isPressed ? 0.7 : 1)
}
}
struct TjDashedDivider: View {
var body: some View {
Rectangle()
.fill(Tj.Palette.line)
.frame(height: 1)
.mask(
HStack(spacing: 4) {
ForEach(0..<200, id: \.self) { _ in
Rectangle().frame(width: 4, height: 1)
}
}
)
}
}

View File

@@ -0,0 +1,62 @@
import SwiftUI
enum Tj {
enum Palette {
static let ink = Color(red: 0.165, green: 0.153, blue: 0.137)
static let ink2 = Color(red: 0.286, green: 0.275, blue: 0.251)
static let inkSoft = Color(red: 0.459, green: 0.447, blue: 0.424)
static let sand = Color(red: 0.976, green: 0.969, blue: 0.949)
static let sand2 = Color(red: 0.929, green: 0.918, blue: 0.886)
static let sand3 = Color(red: 0.878, green: 0.859, blue: 0.816)
static let paper = Color(red: 0.992, green: 0.988, blue: 0.973)
static let line = Color(red: 0.875, green: 0.863, blue: 0.831)
static let lineSoft = Color(red: 0.925, green: 0.918, blue: 0.890)
static let text = Color(red: 0.149, green: 0.137, blue: 0.118)
static let text2 = Color(red: 0.420, green: 0.408, blue: 0.384)
static let text3 = Color(red: 0.616, green: 0.604, blue: 0.580)
static let brick = Color(red: 0.886, green: 0.388, blue: 0.314)
static let brickSoft = Color(red: 0.976, green: 0.863, blue: 0.824)
static let amber = Color(red: 0.871, green: 0.627, blue: 0.314)
static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518)
static let leafSoft = Color(red: 0.867, green: 0.910, blue: 0.941)
static let darkBg = Color(red: 0.051, green: 0.063, blue: 0.059)
}
enum Radius {
static let xs: CGFloat = 8
static let sm: CGFloat = 14
static let md: CGFloat = 20
static let lg: CGFloat = 28
static let xl: CGFloat = 36
static let pill: CGFloat = 999
}
enum Shadow {
static func card() -> some View {
Color.clear
}
}
}
extension Font {
static func tjTitle(_ size: CGFloat = 30) -> Font { .system(size: size, weight: .bold, design: .default) }
static func tjH2(_ size: CGFloat = 18) -> Font { .system(size: size, weight: .bold, design: .default) }
static func tjMono(_ size: CGFloat = 11) -> Font { .system(size: size, weight: .regular, design: .monospaced) }
static func tjSerifBody(_ size: CGFloat = 17) -> Font { .system(size: size, weight: .regular, design: .default) }
}
extension View {
func tjCard(bordered: Bool = false, radius: CGFloat = Tj.Radius.md) -> some View {
self
.background(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: bordered ? 1 : 0)
)
.shadow(color: bordered ? .clear : Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.05),
radius: 2, x: 0, y: 1)
}
}

View File

@@ -0,0 +1,321 @@
import SwiftUI
import SwiftData
struct ArchiveListView: View {
@Query(sort: \Indicator.capturedAt, order: .reverse)
private var indicators: [Indicator]
@Query(sort: \Report.reportDate, order: .reverse)
private var reports: [Report]
@Query(sort: \DiaryEntry.createdAt, order: .reverse)
private var diaries: [DiaryEntry]
@Query(sort: \Symptom.startedAt, order: .reverse)
private var symptoms: [Symptom]
@Query(sort: \HealthExport.createdAt, order: .reverse)
private var exports: [HealthExport]
@Query(sort: \CustomReminder.updatedAt, order: .reverse)
private var customReminders: [CustomReminder]
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
private var metricReminders: [MetricReminder]
/// push `navigationDestination(item:)`
/// `navigationDestination(isPresented:)` SwiftUI ()
private enum Route: Hashable { case exports, reminders }
@State private var filter: TimelineKind? = nil
@State private var endingSymptom: Symptom?
@State private var selectedEntry: TimelineEntry?
@State private var showExportSheet = false
@State private var route: Route?
@MainActor
private var allEntries: [TimelineEntry] {
let mapped =
TimelineEntry.from(indicators: indicators) +
reports.map(TimelineEntry.from(report:)) +
diaries.map(TimelineEntry.from(diary:)) +
symptoms.map(TimelineEntry.from(symptom:))
let filtered = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
return filtered.sorted { $0.date > $1.date }
}
private var grouped: [(section: DateSection, items: [TimelineEntry])] {
TimelineGrouping.group(allEntries)
}
private var totalCount: Int { allEntries.count }
var body: some View {
NavigationStack {
content
.navigationDestination(item: $route) { route in
switch route {
case .exports: HealthExportListView()
case .reminders: RemindersListView()
}
}
}
}
private var content: some View {
VStack(alignment: .leading, spacing: 0) {
header
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 14)
if reminderTotal > 0 {
reminderBoard
.padding(.horizontal, 20)
.padding(.bottom, 14)
}
filterChips
.padding(.bottom, 14)
if allEntries.isEmpty {
emptyState
} else {
ScrollView(showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) {
ForEach(grouped, id: \.section) { group in
Section {
VStack(spacing: 10) {
ForEach(group.items) { entry in
rowView(for: entry)
}
}
.padding(.horizontal, 20)
} header: {
sectionHeader(group.section, count: group.items.count)
}
}
}
.padding(.bottom, 24)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
.sheet(item: $endingSymptom) { sym in
SymptomEndSheet(symptom: sym)
}
.sheet(item: $selectedEntry) { entry in
if let d = detail(for: entry) {
TimelineEntryDetailView(detail: d)
}
}
.fullScreenCover(isPresented: $showExportSheet) {
HealthExportSheet()
}
}
@ViewBuilder
private func rowView(for entry: TimelineEntry) -> some View {
if entry.kind == .symptom, entry.isOngoing,
let sym = symptoms.first(where: { "symptom-\($0.persistentModelID)" == entry.id }) {
// : sheet(沿)
Button {
endingSymptom = sym
} label: {
TimelineRow(entry: entry)
}
.buttonStyle(.plain)
} else {
// (///):
Button {
if detail(for: entry) != nil { selectedEntry = entry }
} label: {
TimelineRow(entry: entry)
}
.buttonStyle(.plain)
}
}
/// 线 `TimelineDetail.resolve`(/)
private func detail(for entry: TimelineEntry) -> TimelineDetail? {
TimelineDetail.resolve(for: entry,
indicators: indicators, reports: reports,
diaries: diaries, symptoms: symptoms)
}
private var header: some View {
HStack(alignment: .lastTextBaseline) {
Text("记录")
.font(.tjTitle(26))
.foregroundStyle(Tj.Palette.text)
Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount)"))
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
Menu {
Button {
showExportSheet = true
} label: {
Label("生成新导出", systemImage: "doc.text.below.ecg")
}
if !exports.isEmpty {
Button {
route = .exports
} label: {
Label("我的导出 · \(exports.count)", systemImage: "clock.arrow.circlepath")
}
}
} label: {
HStack(spacing: 6) {
Image(systemName: "doc.text.below.ecg")
.font(.system(size: 12, weight: .semibold))
Text("导出身体档案")
.font(.system(size: 13, weight: .semibold))
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(Tj.Palette.ink))
}
}
}
// MARK: -
/// ( + ),
private var reminderTotal: Int { customReminders.count + metricReminders.count }
private var reminderEnabledCount: Int {
customReminders.filter(\.enabled).count + metricReminders.filter(\.enabled).count
}
/// updatedAt , 3 (,)
private var reminderTitlePreview: [String] {
let merged: [(title: String, at: Date)] =
customReminders.map { ($0.title, $0.updatedAt) } +
metricReminders.map { ($0.displayName, $0.updatedAt) }
return merged.sorted { $0.at > $1.at }.prefix(3).map(\.title)
}
private var reminderCountLabel: String {
reminderEnabledCount == reminderTotal
? String(appLoc: "\(reminderTotal) 个提醒任务")
: String(appLoc: "\(reminderTotal) 个提醒任务 · \(reminderEnabledCount) 个开启中")
}
private var reminderTitleLine: String {
let joined = reminderTitlePreview.joined(separator: " · ")
return reminderTotal > reminderTitlePreview.count ? joined + "" : joined
}
/// (RemindersListView);
private var reminderBoard: some View {
Button { route = .reminders } label: {
HStack(spacing: 12) {
ZStack {
Circle().fill(reminderEnabledCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "bell.fill")
.font(.system(size: 16))
.foregroundStyle(reminderEnabledCount > 0 ? Tj.Palette.ink : Tj.Palette.text3)
}
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 2) {
Text(reminderCountLabel)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
if !reminderTitlePreview.isEmpty {
Text(reminderTitleLine)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.contentShape(Rectangle())
.tjCard()
}
.buttonStyle(.plain)
}
private var filterChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
chip(label: String(appLoc: "全部"), selected: filter == nil) { filter = nil }
ForEach(TimelineKind.allCases) { kind in
chip(label: kind.label, selected: filter == kind) {
filter = filter == kind ? nil : kind
}
}
}
.padding(.horizontal, 20)
}
}
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)
)
}
.buttonStyle(.plain)
}
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
HStack {
Text(section.label)
.font(.system(size: 12, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text2)
Rectangle()
.fill(Tj.Palette.lineSoft)
.frame(height: 1)
Text("\(count)")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
.padding(.vertical, 8)
.background(Tj.Palette.sand)
}
private var emptyState: some View {
VStack(spacing: 14) {
Spacer()
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
.frame(width: 240, height: 140)
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.frame(maxWidth: .infinity)
}
}
#Preview {
ArchiveListView()
.modelContainer(for: [
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self,
HealthExport.self, ChatTurn.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self
], inMemory: true)
}

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

@@ -0,0 +1,264 @@
import SwiftUI
/// VL
/// title / type / reportDate / institution / summary / indicator;
/// indicator,
/// SwiftData + Vault assets
struct CaptureReviewForm: View {
@State var parsed: ParsedReport
let assets: [FileVault.SavedAsset]
let warning: String?
let onSave: (ParsedReport) -> Void
let onCancel: () -> Void
/// assets () nil,banner
var onReanalyze: (() -> Void)? = nil
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if let warning {
warningBanner(warning)
}
if !assets.isEmpty {
pageThumbnails
}
metaSection
indicatorSection
Spacer(minLength: 8)
actions
}
.padding(.horizontal, 18)
.padding(.bottom, 24)
}
}
// MARK: - warning
private func warningBanner(_ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.amber)
VStack(alignment: .leading, spacing: 8) {
Text(text)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
if let onReanalyze {
Button {
onReanalyze()
} label: {
Label("重新识别", systemImage: "arrow.clockwise")
.font(.system(size: 12, weight: .semibold))
}
.buttonStyle(.plain)
.foregroundStyle(Tj.Palette.ink)
}
}
Spacer(minLength: 0)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.5))
)
}
// MARK: -
private var pageThumbnails: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) {
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: 84, height: 110)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
}
}
}
}
}
// MARK: - meta(title / type / date / institution / summary)
private var metaSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionLabel(String(appLoc: "基本信息"))
VStack(spacing: 10) {
labeledField(String(appLoc: "标题")) {
TextField("如:春季年度体检", text: $parsed.title)
.textFieldStyle(.plain)
}
labeledField(String(appLoc: "类型")) {
Picker("", selection: $parsed.typeRaw) {
ForEach(ReportType.allCases, id: \.rawValue) { t in
Text(t.label).tag(t.rawValue)
}
}
.pickerStyle(.segmented)
}
labeledField(String(appLoc: "报告日期")) {
DatePicker("", selection: $parsed.reportDate,
in: ...Date.now,
displayedComponents: .date)
.datePickerStyle(.compact)
.labelsHidden()
.environment(\.locale, Locale.current)
}
labeledField(String(appLoc: "机构(可选)")) {
TextField("如:协和医院", text: $parsed.institution)
}
labeledField(String(appLoc: "摘要(可选)")) {
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
.lineLimit(1...3)
}
}
.padding(12)
.background(fieldBg)
.overlay(fieldBorder)
}
}
private func labeledField<C: View>(_ label: String, @ViewBuilder content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
content()
}
}
// MARK: - indicators
private var indicatorSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
sectionLabel(String(appLoc: "指标(\(parsed.indicators.count) 项)"))
Spacer()
Button {
parsed.indicators.append(
.init(name: "", value: "", unit: "", range: "", status: .normal)
)
} label: {
Label("加一项", systemImage: "plus.circle")
.font(.system(size: 12, weight: .medium))
}
.buttonStyle(.plain)
.foregroundStyle(Tj.Palette.ink)
}
if parsed.indicators.isEmpty {
Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.padding(.vertical, 8)
} else {
VStack(spacing: 10) {
ForEach($parsed.indicators) { $indicator in
indicatorRow($indicator)
}
}
}
}
}
private func indicatorRow(_ binding: Binding<ParsedReport.ParsedIndicator>) -> some View {
let id = binding.wrappedValue.id
return VStack(spacing: 8) {
HStack(spacing: 8) {
TextField("指标名", text: binding.name)
.font(.system(size: 14, weight: .medium))
Button(role: .destructive) {
parsed.indicators.removeAll { $0.id == id }
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
HStack(spacing: 8) {
TextField("数值", text: binding.value)
.keyboardType(.decimalPad)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.frame(maxWidth: 90)
TextField("单位", text: binding.unit)
.frame(maxWidth: 80)
.autocorrectionDisabled()
TextField("参考", text: binding.range)
.autocorrectionDisabled()
}
Picker("", selection: binding.status) {
Text("正常").tag(IndicatorStatus.normal)
Text("偏高 ↑").tag(IndicatorStatus.high)
Text("偏低 ↓").tag(IndicatorStatus.low)
}
.pickerStyle(.segmented)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(statusColor(binding.status.wrappedValue).opacity(0.4),
lineWidth: 1)
)
}
private func statusColor(_ s: IndicatorStatus) -> Color {
switch s {
case .normal: return Tj.Palette.leaf
case .high: return Tj.Palette.brick
case .low: return Tj.Palette.amber
}
}
// MARK: - actions
private var actions: some View {
VStack(spacing: 10) {
Button {
onSave(parsed)
} label: {
Text("保存到记录")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
Button(action: onCancel) {
Text("取消(图片不保留)")
.frame(maxWidth: .infinity)
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
// MARK: - helpers
private func sectionLabel(_ t: String) -> some View {
Text(t)
.font(.system(size: 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
private var fieldBg: some View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
}
private var fieldBorder: some View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
}
}

View File

@@ -0,0 +1,68 @@
import SwiftUI
import VisionKit
import UIKit
#if canImport(VisionKit) && os(iOS)
/// VisionKit SwiftUI
/// - :,
/// - :`VNDocumentCameraViewController.isSupported == false`,
/// View present , PhotosPicker 退( PhotoPickerSheet)
struct DocumentScannerView: UIViewControllerRepresentable {
let onFinish: ([UIImage]) -> Void
let onCancel: () -> Void
func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
let vc = VNDocumentCameraViewController()
vc.delegate = context.coordinator
return vc
}
func updateUIViewController(_ uiViewController: VNDocumentCameraViewController,
context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onFinish: onFinish, onCancel: onCancel)
}
final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
let onFinish: ([UIImage]) -> Void
let onCancel: () -> Void
init(onFinish: @escaping ([UIImage]) -> Void,
onCancel: @escaping () -> Void) {
self.onFinish = onFinish
self.onCancel = onCancel
}
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFinishWith scan: VNDocumentCameraScan
) {
var images: [UIImage] = []
for i in 0..<scan.pageCount {
images.append(scan.imageOfPage(at: i))
}
onFinish(images)
}
func documentCameraViewControllerDidCancel(
_ controller: VNDocumentCameraViewController
) {
onCancel()
}
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFailWithError error: Error
) {
onCancel()
}
}
static var isSupported: Bool {
VNDocumentCameraViewController.isSupported
}
}
#endif

View File

@@ -0,0 +1,68 @@
import SwiftUI
import PhotosUI
/// VisionKit ,demo / PhotosPicker 退
/// DocumentScannerView
struct PhotoPickerSheet: View {
let onFinish: ([UIImage]) -> Void
let onCancel: () -> Void
@State private var selection: [PhotosPickerItem] = []
@State private var loading = false
var body: some View {
VStack(spacing: 20) {
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 56))
.foregroundStyle(Tj.Palette.text3)
Text("模拟器没有摄像头,从相册选一张化验单/体检报告")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
.multilineTextAlignment(.center)
PhotosPicker(selection: $selection,
maxSelectionCount: 5,
matching: .images) {
Text("从相册选 ≤5 张")
.font(.system(size: 14, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Tj.Palette.ink)
.foregroundStyle(Tj.Palette.paper)
.clipShape(Capsule())
}
Button("取消", action: onCancel)
.foregroundStyle(Tj.Palette.text3)
if loading {
ProgressView().tint(Tj.Palette.ink)
}
}
.padding(28)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Tj.Palette.sand.ignoresSafeArea())
.onChange(of: selection) { _, newValue in
guard !newValue.isEmpty else { return }
loadImages(from: newValue)
}
}
private func loadImages(from items: [PhotosPickerItem]) {
loading = true
Task {
var images: [UIImage] = []
for item in items {
if let data = try? await item.loadTransferable(type: Data.self),
let img = UIImage(data: data) {
images.append(img)
}
}
await MainActor.run {
loading = false
if images.isEmpty { onCancel() }
else { onFinish(images) }
}
}
}
}

View File

@@ -0,0 +1,415 @@
import SwiftUI
import SwiftData
import UIKit
import Combine
/// VL ( + )
/// , A1-A3 / B1-B5 mockup
///
/// :
/// ```
/// idle captured(images) analyzing editing(parsed, assets)
///
/// editing(empty, assets)
/// editing saved dismiss
/// ```
struct UnifiedCaptureFlow: View {
@Environment(\.modelContext) private var ctx
let onClose: () -> Void
@AppStorage("hasSeenCaptureTip") private var hasSeenCaptureTip: Bool = false
@State private var phase: Phase = .idle
@State private var analyzeTask: Task<Void, Never>? = nil
@State private var showTip: Bool = false
/// VL (); cancel ,UI 退
private let analyzeTimeoutSeconds: Int = 30
enum Phase {
case idle
case analyzing(images: [UIImage], assets: [FileVault.SavedAsset]?)
case editing(parsed: ParsedReport,
assets: [FileVault.SavedAsset],
warning: String?)
}
var body: some View {
NavigationStack {
content
.background(Tj.Palette.sand.ignoresSafeArea())
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { cancelAll() }
.foregroundStyle(Tj.Palette.text)
}
}
.navigationTitle(phaseTitle)
.navigationBarTitleDisplayMode(.inline)
}
.onAppear {
if !hasSeenCaptureTip { showTip = true }
}
.sheet(isPresented: $showTip) {
CaptureTipSheet(onDismiss: {
hasSeenCaptureTip = true
showTip = false
})
.presentationDetents([.medium])
}
}
private var phaseTitle: String {
switch phase {
case .idle: return String(appLoc: "拍摄报告")
case .analyzing: return String(appLoc: "本地识别中…")
case .editing: return String(appLoc: "核对识别结果")
}
}
@ViewBuilder
private var content: some View {
switch phase {
case .idle:
captureEntry
case .analyzing(let images, _):
AnalyzingView(
images: images,
timeoutSeconds: analyzeTimeoutSeconds,
onCancel: {
analyzeTask?.cancel()
analyzeTask = nil
phase = .idle
}
)
case .editing(let parsed, let assets, let warning):
CaptureReviewForm(
parsed: parsed,
assets: assets,
warning: warning,
onSave: { final in saveAll(parsed: final, assets: assets) },
onCancel: cancelAll,
onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) }
)
}
}
// MARK: -
/// + SwiftData Vault , sheet
/// (),
/// (§6), Vault
/// .analyzing/.editing assets;.idle ,
private func cancelAll() {
analyzeTask?.cancel()
analyzeTask = nil
switch phase {
case .idle:
break
case .analyzing(_, let maybeAssets):
if let assets = maybeAssets { removeOrphans(assets) }
case .editing(_, let assets, _):
removeOrphans(assets)
}
onClose()
}
private func removeOrphans(_ assets: [FileVault.SavedAsset]) {
for a in assets {
try? FileVault.shared.remove(relativePath: a.relativePath)
}
}
// MARK: - : /
@ViewBuilder
private var captureEntry: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
onFinish: { startAnalyze(images: $0) },
onCancel: onClose
)
#else
if DocumentScannerView.isSupported {
DocumentScannerView(
onFinish: { startAnalyze(images: $0) },
onCancel: onClose
)
.ignoresSafeArea()
} else {
PhotoPickerSheet(
onFinish: { startAnalyze(images: $0) },
onCancel: onClose
)
}
#endif
}
// MARK: -
private func startAnalyze(images: [UIImage]) {
guard !images.isEmpty else { onClose(); return }
analyzeTask?.cancel()
phase = .analyzing(images: images, assets: nil)
let timeout = analyzeTimeoutSeconds
analyzeTask = Task {
// Step 1: Vault
// UI , CaptureService.analyze /退,
// assets phase ,cancelAll ,editingFallback
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
// :,View 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: String(appLoc: "图片保存失败,手动录入并保留文本")
)
}
return
}
// assets phase,使
await MainActor.run {
if case .analyzing(let imgs, _) = phase {
phase = .analyzing(images: imgs, assets: assets)
}
}
// Step 2: VL (timeout cancel ,VLSession token break)
let watchdog = Task {
try? await Task.sleep(for: .seconds(timeout))
analyzeTask?.cancel()
}
defer { watchdog.cancel() }
do {
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
if Task.isCancelled {
await editingFallback(assets: assets,
msg: String(appLoc: "识别超时(>\(timeout)s),先手动录入"))
return
}
await MainActor.run {
phase = .editing(
parsed: parsed,
assets: assets,
warning: parsed.isEmpty ? String(appLoc: "识别没有读出指标,请手动补充") : nil
)
}
} catch let CaptureError.parseFailed(msg) {
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
await editingFallback(assets: assets,
msg: Task.isCancelled
? String(appLoc: "识别超时(>\(timeout)s),先手动录入")
: String(appLoc: "推理失败:\(msg)"))
} catch CaptureError.modelNotReady {
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪,先手动录入"))
} catch {
await editingFallback(assets: assets,
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
}
}
}
/// : assets,, VL
private func reanalyze(assets: [FileVault.SavedAsset]) {
analyzeTask?.cancel()
// UIImage,AnalyzingView
let thumbnails: [UIImage] = assets.compactMap {
try? FileVault.shared.loadImage(relativePath: $0.relativePath)
}
phase = .analyzing(images: thumbnails, assets: assets)
let timeout = analyzeTimeoutSeconds
analyzeTask = Task {
let watchdog = Task {
try? await Task.sleep(for: .seconds(timeout))
analyzeTask?.cancel()
}
defer { watchdog.cancel() }
do {
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
if Task.isCancelled {
await editingFallback(assets: assets,
msg: String(appLoc: "识别超时(>\(timeout)s),保留旧编辑"))
return
}
await MainActor.run {
phase = .editing(
parsed: parsed,
assets: assets,
warning: parsed.isEmpty ? String(appLoc: "重新识别没有读出新指标") : nil
)
}
} catch CaptureError.modelNotReady {
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪"))
} catch let CaptureError.parseFailed(msg) {
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
await editingFallback(assets: assets,
msg: Task.isCancelled
? String(appLoc: "识别超时(>\(timeout)s)")
: String(appLoc: "推理失败:\(msg)"))
} catch {
await editingFallback(assets: assets,
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
}
}
}
/// reanalyze editing, assets parsed
private func editingFallback(assets: [FileVault.SavedAsset], msg: String) async {
await MainActor.run {
phase = .editing(parsed: .empty(), assets: assets, warning: msg)
}
}
// MARK: -
private func saveAll(parsed final: ParsedReport,
assets: [FileVault.SavedAsset]) {
let report = Report(
title: final.title.isEmpty ? String(appLoc: "拍摄识别") : final.title,
type: ReportType(rawValue: final.typeRaw) ?? .other,
reportDate: final.reportDate,
institution: final.institution.isEmpty ? nil : final.institution,
summary: final.summary.isEmpty ? nil : final.summary,
pageCount: final.pageCount
)
ctx.insert(report)
// Asset
for a in assets {
let asset = Asset(relativePath: a.relativePath, bytes: a.bytes)
ctx.insert(asset)
report.assets.append(asset)
}
// Indicator
for ind in final.indicators {
let i = Indicator(
name: ind.name,
value: ind.value,
unit: ind.unit,
range: ind.range,
status: ind.status,
capturedAt: final.reportDate,
report: report
)
ctx.insert(i)
}
try? ctx.save()
onClose()
}
}
// MARK: -
private struct AnalyzingView: View {
let images: [UIImage]
let timeoutSeconds: Int
let onCancel: () -> Void
@State private var elapsed: Int = 0
private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack(spacing: 20) {
Spacer()
if let first = images.first {
Image(uiImage: first)
.resizable()
.scaledToFit()
.frame(maxHeight: 240)
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
ProgressView().tint(Tj.Palette.ink).scaleEffect(1.4)
)
)
}
VStack(spacing: 6) {
Text("本地识别中")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(images.count) 页 · 100% 本地推理 · 已用 \(elapsed)s")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
if elapsed >= timeoutSeconds - 5 {
Text("快超时了,>\(timeoutSeconds)s 会自动转为手动录入")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.amber)
}
}
Button("取消识别 · 改为手动录入", action: onCancel)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
.padding(.top, 4)
Spacer()
}
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onReceive(tick) { _ in elapsed += 1 }
}
}
// MARK: - 使
private struct CaptureTipSheet: View {
let onDismiss: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 10) {
Image(systemName: "doc.viewfinder")
.font(.system(size: 28))
.foregroundStyle(Tj.Palette.ink)
Text("拍报告的小贴士")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
}
VStack(alignment: .leading, spacing: 12) {
tip(String(appLoc: "纸张铺平,避免反光、阴影"))
tip(String(appLoc: "整页入框,避免裁切到指标"))
tip(String(appLoc: "多页报告可连拍,系统自动透视校正"))
tip(String(appLoc: "识别全程在本地,图片不会上传"))
}
Spacer()
Button {
onDismiss()
} label: {
Text("我知道了,开始拍")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
}
private func tip(_ text: String) -> some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Tj.Palette.leaf)
.padding(.top, 2)
Text(text)
.font(.tjSerifBody())
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
}
}

View File

@@ -0,0 +1,573 @@
import SwiftUI
import SwiftData
/// sheet
/// DiaryEntry @Model;UI/, AI :
/// Qwen3 3-4 ,
/// q LLM ; row +
struct DiaryQuickSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@State private var content: String = ""
@State private var createdAt: Date = .now
/// AI
enum AssistPhase {
case idle //
case loading // LLM
case ready // , / /
case failed(Error) //
}
@State private var phase: AssistPhase = .idle
@State private var questions: [DiaryAssistService.Question] = []
@State private var lastRate: Double = 0
@State private var currentRound: Int = 0
/// (question.dim), prompt
@State private var coveredDims: Set<String> = []
@State private var suggestTask: Task<Void, Never>?
/// question id;nil =
@State private var fillingId: UUID?
/// , =
@State private var fillValues: [String] = []
/// () true,
@State private var exhaustedNote = false
/// sheet detent large,
/// medium,()
@State private var detent: PresentationDetent = .large
@FocusState private var contentFocused: Bool
private var hasContent: Bool {
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var hasQuestions: Bool { !questions.isEmpty }
private var isLoading: Bool {
if case .loading = phase { return true }
return false
}
private var canRequestSuggest: Bool { hasContent && !isLoading }
private var canSubmit: Bool { hasContent }
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("健康记录")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Text("本机保存")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
.padding(.bottom, 14)
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "内容"))
TextField("今天身体怎么样?吃了什么药、有什么感觉?",
text: $content, axis: .vertical)
.lineLimit(3...8)
.focused($contentFocused)
.onChange(of: content) { _, _ in exhaustedNote = false }
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
assistSection
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "时间"))
DatePicker("", selection: $createdAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
}
// , question
Color.clear.frame(height: 1).id("assist-bottom")
}
.padding(.horizontal, 20)
.padding(.bottom, 6)
}
.scrollDismissesKeyboard(.interactively)
.onChange(of: questions.count) { old, new in
guard new > old else { return }
// round divider( N ,
// questions)
let roundId = "round-\(questions[old].round)"
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
withAnimation(.easeOut(duration: 0.25)) {
proxy.scrollTo(roundId, anchor: .top)
}
}
}
}
HStack(spacing: 12) {
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
Button("保存") { submit() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
.disabled(!canSubmit)
.opacity(canSubmit ? 1 : 0.4)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
}
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.medium, .large], selection: $detent)
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.onDisappear { suggestTask?.cancel() }
}
// MARK: - AI
@ViewBuilder
private var assistSection: some View {
VStack(alignment: .leading, spacing: 10) {
// section header
HStack(spacing: 6) {
Image(systemName: "sparkles")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
Spacer()
if hasQuestions {
Text("\(questions.count) 个建议")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
if lastRate > 0 {
Text(String(format: "%.1f tok/s", lastRate))
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
}
// questions (,)
if hasQuestions {
VStack(spacing: 8) {
ForEach(Array(questions.enumerated()), id: \.element.id) { idx, q in
if idx == 0 || questions[idx - 1].round != q.round {
roundDivider(round: q.round,
count: questions.filter { $0.round == q.round }.count)
.id("round-\(q.round)")
}
questionRow(index: roundLocalIndex(at: idx), question: q)
}
}
}
if exhaustedNote {
HStack(spacing: 6) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.leaf)
Text("已覆盖主要问诊维度;补充原文后可再追问")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 0)
}
.padding(.vertical, 2)
}
// ()
phaseFooter
}
}
@ViewBuilder
private var phaseFooter: some View {
switch phase {
case .idle:
assistPrimaryButton(
icon: "sparkles",
label: canRequestSuggest
? String(appLoc: "让 AI 帮我想想还能记什么")
: String(appLoc: "先写几个字,AI 来帮忙补充"),
enabled: canRequestSuggest,
action: requestSuggestions
)
case .loading:
HStack(spacing: 10) {
ProgressView().controlSize(.small)
Text("AI 思考中… 本地推理,通常 5-10 秒")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Button("取消") { cancelSuggestions() }
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.vertical, 11)
.padding(.horizontal, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
case .ready:
assistPrimaryButton(
icon: "arrow.clockwise",
label: canRequestSuggest
? String(appLoc: "再问一轮 · 让 AI 从新角度追问")
: String(appLoc: "更新一下原文,再让 AI 继续追问"),
enabled: canRequestSuggest,
action: requestSuggestions
)
case .failed(let err):
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.brick)
Text(err.localizedDescription)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text)
Spacer()
}
Button { requestSuggestions() } label: {
Text("重试")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
.buttonStyle(.plain)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.5))
)
}
}
private func assistPrimaryButton(icon: String,
label: String,
enabled: Bool,
action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: icon)
Text(label)
}
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3)
.frame(maxWidth: .infinity)
.padding(.vertical, 11)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(
enabled ? Tj.Palette.ink : Tj.Palette.line,
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
)
)
// : contentShape (+)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!enabled)
}
/// questions list idx question, round (1-based)
private func roundLocalIndex(at idx: Int) -> Int {
let target = questions[idx].round
var count = 0
for i in 0...idx where questions[i].round == target {
count += 1
}
return count
}
/// N LLM
private func roundDivider(round: Int, count: Int) -> some View {
HStack(spacing: 8) {
HStack(spacing: 6) {
Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text(round == 1
? String(appLoc: "第 1 轮 · \(count)")
: String(appLoc: "\(round) 轮 · 基于你刚才更新的文本 · \(count)"))
.font(.system(size: 11, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
Rectangle()
.fill(Tj.Palette.line)
.frame(height: 1)
.mask(
HStack(spacing: 3) {
ForEach(0..<60, id: \.self) { _ in
Rectangle().frame(width: 3, height: 1)
}
}
)
}
.padding(.top, round == 1 ? 0 : 6)
}
private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View {
let adopted = question.adopted
let filling = fillingId == question.id
return VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 8) {
Text("\(index).")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick)
Text(question.q)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text)
.strikethrough(adopted, color: Tj.Palette.text3)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 4)
if adopted {
HStack(spacing: 4) {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold))
Text("已采纳")
.font(.system(size: 11, weight: .semibold))
}
.foregroundStyle(Tj.Palette.leaf)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(Capsule().fill(Tj.Palette.leafSoft))
} else if !filling {
Button { adopt(question) } label: {
HStack(spacing: 4) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 12))
Text("采纳")
.font(.system(size: 12, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().fill(Tj.Palette.ink))
}
.buttonStyle(.plain)
}
}
if filling {
QuestionFillPanel(
template: question.fill,
values: $fillValues,
onCommit: { assembled in commitAdoption(question, text: assembled) },
onCancel: { closeFill() }
)
} else if !question.fill.isEmpty && !adopted {
HStack(alignment: .top, spacing: 4) {
Text("将追加:")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Text(question.fill)
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.leading, 22)
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(adopted ? Tj.Palette.sand2 : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
// MARK: - Actions
private func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
/// AI (coveredDims) LLM,
/// ,
private func requestSuggestions() {
suggestTask?.cancel()
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
let covered = Array(coveredDims)
// 1.
contentFocused = false
// 2. sheet large( medium AI)
if detent != .large {
withAnimation(.snappy(duration: 0.25)) {
detent = .large
}
}
exhaustedNote = false
phase = .loading
suggestTask = Task { @MainActor in
do {
let result = try await DiaryAssistService.shared.suggest(
content: snapshotContent,
coveredDimensions: covered
)
if Task.isCancelled { return }
// ( 1.7B ):
// ; ;
let coveredSnapshot = coveredDims
var acceptedNorms = questions.map { Self.normalize($0.q) }
var batchDims = Set<String>()
let nextRound = currentRound + 1
let fresh = result.questions.compactMap { q -> DiaryAssistService.Question? in
let dim = q.dim.trimmingCharacters(in: .whitespacesAndNewlines)
let norm = Self.normalize(q.q)
if !dim.isEmpty, coveredSnapshot.contains(dim) { return nil }
if !dim.isEmpty, batchDims.contains(dim) { return nil }
if acceptedNorms.contains(where: { Self.isSimilar($0, norm) }) { return nil }
if !dim.isEmpty { batchDims.insert(dim) }
acceptedNorms.append(norm)
var stamped = q
stamped.round = nextRound
return stamped
}
withAnimation(.snappy(duration: 0.2)) {
if fresh.isEmpty {
exhaustedNote = true //
} else {
questions.append(contentsOf: fresh)
for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) }
currentRound = nextRound
exhaustedNote = false
}
lastRate = result.decodeRate
phase = .ready
}
} catch is CancellationError {
if !Task.isCancelled {
phase = hasQuestions ? .ready : .idle
}
} catch {
if !Task.isCancelled {
phase = .failed(error)
}
}
}
}
/// : + ,
private static func normalize(_ s: String) -> String {
s.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: " ", with: "")
.replacingOccurrences(of: "?", with: "?")
}
/// :, Jaccard 0.8(/)
private static func isSimilar(_ a: String, _ b: String) -> Bool {
if a == b { return true }
let sa = Set(a), sb = Set(b)
guard !sa.isEmpty, !sb.isEmpty else { return false }
let inter = sa.intersection(sb).count
let union = sa.union(sb).count
return union > 0 && Double(inter) / Double(union) >= 0.8
}
private func cancelSuggestions() {
suggestTask?.cancel()
phase = hasQuestions ? .ready : .idle
}
/// : `[]` ;( adopted)
/// q ; coveredDims, prompt
private func adopt(_ question: DiaryAssistService.Question) {
guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else {
// :( fill 退)
commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill)
return
}
withAnimation(.snappy(duration: 0.18)) {
fillingId = question.id
fillValues = Array(repeating: "", count: DiaryFillTemplate.slotCount(question.fill))
}
}
/// ()
private func closeFill() {
withAnimation(.snappy(duration: 0.18)) {
fillingId = nil
fillValues = []
}
}
/// :(), adopted,
private func commitAdoption(_ question: DiaryAssistService.Question, text: String) {
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
withAnimation(.snappy(duration: 0.18)) {
questions[idx].adopted = true
}
}
appendToContent(text)
fillingId = nil
fillValues = []
}
/// (,)
private func appendToContent(_ text: String) {
let toAppend = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !toAppend.isEmpty else { return }
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
content = toAppend
} else if content.hasSuffix("\n") {
content += toAppend
} else {
content += "\n" + toAppend
}
}
private func submit() {
guard canSubmit else { return }
let entry = DiaryEntry(
content: content.trimmingCharacters(in: .whitespacesAndNewlines),
createdAt: createdAt
)
ctx.insert(entry)
try? ctx.save()
dismiss()
}
}
#Preview {
DiaryQuickSheet()
}

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

@@ -0,0 +1,189 @@
import SwiftUI
import SwiftData
struct HomeView: View {
var onTapArchive: () -> Void = {}
@Query(sort: \Indicator.capturedAt, order: .reverse)
private var indicators: [Indicator]
@Query(sort: \Report.reportDate, order: .reverse)
private var reports: [Report]
@Query(sort: \DiaryEntry.createdAt, order: .reverse)
private var diaries: [DiaryEntry]
@Query(sort: \Symptom.startedAt, order: .reverse)
private var symptoms: [Symptom]
/// sheet( C1 )
@State private var selectedEntry: TimelineEntry?
@MainActor
private var recentEntries: [TimelineEntry] {
let all =
TimelineEntry.from(indicators: indicators) +
reports.map(TimelineEntry.from(report:)) +
diaries.map(TimelineEntry.from(diary:)) +
symptoms.map(TimelineEntry.from(symptom:))
return all.sorted { $0.date > $1.date }.prefix(6).map { $0 }
}
private var recentGrouped: [(section: DateSection, items: [TimelineEntry])] {
TimelineGrouping.group(recentEntries)
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
greeting
.padding(.top, 4)
.padding(.bottom, 18)
TodayRemindersCard()
OngoingSymptomsCard()
.padding(.bottom, 18)
recentSection
.padding(.bottom, 22)
archiveSection
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.sheet(item: $selectedEntry) { entry in
if let d = TimelineDetail.resolve(
for: entry,
indicators: indicators, reports: reports,
diaries: diaries, symptoms: symptoms
) {
TimelineEntryDetailView(detail: d)
}
}
}
private var greeting: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(todayLine)
.font(.system(size: 12))
.tracking(1)
.foregroundStyle(Tj.Palette.text3)
Text(greetingWord)
.font(.tjTitle())
.foregroundStyle(Tj.Palette.text)
}
Spacer()
TjLockChip()
.padding(.top, 4)
}
}
private var todayLine: String {
let now = Date()
let day = now.formatted(.dateTime.month().day())
let weekday = now.formatted(.dateTime.weekday(.abbreviated))
return "\(day) · \(weekday)"
}
private var greetingWord: String {
switch Calendar.current.component(.hour, from: Date()) {
case 5..<12: return String(appLoc: "早安")
case 12..<18: return String(appLoc: "下午好")
default: return String(appLoc: "晚上好")
}
}
private var recentSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .lastTextBaseline) {
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
Button(action: onTapArchive) {
Text("全部 ")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
if recentEntries.isEmpty {
emptyRecent
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(recentGrouped, id: \.section) { group in
VStack(alignment: .leading, spacing: 8) {
Text(group.section.label)
.font(.system(size: 11, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
VStack(spacing: 10) {
ForEach(group.items) { entry in
Button {
if TimelineDetail.resolve(
for: entry,
indicators: indicators, reports: reports,
diaries: diaries, symptoms: symptoms
) != nil {
selectedEntry = entry
}
} label: {
TimelineRow(entry: entry)
}
.buttonStyle(.plain)
}
}
}
}
}
}
}
}
private var emptyRecent: some View {
HStack {
Text("还没有任何记录,点底部 + 号开始第一条")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.padding(.vertical, 14)
.padding(.horizontal, 16)
.tjCard(bordered: true)
}
private var archiveSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text)
Button(action: onTapArchive) {
HStack(spacing: 14) {
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
.frame(width: 56, height: 56)
VStack(alignment: .leading, spacing: 2) {
Text("我的报告档案")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("\(reports.count) 份 · \(indicators.count) 项指标 · 端侧加密")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.tjCard(bordered: true)
}
.buttonStyle(.plain)
}
}
}
#Preview {
HomeView()
}

View File

@@ -0,0 +1,59 @@
import SwiftUI
enum RecentItemStatus {
case high, archive, diary
var dotColor: Color {
switch self {
case .high: return Tj.Palette.brick
case .archive: return Tj.Palette.ink2
case .diary: return Tj.Palette.leaf
}
}
var valueColor: Color {
switch self {
case .high: return Tj.Palette.brick
default: return Tj.Palette.text2
}
}
}
struct RecentItemRow: View {
let date: String
let type: String
let name: String
let value: String?
let status: RecentItemStatus
var body: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(status.dotColor)
.frame(width: 6, height: 40)
VStack(alignment: .leading, spacing: 2) {
Text("\(date) · \(type)")
.font(.system(size: 11))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
Text(name)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer(minLength: 8)
if let value {
Text(value)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundStyle(status.valueColor)
.lineLimit(1)
.fixedSize()
}
}
.padding(12)
.tjCard(bordered: true)
}
}

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

@@ -0,0 +1,329 @@
import SwiftUI
import SwiftData
let customMetricIconChoices: [String] = [
"circle.fill",
"drop.fill",
"flame.fill",
"bolt.fill",
"leaf.fill",
"pills.fill",
"gauge.high",
"moon.fill",
]
/// `detectNameConflict` UI
enum CustomMetricNameConflict: Equatable {
case none
case builtin(String) // MonitorMetric.displayName
case existingCustom(String) // CustomMonitorMetric.name
var warningText: String {
switch self {
case .none: return ""
case .builtin(let n): return String(appLoc: "\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块")
case .existingCustom(let n):return String(appLoc: "已经有一个叫「\(n)」的自定义指标")
}
}
}
/// : candidate name + customs + seriesKey,
/// 便, SwiftData
func detectNameConflict(
candidate: String,
customs: [CustomMonitorMetric],
excludingSeriesKey: String? = nil
) -> CustomMetricNameConflict {
let trimmed = candidate.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return .none }
if MonitorMetric.allCases.contains(where: { $0.displayName == trimmed }) {
return .builtin(trimmed)
}
for c in customs where c.seriesKey != excludingSeriesKey && c.name == trimmed {
return .existingCustom(trimmed)
}
return .none
}
/// create / edit / delete sheet
struct CustomMetricEditor: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
/// nil = ; nil =
let existing: CustomMonitorMetric?
/// ,parent setSelectedCustom(metric? ) UI
var onSaved: (CustomMonitorMetric?) -> Void
@Query private var allCustoms: [CustomMonitorMetric]
@State private var name: String = ""
@State private var unit: String = ""
@State private var lower: String = ""
@State private var upper: String = ""
@State private var icon: String = "circle.fill"
@State private var hydrated = false
private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
private var trimmedUnit: String { unit.trimmingCharacters(in: .whitespaces) }
private var canSubmit: Bool { !trimmedName.isEmpty }
private var nameConflict: CustomMetricNameConflict {
detectNameConflict(
candidate: name,
customs: allCustoms,
excludingSeriesKey: existing?.seriesKey
)
}
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
header
.padding(.horizontal, 20)
.padding(.bottom, 16)
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
nameSection
unitSection
rangeRow
iconSection
if existing != nil {
deleteButton
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
footer
}
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.onAppear { hydrate() }
}
private var header: some View {
HStack {
Text(existing == nil ? "新建自定义指标" : "编辑「\(existing!.name)")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
if existing == nil {
Text("保存后会出现在录入选项里")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
}
}
private var nameSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "名称"))
TextField("例如:腰围 / 步数 / 睡眠时长", text: $name)
.padding(.horizontal, 14).padding(.vertical, 12)
.background(fieldBg)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(
nameConflict == .none ? Tj.Palette.line : Tj.Palette.amber,
lineWidth: 1
)
)
if nameConflict != .none {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.amber)
Text(nameConflict.warningText)
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.amber)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
}
}
}
private var unitSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "单位(可选)"))
TextField("例如:cm / 步 / 小时", text: $unit)
.autocorrectionDisabled()
.padding(.horizontal, 14).padding(.vertical, 12)
.background(fieldBg).overlay(fieldBorder)
}
}
private var rangeRow: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel(String(appLoc: "参考范围(可选)"))
Spacer()
Text("用于自动判定 正常/偏高/偏低")
.font(.system(size: 10))
.foregroundStyle(Tj.Palette.text3)
}
HStack(spacing: 12) {
rangeField(label: String(appLoc: "下限"), value: $lower, placeholder: "70")
Text("").foregroundStyle(Tj.Palette.text3)
rangeField(label: String(appLoc: "上限"), value: $upper, placeholder: "90")
}
}
}
private func rangeField(label: String, value: Binding<String>, placeholder: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
TextField(placeholder, text: value)
.keyboardType(.decimalPad)
.font(.system(size: 16, weight: .medium, design: .monospaced))
.padding(.horizontal, 12).padding(.vertical, 10)
.background(fieldBg).overlay(fieldBorder)
}
}
private var iconSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "图标"))
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4),
spacing: 8) {
ForEach(customMetricIconChoices, id: \.self) { sf in
Button {
icon = sf
} label: {
Image(systemName: sf)
.font(.system(size: 20, weight: .medium))
.foregroundStyle(icon == sf ? Tj.Palette.paper : Tj.Palette.ink)
.frame(maxWidth: .infinity, minHeight: 44)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(icon == sf ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: icon == sf ? 0 : 1)
)
}
.buttonStyle(.plain)
}
}
}
}
private var deleteButton: some View {
Button(role: .destructive) {
if let m = existing {
ReminderService.cancel(metricId: m.seriesKey)
ctx.delete(m)
try? ctx.save()
onSaved(nil)
dismiss()
}
} label: {
HStack {
Image(systemName: "trash")
Text("删除这项自定义指标")
}
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.5))
)
}
.buttonStyle(.plain)
.padding(.top, 8)
}
private var footer: some View {
HStack(spacing: 12) {
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
Button(existing == nil ? "新建" : "保存") { submit() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
.disabled(!canSubmit)
.opacity(canSubmit ? 1 : 0.4)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(
Tj.Palette.sand
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
)
}
// MARK: - helpers
private var fieldBg: some View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
}
private var fieldBorder: some View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
}
private func sectionLabel(_ t: String) -> some View {
Text(t).font(.system(size: 12, weight: .semibold)).tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
private func hydrate() {
guard !hydrated, let m = existing else { hydrated = true; return }
name = m.name; unit = m.unit; icon = m.icon
lower = m.lowerBound.map { fmt($0) } ?? ""
upper = m.upperBound.map { fmt($0) } ?? ""
hydrated = true
}
private func submit() {
guard canSubmit else { return }
let lo = Double(lower.trimmingCharacters(in: .whitespaces))
let hi = Double(upper.trimmingCharacters(in: .whitespaces))
if let m = existing {
m.name = trimmedName
m.unit = trimmedUnit
m.lowerBound = lo
m.upperBound = hi
m.icon = icon
try? ctx.save()
onSaved(m)
} else {
let m = CustomMonitorMetric(
name: trimmedName,
unit: trimmedUnit,
lowerBound: lo,
upperBound: hi,
icon: icon
)
ctx.insert(m)
try? ctx.save()
onSaved(m)
}
dismiss()
}
private func fmt(_ v: Double) -> String {
v.truncatingRemainder(dividingBy: 1) == 0
? String(format: "%.0f", v)
: String(format: "%.1f", v)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
import SwiftUI
/// · 使
/// , Service / AIRuntime, DesignSystem token
/// App Store :/
struct AboutView: View {
/// Bundle ,
private var versionText: String {
let info = Bundle.main.infoDictionary
let short = info?["CFBundleShortVersionString"] as? String ?? "0.1"
let build = info?["CFBundleVersion"] as? String
if let build, !build.isEmpty, build != short {
return "v\(short) (\(build))"
}
return "v\(short)"
}
var body: some View {
ScrollView {
VStack(spacing: 16) {
header
section(icon: "sparkles", title: String(appLoc: "这是什么")) {
paragraph(
String(appLoc: "康康是一款以本地优先为设计原则的个人健康影像档案工具。") +
String(appLoc: "你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;") +
String(appLoc: "设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。")
)
}
section(icon: "checklist", title: String(appLoc: "主要功能")) {
bullet(String(appLoc: "拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档"))
bullet(String(appLoc: "通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明"))
bullet(String(appLoc: "长期趋势:关注的指标可生成折线图和简要解读"))
bullet(String(appLoc: "本地问答:基于你自己的档案问答,引用可点击回链到原记录"))
bullet(String(appLoc: "隐私优先:健康数据不上传、无需注册账号"))
}
section(icon: "iphone", title: String(appLoc: "设备要求"), tint: Tj.Palette.leaf) {
bullet(String(appLoc: "系统:iOS 17 或更新版本。"))
bullet(String(appLoc: "本地 AI 功能(拍照识别、解读、问答)需要约 8GB 内存,") +
String(appLoc: "推荐 iPhone 15 Pro / Pro Max 及之后发布的机型(含 iPhone 16 系列)。"))
bullet(String(appLoc: "在内存较小的旧机型上,App 仍可用于手动记录、归档与查看,") +
String(appLoc: "但本地 AI 相关功能可能无法运行。"))
}
section(icon: "lock.shield", title: String(appLoc: "隐私保护")) {
bullet(String(appLoc: "AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据。"))
bullet(String(appLoc: "原图与数据库采用系统级文件加密,随设备锁屏受到保护。"))
bullet(String(appLoc: "支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份。"))
bullet(String(appLoc: "可选开启 Face ID 启动锁,进一步保护隐私。"))
}
section(icon: "exclamationmark.triangle", title: String(appLoc: "使用注意"), tint: Tj.Palette.amber) {
bullet(String(appLoc: "本地 AI 模型体积较大(约 4GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;") +
String(appLoc: "模型未就绪时 App 仍可使用,AI 功能会提示前往下载。"))
bullet(String(appLoc: "AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对,") +
String(appLoc: "并以原始报告 / 化验单为准。"))
bullet(String(appLoc: "AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。"))
bullet(String(appLoc: "数据保存在本设备:卸载 App 或删除数据后可能无法恢复,重要资料请自行留存原件。"))
}
section(icon: "hand.raised", title: String(appLoc: "免责声明"), tint: Tj.Palette.brick) {
bullet(String(appLoc: "康康是一款健康信息记录与参考工具,并非医疗器械,不提供医疗诊断、用药或剂量建议、急诊判断等医疗服务。"))
bullet(String(appLoc: "App 内所有 AI 生成的解读、趋势分析与问答内容仅供信息参考,") +
String(appLoc: "不构成医疗建议,也不能替代执业医师、药师或其他专业人员的面诊、检查与意见。"))
bullet(String(appLoc: "任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。"))
bullet(String(appLoc: "如出现身体不适或紧急情况,请及时就医或拨打当地急救电话,请勿依赖本 App 进行判断。"))
bullet(String(appLoc: "在适用法律允许的范围内,因使用本 App 或依赖其中内容所产生的后果,由使用者自行承担。"))
}
Text("康康 · 本地优先的健康档案 · \(versionText)")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.padding(.top, 4)
Text("本 App 仅供健康信息记录与参考,不能替代专业医疗意见。")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 32)
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
}
.background(Tj.Palette.sand)
.navigationTitle("关于")
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - Header
@ViewBuilder private var header: some View {
VStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.sand2)
Image(systemName: "heart.text.square.fill")
.font(.system(size: 34))
.foregroundStyle(Tj.Palette.brick)
}
.frame(width: 72, height: 72)
Text("康康")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("本地优先的个人健康影像档案")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
Text(versionText)
.font(.tjMono())
.foregroundStyle(Tj.Palette.text3)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.padding(.horizontal, 16)
.tjCard()
}
// MARK: - Section builders
@ViewBuilder
private func section<Content: View>(
icon: String,
title: String,
tint: Color = Tj.Palette.text2,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(tint)
Text(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
}
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.tjCard()
}
@ViewBuilder private func paragraph(_ text: String) -> some View {
Text(text)
.font(.system(size: 14))
.foregroundStyle(Tj.Palette.text2)
.lineSpacing(5)
.fixedSize(horizontal: false, vertical: true)
}
@ViewBuilder private func bullet(_ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Circle()
.fill(Tj.Palette.text3)
.frame(width: 5, height: 5)
.padding(.top, 7)
Text(text)
.font(.system(size: 14))
.foregroundStyle(Tj.Palette.text2)
.lineSpacing(5)
.fixedSize(horizontal: false, vertical: true)
}
}
}
#Preview {
NavigationStack {
AboutView()
}
}

View File

@@ -0,0 +1,153 @@
import SwiftUI
import SwiftData
/// ·
/// MeView ; / / /
struct CustomMetricsListView: View {
@Query(sort: \CustomMonitorMetric.createdAt, order: .reverse)
private var metrics: [CustomMonitorMetric]
@Query private var indicators: [Indicator]
@State private var editingTarget: CustomMetricEditTarget?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
hintBanner
if metrics.isEmpty {
emptyState
} else {
ForEach(metrics) { m in
Button {
editingTarget = CustomMetricEditTarget(metric: m)
} label: {
row(m)
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 32)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("自定义指标")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
editingTarget = CustomMetricEditTarget(metric: nil)
} label: {
Image(systemName: "plus")
.font(.system(size: 16, weight: .semibold))
}
}
}
.sheet(item: $editingTarget) { target in
CustomMetricEditor(existing: target.metric) { _ in }
}
}
// MARK: - subviews
private var hintBanner: some View {
HStack(spacing: 10) {
Image(systemName: "info.circle.fill")
.foregroundStyle(Tj.Palette.text3)
Text("自定义指标会出现在「+ 指标记录 → 长期监测」的 grid 里,可设提醒、进趋势")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2.opacity(0.5))
)
}
private var emptyState: some View {
VStack(spacing: 14) {
Spacer(minLength: 40)
TjPlaceholder(label: String(appLoc: "还没有自定义指标"))
.frame(width: 220, height: 130)
Text("右上角 + 新建一个")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.frame(maxWidth: .infinity)
}
private func row(_ m: CustomMonitorMetric) -> some View {
let count = usageCount(for: m)
return HStack(spacing: 12) {
ZStack {
Circle().fill(Tj.Palette.leafSoft)
Image(systemName: m.icon)
.font(.system(size: 17, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 3) {
Text(m.name)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
HStack(spacing: 6) {
if !m.unit.isEmpty {
Text(m.unit)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
if !m.rangeText.isEmpty {
Text("·")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Text(m.rangeText)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(count == 0 ? String(appLoc: "未使用") : String(appLoc: "\(count)"))
.font(.system(size: 11, weight: count > 0 ? .semibold : .regular))
.foregroundStyle(count > 0 ? Tj.Palette.ink : Tj.Palette.text3)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
private func usageCount(for m: CustomMonitorMetric) -> Int {
indicators.filter { $0.seriesKey == m.seriesKey }.count
}
}
#Preview {
NavigationStack {
CustomMetricsListView()
}
.modelContainer(for: [
CustomMonitorMetric.self, Indicator.self,
UserProfile.self, MetricReminder.self,
], inMemory: true)
}

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

@@ -0,0 +1,257 @@
import SwiftUI
import SwiftData
struct MeView: View {
@Environment(\.modelContext) private var ctx
@Query private var profiles: [UserProfile]
@Query private var customMetrics: [CustomMonitorMetric]
@State private var downloadService = ModelDownloadService.shared
@State private var appLock = AppLock.shared
@State private var lang = LanguageManager.shared
// key AppLock.enabledKey
@AppStorage("faceIDLockEnabled") private var lockEnabled = false
private var profile: UserProfile? { profiles.first }
/// Bundle ,
private var appVersionText: String {
let short = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1"
return "v\(short)"
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
HStack {
Text("我的")
.font(.tjTitle())
.foregroundStyle(Tj.Palette.text)
Spacer()
}
.padding(.top, 4)
.padding(.bottom, 6)
profileCard
customMetricsCard
modelManagementCard
languageCard
faceIDCard
NavigationLink {
AboutView()
} label: {
settingsCard(title: String(appLoc: "关于"),
detail: appVersionText,
icon: "info.circle")
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
}
.background(Tj.Palette.sand.ignoresSafeArea())
// ( // ), .navigationTitle:
// , App
.onAppear {
if profiles.isEmpty {
_ = UserProfileStore.loadOrCreate(in: ctx)
}
downloadService.refreshStates()
appLock.refreshAvailability()
}
}
}
// MARK: - Cards
private var profileCard: some View {
NavigationLink {
ProfileEditView()
} label: {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Tj.Palette.amber.opacity(0.25))
Image(systemName: "person.crop.circle.fill")
.font(.system(size: 22))
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("个人资料")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(profileLine)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.tjCard()
}
.buttonStyle(.plain)
}
private var customMetricsCard: some View {
NavigationLink {
CustomMetricsListView()
} label: {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(customMetrics.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft)
Image(systemName: "slider.horizontal.3")
.font(.system(size: 18))
.foregroundStyle(customMetrics.isEmpty ? Tj.Palette.text2 : Tj.Palette.ink)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("自定义指标")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(customMetricsLine)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.tjCard()
}
.buttonStyle(.plain)
}
private var customMetricsLine: String {
if customMetrics.isEmpty { return String(appLoc: "添加你自己的长期监测项") }
return String(appLoc: "\(customMetrics.count)")
}
private var modelManagementCard: some View {
NavigationLink {
ModelManagementView()
} label: {
settingsCard(title: String(appLoc: "模型管理"), detail: modelDetail, icon: "cpu")
}
.buttonStyle(.plain)
}
private var modelDetail: String {
let states = downloadService.states
if ModelKind.allCases.allSatisfy({ states[$0]?.phase == .ready }) { return String(appLoc: "已就绪") }
if downloadService.isAnyDownloading { return String(appLoc: "下载中…") }
let readyCount = ModelKind.allCases.filter { states[$0]?.phase == .ready }.count
return readyCount == 0 ? String(appLoc: "未下载") : String(appLoc: "\(readyCount)/\(ModelKind.allCases.count) 就绪")
}
private var languageCard: some View {
NavigationLink {
LanguageSettingsView()
} label: {
settingsCard(title: String(appLoc: "语言"),
detail: lang.current.displayName,
icon: "character.bubble")
}
.buttonStyle(.plain)
}
// MARK: - Face ID ( Toggle )
private var faceIDCard: some View {
HStack(spacing: 12) {
ZStack {
Circle().fill(lockEnabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "faceid")
.font(.system(size: 18))
.foregroundStyle(lockEnabled ? Tj.Palette.ink : Tj.Palette.text2)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("Face ID 启动锁")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Tj.Palette.text)
Text(faceIDLine)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Toggle("", isOn: faceIDBinding)
.labelsHidden()
.disabled(!appLock.biometryAvailable)
}
.padding(14)
.tjCard()
}
private var faceIDLine: String {
if !appLock.biometryAvailable { return String(appLoc: "本设备未设置 Face ID 或密码") }
return lockEnabled ? String(appLoc: "已开启 · \(appLock.biometryLabel)") : String(appLoc: "关闭")
}
/// , enabled();
private var faceIDBinding: Binding<Bool> {
Binding(
get: { lockEnabled },
set: { newValue in
if newValue {
Task { await appLock.enableWithAuth() }
} else {
appLock.disable()
}
}
)
}
private func settingsCard(title: String, detail: String, icon: String) -> some View {
HStack(spacing: 12) {
ZStack {
Circle().fill(Tj.Palette.sand2)
Image(systemName: icon)
.font(.system(size: 18))
.foregroundStyle(Tj.Palette.text2)
}
.frame(width: 44, height: 44)
Text(title)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Tj.Palette.text)
Spacer()
Text(detail)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.tjCard()
}
private var profileLine: String {
guard let p = profile, p.hasAnyBasics else {
return String(appLoc: "点这里完善你的资料")
}
return p.summaryLine
}
}
#Preview {
MeView()
.modelContainer(for: [
UserProfile.self, Indicator.self, Report.self, DiaryEntry.self,
Asset.self, ChatTurn.self, Symptom.self, MetricReminder.self,
CustomMonitorMetric.self,
], inMemory: true)
}

View File

@@ -0,0 +1,240 @@
import SwiftUI
import Network
import UniformTypeIdentifiers
/// · :/,/ +
/// ModelDownloadService , URLSession(§3.1)
struct ModelManagementView: View {
@State private var service = ModelDownloadService.shared
@State private var isCellular = false
@State private var showCellularConfirm = false
@State private var showImporter = false
@State private var importError: String?
private let monitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "kk.netmonitor")
private var allReady: Bool {
ModelKind.allCases.allSatisfy { service.states[$0]?.phase == .ready }
}
var body: some View {
ScrollView {
VStack(spacing: 14) {
ForEach(ModelKind.allCases, id: \.self) { kind in
modelCard(kind)
}
actionButtons
.padding(.top, 4)
if service.states[.llm]?.phase == .ready {
NavigationLink {
ModelSelfTestView()
} label: {
HStack(spacing: 6) {
Image(systemName: "play.circle")
Text("运行推理自检")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
}
if let importError {
Text(importError)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.brick)
.frame(maxWidth: .infinity, alignment: .leading)
}
footer
.padding(.top, 8)
}
.padding(.horizontal, 16)
.padding(.vertical, 18)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("模型管理")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
service.refreshStates()
monitor.pathUpdateHandler = { path in
let cellular = path.status == .satisfied && path.usesInterfaceType(.cellular)
Task { @MainActor in isCellular = cellular }
}
monitor.start(queue: monitorQueue)
}
.onDisappear { monitor.cancel() }
.fileImporter(isPresented: $showImporter,
allowedContentTypes: [.folder]) { handleImport($0) }
.alert("使用蜂窝网络下载?", isPresented: $showCellularConfirm) {
Button("取消", role: .cancel) {}
Button("继续下载") { service.downloadAll() }
} message: {
Text("模型约 \(formatBytes(totalAllBytes)),建议在 Wi-Fi 下下载。")
}
}
// MARK: -
private func modelCard(_ kind: ModelKind) -> some View {
let state = service.states[kind]
?? DownloadState(phase: .idle, receivedBytes: 0,
totalBytes: ModelManifest.totalBytes(for: kind), bytesPerSecond: 0)
return VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 3) {
Text(kind.displayName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(subtitle(kind))
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
statusBadge(state.phase)
}
if state.phase == .downloading {
ProgressView(value: min(max(state.fraction, 0), 1))
.tint(Tj.Palette.ink)
HStack {
Text("\(Int(state.fraction * 100))%")
Spacer()
Text(speedText(state))
}
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
} else {
HStack {
Text(formatBytes(ModelManifest.totalBytes(for: kind)))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
Spacer()
if case .failed(let message) = state.phase {
Text(message)
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.brick)
.lineLimit(1)
}
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.tjCard()
.contentShape(Rectangle())
.onTapGesture {
if case .failed = state.phase { service.download(kind) }
}
}
private func statusBadge(_ phase: DownloadPhase) -> some View {
switch phase {
case .idle: return TjBadge(text: String(appLoc: "待下载"), style: .neutral)
case .downloading: return TjBadge(text: String(appLoc: "下载中"), style: .amber)
case .verifying: return TjBadge(text: String(appLoc: "校验中"), style: .amber)
case .ready: return TjBadge(text: String(appLoc: "已就绪"), style: .leaf)
case .failed: return TjBadge(text: String(appLoc: "失败 · 重试"), style: .brick)
}
}
// MARK: -
@ViewBuilder
private var actionButtons: some View {
if service.isAnyDownloading {
Button {
for kind in ModelKind.allCases { service.cancel(kind) }
} label: {
Text("暂停下载").frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
} else if allReady {
HStack(spacing: 6) {
Image(systemName: "checkmark.seal.fill")
Text("两个模型都已就绪")
}
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.leaf)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
} else {
Button {
if isCellular { showCellularConfirm = true } else { service.downloadAll() }
} label: {
Text("下载全部模型 · \(formatBytes(totalAllBytes))")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
}
Button {
importError = nil
showImporter = true
} label: {
Text("从文件导入(离线)").frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
}
private var footer: some View {
VStack(spacing: 8) {
TjLockChip()
Text("100% 本地推理 · 模型仅需下载一次")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
.frame(maxWidth: .infinity)
}
// MARK: -
private func handleImport(_ result: Result<URL, Error>) {
do {
let folder = try result.get()
let scoped = folder.startAccessingSecurityScopedResource()
defer { if scoped { folder.stopAccessingSecurityScopedResource() } }
let name = folder.lastPathComponent
guard let kind = ModelKind.allCases.first(where: { $0.rawValue == name }) else {
let names = ModelKind.allCases.map(\.rawValue).joined(separator: "")
importError = String(appLoc: "请选择名为 \(names) 的文件夹")
return
}
try service.importModel(kind, from: folder)
importError = nil
} catch {
importError = String(appLoc: "导入失败:\(error.localizedDescription)")
}
}
// MARK: -
private var totalAllBytes: Int {
ModelKind.allCases.reduce(0) { $0 + ModelManifest.totalBytes(for: $1) }
}
private func subtitle(_ kind: ModelKind) -> String {
switch kind {
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答")
case .vl: return String(appLoc: "拍照识别报告 → 结构化指标")
}
}
private func formatBytes(_ bytes: Int) -> String {
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
}
private func speedText(_ state: DownloadState) -> String {
guard state.bytesPerSecond > 0 else { return "" }
return formatBytes(Int(state.bytesPerSecond)) + "/s"
}
}
#Preview {
NavigationStack {
ModelManagementView()
}
}

View File

@@ -0,0 +1,119 @@
import SwiftUI
/// : LLM prompt, + tok/s
/// · ,
struct ModelSelfTestView: View {
@State private var output = ""
@State private var phase: Phase = .idle
@State private var rate: Double = 0
private enum Phase: Equatable {
case idle, loading, running, done, failed(String)
var label: String {
switch self {
case .idle: return String(appLoc: "未开始")
case .loading: return String(appLoc: "加载模型…")
case .running: return String(appLoc: "推理中…")
case .done: return String(appLoc: "完成 ✓")
case .failed(let m): return String(appLoc: "失败:\(m)")
}
}
}
private let prompt = "用中文一句话介绍肝功能里 ALT 这个指标。"
private var isBusy: Bool { phase == .loading || phase == .running }
private var statusColor: Color {
switch phase {
case .failed: return Tj.Palette.brick
case .done: return Tj.Palette.leaf
default: return Tj.Palette.text2
}
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("测试 PROMPT")
.font(.system(size: 11, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
Text(prompt)
.font(.system(size: 14))
.foregroundStyle(Tj.Palette.text)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.tjCard()
HStack {
Text(phase.label)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(statusColor)
.lineLimit(1)
Spacer()
if rate > 0 {
Text(String(format: "%.1f tok/s", rate))
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
Button {
Task { await run() }
} label: {
Text(isBusy ? "运行中…" : "运行推理自检").frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
.disabled(isBusy)
ScrollView {
Text(output.isEmpty ? "(暂无输出)" : output)
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(Tj.Palette.text)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
.padding(12)
}
.frame(maxHeight: 280)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
Spacer()
}
.padding(16)
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("推理自检")
.navigationBarTitleDisplayMode(.inline)
}
@MainActor
private func run() async {
output = ""
rate = 0
phase = .loading
do {
try await AIRuntime.shared.prepare()
phase = .running
for try await chunk in await AIRuntime.shared.generate(prompt: prompt, maxTokens: 200) {
output += chunk.text
rate = chunk.decodeRate
}
phase = .done
} catch {
phase = .failed(error.localizedDescription)
}
}
}
#Preview {
NavigationStack { ModelSelfTestView() }
}

View File

@@ -0,0 +1,338 @@
import SwiftUI
import SwiftData
struct RemindersListView: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@Query(sort: \CustomReminder.updatedAt, order: .reverse)
private var customReminders: [CustomReminder]
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
private var reminders: [MetricReminder]
/// sheet ();
/// push , false
var presentedAsSheet = false
@State private var editingId: String?
@State private var creatingNew = false
@State private var editingCustom: CustomReminder?
private var isEmpty: Bool { customReminders.isEmpty && reminders.isEmpty }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
header
createButton
if isEmpty {
emptyState
} else {
ForEach(customReminders) { r in
CustomReminderRow(
reminder: r,
onTapEdit: { editingCustom = r },
onToggle: { Task { await syncCustom(r) } }
)
}
if !reminders.isEmpty {
sectionLabel(String(appLoc: "指标记录提醒"))
ForEach(reminders) { r in
ReminderRow(
reminder: r,
isEditing: editingId == r.metricId,
onTapEdit: { toggleEdit(r.metricId) },
onChange: { Task { await sync(r) } },
onDelete: { delete(r) }
)
}
}
}
}
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 32)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("提醒")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if presentedAsSheet {
ToolbarItem(placement: .topBarTrailing) {
Button(String(appLoc: "完成")) { dismiss() }
}
}
}
.sheet(isPresented: $creatingNew) {
CustomReminderEditSheet()
}
.sheet(item: $editingCustom) { r in
CustomReminderEditSheet(reminder: r)
}
}
private var header: some View {
Text("新建提醒,或在记录指标时开启")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var createButton: some View {
Button { creatingNew = true } label: {
Label(String(appLoc: "新建提醒"), systemImage: "plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton(height: 46, fontSize: 14))
}
private func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
}
private var emptyState: some View {
VStack(spacing: 12) {
Spacer(minLength: 40)
TjPlaceholder(label: String(appLoc: "还没有提醒,点上方新建"))
.frame(width: 240, height: 140)
Spacer()
}
.frame(maxWidth: .infinity)
}
// MARK: -
private func syncCustom(_ r: CustomReminder) async {
r.updatedAt = .now
try? ctx.save()
await ReminderService.sync(r)
}
// MARK: - (沿)
private func toggleEdit(_ id: String) {
editingId = (editingId == id) ? nil : id
}
private func sync(_ r: MetricReminder) async {
r.updatedAt = .now
try? ctx.save()
await ReminderService.sync(r)
}
private func delete(_ r: MetricReminder) {
ReminderService.cancel(metricId: r.metricId)
ctx.delete(r)
try? ctx.save()
}
}
/// : sheet; Toggle
private struct CustomReminderRow: View {
@Bindable var reminder: CustomReminder
let onTapEdit: () -> Void
let onToggle: () -> Void
var body: some View {
HStack(spacing: 12) {
Button(action: onTapEdit) {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "bell.fill")
.font(.system(size: 16))
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
}
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 2) {
Text(reminder.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Toggle("", isOn: $reminder.enabled)
.labelsHidden()
.tint(Tj.Palette.ink)
.onChange(of: reminder.enabled) { _, _ in onToggle() }
// 28×28 , Toggle
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.frame(width: 28, height: 28)
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
}
private struct ReminderRow: View {
@Bindable var reminder: MetricReminder
let isEditing: Bool
let onTapEdit: () -> Void
let onChange: () -> Void
let onDelete: () -> Void
@State private var pickedTime: Date = .now
@State private var hydrated = false
var body: some View {
VStack(spacing: 12) {
headerRow
if isEditing {
editingPanel
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
private var headerRow: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "bell.fill")
.font(.system(size: 16))
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
}
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 2) {
Text(reminder.displayName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Toggle("", isOn: $reminder.enabled)
.labelsHidden()
.tint(Tj.Palette.ink)
.onChange(of: reminder.enabled) { _, _ in onChange() }
Button {
onTapEdit()
} label: {
Image(systemName: isEditing ? "chevron.up" : "chevron.down")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
}
}
private var editingPanel: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("时间").font(.system(size: 13)).foregroundStyle(Tj.Palette.text2)
Spacer()
DatePicker("", selection: $pickedTime, displayedComponents: .hourAndMinute)
.datePickerStyle(.compact)
.labelsHidden()
.onChange(of: pickedTime) { _, new in
let cal = Calendar.current
reminder.hour = cal.component(.hour, from: new)
reminder.minute = cal.component(.minute, from: new)
onChange()
}
}
weekdayRow
HStack {
Spacer()
Button(role: .destructive) {
onDelete()
} label: {
Label("删除提醒", systemImage: "trash")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
}
.buttonStyle(.plain)
}
}
.onAppear {
if !hydrated {
pickedTime = Calendar.current.date(
bySettingHour: reminder.hour, minute: reminder.minute, second: 0, of: .now
) ?? .now
hydrated = true
}
}
}
private var weekdayRow: some View {
let names = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""),
]
let weekdayValues = [2, 3, 4, 5, 6, 7, 1]
return HStack(spacing: 6) {
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
Button {
var s = Set(reminder.weekdays)
if s.contains(w) { s.remove(w) } else { s.insert(w) }
reminder.weekdays = s.sorted()
onChange()
} label: {
Text(names[idx])
.font(.system(size: 13,
weight: reminder.weekdays.contains(w) ? .semibold : .regular))
.foregroundStyle(reminder.weekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 30)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(reminder.weekdays.contains(w) ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line,
lineWidth: reminder.weekdays.contains(w) ? 0 : 1)
)
}
.buttonStyle(.plain)
}
}
}
}
#Preview {
NavigationStack {
RemindersListView()
}
.modelContainer(for: [MetricReminder.self, CustomReminder.self], inMemory: true)
}

View File

@@ -0,0 +1,146 @@
import Foundation
/// `IndicatorRecordSheet` grid `MonitorMetric.allCases`
///
/// metric 1 2 Field; 2 Indicator( capturedAt + seriesKey),
/// 1 `effectiveRange(for:profile:)` Profile ( 1 :
/// 140150)
enum MonitorMetric: String, CaseIterable, Identifiable {
case bloodPressure // bp.systolic + bp.diastolic
case fastingGlucose // glucose.fasting
case postprandialGlucose // glucose.postprandial
case temperature // temperature
case heartRate // heart_rate
case spo2 // spo2
// : / UserProfile (,)
var id: String { rawValue }
var displayName: String {
switch self {
case .bloodPressure: return String(appLoc: "血压")
case .fastingGlucose: return String(appLoc: "空腹血糖")
case .postprandialGlucose: return String(appLoc: "餐后血糖")
case .temperature: return String(appLoc: "体温")
case .heartRate: return String(appLoc: "心率")
case .spo2: return String(appLoc: "血氧")
}
}
/// SF Symbolgrid
var icon: String {
switch self {
case .bloodPressure: return "heart.fill"
case .fastingGlucose: return "drop.fill"
case .postprandialGlucose: return "drop.circle.fill"
case .temperature: return "thermometer.medium"
case .heartRate: return "waveform.path.ecg"
case .spo2: return "lungs.fill"
}
}
var fields: [Field] {
switch self {
case .bloodPressure:
return [
Field(seriesKey: "bp.systolic",
label: String(appLoc: "收缩压"),
unit: "mmHg",
placeholder: "120",
baseRange: 90...140),
Field(seriesKey: "bp.diastolic",
label: String(appLoc: "舒张压"),
unit: "mmHg",
placeholder: "80",
baseRange: 60...90),
]
case .fastingGlucose:
return [Field(seriesKey: "glucose.fasting",
label: String(appLoc: "空腹血糖"),
unit: "mmol/L",
placeholder: "5.0",
baseRange: 3.9...6.1)]
case .postprandialGlucose:
return [Field(seriesKey: "glucose.postprandial",
label: String(appLoc: "餐后 2h"),
unit: "mmol/L",
placeholder: "6.5",
baseRange: 0...7.8)]
case .temperature:
return [Field(seriesKey: "temperature",
label: String(appLoc: "体温"),
unit: "°C",
placeholder: "36.5",
baseRange: 36.0...37.2)]
case .heartRate:
return [Field(seriesKey: "heart_rate",
label: String(appLoc: "心率"),
unit: "bpm",
placeholder: "72",
baseRange: 60...100)]
case .spo2:
return [Field(seriesKey: "spo2",
label: String(appLoc: "血氧"),
unit: "%",
placeholder: "98",
baseRange: 95...100)]
}
}
}
extension MonitorMetric {
struct Field: Identifiable, Hashable {
let seriesKey: String
let label: String
let unit: String
let placeholder: String
let baseRange: ClosedRange<Double>?
var id: String { seriesKey }
/// IndicatorRecordSheet 90-140 mmHg
func rangeText(_ range: ClosedRange<Double>?) -> String {
guard let r = range else { return String(appLoc: "无参考范围") }
let lower = format(r.lowerBound)
let upper = format(r.upperBound)
// baseRange 0...7.8,<7.8
if r.lowerBound == 0 { return "<\(upper) \(unit)" }
return "\(lower)\(upper) \(unit)"
}
private func format(_ v: Double) -> String {
v.truncatingRemainder(dividingBy: 1) == 0
? String(format: "%.0f", v)
: String(format: "%.1f", v)
}
}
/// field profile
/// 1 :age 65 bp.systolic 140 150
/// profile nil() baseRange
func effectiveRange(for field: Field, profile: UserProfile?) -> ClosedRange<Double>? {
if let age = profile?.age, age >= 65, field.seriesKey == "bp.systolic" {
return 90...150
}
return field.baseRange
}
/// effectiveRange , value status
/// value high; low; normal; normal
static func status(value: Double, in range: ClosedRange<Double>?) -> IndicatorStatus {
guard let r = range else { return .normal }
if value > r.upperBound { return .high }
if value < r.lowerBound { return .low }
return .normal
}
/// IndicatorRecordSheet (67):
/// effectiveRange baseRange true
func isRangePersonalized(for field: Field, profile: UserProfile?) -> Bool {
guard let p = profile else { return false }
let base = field.baseRange
let eff = effectiveRange(for: field, profile: p)
return base != eff
}
}

View File

@@ -0,0 +1,389 @@
import SwiftUI
import SwiftData
/// · Form ,( Save )
/// UserProfile SwiftData : UserProfileStore.loadOrCreate
struct ProfileEditView: View {
@Environment(\.modelContext) private var ctx
@Query private var profiles: [UserProfile]
var body: some View {
if let p = profiles.first {
ProfileEditForm(profile: p)
} else {
ProgressView()
.onAppear { _ = UserProfileStore.loadOrCreate(in: ctx) }
}
}
}
///
///
/// ( Row ):
/// SwiftData `@Model` Observation,
/// `body`,(/,
/// `@State` ) `body`,
/// 126 `Text(year)`
///
/// :
/// - `ProfileEditForm.body` `profile.*` `@State`,
/// ,
/// - Row / Section ,Observation
/// - `@State` Section ,
/// - .wheel , 126 ,
/// UIPickerView ,
private struct ProfileEditForm: View {
@Environment(\.modelContext) private var ctx
@Bindable var profile: UserProfile
var body: some View {
Form {
Section {
BirthYearRow(profile: profile)
SexRow(profile: profile)
HeightRow(profile: profile)
WeightRow(profile: profile)
BloodTypeRow(profile: profile)
} header: {
Text("基本")
} footer: {
BMIFooter(profile: profile)
}
ChronicSection(profile: profile)
StringListSection(title: String(appLoc: "过敏史"), placeholder: String(appLoc: "如:青霉素"),
items: $profile.allergies)
StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"),
items: $profile.familyHistory)
StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"),
items: $profile.currentMedications)
}
.navigationTitle("个人资料")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(Tj.Palette.sand.ignoresSafeArea())
.onDisappear {
profile.updatedAt = .now
try? ctx.save()
}
}
}
// MARK: - :(,)
/// : `.wheel` , 126
private struct BirthYearRow: View {
@Bindable var profile: UserProfile
@State private var expanded = false
private var currentYear: Int {
Calendar.current.component(.year, from: .now)
}
/// birthYear / expanded ,;
/// `years` (body )
private var years: [Int] {
Array((1900...currentYear).reversed())
}
private var selectedLabel: String {
if let y = profile.birthYear {
let age = currentYear - y
return age >= 0 ? "\(y)(\(age)\(String(appLoc: "")))" : String(y)
}
return String(appLoc: "未设置")
}
private var yearBinding: Binding<Int> {
Binding(
get: { profile.birthYear ?? 0 },
set: { profile.birthYear = $0 == 0 ? nil : $0 }
)
}
var body: some View {
Button {
withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
} label: {
HStack {
Text("出生年份").foregroundStyle(Tj.Palette.text)
Spacer()
Text(selectedLabel)
.foregroundStyle(profile.birthYear == nil ? Tj.Palette.text3 : Tj.Palette.text2)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.rotationEffect(.degrees(expanded ? 90 : 0))
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if expanded {
Picker("出生年份", selection: yearBinding) {
Text("未设置").tag(0)
ForEach(years, id: \.self) { year in
Text(String(year)).tag(year)
}
}
.pickerStyle(.wheel)
.frame(maxHeight: 140)
}
}
}
private struct SexRow: View {
@Bindable var profile: UserProfile
var body: some View {
Picker("性别", selection: Binding(
get: { profile.sex },
set: { profile.sex = $0 }
)) {
ForEach(UserProfile.Sex.allCases, id: \.self) { s in
Text(s.label).tag(s)
}
}
.pickerStyle(.segmented)
}
}
/// :, 80pt
/// ,,
private struct HeightRow: View {
@Bindable var profile: UserProfile
@FocusState private var focused: Bool
var body: some View {
HStack {
Text("身高")
Spacer()
TextField("cm", value: $profile.heightCM, format: .number)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.frame(width: 80)
.focused($focused)
Text("cm").foregroundStyle(Tj.Palette.text3)
}
.contentShape(Rectangle())
.onTapGesture { focused = true }
}
}
private struct WeightRow: View {
@Bindable var profile: UserProfile
@FocusState private var focused: Bool
var body: some View {
HStack {
Text("体重")
Spacer()
TextField("kg", value: $profile.weightKG, format: .number.precision(.fractionLength(0...1)))
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 80)
.focused($focused)
Text("kg").foregroundStyle(Tj.Palette.text3)
}
.contentShape(Rectangle())
.onTapGesture { focused = true }
}
}
private struct BloodTypeRow: View {
@Bindable var profile: UserProfile
var body: some View {
Picker("血型", selection: $profile.bloodTypeRaw) {
Text("不知道").tag("")
Text("A 型").tag("A")
Text("B 型").tag("B")
Text("AB 型").tag("AB")
Text("O 型").tag("O")
}
}
}
/// BMI : heightCM + weightKG,
private struct BMIFooter: View {
@Bindable var profile: UserProfile
var body: some View {
if let bmi = profile.bmi {
Text("BMI: \(String(format: "%.1f", bmi)) \(label(bmi))")
.font(.system(size: 11))
}
}
private func label(_ bmi: Double) -> String {
switch bmi {
case ..<18.5: return String(appLoc: "(偏瘦)")
case ..<24: return String(appLoc: "(正常)")
case ..<28: return String(appLoc: "(超重)")
default: return String(appLoc: "(肥胖)")
}
}
}
// MARK: -
private struct ChronicSection: View {
@Bindable var profile: UserProfile
@State private var newCustomCondition = ""
/// :,( static/let )
private var presets: [String] {
[String(appLoc: "高血压"), String(appLoc: "糖尿病"), String(appLoc: "冠心病"), String(appLoc: "高血脂"),
String(appLoc: "甲状腺疾病"), String(appLoc: "哮喘"), String(appLoc: "慢性肾病"), String(appLoc: "抑郁/焦虑")]
}
var body: some View {
Section {
FlexibleChipGrid {
ForEach(presets, id: \.self) { name in
chip(label: name,
selected: profile.chronicConditions.contains(name)) {
toggle(name)
}
}
ForEach(profile.chronicConditions.filter { !presets.contains($0) },
id: \.self) { name in
chip(label: name, selected: true) {
profile.chronicConditions.removeAll { $0 == name }
}
}
}
HStack {
TextField("自定义慢病", text: $newCustomCondition)
Button("") {
let trimmed = newCustomCondition.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty,
!profile.chronicConditions.contains(trimmed) else { return }
profile.chronicConditions.append(trimmed)
newCustomCondition = ""
}
.disabled(newCustomCondition.trimmingCharacters(in: .whitespaces).isEmpty)
}
} header: {
Text("慢病(影响参考范围与 AI 解读)")
}
}
private func toggle(_ name: String) {
if profile.chronicConditions.contains(name) {
profile.chronicConditions.removeAll { $0 == name }
} else {
profile.chronicConditions.append(name)
}
}
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper))
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1))
}
.buttonStyle(.plain)
}
}
// MARK: - / / ( @State,)
private struct StringListSection: View {
let title: String
let placeholder: String
@Binding var items: [String]
@State private var newInput = ""
var body: some View {
Section(title) {
ForEach(items, id: \.self) { item in
HStack {
Text(item)
Spacer()
Button(role: .destructive) {
items.removeAll { $0 == item }
} label: {
Image(systemName: "minus.circle")
.foregroundStyle(Tj.Palette.brick)
}
.buttonStyle(.borderless)
}
}
HStack {
TextField(placeholder, text: $newInput)
Button("") {
let trimmed = newInput.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, !items.contains(trimmed) else { return }
items.append(trimmed)
newInput = ""
}
.disabled(newInput.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
// MARK: - chip (SwiftUI Wrap, Layout )
struct FlexibleChipGrid<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
FlowLayout { content() }
}
}
private struct FlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var rows: [CGFloat] = [0]
var rowMaxHeight: [CGFloat] = [0]
var x: CGFloat = 0
for s in subviews {
let size = s.sizeThatFits(.unspecified)
if x + size.width > maxWidth, x > 0 {
rows.append(0); rowMaxHeight.append(0)
x = 0
}
rows[rows.count - 1] = max(rows[rows.count - 1], x + size.width)
rowMaxHeight[rowMaxHeight.count - 1] = max(rowMaxHeight.last ?? 0, size.height)
x += size.width + spacing
}
let totalHeight = rowMaxHeight.reduce(0, +) + spacing * CGFloat(max(0, rows.count - 1))
let totalWidth = rows.max() ?? 0
return CGSize(width: totalWidth, height: totalHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x: CGFloat = bounds.minX
var y: CGFloat = bounds.minY
var rowHeight: CGFloat = 0
for s in subviews {
let size = s.sizeThatFits(.unspecified)
if x + size.width > bounds.maxX, x > bounds.minX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
s.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
#Preview {
NavigationStack {
ProfileEditView()
}
.modelContainer(for: [UserProfile.self], inMemory: true)
}

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

@@ -0,0 +1,148 @@
import SwiftUI
enum RecordKind: String, Identifiable, CaseIterable {
case quick, indicator, archive, diary, symptom, reminder
var id: String { rawValue }
/// RecordSheet () enum ,
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .quick, .archive]
var title: String {
switch self {
case .quick: return String(appLoc: "异常项快拍")
case .indicator: return String(appLoc: "记录指标")
case .archive: return String(appLoc: "体检报告归档")
case .diary: return String(appLoc: "健康日记")
case .symptom: return String(appLoc: "记录症状")
case .reminder: return String(appLoc: "开启一个提醒")
}
}
var subtitle: String {
switch self {
case .quick: return String(appLoc: "拍一张化验单,VL 自动识别")
case .indicator: return String(appLoc: "手动填一项指标(免拍照)")
case .archive: return String(appLoc: "完整保存整份报告(可多页)")
case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助")
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
}
}
var icon: String {
switch self {
case .quick: return "camera.fill"
case .indicator: return "number.square.fill"
case .archive: return "doc.fill"
case .diary: return "heart.text.square"
case .symptom: return "waveform.path.ecg"
case .reminder: return "bell.badge"
}
}
var accent: Color {
switch self {
case .quick: return Tj.Palette.brick
case .indicator: return Tj.Palette.brick
case .archive: return Tj.Palette.ink
case .diary: return Tj.Palette.leaf
case .symptom: return Tj.Palette.amber
case .reminder: return Tj.Palette.leaf
}
}
}
struct RecordSheet: View {
var onPick: (RecordKind) -> Void
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 16)
HStack {
Text("记录什么?")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("本地处理 · 永不上传")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.bottom, 14)
// ScrollView :6 detent ,
ScrollView {
VStack(spacing: 10) {
ForEach(RecordKind.displayOrder) { kind in
Button {
onPick(kind)
} label: {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(kind.accent)
Image(systemName: kind.icon)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(Tj.Palette.paper)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text(kind.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(kind.subtitle)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(16)
.tjCard()
}
.buttonStyle(.plain)
}
}
.padding(.bottom, 22)
}
.scrollIndicators(.hidden)
}
.padding(.horizontal, 18)
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.fraction(0.8)])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
}
}
#Preview("RecordSheet · 直接渲染") {
RecordSheet { kind in print("picked: \(kind)") }
.frame(width: 390, height: 560)
.background(Tj.Palette.sand)
}
#Preview("RecordSheet · sheet 模式") {
PreviewContainer()
}
private struct PreviewContainer: View {
@State private var show = true
var body: some View {
Text("点这里再开一次")
.onTapGesture { show = true }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Tj.Palette.sand.ignoresSafeArea())
.sheet(isPresented: $show) {
RecordSheet { kind in print("picked: \(kind)"); show = false }
}
}
}

View File

@@ -0,0 +1,117 @@
import SwiftUI
import SwiftData
import Combine
struct OngoingSymptomsCard: View {
@Query(filter: #Predicate<Symptom> { $0.endedAt == nil },
sort: \Symptom.startedAt, order: .reverse)
private var ongoing: [Symptom]
@State private var ending: Symptom?
@State private var tick: Date = .now
private let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
var body: some View {
if ongoing.isEmpty {
EmptyView()
} else {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Circle()
.fill(Tj.Palette.brick)
.frame(width: 7, height: 7)
Text("持续中")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(ongoing.count)")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
VStack(spacing: 8) {
ForEach(ongoing) { sym in
row(sym)
}
}
}
.onReceive(timer) { now in tick = now }
.sheet(item: $ending) { sym in
SymptomEndSheet(symptom: sym)
}
}
}
private func row(_ sym: Symptom) -> some View {
let interval = max(0, tick.timeIntervalSince(sym.startedAt))
let isLong = interval >= 3 * 24 * 3600
return HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(sym.name)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
severityDot(sym.severity)
}
Text("已持续 \(formatDuration(interval))")
.font(.system(size: 12))
.foregroundStyle(isLong ? Tj.Palette.brick : Tj.Palette.text3)
}
Spacer(minLength: 8)
Button {
ending = sym
} label: {
Text("结束")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule().fill(Tj.Palette.sand2)
)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
.overlay(alignment: .leading) {
Rectangle()
.fill(severityColor(sym.severity))
.frame(width: 3)
.clipShape(
UnevenRoundedRectangle(
topLeadingRadius: Tj.Radius.sm,
bottomLeadingRadius: Tj.Radius.sm,
bottomTrailingRadius: 0,
topTrailingRadius: 0
)
)
}
)
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04),
radius: 2, x: 0, y: 1)
}
private func severityDot(_ value: Int) -> some View {
HStack(spacing: 2) {
ForEach(1...5, id: \.self) { i in
Circle()
.fill(i <= value ? severityColor(value) : Tj.Palette.line)
.frame(width: 5, height: 5)
}
}
}
private func severityColor(_ value: Int) -> Color {
switch value {
case 1, 2: return Tj.Palette.leaf
case 3: return Tj.Palette.amber
default: return Tj.Palette.brick
}
}
}

View File

@@ -0,0 +1,106 @@
import SwiftUI
import SwiftData
struct SymptomEndSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
let symptom: Symptom
@State private var endedAt: Date = .now
private var lowerBound: Date { symptom.startedAt }
private var durationLabel: String {
let interval = max(0, endedAt.timeIntervalSince(lowerBound))
return formatDuration(interval)
}
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
VStack(alignment: .leading, spacing: 18) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("结束症状")
.font(.system(size: 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text3)
Text(symptom.name)
.font(.tjTitle(24))
.foregroundStyle(Tj.Palette.text)
}
Spacer()
}
VStack(alignment: .leading, spacing: 6) {
Text("开始于")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Text(symptom.startedAt.formatted(date: .abbreviated, time: .shortened))
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
}
VStack(alignment: .leading, spacing: 8) {
Text("结束时间")
.font(.system(size: 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
DatePicker("", selection: $endedAt, in: lowerBound...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
}
HStack {
Text("本次持续")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text3)
Spacer()
Text(durationLabel)
.font(.system(size: 15, weight: .semibold, design: .monospaced))
.foregroundStyle(Tj.Palette.brick)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
Spacer(minLength: 8)
}
.padding(.horizontal, 20)
HStack(spacing: 12) {
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
Button("结束并保存") { submit() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
}
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.medium])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
}
private func submit() {
symptom.endedAt = max(endedAt, symptom.startedAt)
try? ctx.save()
dismiss()
}
}

View File

@@ -0,0 +1,232 @@
import SwiftUI
import SwiftData
/// :,( static/let )
private func symptomPresets() -> [String] {
[String(appLoc: "头痛"), String(appLoc: "咳嗽"), String(appLoc: "腹痛"), String(appLoc: "发烧"),
String(appLoc: "恶心"), String(appLoc: "失眠"), String(appLoc: "疲劳"), String(appLoc: "关节痛")]
}
struct SymptomStartSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@State private var name: String = ""
@State private var customName: String = ""
@State private var startedAt: Date = .now
@State private var severity: Double = 3
@State private var note: String = ""
private var resolvedName: String {
let trimmed = customName.trimmingCharacters(in: .whitespaces)
return trimmed.isEmpty ? name : trimmed
}
private var canSubmit: Bool { !resolvedName.isEmpty }
var body: some View {
VStack(spacing: 0) {
handle
header
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
presetSection
customSection
timeSection
severitySection
noteSection
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
footer
}
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.large])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
}
private var handle: some View {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
}
private var header: some View {
HStack {
Text("症状开始")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("结束时再来点结束")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
private var presetSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "常见症状"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(symptomPresets(), id: \.self) { item in
chip(item, selected: name == item) {
name = item
customName = ""
}
}
}
}
}
}
private var customSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "或者自己写"))
TextField("例如:眼皮跳", text: $customName)
.textInputAutocapitalization(.never)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
.onChange(of: customName) { _, newValue in
if !newValue.trimmingCharacters(in: .whitespaces).isEmpty {
name = ""
}
}
}
}
private var timeSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "开始时间"))
DatePicker("", selection: $startedAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
}
}
private var severitySection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel(String(appLoc: "强度"))
Spacer()
Text("\(Int(severity)) / 5")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundStyle(severityColor)
}
Slider(value: $severity, in: 1...5, step: 1)
.tint(severityColor)
HStack {
Text("轻微").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
Spacer()
Text("剧烈").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
}
}
}
private var noteSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "备注(可选)"))
TextField("位置、可能诱因…", text: $note, axis: .vertical)
.lineLimit(2...4)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
}
private var footer: some View {
HStack(spacing: 12) {
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
Button("开始记录") { submit() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
.disabled(!canSubmit)
.opacity(canSubmit ? 1 : 0.4)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(
Tj.Palette.sand
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
)
}
private var severityColor: Color {
switch Int(severity) {
case 1, 2: return Tj.Palette.leaf
case 3: return Tj.Palette.amber
default: return Tj.Palette.brick
}
}
private func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)
)
}
.buttonStyle(.plain)
}
private func submit() {
guard canSubmit else { return }
let symptom = Symptom(
name: resolvedName,
startedAt: startedAt,
note: note.trimmingCharacters(in: .whitespaces).isEmpty ? nil : note,
severity: Int(severity)
)
ctx.insert(symptom)
try? ctx.save()
dismiss()
}
}
#Preview {
SymptomStartSheet()
.modelContainer(for: Symptom.self, inMemory: true)
}

View File

@@ -0,0 +1,77 @@
import Foundation
nonisolated enum DateSection: Hashable {
case today
case yesterday
case thisWeek
case thisMonth
case year(Int)
var label: String {
switch self {
case .today: return String(appLoc: "今天")
case .yesterday: return String(appLoc: "昨天")
case .thisWeek: return String(appLoc: "本周")
case .thisMonth: return String(appLoc: "本月")
case .year(let y): return String(appLoc: "\(y)")
}
}
var sortIndex: Int {
switch self {
case .today: return 0
case .yesterday: return 1
case .thisWeek: return 2
case .thisMonth: return 3
case .year(let y): return 10_000 - y
}
}
}
enum TimelineGrouping {
static func section(for date: Date,
now: Date = .now,
calendar: Calendar = .current) -> DateSection {
if calendar.isDate(date, inSameDayAs: now) { return .today }
if let yesterday = calendar.date(byAdding: .day, value: -1, to: now),
calendar.isDate(date, inSameDayAs: yesterday) {
return .yesterday
}
if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) {
return .thisWeek
}
if calendar.isDate(date, equalTo: now, toGranularity: .month) {
return .thisMonth
}
let year = calendar.component(.year, from: date)
return .year(year)
}
static func group(_ entries: [TimelineEntry],
now: Date = .now,
calendar: Calendar = .current)
-> [(section: DateSection, items: [TimelineEntry])] {
var buckets: [DateSection: [TimelineEntry]] = [:]
for entry in entries {
let key = section(for: entry.date, now: now, calendar: calendar)
buckets[key, default: []].append(entry)
}
return buckets
.map { ($0.key, $0.value.sorted { $0.date > $1.date }) }
.sorted { $0.0.sortIndex < $1.0.sortIndex }
}
}
func formatDuration(_ interval: TimeInterval) -> String {
let totalMinutes = Int(max(0, interval) / 60)
let days = totalMinutes / (60 * 24)
let hours = (totalMinutes % (60 * 24)) / 60
let minutes = totalMinutes % 60
if days > 0 && hours > 0 { return String(appLoc: "\(days)\(hours) 小时") }
if days > 0 { return String(appLoc: "\(days)") }
if hours > 0 && minutes > 0 { return String(appLoc: "\(hours) 小时 \(minutes)") }
if hours > 0 { return String(appLoc: "\(hours) 小时") }
if minutes > 0 { return String(appLoc: "\(minutes) 分钟") }
return String(appLoc: "刚刚")
}

View File

@@ -0,0 +1,200 @@
import SwiftUI
import SwiftData
import Foundation
enum TimelineKind: String, CaseIterable, Identifiable {
case indicator, report, symptom, diary
var id: String { rawValue }
var label: String {
switch self {
case .indicator: return String(appLoc: "指标")
case .report: return String(appLoc: "报告")
case .symptom: return String(appLoc: "症状")
case .diary: return String(appLoc: "日记")
}
}
var icon: String {
switch self {
case .indicator: return "drop.fill"
case .report: return "doc.fill"
case .symptom: return "waveform.path.ecg"
case .diary: return "pencil"
}
}
var accent: Color {
switch self {
case .indicator: return Tj.Palette.brick
case .report: return Tj.Palette.ink2
case .symptom: return Tj.Palette.amber
case .diary: return Tj.Palette.leaf
}
}
}
struct TimelineEntry: Identifiable, Hashable {
let id: String
let kind: TimelineKind
let date: Date
let title: String
let subtitle: String
let trailing: String?
let trailingIsAlert: Bool
let isOngoing: Bool
static func from(indicator i: Indicator) -> TimelineEntry {
TimelineEntry(
id: "indicator-\(i.persistentModelID)",
kind: .indicator,
date: i.capturedAt,
title: i.name,
subtitle: typeSubtitle(for: i),
trailing: indicatorValue(i),
trailingIsAlert: i.status != .normal,
isOngoing: false
)
}
/// Indicator , bp.systolic + bp.diastolic capturedAt
/// " 120/80 mmHg" timeline entry series from(indicator:)
/// :capturedAt 5 ()
static func from(indicators: [Indicator]) -> [TimelineEntry] {
var entries: [TimelineEntry] = []
var consumed = Set<PersistentIdentifier>()
// bp.systolic, bp.diastolic
for sys in indicators where sys.seriesKey == "bp.systolic" {
if consumed.contains(sys.persistentModelID) { continue }
guard let dia = indicators.first(where: {
$0.seriesKey == "bp.diastolic" &&
!consumed.contains($0.persistentModelID) &&
abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5
}) else { continue }
consumed.insert(sys.persistentModelID)
consumed.insert(dia.persistentModelID)
entries.append(mergedBP(systolic: sys, diastolic: dia))
}
// indicator( systolic/diastolic series)
for i in indicators where !consumed.contains(i.persistentModelID) {
entries.append(from(indicator: i))
}
return entries
}
private static func mergedBP(systolic sys: Indicator, diastolic dia: Indicator) -> TimelineEntry {
let abnormal = sys.status != .normal || dia.status != .normal
// status : /;
// ( , 85/55 )
let arrow: String
switch (sys.status, dia.status) {
case (.high, .high), (.high, .normal), (.normal, .high): arrow = ""
case (.low, .low), (.low, .normal), (.normal, .low): arrow = ""
default: arrow = ""
}
return TimelineEntry(
id: "bp-\(sys.persistentModelID)-\(dia.persistentModelID)",
kind: .indicator,
date: sys.capturedAt,
title: String(appLoc: "血压"),
subtitle: String(appLoc: "长期监测"),
trailing: "\(sys.value)/\(dia.value) mmHg" + arrow,
trailingIsAlert: abnormal,
isOngoing: false
)
}
static func from(report r: Report) -> TimelineEntry {
let highCount = r.indicators.filter { $0.status == .high }.count
let lowCount = r.indicators.filter { $0.status == .low }.count
return TimelineEntry(
id: "report-\(r.persistentModelID)",
kind: .report,
date: r.reportDate,
title: r.title,
subtitle: "\(r.type.label) · " + String(appLoc: "\(r.pageCount)"),
trailing: abnormalSummary(high: highCount, low: lowCount),
trailingIsAlert: highCount + lowCount > 0,
isOngoing: false
)
}
/// trailing N N N nil
/// N ,(demo )
static func abnormalSummary(high: Int, low: Int) -> String? {
switch (high, low) {
case (0, 0): return nil
case (let h, 0): return String(appLoc: "\(h) 项偏高")
case (0, let l): return String(appLoc: "\(l) 项偏低")
case (let h, let l): return String(appLoc: "\(h + l) 项异常")
}
}
static func from(diary d: DiaryEntry) -> TimelineEntry {
TimelineEntry(
id: "diary-\(d.persistentModelID)",
kind: .diary,
date: d.createdAt,
title: d.content.firstLine(),
subtitle: String(appLoc: "文字日记"),
trailing: nil,
trailingIsAlert: false,
isOngoing: false
)
}
static func from(symptom s: Symptom) -> TimelineEntry {
let ongoing = s.isOngoing
let date = s.endedAt ?? s.startedAt
let subtitle: String
let trailing: String?
if ongoing {
subtitle = String(appLoc: "症状 · 持续中")
trailing = String(appLoc: "持续 \(formatDuration(s.duration))")
} else {
subtitle = String(appLoc: "症状 · 已结束")
trailing = String(appLoc: "持续 \(formatDuration(s.duration))")
}
return TimelineEntry(
id: "symptom-\(s.persistentModelID)",
kind: .symptom,
date: date,
title: s.name,
subtitle: subtitle,
trailing: trailing,
trailingIsAlert: ongoing,
isOngoing: ongoing
)
}
private static func typeSubtitle(for i: Indicator) -> String {
if let report = i.report {
return String(appLoc: "指标 · \(report.title)")
}
return String(appLoc: "异常项快拍")
}
private static func indicatorValue(_ i: Indicator) -> String {
let unit = i.unit.isEmpty ? "" : " \(i.unit)"
let arrow: String
switch i.status {
case .high: arrow = ""
case .low: arrow = ""
case .normal: arrow = ""
}
return "\(i.value)\(unit)\(arrow)"
}
}
private extension String {
func firstLine() -> String {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
if let line = trimmed.split(whereSeparator: \.isNewline).first {
let s = String(line)
return s.count > 40 ? String(s.prefix(40)) + "" : s
}
return trimmed.isEmpty ? String(appLoc: "(空日记)") : trimmed
}
}

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

@@ -0,0 +1,67 @@
import SwiftUI
struct TimelineRow: View {
let entry: TimelineEntry
var body: some View {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(entry.kind.accent.opacity(0.12))
Image(systemName: entry.kind.icon)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(entry.kind.accent)
}
.frame(width: 36, height: 36)
.overlay(alignment: .topTrailing) {
if entry.isOngoing {
Circle()
.fill(Tj.Palette.brick)
.frame(width: 7, height: 7)
.overlay(Circle().strokeBorder(Tj.Palette.sand, lineWidth: 1.5))
.offset(x: 3, y: -3)
}
}
VStack(alignment: .leading, spacing: 2) {
Text("\(entry.date.timelineLabel) · \(entry.subtitle)")
.font(.system(size: 11))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
Text(entry.title)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer(minLength: 8)
if let trailing = entry.trailing {
Text(trailing)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundStyle(entry.trailingIsAlert ? Tj.Palette.brick : Tj.Palette.text2)
.lineLimit(1)
.fixedSize()
}
}
.padding(12)
.tjCard(bordered: true)
}
}
extension Date {
var timelineLabel: String {
let cal = Calendar.current
if cal.isDateInToday(self) {
return self.formatted(date: .omitted, time: .shortened)
}
if cal.isDateInYesterday(self) {
return String(appLoc: "昨天") + " " + self.formatted(date: .omitted, time: .shortened)
}
let now = Date.now
if cal.isDate(self, equalTo: now, toGranularity: .year) {
return self.formatted(.dateTime.month().day())
}
return self.formatted(.dateTime.year().month().day())
}
}

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