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:
305
康康/Features/Quick/QuickRegionConfirmView.swift
Normal file
305
康康/Features/Quick/QuickRegionConfirmView.swift
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user