feat(capture): 统一报告捕获流程并集成视觉语言模型识别
- 替换 QuickCaptureFlow 和 ArchiveFlow 为 UnifiedCaptureFlow 统一流程 - 新增 VLSession 封装 Qwen2.5-VL 模型进行图像文本推理 - 实现 AIRuntime 中 VL 模型的准备和分析功能 - 添加 VLPrompts 定义体检化验单识别的 JSON 输出模板 - 创建 CaptureReviewForm 提供 VL 解析结果的可编辑表单界面 - 集成 VisionKit 文档扫描器支持真机多页文档扫描 - 为模拟器实现 PhotosPicker 回退方案选择已有照片 - 在 RootView 中统一使用 UnifiedCaptureFlow 处理快速和归档流程 - 添加 CustomMetricEditor 支持自定义监测指标的创建编辑删除 - 扩展 KangkangApp 模型配置以支持新数据类型 - 实现档案列表中症状结束功能通过时间线行点击触发
This commit is contained in:
@@ -36,6 +36,7 @@ extension SeriesBucket {
|
||||
/// `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 {
|
||||
@@ -55,9 +56,15 @@ extension SeriesBucket {
|
||||
}
|
||||
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) {
|
||||
if let bucket = buildSingle(key: key, items: items,
|
||||
profile: profile,
|
||||
custom: customByKey[key]) {
|
||||
results.append(bucket)
|
||||
}
|
||||
}
|
||||
@@ -67,15 +74,24 @@ extension SeriesBucket {
|
||||
|
||||
private static func buildSingle(key: String,
|
||||
items: [Indicator],
|
||||
profile: UserProfile?) -> SeriesBucket? {
|
||||
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 = metric?.displayName ?? sorted.first?.name ?? key
|
||||
let unit = field?.unit ?? sorted.first?.unit ?? ""
|
||||
let range = field.flatMap { metric?.effectiveRange(for: $0, profile: profile) }
|
||||
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,
|
||||
@@ -151,3 +167,10 @@ extension SeriesBucket {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// 空串 → fallback;非空 → 自身。
|
||||
func nonEmptyOr(_ fallback: String?) -> String? {
|
||||
trimmingCharacters(in: .whitespaces).isEmpty ? fallback : self
|
||||
}
|
||||
}
|
||||
|
||||
179
康康/Features/Trends/SeriesChartCard.swift
Normal file
179
康康/Features/Trends/SeriesChartCard.swift
Normal file
@@ -0,0 +1,179 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct SeriesChartCard: View {
|
||||
let bucket: SeriesBucket
|
||||
|
||||
private var allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)] {
|
||||
bucket.lines.flatMap { line in line.points.map { (line, $0) } }
|
||||
}
|
||||
|
||||
private var dateDomain: ClosedRange<Date>? {
|
||||
let dates = allPoints.map(\.point.date)
|
||||
guard let lo = dates.min(), let hi = dates.max() else { return nil }
|
||||
if lo == hi {
|
||||
// 只有一个点的极端情况:扩 1 天显示
|
||||
let cal = Calendar.current
|
||||
let earlier = cal.date(byAdding: .hour, value: -12, to: lo) ?? lo
|
||||
let later = cal.date(byAdding: .hour, value: 12, to: hi) ?? hi
|
||||
return earlier...later
|
||||
}
|
||||
return lo...hi
|
||||
}
|
||||
|
||||
private var valueDomain: ClosedRange<Double>? {
|
||||
var lo = Double.greatestFiniteMagnitude
|
||||
var hi = -Double.greatestFiniteMagnitude
|
||||
for (_, p) in allPoints {
|
||||
lo = min(lo, p.value)
|
||||
hi = max(hi, p.value)
|
||||
}
|
||||
for line in bucket.lines {
|
||||
if let r = line.referenceRange {
|
||||
lo = min(lo, r.lowerBound)
|
||||
hi = max(hi, r.upperBound)
|
||||
}
|
||||
}
|
||||
guard lo < hi else { return nil }
|
||||
let pad = max(1, (hi - lo) * 0.12)
|
||||
return (lo - pad)...(hi + pad)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
header
|
||||
chart
|
||||
.frame(height: 120)
|
||||
if bucket.lines.count > 1 {
|
||||
legendLine
|
||||
}
|
||||
}
|
||||
.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 var header: some View {
|
||||
HStack(alignment: .lastTextBaseline, spacing: 10) {
|
||||
Text(bucket.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("\(allPoints.count) 条 · 近 \(daysSpanLabel)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
latestValueBadge
|
||||
}
|
||||
}
|
||||
|
||||
private var latestValueBadge: some View {
|
||||
let parts = bucket.lines.compactMap { line -> String? in
|
||||
guard let p = line.latestPoint else { return nil }
|
||||
return formatValue(p.value)
|
||||
}
|
||||
let joined = parts.joined(separator: " / ")
|
||||
let anyAbnormal = bucket.lines.contains { line in
|
||||
(line.latestPoint?.status ?? .normal) != .normal
|
||||
}
|
||||
return HStack(spacing: 4) {
|
||||
Text(joined)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(anyAbnormal ? Tj.Palette.brick : Tj.Palette.text)
|
||||
Text(bucket.unit)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
|
||||
private var chart: some View {
|
||||
Chart {
|
||||
// 参考范围带
|
||||
ForEach(bucket.lines) { line in
|
||||
if let r = line.referenceRange,
|
||||
let dom = dateDomain {
|
||||
RectangleMark(
|
||||
xStart: .value("start", dom.lowerBound),
|
||||
xEnd: .value("end", dom.upperBound),
|
||||
yStart: .value("lo", r.lowerBound),
|
||||
yEnd: .value("hi", r.upperBound)
|
||||
)
|
||||
.foregroundStyle(line.color.opacity(0.08))
|
||||
}
|
||||
}
|
||||
|
||||
// 折线 + 点
|
||||
ForEach(bucket.lines) { line in
|
||||
ForEach(line.points) { p in
|
||||
LineMark(
|
||||
x: .value("时间", p.date),
|
||||
y: .value(line.label ?? bucket.title, p.value)
|
||||
)
|
||||
.foregroundStyle(line.color)
|
||||
.interpolationMethod(.catmullRom)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||
}
|
||||
.symbol {
|
||||
Circle()
|
||||
.fill(line.color)
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .automatic(desiredCount: 4)) { _ in
|
||||
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated).day(),
|
||||
centered: false)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) { _ in
|
||||
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
|
||||
AxisValueLabel()
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
}
|
||||
}
|
||||
.chartYScale(domain: valueDomain ?? 0...1)
|
||||
}
|
||||
|
||||
private var legendLine: some View {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(bucket.lines) { line in
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(line.color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(line.label ?? line.seriesKey)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var daysSpanLabel: String {
|
||||
guard let dom = dateDomain else { return "—" }
|
||||
let days = Calendar.current.dateComponents([.day],
|
||||
from: dom.lowerBound,
|
||||
to: dom.upperBound).day ?? 0
|
||||
if days <= 0 { return "今天" }
|
||||
if days < 30 { return "\(days) 天" }
|
||||
if days < 365 { return "\(days / 30) 个月" }
|
||||
return "\(days / 365) 年"
|
||||
}
|
||||
|
||||
private func formatValue(_ v: Double) -> String {
|
||||
v.truncatingRemainder(dividingBy: 1) == 0
|
||||
? String(format: "%.0f", v)
|
||||
: String(format: "%.1f", v)
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,22 @@ struct TrendsView: View {
|
||||
@Query(sort: \Symptom.startedAt, order: .reverse)
|
||||
private var symptoms: [Symptom]
|
||||
|
||||
@Query private var profiles: [UserProfile]
|
||||
|
||||
@Query private var customMetrics: [CustomMonitorMetric]
|
||||
|
||||
@State private var mode: CalendarMode = .month
|
||||
@State private var anchor: Date = .now
|
||||
@State private var selectedDay: SelectedDay?
|
||||
|
||||
private var profile: UserProfile? { profiles.first }
|
||||
|
||||
private var seriesBuckets: [SeriesBucket] {
|
||||
SeriesBucket.build(from: indicators,
|
||||
profile: profile,
|
||||
customMetrics: customMetrics)
|
||||
}
|
||||
|
||||
private let calendar: Calendar = {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.firstWeekday = 2
|
||||
@@ -54,6 +66,7 @@ struct TrendsView: View {
|
||||
anchorBar
|
||||
calendarBody
|
||||
legend
|
||||
seriesSection
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 24)
|
||||
@@ -186,6 +199,31 @@ struct TrendsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var seriesSection: some View {
|
||||
let buckets = seriesBuckets
|
||||
if !buckets.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text("长期监测")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("\(buckets.count) 项")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(buckets) { bucket in
|
||||
SeriesChartCard(bucket: bucket)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var legend: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("图例")
|
||||
|
||||
Reference in New Issue
Block a user