```
docs(health-profile): 添加防编造加固修订记录到导出健康档案设计文档 补充了关于导出摘要出现虚构病例问题的详细分析和修复方案, 包括检索策略优化、空数据兜底处理和prompt重写等三层防护措施。 ```
@@ -413,3 +413,18 @@ Output:
|
|||||||
| **合计** | **~14h ≈ 2 个工作日** |
|
| **合计** | **~14h ≈ 2 个工作日** |
|
||||||
|
|
||||||
也是 W3「AskService 基础 RAG」的前置铺路工作,工程上一举两得。
|
也是 W3「AskService 基础 RAG」的前置铺路工作,工程上一举两得。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 修订记录:防编造加固(2026-05-30)
|
||||||
|
|
||||||
|
**现象**:导出摘要出现整份虚构病例(疲劳/盗汗/血红蛋白98/阿司匹林…),不符任何真实记录。
|
||||||
|
|
||||||
|
**根因(双重)**:① §数据范围里「Diary 由关键词过滤后入 prompt」在泛化请求(无症状词,如「最近身体异常」)下把日记**全部清空** → 真实记录没进 prompt;② 数据稀疏时,1.7B 在固定 6 段模板上**凭训练先验脑补**完整病例(对「只用数据/缺失写无记录」这类约束遵循差)。
|
||||||
|
|
||||||
|
**修复(三层,客户端硬保证为主)**:
|
||||||
|
1. **检索**:`retrieve` 改为——有症状词→按词过滤(保留隐私);无症状词→纳入时间窗内最近 5 条日记,确保真实记录进 prompt。
|
||||||
|
2. **空数据硬兜底**:`isEffectivelyEmpty` 判定无任何记录且 profile 空时,**跳过 LLM**,用 `fallbackReport` 产出确定性「6 段全无记录、主诉仅照搬原话」的摘要,从根上杜绝空数据编造。
|
||||||
|
3. **prompt 重写**:从「撰写」改为「抽取/搬运」框架;反编造铁律首尾各一遍;加一条**稀疏 few-shot** 教模型「缺失写无记录、数值原样照搬」。
|
||||||
|
|
||||||
|
**残留限制**:部分数据(如仅 1 条日记)仍走 LLM,强约束 + few-shot 大幅降低但不能 100% 杜绝小模型臆造;后续可加生成后数值校验。
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
| 决策点 | 选择 |
|
| 决策点 | 选择 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 模型 | 新建独立 `CustomReminder` @Model,不动现有 `MetricReminder` |
|
| 模型 | 新建独立 `CustomReminder` @Model,不动现有 `MetricReminder` |
|
||||||
| 周期粒度 | 每天 / 每周选几天(复用 weekday 约定,覆盖示例)。不做间隔/按月/一次性 |
|
| 周期粒度 | **每日 / 每周选几天 / 每月某日 / 每年某月某日**(2026-05-30 用户反转原「不做按月/按年」决策)。仍不做「每 N 天间隔」/一次性 |
|
||||||
|
| 时间选择 | 常用时间快捷预设(8:00/12:00/18:00/22:00 chip)+ 保留 `DatePicker` 精调 |
|
||||||
| 入口 | 新建 → 开启一个提醒 → `RemindersListView`(提醒中心),顶部「+ 新建提醒」打开编辑 sheet |
|
| 入口 | 新建 → 开启一个提醒 → `RemindersListView`(提醒中心),顶部「+ 新建提醒」打开编辑 sheet |
|
||||||
| 列表范围 | 自由提醒 + 指标提醒**合展**(上次删了「我的」入口,指标提醒也只能从这里管) |
|
| 列表范围 | 自由提醒 + 指标提醒**合展**(上次删了「我的」入口,指标提醒也只能从这里管) |
|
||||||
| 量词(5公里/2片) | 写在自由文本 `title` 里,不单设字段 |
|
| 量词(5公里/2片) | 写在自由文本 `title` 里,不单设字段 |
|
||||||
@@ -32,20 +33,34 @@
|
|||||||
|
|
||||||
```swift
|
```swift
|
||||||
@Model final class CustomReminder {
|
@Model final class CustomReminder {
|
||||||
|
enum Frequency: String { case daily, weekly, monthly, yearly } // 嵌套枚举
|
||||||
@Attribute(.unique) var id: UUID
|
@Attribute(.unique) var id: UUID
|
||||||
var title: String // 用户文案:"跑步5公里"
|
var title: String // 用户文案:"跑步5公里"
|
||||||
var note: String // 可选备注 → 通知正文
|
var note: String // 可选备注 → 通知正文
|
||||||
var hour: Int // 0...23
|
var hour: Int // 0...23
|
||||||
var minute: Int // 0...59
|
var minute: Int // 0...59
|
||||||
var weekdays: [Int] // 1=日…7=六,全 7 = 每天(复用 MetricReminder 约定)
|
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 enabled: Bool
|
||||||
var createdAt: Date
|
var createdAt: Date
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
// computed: isEveryDay / frequencyLabel / timeLabel(与 MetricReminder 同款,复用同一批本地化 key)
|
// computed: frequency(get/set 包 frequencyRaw)/ isEveryDay / frequencyLabel(分档)/ timeLabel
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema 注册:`App/KangkangApp.swift` 加 `CustomReminder.self`(additive 变更,无需迁移)。
|
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 给提示。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,12 +110,12 @@ static func sync(_ metric: MetricReminder) async // 现有,内部改走共享
|
|||||||
|
|
||||||
| 文件 | 改动 |
|
| 文件 | 改动 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Models/Models.swift` | +`CustomReminder` |
|
| `Models/Models.swift` | `CustomReminder` +`Frequency` 枚举 +`frequencyRaw/dayOfMonth/month`(均带内联默认)+ 分档 `frequencyLabel` |
|
||||||
| `App/KangkangApp.swift` | schema +`CustomReminder.self` |
|
| `App/KangkangApp.swift` | **持久化兜底改造**:迁移失败时由「删库」改为「挪到 `StoreBackups/<时间戳>/` 再重建」(见 §10) |
|
||||||
| `Services/ReminderService.swift` | 泛化共享核心 + custom sync/cancel |
|
| `Services/ReminderService.swift` | 调度核心泛化为 `Slot(suffix,DateComponents)` 列表;custom sync 按 frequency 分档;`cancelBase` 覆盖 daily/monthly/yearly/w1-7 |
|
||||||
| `Features/Me/CustomReminderEditSheet.swift` | **新增** 编辑表单 |
|
| `Features/Me/CustomReminderEditSheet.swift` | 频率分段 Picker + 各档子控件(周几 / 日 / 月+日)+ 时间快捷预设行 |
|
||||||
| `Features/Me/RemindersListView.swift` | 提醒中心:新建按钮 + 合展两类 |
|
| `Features/Me/RemindersListView.swift` | 不变(`frequencyLabel` 来自模型) |
|
||||||
| `Localizable.xcstrings` | 新增文案四语言 |
|
| `Localizable.xcstrings` | 新增 11 个 key × en/ja/ko |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -114,4 +129,18 @@ static func sync(_ metric: MetricReminder) async // 现有,内部改走共享
|
|||||||
|
|
||||||
## 9. 验收(真机)
|
## 9. 验收(真机)
|
||||||
|
|
||||||
① 新建「每天 20:00 跑步 5 公里」→ 列表出现 → 到点收到本地通知(标题=跑步5公里);② 改时间/周几即时重排;③ 关闭 Toggle 取消通知;④ 删除清除 pending;⑤ 切换语言后固定文案随之变化(用户输入文案不变);⑥ 指标提醒仍在同一列表可管。
|
① 新建「每天 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` 的正式迁移(注释已就地标注)。
|
||||||
|
|||||||
@@ -21,10 +21,13 @@ enum DiaryAssistPrompts {
|
|||||||
/// 第一轮传空数组。
|
/// 第一轮传空数组。
|
||||||
static func suggest(content: String, coveredDimensions: [String] = []) -> String {
|
static func suggest(content: String, coveredDimensions: [String] = []) -> String {
|
||||||
let covered = coveredDimensions.filter { !$0.isEmpty }
|
let covered = coveredDimensions.filter { !$0.isEmpty }
|
||||||
let coveredLine = covered.isEmpty ? "无" : covered.joined(separator: "、")
|
let coveredSet = Set(covered)
|
||||||
let excludeRule = covered.isEmpty
|
let allowed = dimensions.filter { !coveredSet.contains($0) }
|
||||||
|
let allowedLine = allowed.isEmpty ? "(已基本问全)" : allowed.joined(separator: "、")
|
||||||
|
// 正向约束:1.7B 对「只能从这些里挑」比对「严禁选这些」遵循更好。
|
||||||
|
let scopeRule = covered.isEmpty
|
||||||
? ""
|
? ""
|
||||||
: "\n- 本轮【严禁】选择这些已覆盖维度:\(covered.joined(separator: "、"));只能从其余维度里挑。"
|
: "\n- 已问过的维度【不要再问】:\(covered.joined(separator: "、"))。本轮只能从这些还没问的维度里挑:\(allowedLine)。"
|
||||||
|
|
||||||
return """
|
return """
|
||||||
你是社区医生的小助手。患者写了一段身体状态的健康记录,信息可能不够完整。
|
你是社区医生的小助手。患者写了一段身体状态的健康记录,信息可能不够完整。
|
||||||
@@ -41,7 +44,7 @@ enum DiaryAssistPrompts {
|
|||||||
8. 生活方式 —— 睡眠、饮食、运动习惯、压力
|
8. 生活方式 —— 睡眠、饮食、运动习惯、压力
|
||||||
|
|
||||||
硬性规则:
|
硬性规则:
|
||||||
- 本轮每个问题必须来自【不同】维度,严禁两条落在同一维度(例如不能两条都问"伴随症状")。\(excludeRule)
|
- 本轮每个问题必须来自【不同】维度,严禁两条落在同一维度(例如不能两条都问"伴随症状")。\(scopeRule)
|
||||||
- 只问【最新记录】里还没写明的事。方括号 `[xxx]` 表示该话题已被提出、只是细节待填,【不要】再作为新问题重复它。
|
- 只问【最新记录】里还没写明的事。方括号 `[xxx]` 表示该话题已被提出、只是细节待填,【不要】再作为新问题重复它。
|
||||||
- 不给诊断、不给用药建议、不写「建议就医」。
|
- 不给诊断、不给用药建议、不写「建议就医」。
|
||||||
- q ≤ 20 字,像真人医生在问;fill 是采纳后追加到原文的中文补充句,可含方括号占位符如 [时间] [部位]。
|
- q ≤ 20 字,像真人医生在问;fill 是采纳后追加到原文的中文补充句,可含方括号占位符如 [时间] [部位]。
|
||||||
@@ -66,7 +69,7 @@ enum DiaryAssistPrompts {
|
|||||||
]}
|
]}
|
||||||
|
|
||||||
现在输出 JSON。
|
现在输出 JSON。
|
||||||
已覆盖维度(必须避开):\(coveredLine)
|
本轮可选维度:\(allowedLine)
|
||||||
【最新记录】:
|
【最新记录】:
|
||||||
\(content)
|
\(content)
|
||||||
|
|
||||||
|
|||||||
@@ -62,15 +62,20 @@ enum HealthExportPrompts {
|
|||||||
? "# 就诊摘要"
|
? "# 就诊摘要"
|
||||||
: "# 就诊摘要 — \(intentLabelCN)"
|
: "# 就诊摘要 — \(intentLabelCN)"
|
||||||
return """
|
return """
|
||||||
你正在帮患者撰写一份给社区医生看的就诊摘要。要求:
|
你是健康数据整理员。任务是把下面【真实数据】(JSON)里**已经存在**的内容,
|
||||||
- 严格输出 Markdown,标题用 # / ##,不要 markdown 围栏
|
原样整理成一份给社区医生看的就诊摘要。这是**抽取 / 搬运**任务,不是创作。
|
||||||
- 只用「数据」中给出的信息,数据缺失就写「无记录」
|
|
||||||
- 不要给诊断意见、用药建议或「建议就医」之类的话
|
|
||||||
- 引用数值时保留单位 + 参考范围,异常项前加 ⚠️
|
|
||||||
- 全文中文,简洁,医生 30 秒内能扫完
|
|
||||||
- 不要复述「数据」二字,不要输出 JSON
|
|
||||||
|
|
||||||
结构(严格按以下 6 段):
|
【最重要的铁律 —— 违反即失败】
|
||||||
|
- 只能使用【真实数据】JSON 里**真实出现过**的内容。
|
||||||
|
- 严禁编造或推测任何数字、日期、症状、药物、检查结果、诊断,哪怕看起来很合理。
|
||||||
|
- JSON 里没有的信息,对应小节一律写「无记录」,不要补全、不要举例、不要套用常见病例模板。
|
||||||
|
- 数值必须原样照搬(含单位与参考范围);status 为 high/low/abnormal 的指标前加 ⚠️。
|
||||||
|
- 「主诉」「患者疑问」可参考【患者原话】,但不得加入原话与数据里都没有的症状。
|
||||||
|
|
||||||
|
输出格式:
|
||||||
|
- 严格 Markdown,标题用 # / ##,不要 markdown 围栏,不要输出 JSON,不写「数据」二字。
|
||||||
|
- 不给诊断意见、用药建议或「建议就医」。全文中文,简洁,医生 30 秒能扫完。
|
||||||
|
- 严格按以下 6 段(顺序与标题固定):
|
||||||
\(labelLine)
|
\(labelLine)
|
||||||
## 主诉
|
## 主诉
|
||||||
## 患者背景
|
## 患者背景
|
||||||
@@ -79,12 +84,33 @@ enum HealthExportPrompts {
|
|||||||
## 在服药与过敏
|
## 在服药与过敏
|
||||||
## 患者疑问
|
## 患者疑问
|
||||||
|
|
||||||
数据:
|
—— 格式示例(只示范「无记录」与数值写法,内容请勿照抄)——
|
||||||
|
真实数据:{"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)
|
\(dataJSON)
|
||||||
|
|
||||||
患者原话:\(userPrompt)
|
【患者原话】:\(userPrompt)
|
||||||
|
|
||||||
现在请生成 Markdown(直接输出,不要思考过程,不要 <think> 标签):
|
再次强调:只整理上面【真实数据】里真实出现过的内容,禁止编造任何数字/日期/症状/药物。
|
||||||
|
直接输出 Markdown,不要思考过程,不要 <think> 标签:
|
||||||
/no_think
|
/no_think
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,23 +23,46 @@ struct KangkangApp: App {
|
|||||||
do {
|
do {
|
||||||
return try ModelContainer(for: schema, configurations: [config])
|
return try ModelContainer(for: schema, configurations: [config])
|
||||||
} catch {
|
} catch {
|
||||||
// Demo 阶段 schema 仍在演进:旧 store 与新 schema 不兼容且无正式迁移时,
|
// Demo 阶段 schema 仍在演进:某次改动若超出 SwiftData 自动轻量迁移能力
|
||||||
// 自动迁移会失败导致启动崩溃。这里重置本地 store 重建(测试数据可丢)。
|
// (最常见:给已存在的 @Model 新增「非可选且无内联默认值」的属性),自动迁移会抛错。
|
||||||
// ⚠️ 生产环境必须改为正式的 SwiftData 迁移方案,不能静默删数据。
|
// 这里不再静默删库,而是把旧 store 连同 -wal/-shm 整体挪到带时间戳的备份目录后重建——
|
||||||
print("⚠️ ModelContainer 创建失败,重置本地 store 重建: \(error)")
|
// 既保证 App 能启动,又让旧数据可手动恢复(挪不动才降级为删除)。
|
||||||
let fm = FileManager.default
|
// ⚠️ 正式发布前仍应改为 VersionedSchema + SchemaMigrationPlan 的正式迁移。
|
||||||
let storePath = config.url.path
|
// 注:新增 @Model 属性请一律给「可选」或「内联默认值」,即可走轻量迁移、不触发本兜底。
|
||||||
for path in [storePath, storePath + "-wal", storePath + "-shm"] {
|
print("⚠️ ModelContainer 创建失败,备份旧 store 后重建: \(error)")
|
||||||
try? fm.removeItem(atPath: path)
|
KangkangApp.backupIncompatibleStore(at: config.url)
|
||||||
}
|
|
||||||
do {
|
do {
|
||||||
return try ModelContainer(for: schema, configurations: [config])
|
return try ModelContainer(for: schema, configurations: [config])
|
||||||
} catch {
|
} catch {
|
||||||
fatalError("Could not create ModelContainer even after reset: \(error)")
|
fatalError("Could not create ModelContainer even after store reset: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// 把与新 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)
|
||||||
|
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)
|
||||||
|
} catch {
|
||||||
|
try? fm.removeItem(at: src) // 挪不动就删,至少保证能启动
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
AppLockContainer {
|
AppLockContainer {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 807 KiB After Width: | Height: | Size: 511 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 990 B |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 807 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 807 KiB After Width: | Height: | Size: 68 KiB |
@@ -19,6 +19,7 @@ struct ArchiveListView: View {
|
|||||||
|
|
||||||
@State private var filter: TimelineKind? = nil
|
@State private var filter: TimelineKind? = nil
|
||||||
@State private var endingSymptom: Symptom?
|
@State private var endingSymptom: Symptom?
|
||||||
|
@State private var selectedEntry: TimelineEntry?
|
||||||
@State private var showExportSheet = false
|
@State private var showExportSheet = false
|
||||||
@State private var showExportList = false
|
@State private var showExportList = false
|
||||||
|
|
||||||
@@ -85,6 +86,11 @@ struct ArchiveListView: View {
|
|||||||
.sheet(item: $endingSymptom) { sym in
|
.sheet(item: $endingSymptom) { sym in
|
||||||
SymptomEndSheet(symptom: sym)
|
SymptomEndSheet(symptom: sym)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $selectedEntry) { entry in
|
||||||
|
if let d = detail(for: entry) {
|
||||||
|
TimelineEntryDetailView(detail: d)
|
||||||
|
}
|
||||||
|
}
|
||||||
.fullScreenCover(isPresented: $showExportSheet) {
|
.fullScreenCover(isPresented: $showExportSheet) {
|
||||||
HealthExportSheet()
|
HealthExportSheet()
|
||||||
}
|
}
|
||||||
@@ -94,6 +100,7 @@ struct ArchiveListView: View {
|
|||||||
private func rowView(for entry: TimelineEntry) -> some View {
|
private func rowView(for entry: TimelineEntry) -> some View {
|
||||||
if entry.kind == .symptom, entry.isOngoing,
|
if entry.kind == .symptom, entry.isOngoing,
|
||||||
let sym = symptoms.first(where: { "symptom-\($0.persistentModelID)" == entry.id }) {
|
let sym = symptoms.first(where: { "symptom-\($0.persistentModelID)" == entry.id }) {
|
||||||
|
// 进行中症状:点 → 标记结束 sheet(沿用原交互)
|
||||||
Button {
|
Button {
|
||||||
endingSymptom = sym
|
endingSymptom = sym
|
||||||
} label: {
|
} label: {
|
||||||
@@ -101,8 +108,43 @@ struct ArchiveListView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
} else {
|
} else {
|
||||||
|
// 其余条目(报告/指标/日记/已结束症状):点 → 只读详情
|
||||||
|
Button {
|
||||||
|
if detail(for: entry) != nil { selectedEntry = entry }
|
||||||
|
} label: {
|
||||||
TimelineRow(entry: entry)
|
TimelineRow(entry: entry)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把时间线条目反查回源记录(id 形如 `<kind>-<persistentModelID>` / `bp-<sys>-<dia>`)。
|
||||||
|
private func detail(for entry: TimelineEntry) -> 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)-") }) {
|
||||||
|
let dia = indicators.first {
|
||||||
|
$0.seriesKey == "bp.diastolic" &&
|
||||||
|
abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5
|
||||||
|
}
|
||||||
|
return .bloodPressure(sys: sys, dia: dia)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ struct DiaryQuickSheet: View {
|
|||||||
/// 累积已覆盖的问诊维度(question.dim),回传下一轮 prompt 用于按维度去重。
|
/// 累积已覆盖的问诊维度(question.dim),回传下一轮 prompt 用于按维度去重。
|
||||||
@State private var coveredDims: Set<String> = []
|
@State private var coveredDims: Set<String> = []
|
||||||
@State private var suggestTask: Task<Void, Never>?
|
@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,确保建议面板有足够展示空间。
|
/// sheet detent。默认 large,确保建议面板有足够展示空间。
|
||||||
/// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。
|
/// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。
|
||||||
@State private var detent: PresentationDetent = .large
|
@State private var detent: PresentationDetent = .large
|
||||||
@@ -76,6 +82,7 @@ struct DiaryQuickSheet: View {
|
|||||||
text: $content, axis: .vertical)
|
text: $content, axis: .vertical)
|
||||||
.lineLimit(3...8)
|
.lineLimit(3...8)
|
||||||
.focused($contentFocused)
|
.focused($contentFocused)
|
||||||
|
.onChange(of: content) { _, _ in exhaustedNote = false }
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(
|
.background(
|
||||||
@@ -177,6 +184,19 @@ struct DiaryQuickSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
phaseFooter
|
||||||
}
|
}
|
||||||
@@ -318,6 +338,7 @@ struct DiaryQuickSheet: View {
|
|||||||
|
|
||||||
private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View {
|
private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View {
|
||||||
let adopted = question.adopted
|
let adopted = question.adopted
|
||||||
|
let filling = fillingId == question.id
|
||||||
return VStack(alignment: .leading, spacing: 6) {
|
return VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
Text("\(index).")
|
Text("\(index).")
|
||||||
@@ -341,7 +362,7 @@ struct DiaryQuickSheet: View {
|
|||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 5)
|
||||||
.background(Capsule().fill(Tj.Palette.leafSoft))
|
.background(Capsule().fill(Tj.Palette.leafSoft))
|
||||||
} else {
|
} else if !filling {
|
||||||
Button { adopt(question) } label: {
|
Button { adopt(question) } label: {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
@@ -357,7 +378,14 @@ struct DiaryQuickSheet: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !question.fill.isEmpty && !adopted {
|
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) {
|
HStack(alignment: .top, spacing: 4) {
|
||||||
Text("将追加:")
|
Text("将追加:")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
@@ -405,6 +433,7 @@ struct DiaryQuickSheet: View {
|
|||||||
detent = .large
|
detent = .large
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
exhaustedNote = false
|
||||||
phase = .loading
|
phase = .loading
|
||||||
suggestTask = Task { @MainActor in
|
suggestTask = Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
@@ -413,21 +442,34 @@ struct DiaryQuickSheet: View {
|
|||||||
coveredDimensions: covered
|
coveredDimensions: covered
|
||||||
)
|
)
|
||||||
if Task.isCancelled { return }
|
if Task.isCancelled { return }
|
||||||
// 客户端字面兜底(防 LLM 不听话);跨轮去重主要靠 prompt 的维度排除。
|
// 客户端硬去重(不依赖 1.7B 听话):
|
||||||
let existing = Set(questions.map { Self.normalize($0.q) })
|
// ① 维度已在往轮覆盖 → 丢;② 本轮内维度重复 → 丢;③ 文本与已有近似 → 丢。
|
||||||
|
let coveredSnapshot = coveredDims
|
||||||
|
var acceptedNorms = questions.map { Self.normalize($0.q) }
|
||||||
|
var batchDims = Set<String>()
|
||||||
let nextRound = currentRound + 1
|
let nextRound = currentRound + 1
|
||||||
let fresh = result.questions
|
let fresh = result.questions.compactMap { q -> DiaryAssistService.Question? in
|
||||||
.filter { !existing.contains(Self.normalize($0.q)) }
|
let dim = q.dim.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.map { q -> DiaryAssistService.Question in
|
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
|
var stamped = q
|
||||||
stamped.round = nextRound
|
stamped.round = nextRound
|
||||||
return stamped
|
return stamped
|
||||||
}
|
}
|
||||||
withAnimation(.snappy(duration: 0.2)) {
|
withAnimation(.snappy(duration: 0.2)) {
|
||||||
|
if fresh.isEmpty {
|
||||||
|
exhaustedNote = true // 这轮没问出任何新维度
|
||||||
|
} else {
|
||||||
questions.append(contentsOf: fresh)
|
questions.append(contentsOf: fresh)
|
||||||
for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) }
|
for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) }
|
||||||
lastRate = result.decodeRate
|
|
||||||
currentRound = nextRound
|
currentRound = nextRound
|
||||||
|
exhaustedNote = false
|
||||||
|
}
|
||||||
|
lastRate = result.decodeRate
|
||||||
phase = .ready
|
phase = .ready
|
||||||
}
|
}
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
@@ -449,20 +491,59 @@ struct DiaryQuickSheet: View {
|
|||||||
.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() {
|
private func cancelSuggestions() {
|
||||||
suggestTask?.cancel()
|
suggestTask?.cancel()
|
||||||
phase = hasQuestions ? .ready : .idle
|
phase = hasQuestions ? .ready : .idle
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把 question.fill 追加到 textfield 末尾,并把该 question 标记为 adopted。
|
/// 采纳:模板含 `[占位]` 时展开就地填空面板;无占位则直接把整句追加(并标记 adopted)。
|
||||||
/// 已采纳的 q 不会从列表里消失;其维度已在生成时计入 coveredDims,下一轮 prompt 会避开。
|
/// 已采纳的 q 不会从列表里消失;其维度已在生成时计入 coveredDims,下一轮 prompt 会避开。
|
||||||
private func adopt(_ question: DiaryAssistService.Question) {
|
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 }) {
|
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
|
||||||
withAnimation(.snappy(duration: 0.18)) {
|
withAnimation(.snappy(duration: 0.18)) {
|
||||||
questions[idx].adopted = true
|
questions[idx].adopted = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let toAppend = question.fill.isEmpty ? question.q : question.fill
|
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)
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed.isEmpty {
|
if trimmed.isEmpty {
|
||||||
content = toAppend
|
content = toAppend
|
||||||
|
|||||||
235
康康/Features/Diary/QuestionFillPanel.swift
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// AI 补充句模板(如「症状从 [时间] 开始,」)的一个片段:字面文本或待填占位槽。
|
||||||
|
enum FillSegment: Equatable {
|
||||||
|
case literal(String)
|
||||||
|
/// `label` 为方括号内原文(如 "时间" / "活动/休息");
|
||||||
|
/// `options` 为可一键填充的短词候选(`/` 分隔且都短时才有,否则空)。
|
||||||
|
case slot(label: String, options: [String])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把 `fill` 模板解析成有序片段、组装回填好的句子。纯值逻辑,便于复用与单测。
|
||||||
|
enum DiaryFillTemplate {
|
||||||
|
|
||||||
|
/// 解析模板为有序片段。无方括号时返回单个 `.literal`。
|
||||||
|
static func parse(_ template: String) -> [FillSegment] {
|
||||||
|
let chars = Array(template)
|
||||||
|
var segs: [FillSegment] = []
|
||||||
|
var i = 0
|
||||||
|
var literalStart = 0
|
||||||
|
func flushLiteral(upTo end: Int) {
|
||||||
|
if end > literalStart { segs.append(.literal(String(chars[literalStart..<end]))) }
|
||||||
|
}
|
||||||
|
while i < chars.count {
|
||||||
|
if chars[i] == "[",
|
||||||
|
let close = (i + 1 ..< chars.count).first(where: { chars[$0] == "]" }) {
|
||||||
|
flushLiteral(upTo: i)
|
||||||
|
let inner = String(chars[(i + 1)..<close])
|
||||||
|
segs.append(.slot(label: inner, options: options(from: inner)))
|
||||||
|
i = close + 1
|
||||||
|
literalStart = i
|
||||||
|
} else {
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushLiteral(upTo: chars.count)
|
||||||
|
return segs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 占位内 `/` 分隔、每段都短(≤5 字)、且 ≥2 段时,视为可点选的快填候选。
|
||||||
|
private static func options(from inner: String) -> [String] {
|
||||||
|
let tokens = inner.split(separator: "/")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
guard tokens.count >= 2, tokens.allSatisfy({ $0.count <= 5 }) else { return [] }
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模板里的占位槽数量。
|
||||||
|
static func slotCount(_ template: String) -> Int {
|
||||||
|
parse(template).reduce(0) { acc, seg in
|
||||||
|
if case .slot = seg { return acc + 1 }
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用 `values` 填充各槽组装成句:已填用输入值,留空回退为方括号内原文(去方括号,读起来仍自然)。
|
||||||
|
static func assemble(_ template: String, values: [String]) -> String {
|
||||||
|
var out = ""
|
||||||
|
var idx = 0
|
||||||
|
for seg in parse(template) {
|
||||||
|
switch seg {
|
||||||
|
case .literal(let t):
|
||||||
|
out += t
|
||||||
|
case .slot(let label, _):
|
||||||
|
let v = idx < values.count
|
||||||
|
? values[idx].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||||
|
out += v.isEmpty ? label : v
|
||||||
|
idx += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「采纳即就地填空」面板:每个 `[占位]` 一个输入框 + 快填 chip,顶部实时预览整句,
|
||||||
|
/// 底部「加入记录 / 取消」。确认时回传**填好的、无方括号**的整句。
|
||||||
|
struct QuestionFillPanel: View {
|
||||||
|
let template: String
|
||||||
|
@Binding var values: [String]
|
||||||
|
let onCommit: (String) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
private var segments: [FillSegment] { DiaryFillTemplate.parse(template) }
|
||||||
|
|
||||||
|
/// 抽出占位槽 + 其在 values 里的下标。
|
||||||
|
private var slots: [(index: Int, label: String, options: [String])] {
|
||||||
|
var result: [(Int, String, [String])] = []
|
||||||
|
var i = 0
|
||||||
|
for seg in segments {
|
||||||
|
if case let .slot(label, options) = seg {
|
||||||
|
result.append((i, label, options))
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
// 实时预览:已填值高亮,未填槽浅色下划线提示。
|
||||||
|
previewText
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.sand2)
|
||||||
|
)
|
||||||
|
|
||||||
|
ForEach(slots, id: \.index) { slot in
|
||||||
|
slotEditor(index: slot.index, label: slot.label, options: slot.options)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Text("取消")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
// 背景仅描边、内部透明:.plain 按钮的命中区会只剩文字本身,
|
||||||
|
// 中间透明区点不到。补 contentShape 让整框可点。
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onCommit(DiaryFillTemplate.assemble(template, values: values))
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: "text.append")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
Text("加入记录")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.ink)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, 22)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 子部件
|
||||||
|
|
||||||
|
/// 预览整句:literal 用正文色,已填值用 brick 加粗,未填槽用浅色下划线。
|
||||||
|
private var previewText: Text {
|
||||||
|
var result = Text("")
|
||||||
|
var idx = 0
|
||||||
|
for seg in segments {
|
||||||
|
switch seg {
|
||||||
|
case .literal(let t):
|
||||||
|
result = result + Text(t).foregroundStyle(Tj.Palette.text)
|
||||||
|
case .slot(let label, _):
|
||||||
|
let v = idx < values.count
|
||||||
|
? values[idx].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||||
|
if v.isEmpty {
|
||||||
|
result = result + Text(label).foregroundStyle(Tj.Palette.text3).underline()
|
||||||
|
} else {
|
||||||
|
result = result + Text(v).foregroundStyle(Tj.Palette.brick).fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
idx += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func slotEditor(index: Int, label: String, options: [String]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
|
||||||
|
if !options.isEmpty {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(options, id: \.self) { opt in
|
||||||
|
let picked = bindingValue(index) == opt
|
||||||
|
Button { values[index] = opt } label: {
|
||||||
|
Text(opt)
|
||||||
|
.font(.system(size: 12, weight: picked ? .semibold : .regular))
|
||||||
|
.foregroundStyle(picked ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(picked ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Capsule().strokeBorder(Tj.Palette.line,
|
||||||
|
lineWidth: picked ? 0 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField(String(appLoc: "填写\(label)"), text: binding(index))
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bindingValue(_ i: Int) -> String {
|
||||||
|
i < values.count ? values[i] : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func binding(_ i: Int) -> Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { i < values.count ? values[i] : "" },
|
||||||
|
set: { if i < values.count { values[i] = $0 } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ struct HomeView: View {
|
|||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
.padding(.bottom, 18)
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
TodayRemindersCard()
|
||||||
|
|
||||||
OngoingSymptomsCard()
|
OngoingSymptomsCard()
|
||||||
.padding(.bottom, 18)
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
|||||||
118
康康/Features/Home/TodayRemindersCard.swift
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// 主页「今日提醒」卡:汇总今天会触发的自由提醒(CustomReminder)+ 指标提醒(MetricReminder),
|
||||||
|
/// 按时间升序展示;已过点的行淡化(只表示「时间已过」,不代表已完成——本期不追踪打卡)。
|
||||||
|
/// 今天没有任何提醒 → 整卡隐藏(返回 EmptyView,与「持续中症状」卡同款)。
|
||||||
|
/// 卡内只读;点右上「全部 ›」打开提醒中心(RemindersListView)管理。
|
||||||
|
struct TodayRemindersCard: View {
|
||||||
|
@Query(sort: \CustomReminder.updatedAt, order: .reverse)
|
||||||
|
private var customReminders: [CustomReminder]
|
||||||
|
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
|
||||||
|
private var metricReminders: [MetricReminder]
|
||||||
|
|
||||||
|
@State private var showingCenter = false
|
||||||
|
/// 每分钟自走一次,用于刷新「今天」判定与「已过点」淡化(与 OngoingSymptomsCard 同款)。
|
||||||
|
@State private var tick: Date = .now
|
||||||
|
private let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
/// 今天会触发的提醒,自由提醒 + 指标提醒合并成统一行模型,按时间升序。
|
||||||
|
private var items: [TodayItem] {
|
||||||
|
let cal = Calendar.current
|
||||||
|
var arr: [TodayItem] = []
|
||||||
|
for r in customReminders where r.occurs(on: tick, calendar: cal) {
|
||||||
|
arr.append(TodayItem(id: "c-\(r.id.uuidString)",
|
||||||
|
hour: r.hour, minute: r.minute, title: r.title))
|
||||||
|
}
|
||||||
|
for r in metricReminders where r.occurs(on: tick, calendar: cal) {
|
||||||
|
arr.append(TodayItem(id: "m-\(r.metricId)",
|
||||||
|
hour: r.hour, minute: r.minute, title: r.displayName))
|
||||||
|
}
|
||||||
|
return arr.sorted { ($0.hour, $0.minute) < ($1.hour, $1.minute) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let rows = items
|
||||||
|
if rows.isEmpty {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
header(count: rows.count)
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(rows) { row($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
.onReceive(timer) { now in tick = now }
|
||||||
|
.sheet(isPresented: $showingCenter) {
|
||||||
|
// 列表页依赖外层 NavigationStack 提供标题栏;sheet 形态补「完成」按钮。
|
||||||
|
NavigationStack { RemindersListView(presentedAsSheet: true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func header(count: Int) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(Tj.Palette.amber)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
Text("今日提醒")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("\(count) 项")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
Button { showingCenter = true } label: {
|
||||||
|
Text("全部 ›")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ item: TodayItem) -> some View {
|
||||||
|
let isPast = item.isPast(now: tick)
|
||||||
|
return HStack(spacing: 12) {
|
||||||
|
Text(item.timeLabel)
|
||||||
|
.font(.system(size: 14, weight: .semibold).monospacedDigit())
|
||||||
|
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.ink)
|
||||||
|
.frame(width: 46, alignment: .leading)
|
||||||
|
Image(systemName: "bell.fill")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.amber)
|
||||||
|
Text(item.title)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04),
|
||||||
|
radius: 2, x: 0, y: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「今日提醒」行的统一展示模型(自由提醒与指标提醒共用)。
|
||||||
|
private struct TodayItem: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let hour: Int
|
||||||
|
let minute: Int
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
var timeLabel: String { String(format: "%02d:%02d", hour, minute) }
|
||||||
|
|
||||||
|
/// 该提醒的时分是否早于此刻(同一天内「已过点」)。
|
||||||
|
func isPast(now: Date) -> Bool {
|
||||||
|
let c = Calendar.current.dateComponents([.hour, .minute], from: now)
|
||||||
|
let nowMinutes = (c.hour ?? 0) * 60 + (c.minute ?? 0)
|
||||||
|
return hour * 60 + minute < nowMinutes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,10 +14,16 @@ struct CustomReminderEditSheet: View {
|
|||||||
@State private var title = ""
|
@State private var title = ""
|
||||||
@State private var note = ""
|
@State private var note = ""
|
||||||
@State private var pickedTime: Date = .now
|
@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 weekdays: Set<Int> = Set(1...7)
|
||||||
|
@State private var dayOfMonth = 1
|
||||||
|
@State private var month = 1
|
||||||
@State private var hydrated = false
|
@State private var hydrated = false
|
||||||
@State private var showAuthDeniedAlert = 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) {
|
init(reminder: CustomReminder? = nil) {
|
||||||
self.reminder = reminder
|
self.reminder = reminder
|
||||||
}
|
}
|
||||||
@@ -26,7 +32,11 @@ struct CustomReminderEditSheet: View {
|
|||||||
private var trimmedTitle: String {
|
private var trimmedTitle: String {
|
||||||
title.trimmingCharacters(in: .whitespacesAndNewlines)
|
title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
private var canSave: Bool { !trimmedTitle.isEmpty && !weekdays.isEmpty }
|
private var canSave: Bool {
|
||||||
|
guard !trimmedTitle.isEmpty else { return false }
|
||||||
|
if frequency == .weekly { return !weekdays.isEmpty }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -41,14 +51,26 @@ struct CustomReminderEditSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
DatePicker(String(appLoc: "时间"), selection: $pickedTime,
|
Picker(String(appLoc: "重复"), selection: $frequency) {
|
||||||
displayedComponents: .hourAndMinute)
|
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 {
|
Section {
|
||||||
weekdayRow
|
timePresetRow
|
||||||
|
DatePicker(String(appLoc: "时间"), selection: $pickedTime,
|
||||||
|
displayedComponents: .hourAndMinute)
|
||||||
} header: {
|
} header: {
|
||||||
Text("重复")
|
Text("时间")
|
||||||
}
|
}
|
||||||
|
|
||||||
if isEditing {
|
if isEditing {
|
||||||
@@ -74,6 +96,11 @@ struct CustomReminderEditSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear(perform: hydrate)
|
.onAppear(perform: hydrate)
|
||||||
|
.onChange(of: month) { _, newMonth in
|
||||||
|
// 切月份后,把超出该月最大天数的「日」收回(避免「2月31日」这种永不触发的组合)。
|
||||||
|
let maxD = Self.daysInMonth(newMonth)
|
||||||
|
if dayOfMonth > maxD { dayOfMonth = maxD }
|
||||||
|
}
|
||||||
.alert(String(appLoc: "通知未开启"), isPresented: $showAuthDeniedAlert) {
|
.alert(String(appLoc: "通知未开启"), isPresented: $showAuthDeniedAlert) {
|
||||||
Button(String(appLoc: "好")) { dismiss() }
|
Button(String(appLoc: "好")) { dismiss() }
|
||||||
} message: {
|
} message: {
|
||||||
@@ -82,6 +109,84 @@ struct CustomReminderEditSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 同款)
|
// MARK: - 周几选择(与 RemindersListView 同款)
|
||||||
|
|
||||||
private var weekdayRow: some View {
|
private var weekdayRow: some View {
|
||||||
@@ -124,7 +229,10 @@ struct CustomReminderEditSheet: View {
|
|||||||
if let r = reminder {
|
if let r = reminder {
|
||||||
title = r.title
|
title = r.title
|
||||||
note = r.note
|
note = r.note
|
||||||
|
frequency = r.frequency
|
||||||
weekdays = Set(r.weekdays)
|
weekdays = Set(r.weekdays)
|
||||||
|
dayOfMonth = r.dayOfMonth
|
||||||
|
month = r.month
|
||||||
pickedTime = Calendar.current.date(
|
pickedTime = Calendar.current.date(
|
||||||
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
|
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
|
||||||
) ?? .now
|
) ?? .now
|
||||||
@@ -145,6 +253,9 @@ struct CustomReminderEditSheet: View {
|
|||||||
r.hour = hour
|
r.hour = hour
|
||||||
r.minute = minute
|
r.minute = minute
|
||||||
r.weekdays = sortedDays
|
r.weekdays = sortedDays
|
||||||
|
r.frequency = frequency
|
||||||
|
r.dayOfMonth = dayOfMonth
|
||||||
|
r.month = month
|
||||||
r.updatedAt = .now
|
r.updatedAt = .now
|
||||||
target = r
|
target = r
|
||||||
} else {
|
} else {
|
||||||
@@ -153,7 +264,10 @@ struct CustomReminderEditSheet: View {
|
|||||||
note: note.trimmingCharacters(in: .whitespacesAndNewlines),
|
note: note.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
hour: hour,
|
hour: hour,
|
||||||
minute: minute,
|
minute: minute,
|
||||||
weekdays: sortedDays
|
weekdays: sortedDays,
|
||||||
|
frequency: frequency,
|
||||||
|
dayOfMonth: dayOfMonth,
|
||||||
|
month: month
|
||||||
)
|
)
|
||||||
ctx.insert(new)
|
ctx.insert(new)
|
||||||
target = new
|
target = new
|
||||||
|
|||||||
295
康康/Features/Timeline/TimelineEntryDetailView.swift
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 时间线条目的只读详情:展示该记录的完整字段。各类型一屏看完,不可编辑。
|
||||||
|
struct TimelineEntryDetailView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
let detail: TimelineDetail
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
bodyContent
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
15300
康康/Localizable.xcstrings
@@ -261,6 +261,13 @@ final class MetricReminder {
|
|||||||
var timeLabel: String {
|
var timeLabel: String {
|
||||||
String(format: "%02d:%02d", hour, minute)
|
String(format: "%02d:%02d", hour, minute)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 这条指标提醒在给定日期「这天」是否会触发(weekday 制,全 7 = 每天);关闭则恒为 false。
|
||||||
|
/// 供主页「今日提醒」筛选。
|
||||||
|
func occurs(on date: Date, calendar: Calendar = .current) -> Bool {
|
||||||
|
guard enabled else { return false }
|
||||||
|
return weekdays.contains(calendar.component(.weekday, from: date))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 自由文案的周期性提醒(如「每天 20:00 跑步 5 公里」「每天 12:30 吃 2 片护肝片」)。
|
/// 自由文案的周期性提醒(如「每天 20:00 跑步 5 公里」「每天 12:30 吃 2 片护肝片」)。
|
||||||
@@ -269,12 +276,20 @@ final class MetricReminder {
|
|||||||
/// 周期粒度沿用 weekday 约定(全 7 = 每天);本地通知调度见 `ReminderService`。
|
/// 周期粒度沿用 weekday 约定(全 7 = 每天);本地通知调度见 `ReminderService`。
|
||||||
@Model
|
@Model
|
||||||
final class CustomReminder {
|
final class CustomReminder {
|
||||||
|
/// 周期粒度。每日只看时间;每周看 weekdays;每月看 dayOfMonth;每年看 month + dayOfMonth。
|
||||||
|
enum Frequency: String, CaseIterable, Sendable {
|
||||||
|
case daily, weekly, monthly, yearly
|
||||||
|
}
|
||||||
|
|
||||||
@Attribute(.unique) var id: UUID
|
@Attribute(.unique) var id: UUID
|
||||||
var title: String // 用户文案,如 "跑步5公里"
|
var title: String // 用户文案,如 "跑步5公里"
|
||||||
var note: String // 可选备注 → 通知正文
|
var note: String // 可选备注 → 通知正文
|
||||||
var hour: Int // 0...23
|
var hour: Int // 0...23
|
||||||
var minute: Int // 0...59
|
var minute: Int // 0...59
|
||||||
var weekdays: [Int] // iOS Calendar 约定:1=日, 2=一, ..., 7=六。全 7 个 = 每天
|
var weekdays: [Int] // iOS Calendar 约定:1=日, 2=一, ..., 7=六。全 7 个 = 每天
|
||||||
|
var frequencyRaw: String = "daily" // CustomReminder.Frequency 原始值
|
||||||
|
var dayOfMonth: Int = 1 // monthly / yearly 用,1...31
|
||||||
|
var month: Int = 1 // yearly 用,1...12
|
||||||
var enabled: Bool
|
var enabled: Bool
|
||||||
var createdAt: Date
|
var createdAt: Date
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
@@ -285,6 +300,9 @@ final class CustomReminder {
|
|||||||
hour: Int = 8,
|
hour: Int = 8,
|
||||||
minute: Int = 0,
|
minute: Int = 0,
|
||||||
weekdays: [Int] = [1, 2, 3, 4, 5, 6, 7],
|
weekdays: [Int] = [1, 2, 3, 4, 5, 6, 7],
|
||||||
|
frequency: Frequency = .daily,
|
||||||
|
dayOfMonth: Int = 1,
|
||||||
|
month: Int = 1,
|
||||||
enabled: Bool = true,
|
enabled: Bool = true,
|
||||||
createdAt: Date = .now) {
|
createdAt: Date = .now) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -293,6 +311,9 @@ final class CustomReminder {
|
|||||||
self.hour = max(0, min(23, hour))
|
self.hour = max(0, min(23, hour))
|
||||||
self.minute = max(0, min(59, minute))
|
self.minute = max(0, min(59, minute))
|
||||||
self.weekdays = weekdays
|
self.weekdays = weekdays
|
||||||
|
self.frequencyRaw = frequency.rawValue
|
||||||
|
self.dayOfMonth = max(1, min(31, dayOfMonth))
|
||||||
|
self.month = max(1, min(12, month))
|
||||||
self.enabled = enabled
|
self.enabled = enabled
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = createdAt
|
self.updatedAt = createdAt
|
||||||
@@ -300,19 +321,46 @@ final class CustomReminder {
|
|||||||
|
|
||||||
var isEveryDay: Bool { Set(weekdays) == Set(1...7) }
|
var isEveryDay: Bool { Set(weekdays) == Set(1...7) }
|
||||||
|
|
||||||
/// 与 MetricReminder.frequencyLabel 同款,复用同一批本地化 key。
|
var frequency: Frequency {
|
||||||
|
get { Frequency(rawValue: frequencyRaw) ?? .daily }
|
||||||
|
set { frequencyRaw = newValue.rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表行副标题:按频率展示「每天 / 每周 一三五 / 每月15日 / 每年3月15日」。
|
||||||
var frequencyLabel: String {
|
var frequencyLabel: String {
|
||||||
if !enabled { return String(appLoc: "已关闭") }
|
if !enabled { return String(appLoc: "已关闭") }
|
||||||
|
switch frequency {
|
||||||
|
case .daily:
|
||||||
|
return String(appLoc: "每天")
|
||||||
|
case .weekly:
|
||||||
if isEveryDay { return String(appLoc: "每天") }
|
if isEveryDay { return String(appLoc: "每天") }
|
||||||
if weekdays.isEmpty { return String(appLoc: "未选日") }
|
if weekdays.isEmpty { return String(appLoc: "未选日") }
|
||||||
let names = [String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六")]
|
let names = [String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六")]
|
||||||
let sorted = weekdays.sorted()
|
return String(appLoc: "每周 ") + weekdays.sorted().map { names[$0 - 1] }.joined()
|
||||||
return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined()
|
case .monthly:
|
||||||
|
return String(appLoc: "每月\(dayOfMonth)日")
|
||||||
|
case .yearly:
|
||||||
|
return String(appLoc: "每年\(month)月\(dayOfMonth)日")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var timeLabel: String {
|
var timeLabel: String {
|
||||||
String(format: "%02d:%02d", hour, minute)
|
String(format: "%02d:%02d", hour, minute)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 这条提醒在给定日期「这天」是否会触发(只看哪天,不看时分);关闭则恒为 false。
|
||||||
|
/// 供主页「今日提醒」筛选。monthly/yearly 选了无此日的月份(如 31 日)自然返回 false,
|
||||||
|
/// 与 iOS「该月跳过、不顺延」的行为一致。
|
||||||
|
func occurs(on date: Date, calendar: Calendar = .current) -> Bool {
|
||||||
|
guard enabled else { return false }
|
||||||
|
let c = calendar.dateComponents([.weekday, .day, .month], from: date)
|
||||||
|
switch frequency {
|
||||||
|
case .daily: return true
|
||||||
|
case .weekly: return weekdays.contains(c.weekday ?? -1)
|
||||||
|
case .monthly: return dayOfMonth == (c.day ?? -1)
|
||||||
|
case .yearly: return month == (c.month ?? -1) && dayOfMonth == (c.day ?? -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Model
|
@Model
|
||||||
|
|||||||
@@ -85,6 +85,16 @@ struct HealthExportService {
|
|||||||
// —— Phase 3: 生成 ——
|
// —— Phase 3: 生成 ——
|
||||||
continuation.yield(.phaseChanged(.generating))
|
continuation.yield(.phaseChanged(.generating))
|
||||||
let dataJSON = Self.serializeData(snapshot: snapshot)
|
let dataJSON = Self.serializeData(snapshot: snapshot)
|
||||||
|
|
||||||
|
var generated = ""
|
||||||
|
var lastRate: Double = 0
|
||||||
|
|
||||||
|
if Self.isEffectivelyEmpty(snapshot) {
|
||||||
|
// 没有任何真实记录:跳过 LLM,直接产出确定性「无记录」摘要,
|
||||||
|
// 从根上杜绝小模型在空数据上编造病例(用户红线:严格按历史信息)。
|
||||||
|
generated = Self.fallbackReport(label: intent.labelCN, userPrompt: prompt)
|
||||||
|
continuation.yield(.token(TokenChunk(text: generated, decodeRate: 0)))
|
||||||
|
} else {
|
||||||
let genPrompt = HealthExportPrompts.reportGeneration(
|
let genPrompt = HealthExportPrompts.reportGeneration(
|
||||||
userPrompt: prompt,
|
userPrompt: prompt,
|
||||||
intentLabelCN: intent.labelCN,
|
intentLabelCN: intent.labelCN,
|
||||||
@@ -97,8 +107,6 @@ struct HealthExportService {
|
|||||||
// - thinking 阶段,UI 看到的 generated 始终为空
|
// - thinking 阶段,UI 看到的 generated 始终为空
|
||||||
// - 看到 </think> 后,真实内容流式出现
|
// - 看到 </think> 后,真实内容流式出现
|
||||||
var rawAccum = ""
|
var rawAccum = ""
|
||||||
var generated = ""
|
|
||||||
var lastRate: Double = 0
|
|
||||||
let stream = await AIRuntime.shared.generate(
|
let stream = await AIRuntime.shared.generate(
|
||||||
prompt: genPrompt,
|
prompt: genPrompt,
|
||||||
maxTokens: 1024
|
maxTokens: 1024
|
||||||
@@ -121,6 +129,7 @@ struct HealthExportService {
|
|||||||
generated = clean
|
generated = clean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
throw ServiceError.generationFailed("模型未输出任何内容")
|
throw ServiceError.generationFailed("模型未输出任何内容")
|
||||||
@@ -292,18 +301,21 @@ struct HealthExportService {
|
|||||||
)
|
)
|
||||||
let reports = Array(((try? ctx.fetch(reportDesc)) ?? []).prefix(8))
|
let reports = Array(((try? ctx.fetch(reportDesc)) ?? []).prefix(8))
|
||||||
|
|
||||||
// —— Diary(隐私过滤:必须有 symptom_keyword 命中,否则不入 prompt) ——
|
// —— Diary ——
|
||||||
let diaries: [DiaryEntry]
|
// 有具体症状词 → 按词过滤(targeted,保留隐私);
|
||||||
if intent.symptomKeywords.isEmpty {
|
// 无症状词(泛化请求,如「最近身体异常」)→ 纳入时间窗内最近 5 条日记。
|
||||||
diaries = []
|
// 之前「无词即清空」会让真实记录完全不进 prompt → 数据为空 → 小模型编造,是本次 bug 主因之一。
|
||||||
} else {
|
|
||||||
let diaryDesc = FetchDescriptor<DiaryEntry>(
|
let diaryDesc = FetchDescriptor<DiaryEntry>(
|
||||||
predicate: #Predicate { $0.createdAt >= fromDate && $0.createdAt <= toDate },
|
predicate: #Predicate { $0.createdAt >= fromDate && $0.createdAt <= toDate },
|
||||||
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
|
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
|
||||||
)
|
)
|
||||||
let all = (try? ctx.fetch(diaryDesc)) ?? []
|
let allDiaries = (try? ctx.fetch(diaryDesc)) ?? []
|
||||||
|
let diaries: [DiaryEntry]
|
||||||
|
if intent.symptomKeywords.isEmpty {
|
||||||
|
diaries = Array(allDiaries.prefix(5))
|
||||||
|
} else {
|
||||||
diaries = Array(
|
diaries = Array(
|
||||||
all.filter { d in
|
allDiaries.filter { d in
|
||||||
intent.symptomKeywords.contains { kw in
|
intent.symptomKeywords.contains { kw in
|
||||||
d.content.localizedCaseInsensitiveContains(kw)
|
d.content.localizedCaseInsensitiveContains(kw)
|
||||||
}
|
}
|
||||||
@@ -416,6 +428,56 @@ struct HealthExportService {
|
|||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 空数据兜底(杜绝编造)
|
||||||
|
|
||||||
|
/// 检索结果是否「实质为空」:无症状/指标/报告/日记,且 profile 也没有任何可写字段。
|
||||||
|
/// 为真时跳过 LLM,改用确定性「无记录」摘要,避免小模型凭先验编造病例。
|
||||||
|
static func isEffectivelyEmpty(_ s: Snapshot) -> Bool {
|
||||||
|
guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty, s.diaries.isEmpty else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let p = s.profile
|
||||||
|
return p.age == nil
|
||||||
|
&& p.sex == .undisclosed
|
||||||
|
&& p.heightCM == nil
|
||||||
|
&& p.weightKG == nil
|
||||||
|
&& p.bloodTypeRaw.isEmpty
|
||||||
|
&& p.allergies.isEmpty
|
||||||
|
&& p.chronicConditions.isEmpty
|
||||||
|
&& p.familyHistory.isEmpty
|
||||||
|
&& p.currentMedications.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 无真实记录时的确定性摘要:6 段全「无记录」,主诉仅照搬患者原话,不做任何推断。
|
||||||
|
static func fallbackReport(label: String, userPrompt: String) -> String {
|
||||||
|
let title = label.isEmpty ? "# 就诊摘要" : "# 就诊摘要 — \(label)"
|
||||||
|
let complaint = userPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let complaintLine = complaint.isEmpty ? "无记录" : complaint
|
||||||
|
return """
|
||||||
|
\(title)
|
||||||
|
|
||||||
|
> 本次未检索到可用的健康记录(指标 / 症状 / 报告 / 日记均为空),以下仅据患者原话,未做任何推断。
|
||||||
|
|
||||||
|
## 主诉
|
||||||
|
\(complaintLine)
|
||||||
|
|
||||||
|
## 患者背景
|
||||||
|
无记录
|
||||||
|
|
||||||
|
## 近期症状(按时间倒序)
|
||||||
|
无记录
|
||||||
|
|
||||||
|
## 关键指标(异常项优先)
|
||||||
|
无记录
|
||||||
|
|
||||||
|
## 在服药与过敏
|
||||||
|
无记录
|
||||||
|
|
||||||
|
## 患者疑问
|
||||||
|
无记录
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
/// 把 SwiftData persistentModelID 编成稳定字符串。
|
/// 把 SwiftData persistentModelID 编成稳定字符串。
|
||||||
|
|||||||
@@ -53,14 +53,16 @@ enum ReminderService {
|
|||||||
static func sync(_ reminder: MetricReminder) async {
|
static func sync(_ reminder: MetricReminder) async {
|
||||||
cancel(metricId: reminder.metricId)
|
cancel(metricId: reminder.metricId)
|
||||||
guard reminder.enabled else { return }
|
guard reminder.enabled else { return }
|
||||||
|
let slots = reminder.weekdays.map { wd in
|
||||||
|
Slot(suffix: "w\(wd)",
|
||||||
|
dc: DateComponents(hour: reminder.hour, minute: reminder.minute, weekday: wd))
|
||||||
|
}
|
||||||
await schedule(
|
await schedule(
|
||||||
idBase: "\(idPrefix)\(reminder.metricId)",
|
idBase: "\(idPrefix)\(reminder.metricId)",
|
||||||
title: String(appLoc: "该测\(reminder.displayName)了"),
|
title: String(appLoc: "该测\(reminder.displayName)了"),
|
||||||
body: String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"),
|
body: String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"),
|
||||||
hour: reminder.hour,
|
thread: "kangkang.reminder.\(reminder.metricId)",
|
||||||
minute: reminder.minute,
|
slots: slots
|
||||||
weekdays: reminder.weekdays,
|
|
||||||
thread: "kangkang.reminder.\(reminder.metricId)"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,14 +79,28 @@ enum ReminderService {
|
|||||||
guard reminder.enabled else { return }
|
guard reminder.enabled else { return }
|
||||||
let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines)
|
let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let h = reminder.hour, m = reminder.minute
|
||||||
|
let slots: [Slot]
|
||||||
|
switch reminder.frequency {
|
||||||
|
case .daily:
|
||||||
|
slots = [Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m))]
|
||||||
|
case .weekly:
|
||||||
|
slots = reminder.weekdays.map { wd in
|
||||||
|
Slot(suffix: "w\(wd)", dc: DateComponents(hour: h, minute: m, weekday: wd))
|
||||||
|
}
|
||||||
|
case .monthly:
|
||||||
|
slots = [Slot(suffix: "monthly",
|
||||||
|
dc: DateComponents(day: reminder.dayOfMonth, hour: h, minute: m))]
|
||||||
|
case .yearly:
|
||||||
|
slots = [Slot(suffix: "yearly",
|
||||||
|
dc: DateComponents(month: reminder.month, day: reminder.dayOfMonth, hour: h, minute: m))]
|
||||||
|
}
|
||||||
await schedule(
|
await schedule(
|
||||||
idBase: "\(customIdPrefix)\(reminder.id.uuidString)",
|
idBase: "\(customIdPrefix)\(reminder.id.uuidString)",
|
||||||
title: title.isEmpty ? String(appLoc: "提醒") : title,
|
title: title.isEmpty ? String(appLoc: "提醒") : title,
|
||||||
body: body.isEmpty ? String(appLoc: "到点啦,记得完成") : body,
|
body: body.isEmpty ? String(appLoc: "到点啦,记得完成") : body,
|
||||||
hour: reminder.hour,
|
thread: "\(customIdPrefix)\(reminder.id.uuidString)",
|
||||||
minute: reminder.minute,
|
slots: slots
|
||||||
weekdays: reminder.weekdays,
|
|
||||||
thread: "\(customIdPrefix)\(reminder.id.uuidString)"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,16 +116,20 @@ enum ReminderService {
|
|||||||
|
|
||||||
// MARK: - 共享调度核心
|
// MARK: - 共享调度核心
|
||||||
|
|
||||||
/// 把一条提醒按 weekdays 展开成 N 条 weekly-repeats 通知。
|
/// 一条触发槽:`suffix` 用于拼出稳定且可单独取消的通知 id(`<idBase>.<suffix>`,
|
||||||
/// `idBase` 是不含 `.w<weekday>` 后缀的稳定前缀;两类提醒共用本核心。
|
/// 如 `.daily` / `.w2` / `.monthly` / `.yearly`),`dc` 为对应的重复触发时间分量。
|
||||||
|
private struct Slot {
|
||||||
|
let suffix: String
|
||||||
|
let dc: DateComponents
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把若干 `Slot` 展开成 N 条 repeats 通知。每日/每周/每月/每年两类提醒共用本核心。
|
||||||
private static func schedule(idBase: String,
|
private static func schedule(idBase: String,
|
||||||
title: String,
|
title: String,
|
||||||
body: String,
|
body: String,
|
||||||
hour: Int,
|
thread: String,
|
||||||
minute: Int,
|
slots: [Slot]) async {
|
||||||
weekdays: [Int],
|
guard !slots.isEmpty else { return }
|
||||||
thread: String) async {
|
|
||||||
guard !weekdays.isEmpty else { return }
|
|
||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = title
|
content.title = title
|
||||||
@@ -117,23 +137,20 @@ enum ReminderService {
|
|||||||
content.sound = .default
|
content.sound = .default
|
||||||
content.threadIdentifier = thread
|
content.threadIdentifier = thread
|
||||||
|
|
||||||
for weekday in weekdays {
|
for slot in slots {
|
||||||
var comps = DateComponents()
|
let trigger = UNCalendarNotificationTrigger(dateMatching: slot.dc, repeats: true)
|
||||||
comps.hour = hour
|
let request = UNNotificationRequest(identifier: "\(idBase).\(slot.suffix)",
|
||||||
comps.minute = minute
|
|
||||||
comps.weekday = weekday
|
|
||||||
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
|
|
||||||
let request = UNNotificationRequest(identifier: "\(idBase).w\(weekday)",
|
|
||||||
content: content,
|
content: content,
|
||||||
trigger: trigger)
|
trigger: trigger)
|
||||||
try? await center.add(request)
|
try? await center.add(request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取消某个 idBase 下 7 个 weekday 的全部 pending 通知(不漏)。
|
/// 取消某个 idBase 下所有可能后缀的 pending 通知(daily/monthly/yearly + 7 个 weekday,不漏)。
|
||||||
private static func cancelBase(_ idBase: String) {
|
private static func cancelBase(_ idBase: String) {
|
||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
let ids = (1...7).map { "\(idBase).w\($0)" }
|
var ids = ["\(idBase).daily", "\(idBase).monthly", "\(idBase).yearly"]
|
||||||
|
ids += (1...7).map { "\(idBase).w\($0)" }
|
||||||
center.removePendingNotificationRequests(withIdentifiers: ids)
|
center.removePendingNotificationRequests(withIdentifiers: ids)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
康康Tests/TodayRemindersLogicTests.swift
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import 康康
|
||||||
|
|
||||||
|
/// 主页「今日提醒」筛选逻辑(`occurs(on:)`)的纯函数测试。
|
||||||
|
/// 用固定 Gregorian 日历构造确定日期,避免依赖 `Date.now` / 本机时区。
|
||||||
|
struct TodayRemindersLogicTests {
|
||||||
|
|
||||||
|
private var cal: Calendar {
|
||||||
|
var c = Calendar(identifier: .gregorian)
|
||||||
|
c.timeZone = TimeZone(identifier: "Asia/Shanghai")!
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
private func date(_ y: Int, _ mo: Int, _ d: Int) -> Date {
|
||||||
|
cal.date(from: DateComponents(year: y, month: mo, day: d, hour: 12))!
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CustomReminder
|
||||||
|
|
||||||
|
@Test func dailyOccursEveryDay() {
|
||||||
|
let r = CustomReminder(title: "跑步", frequency: .daily)
|
||||||
|
#expect(r.occurs(on: date(2026, 5, 30), calendar: cal))
|
||||||
|
#expect(r.occurs(on: date(2026, 1, 1), calendar: cal))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func disabledNeverOccurs() {
|
||||||
|
let r = CustomReminder(title: "跑步", frequency: .daily, enabled: false)
|
||||||
|
#expect(!r.occurs(on: date(2026, 5, 30), calendar: cal))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func weeklyOccursOnlyOnSelectedWeekdays() {
|
||||||
|
let d = date(2026, 5, 30)
|
||||||
|
let wd = cal.component(.weekday, from: d)
|
||||||
|
let other = wd == 1 ? 2 : 1
|
||||||
|
|
||||||
|
let hit = CustomReminder(title: "x", weekdays: [wd], frequency: .weekly)
|
||||||
|
#expect(hit.occurs(on: d, calendar: cal))
|
||||||
|
|
||||||
|
let miss = CustomReminder(title: "x", weekdays: [other], frequency: .weekly)
|
||||||
|
#expect(!miss.occurs(on: d, calendar: cal))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func monthlyOccursOnlyOnMatchingDay() {
|
||||||
|
let d = date(2026, 5, 30) // 30 日
|
||||||
|
#expect(CustomReminder(title: "x", frequency: .monthly, dayOfMonth: 30).occurs(on: d, calendar: cal))
|
||||||
|
#expect(!CustomReminder(title: "x", frequency: .monthly, dayOfMonth: 15).occurs(on: d, calendar: cal))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func monthlyDay31SkipsShortMonths() {
|
||||||
|
// 4 月只有 30 天:选「31 日」的提醒在 4/30 这天不应触发(无 4/31,该月跳过)。
|
||||||
|
let apr30 = date(2026, 4, 30)
|
||||||
|
let r = CustomReminder(title: "x", frequency: .monthly, dayOfMonth: 31)
|
||||||
|
#expect(!r.occurs(on: apr30, calendar: cal))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func yearlyOccursOnlyOnMatchingMonthAndDay() {
|
||||||
|
let d = date(2026, 5, 30)
|
||||||
|
#expect(CustomReminder(title: "x", frequency: .yearly, dayOfMonth: 30, month: 5).occurs(on: d, calendar: cal))
|
||||||
|
#expect(!CustomReminder(title: "x", frequency: .yearly, dayOfMonth: 30, month: 6).occurs(on: d, calendar: cal))
|
||||||
|
#expect(!CustomReminder(title: "x", frequency: .yearly, dayOfMonth: 29, month: 5).occurs(on: d, calendar: cal))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MetricReminder
|
||||||
|
|
||||||
|
@Test func metricReminderOccursOnSelectedWeekday() {
|
||||||
|
let d = date(2026, 5, 30)
|
||||||
|
let wd = cal.component(.weekday, from: d)
|
||||||
|
let other = wd == 1 ? 2 : 1
|
||||||
|
|
||||||
|
#expect(MetricReminder(metricId: "bp", displayName: "血压", weekdays: [wd]).occurs(on: d, calendar: cal))
|
||||||
|
#expect(!MetricReminder(metricId: "bp2", displayName: "血压", weekdays: [other]).occurs(on: d, calendar: cal))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func disabledMetricReminderNeverOccurs() {
|
||||||
|
let d = date(2026, 5, 30)
|
||||||
|
let wd = cal.component(.weekday, from: d)
|
||||||
|
let r = MetricReminder(metricId: "bp", displayName: "血压", weekdays: [wd], enabled: false)
|
||||||
|
#expect(!r.occurs(on: d, calendar: cal))
|
||||||
|
}
|
||||||
|
}
|
||||||