Files
kangkang/康康/Features/Trends/SeriesBucket.swift
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

177 lines
6.0 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 bpIndicators = bpKeys.flatMap { buckets[$0] ?? [] }
let bpHasEnoughPoints = bpIndicators.filter { $0.seriesKey == "bp.systolic" }.count >= 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: "收缩",
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: "舒张",
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
)
return SeriesBucket(
id: "bp",
title: "血压",
unit: "mmHg",
lines: [sysLine, diaLine],
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
}
}