将「异常项快拍」从复用整页报告归档流程,改造成独立的局部识别路径: 小框拍局部 → Qwen-VL 只抽 indicators → 用户确认逐项编辑 → 存成独立 Indicator(不建 Report、不留原图,与「记录指标」统一落库)。 - RegionCameraView: AVFoundation 实时预览 + 居中小框,快门后按 metadataOutputRectConverted 裁剪到框内区域;含裁剪纯函数与权限态。 - VLPrompts.regionExtraction(): 局部识别 prompt,严格 JSON 只要 indicators。 - CaptureService.recognizeRegion(): 临时文件推理后即删,不写 Vault; 新增 parseIndicatorsJSON / extractBalancedJSON 解析容错。 - QuickRegionConfirmView: 异常项高亮置顶、默认勾选,可编辑/增删/选纳入。 - QuickRegionCaptureFlow: 状态机 idle→analyzing→confirm,30s 超时回退手动。 - RootView: .quick 路由改指向新流程(.archive 仍走 UnifiedCaptureFlow)。 - 删除 5 个无引用的旧 mockup(A1/A2/A3/SmartFramer/QuickCaptureFlow)。 模拟器无相机退化为相册整图;小框裁剪坐标需真机验证。 设计见 docs/superpowers/specs/2026-05-31-abnormal-quick-capture-design.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
255 lines
9.7 KiB
Swift
255 lines
9.7 KiB
Swift
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<Void, Never>? = 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 }
|
|
}
|
|
}
|