feat(Quick): 异常项快拍流程重构为静态图框选识别模式

重构异常项快拍功能,将原有的局部小框拍摄改为整幅单拍后静态框选模式。
新流程为:整幅单拍/相册选择 → 静态图手动框选 → 框内OCR+LLM提取指标 → 核对 → 存储独立Indicator。

主要变更包括:
- 移除实时预览小框拍摄模式,改为整幅拍摄后手动框选
- 新增RegionAdjustView组件用于静态图框选和识别
- 更新状态机流程:idle → adjust(静态图框选) → confirm → save
- 修改识别逻辑,对框选区域进行OCR+LLM处理
- 更新相机组件为SingleShotCameraView,支持整幅拍摄
- 调整错误处理策略,识别失败时可挪框重试而非强制手动录入
- 优化本地化字符串,更新用户界面提示文案
```
This commit is contained in:
link2026
2026-06-07 14:27:25 +08:00
parent 77a4ee1c37
commit ac11aa0f99
5 changed files with 509 additions and 313 deletions

View File

@@ -0,0 +1,261 @@
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<Void, Never>? = 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)
}
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, 18)
.padding(.vertical, 12)
.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) 项,可继续挪框或进入核对")
}
}
}