import SwiftUI import SwiftData import UIKit /// 拍报告 → VL 识别 → 编辑 → 保存(图 + 结构化文本) /// 一条统一流程,替代原 A1-A3 / B1-B5 两套 mockup。 /// /// 状态机: /// ``` /// idle → captured(images) → analyzing → editing(parsed, assets) /// ↓ 失败 /// editing(empty, assets) /// editing → saved → dismiss /// ``` struct UnifiedCaptureFlow: View { @Environment(\.modelContext) private var ctx let onClose: () -> Void @State private var phase: Phase = .idle enum Phase { case idle case analyzing(images: [UIImage]) case editing(parsed: ParsedReport, assets: [FileVault.SavedAsset], warning: String?) } var body: some View { NavigationStack { content .background(Tj.Palette.sand.ignoresSafeArea()) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("取消") { onClose() } .foregroundStyle(Tj.Palette.text) } } .navigationTitle(phaseTitle) .navigationBarTitleDisplayMode(.inline) } } private var phaseTitle: String { switch phase { case .idle: return "拍摄报告" case .analyzing: return "本地识别中…" case .editing: return "核对识别结果" } } @ViewBuilder private var content: some View { switch phase { case .idle: captureEntry case .analyzing(let images): AnalyzingView(images: images) case .editing(let parsed, let assets, let warning): CaptureReviewForm( parsed: parsed, assets: assets, warning: warning, onSave: { final in saveAll(parsed: final, assets: assets) }, onCancel: onClose ) } } // MARK: - 入口:相机 / 相册 private var captureEntry: some View { #if targetEnvironment(simulator) PhotoPickerSheet( onFinish: { startAnalyze(images: $0) }, onCancel: onClose ) #else if DocumentScannerView.isSupported { DocumentScannerView( onFinish: { startAnalyze(images: $0) }, onCancel: onClose ) .ignoresSafeArea() } else { PhotoPickerSheet( onFinish: { startAnalyze(images: $0) }, onCancel: onClose ) } #endif } // MARK: - 启动识别 private func startAnalyze(images: [UIImage]) { guard !images.isEmpty else { onClose(); return } phase = .analyzing(images: images) Task { do { let result = try await CaptureService.shared.analyze(images: images) await MainActor.run { phase = .editing( parsed: result.parsed, assets: result.assets, warning: result.parsed.isEmpty ? "识别没有读出指标,请手动补充" : nil ) } } catch let CaptureError.parseFailed(msg) { // 解析失败:仍然展示编辑表单,只是 indicators 为空,assets 已保存 await fallbackToManual(images: images, msg: "VL 输出无法解析:\(msg)") } catch let CaptureError.inferenceFailed(msg) { await fallbackToManual(images: images, msg: "推理失败:\(msg)") } catch let CaptureError.modelNotReady { await fallbackToManual(images: images, msg: "VL 模型未就绪,先手动录入") } catch CaptureError.writeAssetFailed { await MainActor.run { phase = .editing( parsed: .empty(), assets: [], warning: "图片保存失败,手动录入并保留文本" ) } } catch { await fallbackToManual(images: images, msg: "未知错误:\(error.localizedDescription)") } } } private func fallbackToManual(images: [UIImage], msg: String) async { // 即便 VL 失败,图片应当已经写入了 Vault(在 CaptureService.analyze 第 1 步)。 // 但若是 writeAsset 之前的失败(modelNotReady / inferenceFailed), // 这里再补一次写,保证图不丢。 var assets: [FileVault.SavedAsset] = [] for img in images { if let a = try? FileVault.shared.writeJPEG(img) { assets.append(a) } } await MainActor.run { phase = .editing( parsed: .empty(), assets: assets, warning: msg ) } } // MARK: - 持久化 private func saveAll(parsed final: ParsedReport, assets: [FileVault.SavedAsset]) { let report = Report( title: final.title.isEmpty ? "拍摄识别" : final.title, type: ReportType(rawValue: final.typeRaw) ?? .other, reportDate: final.reportDate, institution: final.institution.isEmpty ? nil : final.institution, summary: final.summary.isEmpty ? nil : final.summary, pageCount: final.pageCount ) ctx.insert(report) // 关联 Asset for a in assets { let asset = Asset(relativePath: a.relativePath, bytes: a.bytes) ctx.insert(asset) report.assets.append(asset) } // 关联 Indicator for ind in final.indicators { let i = Indicator( name: ind.name, value: ind.value, unit: ind.unit, range: ind.range, status: ind.status, capturedAt: final.reportDate, report: report ) ctx.insert(i) } try? ctx.save() onClose() } } // MARK: - 分析中视图 private struct AnalyzingView: View { let images: [UIImage] var body: some View { VStack(spacing: 20) { Spacer() if let first = images.first { Image(uiImage: first) .resizable() .scaledToFit() .frame(maxHeight: 240) .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: 1) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .fill(.ultraThinMaterial) .overlay( ProgressView().tint(Tj.Palette.ink).scaleEffect(1.4) ) ) } VStack(spacing: 6) { Text("本地识别中") .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text("\(images.count) 页 · 100% 本地推理") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) } Spacer() } .padding(.horizontal, 20) .frame(maxWidth: .infinity, maxHeight: .infinity) } }