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,305 @@
import SwiftUI
import UIKit
/// · VL + ,()
/// = Indicator
struct QuickRegionConfirmView: View {
let image: UIImage?
let warning: String?
let onSave: ([QuickRegionItem], Date) -> Void
let onCancel: () -> Void
let onRetake: () -> Void
@State private var items: [QuickRegionItem]
@State private var capturedAt: Date
init(image: UIImage?,
items: [QuickRegionItem],
warning: String?,
capturedAt: Date = .now,
onSave: @escaping ([QuickRegionItem], Date) -> Void,
onCancel: @escaping () -> Void,
onRetake: @escaping () -> Void) {
self.image = image
self.warning = warning
self.onSave = onSave
self.onCancel = onCancel
self.onRetake = onRetake
_items = State(initialValue: items)
_capturedAt = State(initialValue: capturedAt)
}
private var selectedCount: Int {
items.filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty
&& !$0.value.trimmingCharacters(in: .whitespaces).isEmpty }.count
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if let warning { warningBanner(warning) }
if let image { thumbnailCard(image) }
timeCard
itemsCard
}
.padding(20)
}
.safeAreaInset(edge: .bottom) { bottomBar }
.background(Tj.Palette.sand.ignoresSafeArea())
}
// MARK: -
private func warningBanner(_ text: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.amber)
Text(text)
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.amber.opacity(0.12))
)
}
private func thumbnailCard(_ image: UIImage) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("拍到的局部")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Text("仅核对用 · 不保存照片")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
.frame(maxHeight: 180)
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
Button {
onRetake()
} label: {
Label("重拍", systemImage: "camera.rotate")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
}
}
.padding(16)
.tjCard()
}
private var timeCard: some View {
VStack(alignment: .leading, spacing: 10) {
Text("测量时间")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
DatePicker("", selection: $capturedAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
}
.padding(16)
.tjCard()
}
private var itemsCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("识别到的指标 (\(items.count))")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Button {
items.append(QuickRegionItem(name: "", value: "", unit: "", range: "",
status: .high, include: true))
} label: {
Label("加一项", systemImage: "plus.circle.fill")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
}
}
if items.isEmpty {
Text("没有识别到指标,点「加一项」手动补充,或返回重拍")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 20)
} else {
ForEach($items) { $item in
itemRow($item)
}
}
}
.padding(16)
.tjCard()
}
private func itemRow(_ item: Binding<QuickRegionItem>) -> some View {
let abnormal = item.wrappedValue.status != .normal
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Button {
item.wrappedValue.include.toggle()
} label: {
Image(systemName: item.wrappedValue.include ? "checkmark.circle.fill" : "circle")
.font(.system(size: 20))
.foregroundStyle(item.wrappedValue.include ? Tj.Palette.ink : Tj.Palette.text3)
}
.buttonStyle(.plain)
TextField(String(appLoc: "指标名"), text: item.name)
.font(.system(size: 15, weight: .medium))
if abnormal {
Text(statusLabel(item.wrappedValue.status))
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(statusColor(item.wrappedValue.status))
.padding(.horizontal, 7).padding(.vertical, 3)
.background(Capsule().fill(statusColor(item.wrappedValue.status).opacity(0.16)))
}
Button {
if let idx = items.firstIndex(where: { $0.id == item.wrappedValue.id }) {
items.remove(at: idx)
}
} label: {
Image(systemName: "trash")
.font(.system(size: 14))
.foregroundStyle(Tj.Palette.brick)
}
}
HStack(spacing: 10) {
fieldCol(String(appLoc: "数值"), item.value, width: 80, mono: true)
fieldCol(String(appLoc: "单位"), item.unit, width: 80)
fieldCol(String(appLoc: "范围"), item.range)
}
statusPicker(item)
}
.padding(12)
.opacity(item.wrappedValue.include ? 1 : 0.5)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(abnormal ? statusColor(item.wrappedValue.status).opacity(0.6) : Tj.Palette.line,
lineWidth: abnormal ? 1.5 : 1)
)
}
private func fieldCol(_ label: String, _ text: Binding<String>, width: CGFloat? = nil,
mono: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
TextField("", text: text)
.font(.system(size: 14, weight: mono ? .semibold : .regular,
design: mono ? .monospaced : .default))
.keyboardType(mono ? .decimalPad : .default)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
.frame(width: width)
}
.frame(maxWidth: width == nil ? .infinity : nil, alignment: .leading)
}
private func statusPicker(_ item: Binding<QuickRegionItem>) -> some View {
HStack(spacing: 8) {
ForEach(IndicatorStatus.allCases, id: \.self) { st in
let selected = item.wrappedValue.status == st
Button {
item.wrappedValue.status = st
} label: {
Text(statusLabel(st))
.font(.system(size: 12, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text2)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Capsule().fill(selected ? statusColor(st) : Tj.Palette.paper))
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1))
}
.buttonStyle(.plain)
}
Spacer()
}
}
private func statusLabel(_ s: IndicatorStatus) -> String {
switch s {
case .normal: return String(appLoc: "正常")
case .high: return String(appLoc: "偏高 ↑")
case .low: return String(appLoc: "偏低 ↓")
}
}
private func statusColor(_ s: IndicatorStatus) -> Color {
switch s {
case .normal: return Tj.Palette.leaf
case .high: return Tj.Palette.brick
case .low: return Tj.Palette.amber
}
}
private var bottomBar: some View {
HStack(spacing: 12) {
Button(action: onCancel) {
Text("取消")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
Button {
onSave(items, capturedAt)
} label: {
Text(selectedCount > 0 ? "\(String(appLoc: "保存到记录"))(\(selectedCount))"
: String(appLoc: "保存到记录"))
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
.disabled(selectedCount == 0)
.opacity(selectedCount == 0 ? 0.4 : 1)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(
Tj.Palette.sand
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
)
}
}
/// `include` (,)
struct QuickRegionItem: Identifiable {
let id = UUID()
var name: String
var value: String
var unit: String
var range: String
var status: IndicatorStatus
var include: Bool
}