import SwiftUI import SwiftData import UIKit import Combine /// 异常项快拍 · 统一流程。 /// 局部小框拍摄 → VL 识别(只抽 indicators)→ 确认 → 存成独立 Indicator(不建 Report、不留图)。 /// /// 状态机: /// ``` /// idle(相机/相册) → analyzing(croppedImage) → confirm(items) /// ↓ 失败/超时 /// confirm(空 + warning) /// confirm → save → dismiss · confirm → 重拍 → idle /// ``` struct QuickRegionCaptureFlow: View { @Environment(\.modelContext) private var ctx let onClose: () -> Void @State private var phase: Phase = .idle @State private var analyzeTask: Task? = nil /// VL 单次推理超时(防卡死);超时后 cancel 子任务,UI 转手动录入。 private let analyzeTimeoutSeconds: Int = 30 enum Phase { case idle case analyzing(image: UIImage) case confirm(image: UIImage?, items: [QuickRegionItem], warning: String?) } var body: some View { content .background(Tj.Palette.sand.ignoresSafeArea()) } @ViewBuilder private var content: some View { switch phase { case .idle: captureEntry .ignoresSafeArea() case .analyzing(let image): NavigationStack { AnalyzingRegionView( image: image, timeoutSeconds: analyzeTimeoutSeconds, onCancel: { analyzeTask?.cancel() analyzeTask = nil // 取消识别 → 直接进确认页手动补充(图仍在内存,可重拍) phase = .confirm(image: image, items: [], warning: String(appLoc: "已取消识别,手动补充或重拍")) } ) .navigationTitle(String(appLoc: "本地识别中…")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("取消") { cancelAll() } .foregroundStyle(Tj.Palette.text) } } } case .confirm(let image, let items, let warning): NavigationStack { QuickRegionConfirmView( image: image, items: items, warning: warning, onSave: { finalItems, capturedAt in save(items: finalItems, capturedAt: capturedAt) }, onCancel: cancelAll, onRetake: { phase = .idle } ) .navigationTitle(String(appLoc: "核对异常项")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("取消") { cancelAll() } .foregroundStyle(Tj.Palette.text) } } } } } // MARK: - 入口:相机(真机)/ 相册(模拟器) @ViewBuilder private var captureEntry: some View { #if targetEnvironment(simulator) PhotoPickerSheet( onFinish: { imgs in if let first = imgs.first { startAnalyze(image: first) } }, onCancel: onClose ) #else RegionCameraView( onCapture: { startAnalyze(image: $0) }, onCancel: onClose ) #endif } // MARK: - 识别 private func startAnalyze(image: UIImage) { analyzeTask?.cancel() phase = .analyzing(image: image) let timeout = analyzeTimeoutSeconds // 本类型默认 MainActor 隔离,Task{} 继承之,故内部 phase 写入都在主线程,直接赋值即可。 analyzeTask = Task { guard let data = image.jpegData(compressionQuality: 0.9) else { phase = .confirm(image: image, items: [], warning: String(appLoc: "图片编码失败,手动补充或重拍")) return } let watchdog = Task { try? await Task.sleep(for: .seconds(timeout)) analyzeTask?.cancel() } defer { watchdog.cancel() } do { let parsed = try await CaptureService.shared.recognizeRegion(imageData: data) if Task.isCancelled { phase = .confirm(image: image, items: [], warning: String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍")) return } let items = Self.buildItems(from: parsed) phase = .confirm( image: image, items: items, warning: items.isEmpty ? String(appLoc: "没读出指标,手动补充或重拍") : nil ) } catch CaptureError.modelNotReady { phase = .confirm(image: image, items: [], warning: String(appLoc: "VL 模型未就绪,手动补充")) } catch let CaptureError.parseFailed(msg) { phase = .confirm(image: image, items: [], warning: String(appLoc: "VL 输出无法解析:\(msg)")) } catch let CaptureError.inferenceFailed(msg) { phase = .confirm(image: image, items: [], warning: Task.isCancelled ? String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍") : String(appLoc: "推理失败:\(msg)")) } catch { phase = .confirm(image: image, items: [], warning: String(appLoc: "未知错误:\(error.localizedDescription)")) } } } /// VL 结果 → 可编辑行,异常项(high/low)置顶、默认勾选。 private static func buildItems(from parsed: [ParsedReport.ParsedIndicator]) -> [QuickRegionItem] { let mapped = parsed.map { QuickRegionItem(name: $0.name, value: $0.value, unit: $0.unit, range: $0.range, status: $0.status, include: true) } // 异常优先(stable):high/low 在前,normal 在后 return mapped.enumerated().sorted { a, b in let aAbn = a.element.status != .normal let bAbn = b.element.status != .normal if aAbn != bAbn { return aAbn && !bAbn } return a.offset < b.offset }.map { $0.element } } // MARK: - 取消 / 保存 private func cancelAll() { analyzeTask?.cancel() analyzeTask = nil onClose() } /// 勾选项各存一条独立 Indicator(与「记录指标」自由输入一致):无 Report、无 Asset、无 seriesKey。 private func save(items: [QuickRegionItem], capturedAt: Date) { let selected = items.filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty && !$0.value.trimmingCharacters(in: .whitespaces).isEmpty } for item in selected { let indicator = Indicator( name: item.name.trimmingCharacters(in: .whitespaces), value: item.value.trimmingCharacters(in: .whitespaces), unit: item.unit.trimmingCharacters(in: .whitespaces), range: item.range.trimmingCharacters(in: .whitespaces), status: item.status, capturedAt: capturedAt ) ctx.insert(indicator) } try? ctx.save() onClose() } } // MARK: - 识别中视图 private struct AnalyzingRegionView: View { let image: 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() Image(uiImage: image) .resizable() .scaledToFit() .frame(maxHeight: 200) .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.3)) ) VStack(spacing: 6) { Text("识别框内指标") .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text("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) .background(Tj.Palette.sand) .onReceive(tick) { _ in elapsed += 1 } } }