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) /// 把时间线条目反查回源记录(id 形如 `-` / `bp--`)。 /// 主页「最近记录」与档案库 C1 共用同一套反查,避免逻辑重复。无法定位源记录时返回 nil。 static func resolve(for entry: TimelineEntry, indicators: [Indicator], reports: [Report], diaries: [DiaryEntry], symptoms: [Symptom]) -> TimelineDetail? { switch entry.kind { case .report: return reports.first { "report-\($0.persistentModelID)" == entry.id } .map(TimelineDetail.report) case .diary, .medication: // 用药记录本质是带「用药」tag 的 DiaryEntry,详情同日记。 return diaries.first { "diary-\($0.persistentModelID)" == entry.id } .map(TimelineDetail.diary) case .symptom: return symptoms.first { "symptom-\($0.persistentModelID)" == entry.id } .map(TimelineDetail.symptom) case .indicator: if let i = indicators.first(where: { "indicator-\($0.persistentModelID)" == entry.id }) { return .indicator(i) } // 合并血压条目:bp-- if entry.id.hasPrefix("bp-"), let sys = indicators.first(where: { entry.id.hasPrefix("bp-\($0.persistentModelID)-") }) { // 用 id 里编码的 diaID 精确反查,不再用 ±5s 时间窗近似匹配 //(后者在同日多次量血压时会把详情配到错误的舒张读数)。 let dia = indicators.first { entry.id.hasSuffix("-\($0.persistentModelID)") } return .bloodPressure(sys: sys, dia: dia) } return nil } } } /// 时间线条目的只读详情:展示该记录的完整字段。各类型一屏看完,不可编辑。 struct TimelineEntryDetailView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var ctx let detail: TimelineDetail @State private var showDeleteConfirm = false @State private var evidenceTarget: Indicator? @State private var reminderPrefill: ReminderPrefill? /// 「用药记录」点药 → 预填吃药提醒表单用的载体。 private struct ReminderPrefill: Identifiable { let id = UUID() let title: String let note: String } /// 报告详情「查看原图」起始页载体。 @State private var reportPhotoStart: ReportPhotoPage? private struct ReportPhotoPage: Identifiable { let id = UUID() let index: Int } /// 当前详情若是报告则取出,供「查看原图」用。 private var reportEntry: Report? { if case .report(let r) = detail { return r } return nil } var body: some View { VStack(spacing: 0) { header ScrollView { VStack(alignment: .leading, spacing: 16) { bodyContent deleteButton } .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) .alert(String(appLoc: "永久删除这条记录?"), isPresented: $showDeleteConfirm) { Button(String(appLoc: "删除"), role: .destructive) { performDelete() } Button(String(appLoc: "取消"), role: .cancel) { } } message: { Text("删除后无法恢复。") } .sheet(item: $evidenceTarget) { indicator in if let report = indicator.report { EvidenceImagePreview(report: report, indicator: indicator) } } .sheet(item: $reminderPrefill) { prefill in // 复用自由提醒表单(每天/每周/每月/每年 + 时间点;一日多次就再建一条)。 CustomReminderEditSheet(prefillTitle: prefill.title, prefillNote: prefill.note) } .sheet(item: $reportPhotoStart) { start in if let r = reportEntry { ReportImagesViewer(assets: r.assets, startIndex: start.index) } } } // MARK: - 删除(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6) 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 让整框可点(否则中间透明区点不到)。 .contentShape(Rectangle()) } .buttonStyle(.plain) .padding(.top, 8) } private func performDelete() { switch detail { case .indicator(let i): deleteIndicator(i) case .bloodPressure(let sys, let dia): deleteIndicator(sys) if let dia { deleteIndicator(dia) } case .report(let r): // cascade 只删 Asset/Indicator 记录,Vault 里的 JPEG 要手动 unlink。 var paths = Set(r.assets.map(\.relativePath)) paths.formUnion(r.indicators.compactMap { $0.asset?.relativePath }) for p in paths { try? FileVault.shared.remove(relativePath: p) } ctx.delete(r) case .diary(let d): // 拍药盒日记可能挂原图;cascade 删 Asset 记录,Vault 里的 JPEG 要手动 unlink。 for p in Set(d.assets.map(\.relativePath)) { try? FileVault.shared.remove(relativePath: p) } ctx.delete(d) case .symptom(let s): ctx.delete(s) } try? ctx.save() dismiss() } /// 删一条指标:先 unlink 其原图文件 + Asset 记录(关系默认 nullify,不会自动级联),再删指标本身。 private func deleteIndicator(_ i: Indicator) { if let asset = i.asset { try? FileVault.shared.remove(relativePath: asset.relativePath) ctx.delete(asset) } ctx.delete(i) } // 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(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(let d): return d.isMedicationLog ? String(appLoc: "用药详情") : 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 { VStack(alignment: .leading, spacing: 16) { 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 let report = i.report { evidenceButton(for: i, assets: report.assets) } if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) } } RecordAnotherButton(name: i.name, prefill: .init(indicator: i)) } } // MARK: - 血压(合并条目) private func bpBody(sys: Indicator, dia: Indicator?) -> some View { let combined: IndicatorStatus = sys.status != .normal ? sys.status : (dia?.status ?? .normal) return VStack(alignment: .leading, spacing: 16) { 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)) } // 血压走双字段:seriesKey 用 bp.systolic 反查到 MonitorMetric.bloodPressure。 RecordAnotherButton(name: String(appLoc: "血压"), prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic", name: String(appLoc: "血压"), unit: "mmHg", range: sys.range)) } } // 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(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) } if let inst = r.institution, !inst.isEmpty { field(String(appLoc: "机构"), inst) } } if !r.assets.isEmpty { reportPhotosCard(r.assets) } ReportSummaryCard(report: r) if !r.indicators.isEmpty { card { Text(String(appLoc: "指标")) .font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2) ForEach(sorted) { ind in VStack(alignment: .leading, spacing: 6) { HStack { Text(ind.name).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text) Spacer(minLength: 8) Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)") .font(.tjScaled( 13, design: .monospaced)) .foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick) statusChip(ind.status) } evidenceButton(for: ind, assets: r.assets) } } } } if let note = r.note, !note.isEmpty { card { field(String(appLoc: "备注"), note) } } } } /// 报告原图卡:可点缩略图 → 全屏翻页查看。归档只存图时,这是看原图的唯一入口,必须独立于指标存在。 private func reportPhotosCard(_ assets: [Asset]) -> some View { card { HStack { Text(String(appLoc: "原图\(assets.count)张")) .font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2) Spacer() Text(String(appLoc: "点图放大")).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3) } ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(Array(assets.enumerated()), id: \.offset) { idx, asset in Button { reportPhotoStart = ReportPhotoPage(index: idx) } label: { reportThumb(asset) } .buttonStyle(.plain) } } } } } private func reportThumb(_ asset: Asset) -> some View { VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in Image(uiImage: img).resizable().scaledToFill() } placeholder: { isLoading in if isLoading { Tj.Palette.paper } else { TjPlaceholder(label: String(appLoc: "原图无法读取")) } } .frame(width: 96, height: 120) .clipped() .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: 1) ) } // MARK: - 日记 @ViewBuilder private func diaryBody(_ d: DiaryEntry) -> some View { if d.isMedicationLog { medicationBody(d) } else { VStack(alignment: .leading, spacing: 16) { card { Text(Self.dateTimeText(d.createdAt)) .font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) Text(d.content) .font(.tjScaled( 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: - 用药使用记录(展示药名/剂量/时间 + 设置提醒) /// 用药使用记录(带「用药」tag 的日记):展示「药名 [规格] · 剂量」+ 时间,下方「设置提醒」。 /// 只到点提示,不做剂量/频次建议(CLAUDE.md §1、§10)。 private func medicationBody(_ d: DiaryEntry) -> some View { let lines = Self.medicationLines(d.content) return VStack(alignment: .leading, spacing: 16) { card { Text(Self.dateTimeText(d.createdAt)) .font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) if lines.isEmpty { Text(d.content) .font(.tjScaled( 15)).foregroundStyle(Tj.Palette.text) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } else { ForEach(Array(lines.enumerated()), id: \.offset) { idx, line in if idx > 0 { divider } Text(line) .font(.tjScaled( 15)).foregroundStyle(Tj.Palette.text) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } } } medicationActionRow(d) Text("「设置提醒」只到点提示,不提供任何用药或剂量建议。") .font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } } /// 单动作:设置提醒(复用自由提醒表单,预填药名 + 用法)。只到点提示,不做剂量建议。 private func medicationActionRow(_ d: DiaryEntry) -> some View { HStack(spacing: 10) { medAction(title: String(appLoc: "设置提醒"), icon: "bell.badge") { let lines = Self.medicationLines(d.content) if lines.count <= 1 { let f = Self.medicationReminderFields(forLine: lines.first ?? d.content) reminderPrefill = ReminderPrefill(title: f.title, note: f.note) } else { // 多种药:一个提醒涵盖,药名清单进备注,用户据此自定时间/频率。 reminderPrefill = ReminderPrefill(title: String(appLoc: "服药提醒"), note: lines.joined(separator: "\n")) } } } } private func medAction(title: String, icon: String, action: @escaping () -> Void) -> some View { Button(action: action) { VStack(spacing: 6) { Image(systemName: icon).font(.tjScaled( 18, weight: .medium)) Text(title).font(.tjScaled( 12, weight: .semibold)) } .foregroundStyle(Tj.Palette.ink) .frame(maxWidth: .infinity) .padding(.vertical, 12) .background( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .fill(Tj.Palette.amber.opacity(0.14)) ) .contentShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)) } .buttonStyle(.plain) } // 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(.tjScaled( 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(.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, assets: [Asset]) -> some View { if indicator.hasEvidenceBox, let page = indicator.sourcePageIndex, 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()) } private nonisolated static func dateText(_ d: Date) -> String { d.formatted(.dateTime.year().month().day()) } // MARK: - 用药行解析(纯函数,便于单测) /// 把用药日记 content 按换行拆成单行药品,去掉空白行与首尾空格。 nonisolated static func medicationLines(_ content: String) -> [String] { content.split(whereSeparator: \.isNewline) .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } } /// 从一行药品文本(如「缬沙坦胶囊 80mg · 一日一次」)派生吃药提醒预填: /// 标题 =「吃药:<药名+规格>」,备注 = 用法(" · " 之后部分,供用户据此选时间/频率)。 nonisolated static func medicationReminderFields(forLine line: String) -> (title: String, note: String) { let parts = line.components(separatedBy: " · ") let head = (parts.first ?? line).trimmingCharacters(in: .whitespaces) let usage = parts.count > 1 ? parts.dropFirst().joined(separator: " · ").trimmingCharacters(in: .whitespaces) : "" let name = head.isEmpty ? line.trimmingCharacters(in: .whitespaces) : head return (title: String(appLoc: "吃药:") + name, note: usage) } } /// 报告原图浏览(纯翻页看图,无指标高亮)。归档只存图的报告也能随时调取查看。 private struct ReportImagesViewer: View { @Environment(\.dismiss) private var dismiss let assets: [Asset] @State private var selection: Int init(assets: [Asset], startIndex: Int) { self.assets = assets _selection = State(initialValue: min(max(startIndex, 0), max(assets.count - 1, 0))) } var body: some View { VStack(spacing: 0) { 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("原图 · 第 \(selection + 1)/\(assets.count) 页") .font(.tjScaled( 14, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Spacer() } .padding(.horizontal, 20) .padding(.vertical, 14) .background(Tj.Palette.sand) .overlay(alignment: .bottom) { Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) } TabView(selection: $selection) { ForEach(Array(assets.enumerated()), id: \.offset) { index, asset in EvidenceImagePage(asset: asset, highlight: nil) .tag(index) .padding(16) } } .tabViewStyle(.page(indexDisplayMode: assets.count > 1 ? .automatic : .never)) } .background(Tj.Palette.sand.ignoresSafeArea()) .presentationDetents([.large]) .presentationDragIndicator(.visible) .presentationBackground(Tj.Palette.sand) } } /// 原图证据预览(翻页 + 高亮框)。指标详情与同类聚合详情共用,故为模块内可见。 struct EvidenceImagePreview: View { @Environment(\.dismiss) private var dismiss let report: Report let indicator: Indicator @State private var selection: Int init(report: Report, indicator: Indicator) { self.report = report self.indicator = indicator let page = indicator.sourcePageIndex ?? 0 _selection = State(initialValue: min(max(page, 0), max(report.assets.count - 1, 0))) } var body: some View { VStack(spacing: 0) { 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)) } VStack(alignment: .leading, spacing: 2) { Text(indicator.name) .font(.tjScaled( 16, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Text("第 \(selection + 1) 页 · 原图证据") .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } Spacer() } .padding(.horizontal, 20) .padding(.vertical, 14) .background(Tj.Palette.sand) .overlay(alignment: .bottom) { Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) } TabView(selection: $selection) { ForEach(Array(report.assets.enumerated()), id: \.offset) { index, asset in EvidenceImagePage( asset: asset, highlight: index == indicator.sourcePageIndex ? indicator.evidenceRect : nil ) .tag(index) .padding(16) } } .tabViewStyle(.page(indexDisplayMode: report.assets.count > 1 ? .automatic : .never)) } .background(Tj.Palette.sand.ignoresSafeArea()) .presentationDetents([.large]) .presentationDragIndicator(.visible) .presentationBackground(Tj.Palette.sand) } } private struct EvidenceImagePage: View { let asset: Asset let highlight: CGRect? var body: some View { GeometryReader { geo in VaultImage(relativePath: asset.relativePath, maxPixel: 2000) { image in ZStack { Image(uiImage: image) .resizable() .scaledToFit() .frame(width: geo.size.width, height: geo.size.height) if let highlight { // 降采样保持原始宽高比,imageSize 仅用于算 letterbox 比例,定位不受影响。 EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight) } } .frame(width: geo.size.width, height: geo.size.height) .background(Tj.Palette.paper) .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) ) } placeholder: { isLoading in if isLoading { ProgressView() .frame(width: geo.size.width, height: geo.size.height) } else { TjPlaceholder(label: String(appLoc: "原图无法读取")) .frame(width: geo.size.width, height: geo.size.height) } } } } } private struct EvidenceHighlightOverlay: View { let imageSize: CGSize let normalizedRect: CGRect var body: some View { GeometryReader { geo in let fitted = fittedRect(imageSize: imageSize, containerSize: geo.size) let rect = CGRect( x: fitted.minX + normalizedRect.minX * fitted.width, y: fitted.minY + normalizedRect.minY * fitted.height, width: normalizedRect.width * fitted.width, height: normalizedRect.height * fitted.height ) RoundedRectangle(cornerRadius: 4, style: .continuous) .fill(Tj.Palette.brick.opacity(0.16)) .overlay( RoundedRectangle(cornerRadius: 4, style: .continuous) .stroke(Tj.Palette.brick, lineWidth: 2) ) .frame(width: rect.width, height: rect.height) .position(x: rect.midX, y: rect.midY) .shadow(color: Tj.Palette.brick.opacity(0.24), radius: 8, y: 2) } .allowsHitTesting(false) } private func fittedRect(imageSize: CGSize, containerSize: CGSize) -> CGRect { guard imageSize.width > 0, imageSize.height > 0, containerSize.width > 0, containerSize.height > 0 else { return .zero } let scale = min(containerSize.width / imageSize.width, containerSize.height / imageSize.height) let size = CGSize(width: imageSize.width * scale, height: imageSize.height * scale) return CGRect( x: (containerSize.width - size.width) / 2, y: (containerSize.height - size.height) / 2, width: size.width, height: size.height ) } } // MARK: - 报告摘要卡(无摘要时后台预生成兜底) /// 有摘要直接显示;无摘要且有指标时触发后台预生成(归档时若被抢占,这里兜底), /// 生成期间显示流光线,完成后 SwiftData 观察自动刷新出文本。 private struct ReportSummaryCard: View { @Environment(\.modelContext) private var ctx let report: Report @State private var generating = false var body: some View { Group { if let sum = report.summary, !sum.isEmpty { container { Text(String(appLoc: "摘要")) .font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2) Text(sum).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) } } else if generating { container { Text("本地 AI 正在解读这份报告…") .font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) AIFlowBar() } } } .task { guard (report.summary ?? "").isEmpty, !report.indicators.isEmpty else { return } generating = true await ReportInsightService.shared.pregenerateIfNeeded(report: report, in: ctx) generating = false } } private func container(@ViewBuilder _ body: () -> C) -> some View { VStack(alignment: .leading, spacing: 10) { body() } .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) ) } }