import SwiftUI import SwiftData import UIKit import Combine /// 拍报告 → 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 @AppStorage("hasSeenCaptureTip") private var hasSeenCaptureTip: Bool = false @State private var phase: Phase = .idle @State private var analyzeTask: Task? = nil @State private var showTip: Bool = false /// VL 单次推理超时(防止卡死);超时后 cancel 子任务,UI 走手动录入回退。 private let analyzeTimeoutSeconds: Int = 30 enum Phase { case idle case analyzing(images: [UIImage], assets: [FileVault.SavedAsset]?) 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("取消") { cancelAll() } .foregroundStyle(Tj.Palette.text) } } .navigationTitle(phaseTitle) .navigationBarTitleDisplayMode(.inline) } .onAppear { if !hasSeenCaptureTip { showTip = true } } .sheet(isPresented: $showTip) { CaptureTipSheet(onDismiss: { hasSeenCaptureTip = true showTip = false }) .presentationDetents([.medium]) } } private var phaseTitle: String { switch phase { case .idle: return String(appLoc: "拍摄报告") case .analyzing: return String(appLoc: "本地识别中…") case .editing: return String(appLoc: "核对识别结果") } } @ViewBuilder private var content: some View { switch phase { case .idle: captureEntry case .analyzing(let images, _): AnalyzingView( images: images, timeoutSeconds: analyzeTimeoutSeconds, onCancel: { analyzeTask?.cancel() analyzeTask = nil phase = .idle } ) case .editing(let parsed, let assets, let warning): CaptureReviewForm( parsed: parsed, assets: assets, warning: warning, onSave: { final in saveAll(parsed: final, assets: assets) }, onCancel: cancelAll, onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) } ) } } // MARK: - 取消统一入口 /// 取消推理 + 清理未保存到 SwiftData 的 Vault 孤儿图片,再关闭 sheet。 /// 工具栏「取消」与编辑表单底部「取消(图片不保留)」都走这里, /// 保证「图片不保留」的隐私承诺(§6)真的成立,且 Vault 不被孤儿图片堆爆。 /// 仅清理 .analyzing/.editing 阶段的 assets;.idle 时还没写图,无需清理。 private func cancelAll() { analyzeTask?.cancel() analyzeTask = nil switch phase { case .idle: break case .analyzing(_, let maybeAssets): if let assets = maybeAssets { removeOrphans(assets) } case .editing(_, let assets, _): removeOrphans(assets) } onClose() } private func removeOrphans(_ assets: [FileVault.SavedAsset]) { for a in assets { try? FileVault.shared.remove(relativePath: a.relativePath) } } // MARK: - 入口:相机 / 相册 @ViewBuilder 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 } analyzeTask?.cancel() phase = .analyzing(images: images, assets: nil) let timeout = analyzeTimeoutSeconds analyzeTask = Task { // Step 1: 先把图写进 Vault。 // 在 UI 这一层写,而不是塞进 CaptureService.analyze —— 这样取消/失败回退时, // assets 已经在 phase 里,cancelAll 能清理孤儿,editingFallback 也不必再补写。 let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) } // 极端情况:用户在写图过程中按了「取消」,View 已 dismiss、cancelAll 看到的 // phase 还是 .analyzing(_, nil),清不到这批刚写完的图 — 这里手动收尾。 if Task.isCancelled { for a in assets { try? FileVault.shared.remove(relativePath: a.relativePath) } return } guard !assets.isEmpty else { await MainActor.run { phase = .editing( parsed: .empty(), assets: [], warning: String(appLoc: "图片保存失败,手动录入并保留文本") ) } return } // 把 assets 暴露给 phase,使工具栏「取消」也能找到孤儿清理。 await MainActor.run { if case .analyzing(let imgs, _) = phase { phase = .analyzing(images: imgs, assets: assets) } } // Step 2: VL 推理(timeout 哨兵到点 cancel 父任务,VLSession 在下一个 token break)。 let watchdog = Task { try? await Task.sleep(for: .seconds(timeout)) analyzeTask?.cancel() } defer { watchdog.cancel() } do { let parsed = try await CaptureService.shared.reanalyze(assets: assets) if Task.isCancelled { await editingFallback(assets: assets, msg: String(appLoc: "识别超时(>\(timeout)s),先手动录入")) return } await MainActor.run { phase = .editing( parsed: parsed, assets: assets, warning: parsed.isEmpty ? String(appLoc: "识别没有读出指标,请手动补充") : nil ) } } catch let CaptureError.parseFailed(msg) { await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)")) } catch let CaptureError.inferenceFailed(msg) { await editingFallback(assets: assets, msg: Task.isCancelled ? String(appLoc: "识别超时(>\(timeout)s),先手动录入") : String(appLoc: "推理失败:\(msg)")) } catch CaptureError.modelNotReady { await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪,先手动录入")) } catch { await editingFallback(assets: assets, msg: String(appLoc: "未知错误:\(error.localizedDescription)")) } } } /// 「重新识别」:复用已存 assets,不再写图,只重跑 VL。 private func reanalyze(assets: [FileVault.SavedAsset]) { analyzeTask?.cancel() // 这里没有原始 UIImage,AnalyzingView 显示首张缩略图即可 let thumbnails: [UIImage] = assets.compactMap { try? FileVault.shared.loadImage(relativePath: $0.relativePath) } phase = .analyzing(images: thumbnails, assets: assets) let timeout = analyzeTimeoutSeconds analyzeTask = Task { let watchdog = Task { try? await Task.sleep(for: .seconds(timeout)) analyzeTask?.cancel() } defer { watchdog.cancel() } do { let parsed = try await CaptureService.shared.reanalyze(assets: assets) if Task.isCancelled { await editingFallback(assets: assets, msg: String(appLoc: "识别超时(>\(timeout)s),保留旧编辑")) return } await MainActor.run { phase = .editing( parsed: parsed, assets: assets, warning: parsed.isEmpty ? String(appLoc: "重新识别没有读出新指标") : nil ) } } catch CaptureError.modelNotReady { await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪")) } catch let CaptureError.parseFailed(msg) { await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)")) } catch let CaptureError.inferenceFailed(msg) { await editingFallback(assets: assets, msg: Task.isCancelled ? String(appLoc: "识别超时(>\(timeout)s)") : String(appLoc: "推理失败:\(msg)")) } catch { await editingFallback(assets: assets, msg: String(appLoc: "未知错误:\(error.localizedDescription)")) } } } /// reanalyze 失败时回到 editing,保留 assets 但清空 parsed。 private func editingFallback(assets: [FileVault.SavedAsset], msg: String) async { 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 ? String(appLoc: "拍摄识别") : 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] let timeoutSeconds: Int let onCancel: () -> Void @State private var elapsed: Int = 0 private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect() 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% 本地推理 · 已用 \(elapsed)s") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) if elapsed >= timeoutSeconds - 5 { Text("快超时了,>\(timeoutSeconds)s 会自动转为手动录入") .font(.system(size: 11)) .foregroundStyle(Tj.Palette.amber) } } Button("取消识别 · 改为手动录入", action: onCancel) .font(.system(size: 13, weight: .medium)) .foregroundStyle(Tj.Palette.text3) .padding(.top, 4) Spacer() } .padding(.horizontal, 20) .frame(maxWidth: .infinity, maxHeight: .infinity) .onReceive(tick) { _ in elapsed += 1 } } } // MARK: - 一次性使用提示 private struct CaptureTipSheet: View { let onDismiss: () -> Void var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(spacing: 10) { Image(systemName: "doc.viewfinder") .font(.system(size: 28)) .foregroundStyle(Tj.Palette.ink) Text("拍报告的小贴士") .font(.tjH2()) .foregroundStyle(Tj.Palette.text) } VStack(alignment: .leading, spacing: 12) { tip(String(appLoc: "纸张铺平,避免反光、阴影")) tip(String(appLoc: "整页入框,避免裁切到指标")) tip(String(appLoc: "多页报告可连拍,系统自动透视校正")) tip(String(appLoc: "识别全程在本地,图片不会上传")) } Spacer() Button { onDismiss() } label: { Text("我知道了,开始拍") .frame(maxWidth: .infinity) } .buttonStyle(TjPrimaryButton()) } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Tj.Palette.sand.ignoresSafeArea()) } private func tip(_ text: String) -> some View { HStack(alignment: .top, spacing: 10) { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Tj.Palette.leaf) .padding(.top, 2) Text(text) .font(.tjSerifBody()) .foregroundStyle(Tj.Palette.text2) .fixedSize(horizontal: false, vertical: true) Spacer() } } }