import SwiftUI import SwiftData /// 时间线条目反查到的源记录,驱动只读详情 sheet。 /// 注:报告详情这里是 W2 轻量只读版;W4 的 C2 `ReportDetailView`(三 Tab + 对比上次)另建, /// 届时把时间线报告行改路由到 C2 即可,本类型不与之冲突。 enum TimelineDetail { case indicator(Indicator) case bloodPressure(sys: Indicator, dia: Indicator?) case report(Report) case diary(DiaryEntry) case symptom(Symptom) } /// 时间线条目的只读详情:展示该记录的完整字段。各类型一屏看完,不可编辑。 struct TimelineEntryDetailView: View { @Environment(\.dismiss) private var dismiss let detail: TimelineDetail var body: some View { VStack(spacing: 0) { header ScrollView { VStack(alignment: .leading, spacing: 16) { bodyContent } .padding(.horizontal, 20) .padding(.vertical, 16) .frame(maxWidth: .infinity, alignment: .leading) } } .background(Tj.Palette.sand.ignoresSafeArea()) .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) .presentationBackground(Tj.Palette.sand) .presentationCornerRadius(Tj.Radius.xl) } // MARK: - Header private var header: some View { HStack(spacing: 12) { Button { dismiss() } label: { Image(systemName: "xmark") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .frame(width: 32, height: 32) .background(Circle().fill(Tj.Palette.sand2)) } Text(titleText) .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Spacer() TjLockChip() } .padding(.horizontal, 20) .padding(.vertical, 14) .background(Tj.Palette.sand) .overlay(alignment: .bottom) { Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) } } private var titleText: String { switch detail { case .indicator: return String(appLoc: "指标详情") case .bloodPressure: return String(appLoc: "血压详情") case .report: return String(appLoc: "报告详情") case .diary: return String(appLoc: "日记详情") case .symptom: return String(appLoc: "症状详情") } } @ViewBuilder private var bodyContent: some View { switch detail { case .indicator(let i): indicatorBody(i) case .bloodPressure(let s, let d): bpBody(sys: s, dia: d) case .report(let r): reportBody(r) case .diary(let d): diaryBody(d) case .symptom(let s): symptomBody(s) } } // MARK: - 指标 private func indicatorBody(_ 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(.system(size: 30, weight: .bold, design: .rounded)) .foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick) if !i.unit.isEmpty { Text(i.unit).font(.system(size: 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 ?? String(appLoc: "异常项快拍")) if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) } } } // MARK: - 血压(合并条目) private func bpBody(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(.system(size: 30, weight: .bold, design: .rounded)) .foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick) Text("mmHg").font(.system(size: 14)).foregroundStyle(Tj.Palette.text3) } divider if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) } field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt)) } } // MARK: - 报告 private func reportBody(_ r: Report) -> some View { let sorted = r.indicators.sorted { ($0.status == .normal ? 1 : 0) < ($1.status == .normal ? 1 : 0) } return VStack(alignment: .leading, spacing: 16) { card { Text(r.title).font(.tjH2()).foregroundStyle(Tj.Palette.text) HStack(spacing: 8) { TjBadge(text: r.type.label, style: .neutral) Text(Self.dateText(r.reportDate)) .font(.system(size: 12)).foregroundStyle(Tj.Palette.text3) if !r.assets.isEmpty { Text(String(appLoc: "原图\(r.assets.count)张")) .font(.system(size: 12)).foregroundStyle(Tj.Palette.text3) } } if let inst = r.institution, !inst.isEmpty { field(String(appLoc: "机构"), inst) } } if let sum = r.summary, !sum.isEmpty { card { Text(String(appLoc: "摘要")) .font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2) Text(sum).font(.system(size: 14)).foregroundStyle(Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) } } if !r.indicators.isEmpty { card { Text(String(appLoc: "指标")) .font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2) ForEach(sorted) { ind in HStack { Text(ind.name).font(.system(size: 14)).foregroundStyle(Tj.Palette.text) Spacer(minLength: 8) Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)") .font(.system(size: 13, design: .monospaced)) .foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick) statusChip(ind.status) } } } } if let note = r.note, !note.isEmpty { card { field(String(appLoc: "备注"), note) } } } } // MARK: - 日记 private func diaryBody(_ d: DiaryEntry) -> some View { VStack(alignment: .leading, spacing: 16) { card { Text(Self.dateTimeText(d.createdAt)) .font(.system(size: 12)).foregroundStyle(Tj.Palette.text3) Text(d.content) .font(.system(size: 15)) .foregroundStyle(Tj.Palette.text) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) if !d.tags.isEmpty { field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " ")) } } } } // MARK: - 症状 private func symptomBody(_ s: Symptom) -> some View { card { HStack(alignment: .firstTextBaseline) { Text(s.name).font(.tjH2()).foregroundStyle(Tj.Palette.text) Spacer() if s.isOngoing { Text(String(appLoc: "进行中")) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Tj.Palette.brick) .padding(.horizontal, 8).padding(.vertical, 4) .background(Capsule().fill(Tj.Palette.brick.opacity(0.14))) } } divider field(String(appLoc: "程度"), "\(s.severity) / 5") field(String(appLoc: "开始"), Self.dateTimeText(s.startedAt)) field(String(appLoc: "结束"), s.endedAt.map(Self.dateTimeText) ?? String(appLoc: "进行中")) field(String(appLoc: "持续"), formatDuration(s.duration)) if let note = s.note, !note.isEmpty { field(String(appLoc: "备注"), note) } if !s.tags.isEmpty { field(String(appLoc: "标签"), s.tags.map { "#\($0)" }.joined(separator: " ")) } } } // 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(.system(size: 13)).foregroundStyle(Tj.Palette.text3) Spacer(minLength: 12) Text(value) .font(.system(size: 14, weight: .medium)) .foregroundStyle(Tj.Palette.text) .multilineTextAlignment(.trailing) .fixedSize(horizontal: false, vertical: true) } } 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(.system(size: 11, weight: .bold)) } Text(text).font(.system(size: 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()) } private nonisolated static func dateText(_ d: Date) -> String { d.formatted(.dateTime.year().month().day()) } }