import SwiftUI import SwiftData /// 一个指标的「同类组」标识。决定聚合详情里收哪些历次记录、跳哪个趋势 bucket。 /// - `.series`:有 seriesKey 的长期监测指标(血糖/体重/...)。 /// - `.bloodPressure`:血压(bp.systolic + bp.diastolic 合并成一对)。 /// - `.lab`:无 seriesKey 的化验/手动指标,按 name+unit 归一化 key 聚合。 enum IndicatorGroup: Identifiable, Hashable { case series(key: String) case bloodPressure case lab(key: String) var id: String { switch self { case .series(let k): return "series:\(k)" case .bloodPressure: return "bp" case .lab(let k): return "lab:\(k)" } } /// 从单条指标推断其所属同类组(与趋势页 SeriesBucket 的分组语义一致)。 static func of(_ i: Indicator) -> IndicatorGroup { if let key = i.seriesKey, !key.isEmpty { return key.hasPrefix("bp.") ? .bloodPressure : .series(key: key) } return .lab(key: SeriesBucket.normalizedKey(name: i.name, unit: i.unit)) } } /// 同类指标聚合详情:横向翻页看该指标的历次记录,底部可跳趋势图。 /// 从「记录」页点指标条目进入。数据全部 @Query 实时,删除后翻页列表自动更新。 struct IndicatorSeriesDetailView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var ctx let group: IndicatorGroup @Query(sort: \Indicator.capturedAt, order: .reverse) private var indicators: [Indicator] @Query private var profiles: [UserProfile] @Query private var customMetrics: [CustomMonitorMetric] @State private var selection: String? @State private var showTrend = false @State private var showDeleteConfirm = false @State private var evidenceTarget: Indicator? // MARK: - 数据 /// 聚合详情里的一页:单值指标一条;血压一对。 private enum Record: Identifiable { case single(Indicator) case bp(sys: Indicator, dia: Indicator?) var id: String { switch self { case .single(let i): return "\(i.persistentModelID)" case .bp(let s, _): return "bp-\(s.persistentModelID)" } } } /// 历次血压对:以 bp.systolic 为锚,按 ±5s 配 bp.diastolic(同 TimelineEntry 合并规则)。 private var bloodPressureRecords: [Record] { let sysList = indicators .filter { $0.seriesKey == "bp.systolic" } .sorted { $0.capturedAt > $1.capturedAt } var usedDia = Set() return sysList.map { sys in let dia = indicators.first { $0.seriesKey == "bp.diastolic" && !usedDia.contains($0.persistentModelID) && abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5 } if let dia { usedDia.insert(dia.persistentModelID) } return .bp(sys: sys, dia: dia) } } private var records: [Record] { switch group { case .bloodPressure: return bloodPressureRecords case .series(let key): return indicators .filter { $0.seriesKey == key } .sorted { $0.capturedAt > $1.capturedAt } .map(Record.single) case .lab(let nk): return indicators .filter { ($0.seriesKey ?? "").isEmpty && SeriesBucket.normalizedKey(name: $0.name, unit: $0.unit) == nk } .sorted { $0.capturedAt > $1.capturedAt } .map(Record.single) } } private var title: String { switch group { case .bloodPressure: return String(appLoc: "血压") case .series, .lab: if case let .single(i)? = records.first { return i.name } return String(appLoc: "指标详情") } } /// 对应的趋势 bucket(需 ≥2 个可解析数值点才存在);nil 时隐藏「查看趋势图」。 private var bucket: SeriesBucket? { let all = SeriesBucket.build(from: indicators, profile: profiles.first, customMetrics: customMetrics) switch group { case .bloodPressure: return all.first { $0.id == "bp" } case .series(let key): return all.first { b in b.lines.contains { $0.seriesKey == key } } case .lab(let nk): return all.first { $0.kind == .lab && $0.id == "lab:\(nk)" } } } private var currentIndex: Int { records.firstIndex { $0.id == selection } ?? 0 } // MARK: - Body var body: some View { NavigationStack { VStack(spacing: 0) { header if records.isEmpty { Spacer() TjPlaceholder(label: String(appLoc: "记录已不存在")) .frame(width: 200, height: 120) Spacer() } else { pages pager recordAnotherRow if bucket != nil { trendButton } } } .background(Tj.Palette.sand.ignoresSafeArea()) .navigationDestination(isPresented: $showTrend) { if let bucket { TrendDetailView(bucket: bucket) } } } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) .presentationBackground(Tj.Palette.sand) .presentationCornerRadius(Tj.Radius.xl) .onAppear { if selection == nil { selection = records.first?.id } } .alert(String(appLoc: "永久删除这条记录?"), isPresented: $showDeleteConfirm) { Button(String(appLoc: "删除"), role: .destructive) { deleteCurrent() } Button(String(appLoc: "取消"), role: .cancel) { } } message: { Text("删除后无法恢复。") } .sheet(item: $evidenceTarget) { indicator in if let report = indicator.report { EvidenceImagePreview(report: report, indicator: indicator) } } } // MARK: - Header private var header: some View { HStack(spacing: 12) { Button { dismiss() } label: { Image(systemName: "xmark") .font(.tjScaled(16, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .frame(width: 32, height: 32) .background(Circle().fill(Tj.Palette.sand2)) } Text(title) .font(.tjH2()) .foregroundStyle(Tj.Palette.text) .lineLimit(1) if records.count > 1 { Text("\(records.count) 条") .font(.tjScaled(12)) .foregroundStyle(Tj.Palette.text3) } Spacer() TjLockChip() } .padding(.horizontal, 20) .padding(.vertical, 14) .background(Tj.Palette.sand) .overlay(alignment: .bottom) { Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) } } // MARK: - 翻页内容 private var pages: some View { TabView(selection: $selection) { ForEach(records) { rec in ScrollView { VStack(alignment: .leading, spacing: 16) { recordCard(rec) deleteButton } .padding(.horizontal, 20) .padding(.vertical, 16) .frame(maxWidth: .infinity, alignment: .leading) } .tag(Optional(rec.id)) } } .tabViewStyle(.page(indexDisplayMode: .never)) } @ViewBuilder private func recordCard(_ rec: Record) -> some View { switch rec { case .single(let i): singleCard(i) case .bp(let sys, let dia): bpCard(sys: sys, dia: dia) } } private func singleCard(_ i: Indicator) -> some View { card { HStack(alignment: .firstTextBaseline) { Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text) Spacer() statusChip(i.status) } HStack(alignment: .firstTextBaseline, spacing: 4) { Text(i.value) .font(.tjScaled(30, weight: .bold, design: .rounded)) .foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick) if !i.unit.isEmpty { Text(i.unit).font(.tjScaled(14)).foregroundStyle(Tj.Palette.text3) } } divider if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) } field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt)) field(String(appLoc: "来源"), i.report?.title ?? i.source.label) if i.report != nil { evidenceButton(for: i) } if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) } } } private func bpCard(sys: Indicator, dia: Indicator?) -> some View { let combined: IndicatorStatus = sys.status != .normal ? sys.status : (dia?.status ?? .normal) return card { HStack(alignment: .firstTextBaseline) { Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text) Spacer() statusChip(combined) } HStack(alignment: .firstTextBaseline, spacing: 4) { Text("\(sys.value)/\(dia?.value ?? "—")") .font(.tjScaled(30, weight: .bold, design: .rounded)) .foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick) Text("mmHg").font(.tjScaled(14)).foregroundStyle(Tj.Palette.text3) } divider if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) } field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt)) } } // MARK: - 翻页器 private var pager: some View { VStack(spacing: 8) { HStack(spacing: 20) { pagerArrow("chevron.left", enabled: currentIndex > 0) { if currentIndex > 0 { selection = records[currentIndex - 1].id } } if records.count <= 7 { HStack(spacing: 6) { ForEach(Array(records.enumerated()), id: \.offset) { idx, _ in Circle() .fill(idx == currentIndex ? Tj.Palette.ink : Tj.Palette.line) .frame(width: 6, height: 6) } } } pagerArrow("chevron.right", enabled: currentIndex < records.count - 1) { if currentIndex < records.count - 1 { selection = records[currentIndex + 1].id } } } Text("第 \(currentIndex + 1) / 共 \(records.count) 条") .font(.tjScaled(11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } .padding(.top, 4) .padding(.bottom, 10) .frame(maxWidth: .infinity) } private func pagerArrow(_ system: String, enabled: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: system) .font(.tjScaled(13, weight: .semibold)) .foregroundStyle(enabled ? Tj.Palette.text : Tj.Palette.text3.opacity(0.4)) .frame(width: 30, height: 30) .background(Circle().fill(Tj.Palette.sand2)) } .buttonStyle(.plain) .disabled(!enabled) } // MARK: - 再记一条(与指标详情共用 RecordAnotherButton 组件) /// 按当前翻到的那一页指标预选「再记一条」:血压走双字段,其余按 name/unit/range/seriesKey。 @ViewBuilder private var recordAnotherRow: some View { if records.indices.contains(currentIndex) { switch records[currentIndex] { case .single(let i): RecordAnotherButton(name: i.name, prefill: .init(indicator: i)) .padding(.horizontal, 20) .padding(.bottom, bucket == nil ? 20 : 10) case .bp(let sys, _): RecordAnotherButton( name: String(appLoc: "血压"), prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic", name: String(appLoc: "血压"), unit: "mmHg", range: sys.range) ) .padding(.horizontal, 20) .padding(.bottom, bucket == nil ? 20 : 10) } } } // MARK: - 趋势 / 删除 private var trendButton: some View { Button { showTrend = true } label: { Label(String(appLoc: "查看趋势图"), systemImage: "chart.xyaxis.line") .font(.tjScaled(15, weight: .semibold)) .foregroundStyle(Tj.Palette.paper) .frame(maxWidth: .infinity) .padding(.vertical, 14) .background( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .fill(Tj.Palette.ink) ) } .buttonStyle(.plain) .padding(.horizontal, 20) .padding(.bottom, 20) } private var deleteButton: some View { Button(role: .destructive) { showDeleteConfirm = true } label: { Label(String(appLoc: "永久删除"), systemImage: "trash") .font(.tjScaled(12, weight: .medium)) .foregroundStyle(Tj.Palette.brick.opacity(0.8)) .padding(.horizontal, 14) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .strokeBorder(Tj.Palette.brick.opacity(0.3), lineWidth: 1) ) .contentShape(Rectangle()) } .buttonStyle(.plain) .padding(.top, 8) } /// 删当前页记录(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6)。 /// 删后把 selection 落到相邻一条;删空则关闭。 private func deleteCurrent() { guard records.indices.contains(currentIndex) else { return } let removingIndex = currentIndex switch records[removingIndex] { case .single(let i): deleteIndicator(i) case .bp(let sys, let dia): deleteIndicator(sys) if let dia { deleteIndicator(dia) } } try? ctx.save() let remaining = records if remaining.isEmpty { dismiss() } else { let next = min(removingIndex, remaining.count - 1) selection = remaining[next].id } } private func deleteIndicator(_ i: Indicator) { if let asset = i.asset { try? FileVault.shared.remove(relativePath: asset.relativePath) ctx.delete(asset) } ctx.delete(i) } // MARK: - 复用件 @ViewBuilder private func card(@ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 10) { content() } .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 func field(_ label: String, _ value: String) -> some View { HStack(alignment: .top, spacing: 12) { Text(label).font(.tjScaled(13)).foregroundStyle(Tj.Palette.text3) Spacer(minLength: 12) Text(value) .font(.tjScaled(14, weight: .medium)) .foregroundStyle(Tj.Palette.text) .multilineTextAlignment(.trailing) .fixedSize(horizontal: false, vertical: true) } } @ViewBuilder private func evidenceButton(for indicator: Indicator) -> some View { if indicator.hasEvidenceBox, let page = indicator.sourcePageIndex, let assets = indicator.report?.assets, assets.indices.contains(page) { Button { evidenceTarget = indicator } label: { Label(String(appLoc: "查看原图位置"), systemImage: "viewfinder") .font(.tjScaled(12, weight: .semibold)) .foregroundStyle(Tj.Palette.ink) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Capsule().fill(Tj.Palette.leaf.opacity(0.14))) .contentShape(Rectangle()) } .buttonStyle(.plain) } } private var divider: some View { Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) } private func statusChip(_ s: IndicatorStatus) -> some View { let text: String let color: Color let arrow: String switch s { case .high: text = String(appLoc: "偏高"); color = Tj.Palette.brick; arrow = "↑" case .low: text = String(appLoc: "偏低"); color = Tj.Palette.brick; arrow = "↓" case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = "" } return HStack(spacing: 3) { if !arrow.isEmpty { Text(arrow).font(.tjScaled(11, weight: .bold)) } Text(text).font(.tjScaled(12, weight: .semibold)) } .foregroundStyle(color) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Capsule().fill(color.opacity(0.14))) } private nonisolated static func dateTimeText(_ d: Date) -> String { d.formatted(.dateTime.year().month().day().hour().minute()) } }