- 替换 QuickCaptureFlow 和 ArchiveFlow 为 UnifiedCaptureFlow 统一流程 - 新增 VLSession 封装 Qwen2.5-VL 模型进行图像文本推理 - 实现 AIRuntime 中 VL 模型的准备和分析功能 - 添加 VLPrompts 定义体检化验单识别的 JSON 输出模板 - 创建 CaptureReviewForm 提供 VL 解析结果的可编辑表单界面 - 集成 VisionKit 文档扫描器支持真机多页文档扫描 - 为模拟器实现 PhotosPicker 回退方案选择已有照片 - 在 RootView 中统一使用 UnifiedCaptureFlow 处理快速和归档流程 - 添加 CustomMetricEditor 支持自定义监测指标的创建编辑删除 - 扩展 KangkangApp 模型配置以支持新数据类型 - 实现档案列表中症状结束功能通过时间线行点击触发
180 lines
6.3 KiB
Swift
180 lines
6.3 KiB
Swift
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)
|
|
}
|
|
}
|