feat(quick): 异常项快拍改为局部小框 + VL 识别

将「异常项快拍」从复用整页报告归档流程,改造成独立的局部识别路径:
小框拍局部 → 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>
This commit is contained in:
link2026
2026-05-31 17:12:36 +08:00
parent da6223e051
commit adb589af16
12 changed files with 1163 additions and 625 deletions

View File

@@ -0,0 +1,349 @@
import SwiftUI
import AVFoundation
import UIKit
import Combine
/// ·
/// + + **** UIImage
/// (,QuickRegionCaptureFlow 退 PhotoPicker)
///
/// :`previewLayer.metadataOutputRectConverted(fromLayerRect:)`
/// (0-1) rect; bake `.up`, rect CGImage
struct RegionCameraView: View {
let onCapture: (UIImage) -> Void
let onCancel: () -> Void
@StateObject private var controller = RegionCameraController()
@State private var authState: AuthState = .checking
@State private var isCapturing = false
@State private var flash = false
enum AuthState { case checking, authorized, denied }
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
switch authState {
case .checking:
ProgressView().tint(.white)
case .denied:
deniedView
case .authorized:
cameraStack
}
if flash {
Color.white.ignoresSafeArea().transition(.opacity)
}
}
.task { await resolveAuth() }
}
// MARK: - + +
private var cameraStack: some View {
GeometryReader { proxy in
let box = RegionFraming.box(in: proxy.size)
ZStack {
RegionCameraPreview(controller: controller)
.ignoresSafeArea()
// (even-odd ),
Canvas { ctx, size in
var path = Path(CGRect(origin: .zero, size: size))
path.addPath(Path(roundedRect: box, cornerRadius: Tj.Radius.md))
ctx.fill(path, with: .color(.black.opacity(0.5)), style: FillStyle(eoFill: true))
}
.ignoresSafeArea()
.allowsHitTesting(false)
//
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Color.white.opacity(0.95),
style: StrokeStyle(lineWidth: 2, dash: [8, 6]))
.frame(width: box.width, height: box.height)
.position(x: box.midX, y: box.midY)
.allowsHitTesting(false)
//
Text("把异常项放进框里 · 对准一两行")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Capsule().fill(.black.opacity(0.4)))
.position(x: box.midX, y: box.minY - 22)
.allowsHitTesting(false)
controlsOverlay
}
}
}
private var controlsOverlay: some View {
VStack {
HStack {
Button {
onCancel()
} label: {
Text("取消")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Capsule().fill(.black.opacity(0.35)))
}
Spacer()
}
.padding(.horizontal, 18)
.padding(.top, 8)
Spacer()
shutterButton
.padding(.bottom, 36)
}
}
private var shutterButton: some View {
Button {
capture()
} label: {
ZStack {
Circle().fill(.white).frame(width: 72, height: 72)
Circle().strokeBorder(.white.opacity(0.6), lineWidth: 3).frame(width: 84, height: 84)
if isCapturing {
ProgressView().tint(.black)
}
}
}
.disabled(isCapturing)
.accessibilityLabel("拍摄异常项")
}
private var deniedView: some View {
VStack(spacing: 16) {
Image(systemName: "camera.fill")
.font(.system(size: 40))
.foregroundStyle(.white.opacity(0.8))
Text("相机权限未开启")
.font(.tjH2())
.foregroundStyle(.white)
Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。")
.font(.system(size: 13))
.foregroundStyle(.white.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 36)
HStack(spacing: 12) {
Button("取消") { onCancel() }
.font(.system(size: 15))
.foregroundStyle(.white)
.padding(.horizontal, 18).padding(.vertical, 10)
.background(Capsule().strokeBorder(.white.opacity(0.5), lineWidth: 1))
Button("去设置") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.black)
.padding(.horizontal, 18).padding(.vertical, 10)
.background(Capsule().fill(.white))
}
}
}
// MARK: -
private func capture() {
guard !isCapturing else { return }
isCapturing = true
withAnimation(.easeOut(duration: 0.08)) { flash = true }
controller.capture { image in
withAnimation(.easeIn(duration: 0.15)) { flash = false }
isCapturing = false
guard let image else { return }
onCapture(image)
}
}
private func resolveAuth() async {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
authState = .authorized
case .notDetermined:
let granted = await AVCaptureDevice.requestAccess(for: .video)
authState = granted ? .authorized : .denied
default:
authState = .denied
}
}
}
// MARK: - (UIView SwiftUI ,)
enum RegionFraming {
/// 84% , 160 28%
static func box(in size: CGSize) -> CGRect {
guard size.width > 0, size.height > 0 else { return .zero }
let w = size.width * 0.84
let h = min(160, size.height * 0.28)
let x = (size.width - w) / 2
let y = (size.height - h) / 2 - size.height * 0.06
return CGRect(x: x, y: y, width: w, height: h)
}
}
// MARK: -
enum RegionImageCropper {
/// rect( 0-1) `.up`
/// image bake `.up`( `UIImage.normalizedUp()`);退
static func crop(_ image: UIImage, normalizedRect: CGRect) -> UIImage {
guard let cg = image.cgImage else { return image }
let w = CGFloat(cg.width), h = CGFloat(cg.height)
let nx = max(0, min(1, normalizedRect.origin.x))
let ny = max(0, min(1, normalizedRect.origin.y))
let nw = max(0, min(1 - nx, normalizedRect.size.width))
let nh = max(0, min(1 - ny, normalizedRect.size.height))
let rect = CGRect(x: nx * w, y: ny * h, width: nw * w, height: nh * h).integral
guard rect.width >= 1, rect.height >= 1, let cropped = cg.cropping(to: rect) else { return image }
return UIImage(cgImage: cropped, scale: image.scale, orientation: .up)
}
}
extension UIImage {
/// EXIF bake , `.up` ,便 rect CGImage
func normalizedUp() -> UIImage {
if imageOrientation == .up { return self }
let format = UIGraphicsImageRendererFormat.default()
format.scale = scale
let renderer = UIGraphicsImageRenderer(size: size, format: format)
return renderer.image { _ in draw(in: CGRect(origin: .zero, size: size)) }
}
}
// MARK: - AVFoundation
/// SwiftUI ,(weak UIView)
final class RegionCameraController: ObservableObject {
weak var view: RegionPreviewUIView?
func capture(_ completion: @escaping (UIImage?) -> Void) {
guard let view else { completion(nil); return }
view.capture(completion: completion)
}
}
struct RegionCameraPreview: UIViewRepresentable {
let controller: RegionCameraController
func makeUIView(context: Context) -> RegionPreviewUIView {
let v = RegionPreviewUIView()
controller.view = v
return v
}
func updateUIView(_ uiView: RegionPreviewUIView, context: Context) {}
static func dismantleUIView(_ uiView: RegionPreviewUIView, coordinator: ()) {
uiView.stop()
}
}
/// + ,
final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
private let session = AVCaptureSession()
private let output = AVCapturePhotoOutput()
private var previewLayer: AVCaptureVideoPreviewLayer?
private var setupDone = false
private var captureCompletion: ((UIImage?) -> Void)?
override func didMoveToWindow() {
super.didMoveToWindow()
guard !setupDone, window != nil else { return }
setupDone = true
configure()
}
private func configure() {
session.beginConfiguration()
session.sessionPreset = .photo
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
session.commitConfiguration()
return
}
session.addInput(input)
if session.canAddOutput(output) { session.addOutput(output) }
session.commitConfiguration()
let preview = AVCaptureVideoPreviewLayer(session: session)
preview.videoGravity = .resizeAspectFill
preview.frame = bounds
layer.addSublayer(preview)
self.previewLayer = preview
applyPortrait(preview.connection)
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.session.startRunning()
}
}
/// (iOS 17+ videoRotationAngle, videoOrientation )
private func applyPortrait(_ connection: AVCaptureConnection?) {
guard let connection else { return }
if connection.isVideoRotationAngleSupported(90) {
connection.videoRotationAngle = 90
}
}
override func layoutSubviews() {
super.layoutSubviews()
previewLayer?.frame = bounds
}
func capture(completion: @escaping (UIImage?) -> Void) {
guard session.isRunning else { completion(nil); return }
captureCompletion = completion
applyPortrait(output.connection(with: .video))
output.capturePhoto(with: AVCapturePhotoSettings(), delegate: self)
}
func stop() {
guard session.isRunning else { return }
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.session.stopRunning()
}
}
func photoOutput(_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?) {
let completion = captureCompletion
captureCompletion = nil
// AVFoundation ,SwiftUI 线
let deliver: (UIImage?) -> Void = { result in
DispatchQueue.main.async { completion?(result) }
}
guard error == nil,
let data = photo.fileDataRepresentation(),
let image = UIImage(data: data) else {
deliver(nil)
return
}
let upright = image.normalizedUp()
guard let preview = previewLayer else {
deliver(upright)
return
}
// metadataOutputRectConverted previewLayer ,线
DispatchQueue.main.async {
let box = RegionFraming.box(in: self.bounds.size)
let normalized = preview.metadataOutputRectConverted(fromLayerRect: box)
let cropped = RegionImageCropper.crop(upright, normalizedRect: normalized)
completion?(cropped)
}
}
}