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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user