import SwiftUI /// VL 解析后的可编辑表单。 /// 用户可改 title / type / reportDate / institution / summary / 各 indicator; /// 也可删除识别错的 indicator,或手加一行。 /// 「保存」回调写 SwiftData + 关联已写入 Vault 的 assets。 struct CaptureReviewForm: View { @State var parsed: ParsedReport let assets: [FileVault.SavedAsset] let warning: String? let onSave: (ParsedReport) -> Void let onCancel: () -> Void /// 「重新识别」回调。assets 为空(写图失败)时传 nil,banner 上不显示该按钮。 var onReanalyze: (() -> Void)? = nil var body: some View { ScrollView { VStack(alignment: .leading, spacing: 18) { if let warning { warningBanner(warning) } if !assets.isEmpty { pageThumbnails } metaSection indicatorSection Spacer(minLength: 8) actions } .padding(.horizontal, 18) .padding(.bottom, 24) } } // MARK: - 顶部 warning private func warningBanner(_ text: String) -> some View { HStack(alignment: .top, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(Tj.Palette.amber) VStack(alignment: .leading, spacing: 8) { Text(text) .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text2) .fixedSize(horizontal: false, vertical: true) if let onReanalyze { Button { onReanalyze() } label: { Label("重新识别", systemImage: "arrow.clockwise") .font(.system(size: 12, weight: .semibold)) } .buttonStyle(.plain) .foregroundStyle(Tj.Palette.ink) } } Spacer(minLength: 0) } .padding(12) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.brickSoft.opacity(0.5)) ) } // MARK: - 缩略图 private var pageThumbnails: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)")) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) { Image(uiImage: img) .resizable() .scaledToFill() .frame(width: 84, height: 110) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: 1) ) } } } } } } // MARK: - meta(title / type / date / institution / summary) private var metaSection: some View { VStack(alignment: .leading, spacing: 12) { sectionLabel(String(appLoc: "基本信息")) VStack(spacing: 10) { labeledField(String(appLoc: "标题")) { TextField("如:春季年度体检", text: $parsed.title) .textFieldStyle(.plain) } labeledField(String(appLoc: "类型")) { Picker("", selection: $parsed.typeRaw) { ForEach(ReportType.allCases, id: \.rawValue) { t in Text(t.label).tag(t.rawValue) } } .pickerStyle(.segmented) } labeledField(String(appLoc: "报告日期")) { DatePicker("", selection: $parsed.reportDate, in: ...Date.now, displayedComponents: .date) .datePickerStyle(.compact) .labelsHidden() .environment(\.locale, Locale.current) } labeledField(String(appLoc: "机构(可选)")) { TextField("如:协和医院", text: $parsed.institution) } labeledField(String(appLoc: "摘要(可选)")) { TextField("一句话总结", text: $parsed.summary, axis: .vertical) .lineLimit(1...3) } } .padding(12) .background(fieldBg) .overlay(fieldBorder) } } private func labeledField(_ label: String, @ViewBuilder content: () -> C) -> some View { VStack(alignment: .leading, spacing: 4) { Text(label) .font(.system(size: 11, weight: .medium)) .foregroundStyle(Tj.Palette.text3) content() } } // MARK: - indicators private var indicatorSection: some View { VStack(alignment: .leading, spacing: 10) { HStack { sectionLabel(String(appLoc: "指标(\(parsed.indicators.count) 项)")) Spacer() Button { parsed.indicators.append( .init(name: "", value: "", unit: "", range: "", status: .normal) ) } label: { Label("加一项", systemImage: "plus.circle") .font(.system(size: 12, weight: .medium)) } .buttonStyle(.plain) .foregroundStyle(Tj.Palette.ink) } if parsed.indicators.isEmpty { Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) .padding(.vertical, 8) } else { VStack(spacing: 10) { ForEach($parsed.indicators) { $indicator in indicatorRow($indicator) } } } } } private func indicatorRow(_ binding: Binding) -> some View { let id = binding.wrappedValue.id return VStack(spacing: 8) { HStack(spacing: 8) { TextField("指标名", text: binding.name) .font(.system(size: 14, weight: .medium)) Button(role: .destructive) { parsed.indicators.removeAll { $0.id == id } } label: { Image(systemName: "minus.circle.fill") .foregroundStyle(Tj.Palette.text3) } .buttonStyle(.plain) } HStack(spacing: 8) { TextField("数值", text: binding.value) .keyboardType(.decimalPad) .font(.system(size: 14, weight: .semibold, design: .monospaced)) .frame(maxWidth: 90) TextField("单位", text: binding.unit) .frame(maxWidth: 80) .autocorrectionDisabled() TextField("参考", text: binding.range) .autocorrectionDisabled() } Picker("", selection: binding.status) { Text("正常").tag(IndicatorStatus.normal) Text("偏高 ↑").tag(IndicatorStatus.high) Text("偏低 ↓").tag(IndicatorStatus.low) } .pickerStyle(.segmented) } .padding(12) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(statusColor(binding.status.wrappedValue).opacity(0.4), lineWidth: 1) ) } private func statusColor(_ s: IndicatorStatus) -> Color { switch s { case .normal: return Tj.Palette.leaf case .high: return Tj.Palette.brick case .low: return Tj.Palette.amber } } // MARK: - actions private var actions: some View { VStack(spacing: 10) { Button { onSave(parsed) } label: { Text("保存到记录") .frame(maxWidth: .infinity) } .buttonStyle(TjPrimaryButton()) Button(action: onCancel) { Text("取消(图片不保留)") .frame(maxWidth: .infinity) .foregroundStyle(Tj.Palette.text3) } .buttonStyle(.plain) } } // MARK: - helpers private func sectionLabel(_ t: String) -> some View { Text(t) .font(.system(size: 12, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) } private var fieldBg: some View { RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.paper) } private var fieldBorder: some View { RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: 1) } }