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

@@ -1,32 +1,25 @@
import SwiftUI
import SwiftData
import UIKit
import Combine
/// ·
/// VL ( indicators) Indicator( Report)
/// ()/ () OCR+LLM Indicator
///
/// :
/// ```
/// idle(/) analyzing(croppedImage) confirm(items)
/// /
/// confirm( + warning)
/// confirm save dismiss · confirm idle
/// 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
@State private var analyzeTask: Task<Void, Never>? = nil
/// VL (); cancel ,UI
/// token ,30s , 60s
private let analyzeTimeoutSeconds: Int = 60
enum Phase {
case idle
case analyzing(image: UIImage)
case adjust(image: UIImage)
case confirm(image: UIImage?, items: [QuickRegionItem], warning: String?)
}
@@ -42,28 +35,17 @@ struct QuickRegionCaptureFlow: View {
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 .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() }
)
.ignoresSafeArea()
case .confirm(let image, let items, let warning):
NavigationStack {
@@ -72,14 +54,14 @@ struct QuickRegionCaptureFlow: View {
items: items,
warning: warning,
onSave: { finalItems, capturedAt in save(items: finalItems, capturedAt: capturedAt) },
onCancel: cancelAll,
onCancel: { onClose() },
onRetake: { phase = .idle }
)
.navigationTitle(String(appLoc: "核对异常项"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { cancelAll() }
Button("取消") { onClose() }
.foregroundStyle(Tj.Palette.text)
}
}
@@ -87,100 +69,59 @@ struct QuickRegionCaptureFlow: View {
}
}
// MARK: - :()/ ()
// MARK: - :()/ ()
// RegionCameraView ( 1-2 ); ·
// , ,VL VisionKit :
// + ,VL / 退
@ViewBuilder
private var captureEntry: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
onFinish: { imgs in handleScanned(imgs) },
onFinish: { handlePicked($0) },
onCancel: onClose
)
#else
if DocumentScannerView.isSupported {
DocumentScannerView(
onFinish: { imgs in handleScanned(imgs) },
onCancel: onClose
)
} else {
PhotoPickerSheet(
onFinish: { imgs in handleScanned(imgs) },
onCancel: onClose
)
}
SingleShotCameraView(
onCapture: { phase = .adjust(image: $0) },
onCancel: onClose
)
#endif
}
/// /:();
private func handleScanned(_ images: [UIImage]) {
/// /:;(=)
private func handlePicked(_ images: [UIImage]) {
if let first = images.first {
startAnalyze(image: first)
phase = .adjust(image: first)
} else {
onClose()
}
}
// MARK: -
// MARK: - ( OCR LLM)
private func startAnalyze(image: UIImage) {
analyzeTask?.cancel()
phase = .analyzing(image: image)
let timeout = analyzeTimeoutSeconds
// MainActor ,Task{} , phase 线,
// :Vision OCR Qwen3-1.7B LLM ( 3B VL )
analyzeTask = Task {
let timeoutWarn = String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍")
let watchdog = Task {
try? await Task.sleep(for: .seconds(timeout))
analyzeTask?.cancel()
}
defer { watchdog.cancel() }
do {
// 1. OCR
let text = try await OCRService.recognizeText(in: image)
if Task.isCancelled {
phase = .confirm(image: image, items: [], warning: timeoutWarn); return
}
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
#if DEBUG
print("🔤 [OCR] recognized text:\n\(trimmed)\n--- end OCR ---")
#endif
if trimmed.isEmpty {
phase = .confirm(image: image, items: [],
warning: String(appLoc: "没识别到文字,手动补充或重拍"))
return
}
// 2. LLM
let parsed = try await CaptureService.shared.recognizeIndicators(fromOCRText: trimmed)
if Task.isCancelled {
phase = .confirm(image: image, items: [], warning: timeoutWarn); 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: "AI 模型未就绪,手动补充"))
} catch let CaptureError.parseFailed(msg) {
phase = .confirm(image: image, items: [],
warning: String(appLoc: "解析失败:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
phase = .confirm(image: image, items: [],
warning: Task.isCancelled ? timeoutWarn
: String(appLoc: "识别失败:\(msg)"))
} catch {
phase = .confirm(image: image, items: [],
warning: Task.isCancelled ? timeoutWarn
: String(appLoc: "未知错误:\(error.localizedDescription)"))
/// /,( RegionAdjustView )
/// :Vision OCR Qwen3-1.7B ( indicator-capture-ocr-llm)
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
print("🔤 [OCR · region] recognized text:\n\(trimmed)\n--- end OCR ---")
#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)"))
}
}
@@ -190,7 +131,6 @@ struct QuickRegionCaptureFlow: View {
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
@@ -199,13 +139,7 @@ struct QuickRegionCaptureFlow: View {
}.map { $0.element }
}
// MARK: - /
private func cancelAll() {
analyzeTask?.cancel()
analyzeTask = nil
onClose()
}
// MARK: -
/// Indicator(): Report Asset seriesKey
private func save(items: [QuickRegionItem], capturedAt: Date) {
@@ -230,56 +164,3 @@ struct QuickRegionCaptureFlow: View {
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(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
if elapsed >= timeoutSeconds - 5 {
Text("快超时了,>\(timeoutSeconds)s 会自动转手动录入")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.amber)
}
}
Button("取消识别 · 改为手动录入", action: onCancel)
.font(.tjScaled( 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 }
}
}