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以扩大点击区域 - 修复提醒行展开按钮尺寸,保证不同提醒类型的垂直对齐 ```
178 lines
6.1 KiB
Swift
178 lines
6.1 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
import Foundation
|
|
|
|
/// 长期监测系列在 Trends 折线图里的展示桶。
|
|
/// 单系列(血糖/体重/...)= 1 个 SeriesLine;血压特殊 = 收缩 + 舒张 2 条线同卡。
|
|
struct SeriesBucket: Identifiable {
|
|
let id: String
|
|
let title: String
|
|
let unit: String
|
|
let lines: [SeriesLine]
|
|
let latestDate: Date
|
|
|
|
struct SeriesLine: Identifiable {
|
|
let id: String
|
|
let seriesKey: String
|
|
let label: String?
|
|
let color: Color
|
|
let points: [Point]
|
|
let referenceRange: ClosedRange<Double>?
|
|
|
|
var latestPoint: Point? { points.last }
|
|
}
|
|
|
|
struct Point: Identifiable, Hashable {
|
|
let id: String
|
|
let date: Date
|
|
let value: Double
|
|
let status: IndicatorStatus
|
|
}
|
|
}
|
|
|
|
extension SeriesBucket {
|
|
/// 把全表 Indicator(无 seriesKey 的会被跳过)折成 SeriesBucket 列表。
|
|
/// 同 seriesKey 内按 capturedAt 升序;BP 两个 key 合并成一个 bucket;
|
|
/// `minPoints` 以下的系列不返回,默认 2(单点不画线)。
|
|
static func build(from indicators: [Indicator],
|
|
profile: UserProfile? = nil,
|
|
customMetrics: [CustomMonitorMetric] = [],
|
|
minPoints: Int = 2) -> [SeriesBucket] {
|
|
var buckets: [String: [Indicator]] = [:]
|
|
for i in indicators {
|
|
guard let key = i.seriesKey, !key.isEmpty else { continue }
|
|
buckets[key, default: []].append(i)
|
|
}
|
|
|
|
// 合并血压
|
|
let bpKeys: Set<String> = ["bp.systolic", "bp.diastolic"]
|
|
let bpHasEnoughPoints = (buckets["bp.systolic"]?.count ?? 0) >= minPoints
|
|
|
|
var results: [SeriesBucket] = []
|
|
|
|
if bpHasEnoughPoints {
|
|
results.append(buildBP(from: buckets, profile: profile))
|
|
}
|
|
for k in bpKeys { buckets.removeValue(forKey: k) }
|
|
|
|
let customByKey: [String: CustomMonitorMetric] = Dictionary(
|
|
uniqueKeysWithValues: customMetrics.map { ($0.seriesKey, $0) }
|
|
)
|
|
|
|
for (key, items) in buckets {
|
|
guard items.count >= minPoints else { continue }
|
|
if let bucket = buildSingle(key: key, items: items,
|
|
profile: profile,
|
|
custom: customByKey[key]) {
|
|
results.append(bucket)
|
|
}
|
|
}
|
|
|
|
return results.sorted { $0.latestDate > $1.latestDate }
|
|
}
|
|
|
|
private static func buildSingle(key: String,
|
|
items: [Indicator],
|
|
profile: UserProfile?,
|
|
custom: CustomMonitorMetric? = nil) -> SeriesBucket? {
|
|
let sorted = items.sorted { $0.capturedAt < $1.capturedAt }
|
|
guard let latest = sorted.last else { return nil }
|
|
|
|
// 优先 custom,其次 builtin metric,最后 fallback 到 Indicator 自身
|
|
let metric = monitorMetric(for: key)
|
|
let field = metric?.fields.first { $0.seriesKey == key }
|
|
let title = custom?.name
|
|
?? metric?.displayName
|
|
?? sorted.first?.name
|
|
?? key
|
|
let unit = custom?.unit.nonEmptyOr(nil)
|
|
?? field?.unit
|
|
?? sorted.first?.unit
|
|
?? ""
|
|
let range = custom?.referenceRange
|
|
?? field.flatMap { metric?.effectiveRange(for: $0, profile: profile) }
|
|
|
|
let line = SeriesLine(
|
|
id: key,
|
|
seriesKey: key,
|
|
label: nil,
|
|
color: Tj.Palette.ink,
|
|
points: sorted.compactMap { point(from: $0) },
|
|
referenceRange: range
|
|
)
|
|
|
|
return SeriesBucket(
|
|
id: key,
|
|
title: title,
|
|
unit: unit,
|
|
lines: [line],
|
|
latestDate: latest.capturedAt
|
|
)
|
|
}
|
|
|
|
private static func buildBP(from buckets: [String: [Indicator]],
|
|
profile: UserProfile?) -> SeriesBucket {
|
|
let m = MonitorMetric.bloodPressure
|
|
let sysField = m.fields[0]
|
|
let diaField = m.fields[1]
|
|
|
|
let sysItems = (buckets["bp.systolic"] ?? []).sorted { $0.capturedAt < $1.capturedAt }
|
|
let diaItems = (buckets["bp.diastolic"] ?? []).sorted { $0.capturedAt < $1.capturedAt }
|
|
|
|
let sysLine = SeriesLine(
|
|
id: "bp.systolic",
|
|
seriesKey: "bp.systolic",
|
|
label: String(appLoc: "收缩"),
|
|
color: Tj.Palette.brick,
|
|
points: sysItems.compactMap { point(from: $0) },
|
|
referenceRange: m.effectiveRange(for: sysField, profile: profile)
|
|
)
|
|
let diaLine = SeriesLine(
|
|
id: "bp.diastolic",
|
|
seriesKey: "bp.diastolic",
|
|
label: String(appLoc: "舒张"),
|
|
color: Tj.Palette.leaf,
|
|
points: diaItems.compactMap { point(from: $0) },
|
|
referenceRange: m.effectiveRange(for: diaField, profile: profile)
|
|
)
|
|
|
|
let latest = max(
|
|
sysItems.last?.capturedAt ?? .distantPast,
|
|
diaItems.last?.capturedAt ?? .distantPast
|
|
)
|
|
|
|
// 只保留有数据点的线:某一路(收缩/舒张)为空时不画空线 + 残缺图例。
|
|
let lines = [sysLine, diaLine].filter { !$0.points.isEmpty }
|
|
return SeriesBucket(
|
|
id: "bp",
|
|
title: String(appLoc: "血压"),
|
|
unit: "mmHg",
|
|
lines: lines,
|
|
latestDate: latest
|
|
)
|
|
}
|
|
|
|
private static func point(from i: Indicator) -> Point? {
|
|
guard let v = Double(i.value.trimmingCharacters(in: .whitespaces)) else { return nil }
|
|
return Point(
|
|
id: "\(i.persistentModelID)",
|
|
date: i.capturedAt,
|
|
value: v,
|
|
status: i.status
|
|
)
|
|
}
|
|
|
|
private static func monitorMetric(for seriesKey: String) -> MonitorMetric? {
|
|
MonitorMetric.allCases.first { m in
|
|
m.fields.contains { $0.seriesKey == seriesKey }
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension String {
|
|
/// 空串 → fallback;非空 → 自身。
|
|
func nonEmptyOr(_ fallback: String?) -> String? {
|
|
trimmingCharacters(in: .whitespaces).isEmpty ? fallback : self
|
|
}
|
|
}
|