import SwiftUI import AVFoundation import UIKit /// 指标速记 · 静态图框选识别。 /// 拍/选一张后,在静态照片上手动拖动 + 缩放一个方框,点「识别」只对框内做 OCR+LLM。 /// 可反复挪框重识别,满意后进入核对页;0 项也能进核对手动补(失败回退红线)。 struct RegionAdjustView: View { let image: UIImage /// 对裁好的子图跑 OCR+LLM,返回(识别项, 提示文案?)。几何裁剪由本视图负责。 let recognize: (UIImage) async -> (items: [QuickRegionItem], warning: String?) let onProceed: ([QuickRegionItem]) -> Void let onRetake: () -> Void let onCancel: () -> Void /// 单次识别超时(秒)。超时取消并提示挪框重试。 let timeoutSeconds: Int = 60 @State private var box: CGRect = .zero @State private var fittedRect: CGRect = .zero @State private var boxInited = false @State private var dragStartBox: CGRect? = nil @State private var resizeStartBox: CGRect? = nil @State private var isRecognizing = false @State private var items: [QuickRegionItem] = [] @State private var statusText: String? = nil @State private var recognizeTask: Task? = nil private let handleSize: CGFloat = 30 private let minBox: CGFloat = 56 var body: some View { VStack(spacing: 0) { topBar canvas controls } .background(Color.black.ignoresSafeArea()) } // MARK: - 顶栏 private var topBar: some View { HStack { Button { recognizeTask?.cancel() onCancel() } label: { Text("取消") .font(.tjScaled( 16, weight: .medium)) .foregroundStyle(.white) .padding(.horizontal, 12) .frame(minWidth: 60, minHeight: 44) // HIG 最小命中区,命中整块而非文字 .contentShape(Rectangle()) } .buttonStyle(.plain) Spacer() Text("框住异常指标") .font(.tjScaled( 16, weight: .semibold)) .foregroundStyle(.white) Spacer() Button { recognizeTask?.cancel() onRetake() } label: { Text("重拍") .font(.tjScaled( 16, weight: .medium)) .foregroundStyle(.white) .padding(.horizontal, 12) .frame(minWidth: 60, minHeight: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) } .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color.black) } // MARK: - 图 + 选框 private var canvas: some View { GeometryReader { proxy in let fitted = AVMakeRect( aspectRatio: image.size == .zero ? CGSize(width: 1, height: 1) : image.size, insideRect: CGRect(origin: .zero, size: proxy.size) ) ZStack { Color.black Image(uiImage: image) .resizable() .scaledToFit() .frame(width: proxy.size.width, height: proxy.size.height) // 框外压暗,突出框内 Canvas { ctx, size in var path = Path(CGRect(origin: .zero, size: size)) path.addPath(Path(roundedRect: box, cornerRadius: 10)) ctx.fill(path, with: .color(.black.opacity(0.5)), style: FillStyle(eoFill: true)) } .allowsHitTesting(false) // 选框边框 + 拖动 RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(Color.white, style: StrokeStyle(lineWidth: 2, dash: [7, 5])) .frame(width: max(box.width, 1), height: max(box.height, 1)) .position(x: box.midX, y: box.midY) .contentShape(Rectangle()) .gesture(moveGesture(in: fitted)) // 右下角缩放手柄 Circle() .fill(.white) .frame(width: handleSize, height: handleSize) .overlay( Image(systemName: "arrow.down.right") .font(.system(size: 12, weight: .bold)) .foregroundStyle(.black) ) .position(x: box.maxX, y: box.maxY) .gesture(resizeGesture(in: fitted)) } .onAppear { fittedRect = fitted if !boxInited { box = defaultBox(in: fitted) boxInited = true } } .onChange(of: proxy.size) { _, _ in fittedRect = fitted box = clampSize(clampOrigin(box, in: fitted), in: fitted) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.black) } // MARK: - 底部控件 private var controls: some View { VStack(spacing: 12) { Text(statusText ?? String(appLoc: "拖动方框对准要识别的指标,可拖右下角缩放")) .font(.tjScaled( 13)) .foregroundStyle(.white.opacity(0.85)) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .frame(minHeight: 34) Button { runRecognize() } label: { HStack(spacing: 8) { if isRecognizing { ProgressView().tint(.black) } Text(isRecognizing ? String(appLoc: "本地识别中…") : String(appLoc: "识别框内指标")) .font(.tjScaled( 16, weight: .semibold)) } .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(Capsule().fill(.white)) .foregroundStyle(.black) } .disabled(isRecognizing) Button { recognizeTask?.cancel() onProceed(items) } label: { Text(items.isEmpty ? String(appLoc: "跳过 · 手动录入") : String(appLoc: "进入核对(\(items.count))")) .font(.tjScaled( 15, weight: .medium)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding(.vertical, 10) .background(Capsule().strokeBorder(.white.opacity(0.6), lineWidth: 1)) } .disabled(isRecognizing) } .padding(.horizontal, 20) .padding(.top, 14) .padding(.bottom, 28) .background(Color.black) } // MARK: - 选框默认值 / 夹紧 private func defaultBox(in fitted: CGRect) -> CGRect { guard fitted.width > 0, fitted.height > 0 else { return .zero } let w = fitted.width * 0.8 let h = min(fitted.height * 0.3, max(minBox, fitted.height * 0.18)) let x = fitted.minX + (fitted.width - w) / 2 let y = fitted.minY + (fitted.height - h) / 2 return CGRect(x: x, y: y, width: w, height: h) } /// 移动时夹紧原点(尺寸不变),保证框不超出图片显示区。 private func clampOrigin(_ b: CGRect, in fitted: CGRect) -> CGRect { guard fitted.width > 0 else { return b } let w = min(b.width, fitted.width) let h = min(b.height, fitted.height) let x = min(max(b.minX, fitted.minX), fitted.maxX - w) let y = min(max(b.minY, fitted.minY), fitted.maxY - h) return CGRect(x: x, y: y, width: w, height: h) } /// 缩放时夹紧尺寸,保证不超出图片显示区。 private func clampSize(_ b: CGRect, in fitted: CGRect) -> CGRect { guard fitted.width > 0 else { return b } let w = max(minBox, min(b.width, fitted.maxX - b.minX)) let h = max(minBox, min(b.height, fitted.maxY - b.minY)) return CGRect(x: b.minX, y: b.minY, width: w, height: h) } private func moveGesture(in fitted: CGRect) -> some Gesture { DragGesture() .onChanged { v in if dragStartBox == nil { dragStartBox = box } let start = dragStartBox ?? box let moved = start.offsetBy(dx: v.translation.width, dy: v.translation.height) box = clampOrigin(moved, in: fitted) } .onEnded { _ in dragStartBox = nil } } private func resizeGesture(in fitted: CGRect) -> some Gesture { DragGesture() .onChanged { v in if resizeStartBox == nil { resizeStartBox = box } let start = resizeStartBox ?? box let grown = CGRect( x: start.minX, y: start.minY, width: start.width + v.translation.width, height: start.height + v.translation.height ) box = clampSize(grown, in: fitted) } .onEnded { _ in resizeStartBox = nil } } // MARK: - 识别 private func runRecognize() { guard !isRecognizing, fittedRect.width > 1, box.width > 1, box.height > 1 else { return } let cropped = RegionImageCropper.cropAspectFit(image, box: box, imageFrame: fittedRect) recognizeTask?.cancel() isRecognizing = true statusText = String(appLoc: "本地识别中…") recognizeTask = Task { let watchdog = Task { try? await Task.sleep(for: .seconds(timeoutSeconds)) recognizeTask?.cancel() } defer { watchdog.cancel() } let result = await recognize(cropped) isRecognizing = false if Task.isCancelled { statusText = String(appLoc: "识别超时,挪一下框再试或手动补充") return } items = result.items statusText = result.warning ?? String(appLoc: "识别到 \(result.items.count) 项,可继续挪框或进入核对") } } }