import SwiftUI import SwiftData import UIKit /// 指标速记 · 统一流程。 /// 整幅单拍(真机)/ 相册(模拟器)→ 静态图手动框选 → 框内 OCR+LLM 抽指标 → 核对 → 存独立 Indicator。 /// /// 状态机: /// ``` /// idle(相机/相册) → adjust(静态图框选,可反复识别) → confirm(核对) → save → dismiss /// confirm → 重拍 → idle /// ``` /// 识别失败/超时不卡死:停在 adjust 提示挪框重试,或直接进 confirm 手动补(§3.2 失败回退红线)。 struct QuickRegionCaptureFlow: View { @Environment(\.modelContext) private var ctx let onClose: () -> Void @State private var phase: Phase = .idle enum Phase { case idle case adjust(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: // 不再整体 ignoresSafeArea:相机/框选内部已各自做全屏黑底, // 这里再忽略安全区会把「取消」顶进灵动岛,几乎点不到。 captureEntry case .adjust(let image): RegionAdjustView( image: image, recognize: { await recognizeRegion($0) }, onProceed: { items in phase = .confirm(image: image, items: items, warning: nil) }, onRetake: { phase = .idle }, onCancel: { onClose() } ) 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: { onClose() }, onRetake: { phase = .idle } ) .navigationTitle(String(appLoc: "核对指标")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("取消") { onClose() } .foregroundStyle(Tj.Palette.text) } } } } } // MARK: - 入口:整幅单拍(真机)/ 相册(模拟器或无相机) @ViewBuilder private var captureEntry: some View { #if targetEnvironment(simulator) PhotoPickerSheet( onFinish: { handlePicked($0) }, onCancel: onClose ) #else SingleShotCameraView( onCapture: { phase = .adjust(image: $0) }, onCancel: onClose ) #endif } /// 拍/选回来:取首张进框选;无图则关闭(「只能拍一张」=只用第一张)。 private func handlePicked(_ images: [UIImage]) { if let first = images.first { phase = .adjust(image: first) } else { onClose() } } // MARK: - 识别(框内子图 → Vision OCR → Qwen3 整理) /// 对已裁好的框内子图跑识别。失败/超时返回提示文案,绝不抛出(由 RegionAdjustView 展示)。 /// 固定链路:Vision 端侧 OCR 出文字 → Qwen3 跑一次结构化整理抽指标。 /// (旧的「大模型直读」VL 路径已移除:端侧看图慢且易卡,OCR→整理又快又准。) private func recognizeRegion(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) { do { let text = try await OCRService.recognizeText(in: image) if Task.isCancelled { return ([], nil) } // 超时:文案由调用方给 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) #if DEBUG NSLog("KKDBG-OCR region text:\n%@\n--- end OCR ---", trimmed) #endif if trimmed.isEmpty { return ([], String(appLoc: "没识别到文字,挪一下框再试")) } let parsed = try await CaptureService.shared.recognizeIndicators(fromOCRText: trimmed) if Task.isCancelled { return ([], nil) } let items = Self.buildItems(from: parsed) return (items, items.isEmpty ? String(appLoc: "没读出指标,挪一下框再试") : nil) } catch CaptureError.modelNotReady { return ([], String(appLoc: "AI 模型未就绪,手动补充")) } catch let CaptureError.parseFailed(msg) { return ([], String(appLoc: "解析失败:\(msg)")) } catch let CaptureError.inferenceFailed(msg) { return ([], Task.isCancelled ? nil : String(appLoc: "识别失败:\(msg)")) } catch { return ([], Task.isCancelled ? nil : String(appLoc: "未知错误:\(error.localizedDescription)")) } } /// LLM 结果 → 可编辑行,异常项(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) } 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: - 保存 /// 勾选项各存一条独立 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, source: .quickCapture ) ctx.insert(indicator) } try? ctx.save() onClose() } }