将「异常项快拍」从复用整页报告归档流程,改造成独立的局部识别路径: 小框拍局部 → 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>
306 lines
12 KiB
Swift
306 lines
12 KiB
Swift
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
|
|
}
|