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

@@ -1,159 +0,0 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct A1ViewfinderView: View {
var onShoot: () -> Void
var onClose: () -> Void
@State private var dotPulse = false
var body: some View {
GeometryReader { geometry in
ZStack {
Color(red: 0.04, green: 0.047, blue: 0.04).ignoresSafeArea()
mockCameraPreview(screenHeight: geometry.size.height)
VStack {
HStack {
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white)
.frame(width: 36, height: 36)
}
Spacer()
}
.padding(.horizontal, 6)
.padding(.top, 50)
topHint
Spacer()
}
SmartFramer()
.allowsHitTesting(false)
.ignoresSafeArea()
identifiedPill
.padding(.top, geometry.size.height * 0.62 - 20)
VStack {
Spacer()
bottomControls
}
}
}
#if os(iOS)
.statusBarHidden(false)
#endif
.preferredColorScheme(.dark)
}
private func mockCameraPreview(screenHeight: CGFloat) -> some View {
RadialGradient(
colors: [Color.white.opacity(0.05), Color.clear],
center: .init(x: 0.5, y: 0.3),
startRadius: 20,
endRadius: 400
)
.overlay(alignment: .center) {
VStack(alignment: .leading, spacing: 6) {
Text("总胆固醇 TC 5.42 mmol/L").opacity(0.65)
Text("甘油三酯 TG 1.78 mmol/L").opacity(0.65)
Text("低密度脂蛋白 3.84 mmol/L ↑").fontWeight(.semibold).opacity(1)
Text("高密度脂蛋白 1.21 mmol/L").opacity(0.65)
Text("载脂蛋白 A1 1.42 g/L").opacity(0.45)
Text("载脂蛋白 B 1.04 g/L").opacity(0.45)
}
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text)
.padding(.vertical, 20)
.padding(.horizontal, 18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.92))
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
.rotationEffect(.degrees(-1.2))
.shadow(color: .black.opacity(0.45), radius: 15, x: 0, y: 12)
.padding(.horizontal, 24)
.padding(.vertical, screenHeight * 0.20)
}
}
private var topHint: some View {
Text("对准异常的那一行就好 · 不用拍整张")
.font(.system(size: 12))
.tracking(0.5)
.foregroundStyle(Color.white.opacity(0.92))
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(Capsule().fill(Color(red: 0.08, green: 0.11, blue: 0.094).opacity(0.7)))
.padding(.top, 6)
}
private var identifiedPill: some View {
HStack(spacing: 6) {
Circle()
.fill(Tj.Palette.paper)
.frame(width: 6, height: 6)
.opacity(dotPulse ? 1 : 0.35)
Text("AI 已识别到 1 项指标")
.font(.system(size: 11))
.tracking(0.5)
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Capsule().fill(Color(red: 0.37, green: 0.47, blue: 0.31).opacity(0.85)))
.onAppear {
withAnimation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true)) {
dotPulse.toggle()
}
}
}
private var bottomControls: some View {
HStack {
CircleIconButton(icon: "bolt.fill", size: 44) { }
Spacer()
Button(action: onShoot) {
ZStack {
Circle().fill(Tj.Palette.ink)
Circle().strokeBorder(Tj.Palette.paper, lineWidth: 4)
}
.frame(width: 72, height: 72)
.overlay(
Circle().strokeBorder(Color.white.opacity(0.2), lineWidth: 1)
.frame(width: 76, height: 76)
)
}
.buttonStyle(.plain)
Spacer()
CircleIconButton(icon: "photo.on.rectangle", size: 44) { }
}
.padding(.horizontal, 32)
.padding(.bottom, 40)
}
}
private struct CircleIconButton: View {
let icon: String
let size: CGFloat
let action: () -> Void
var body: some View {
Button(action: action) {
ZStack {
Circle().fill(Color.white.opacity(0.12))
Image(systemName: icon)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(Tj.Palette.paper)
}
.frame(width: size, height: size)
}
.buttonStyle(.plain)
}
}

View File

@@ -1,180 +0,0 @@
import SwiftUI
struct A2ConfirmView: View {
var onSave: () -> Void
var onNext: () -> Void
var onBack: () -> Void
@State private var expanded = false
var body: some View {
VStack(spacing: 0) {
header
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
croppedPhoto.padding(.bottom, 14)
resultCard.padding(.bottom, 16)
actions
}
.padding(.horizontal, 18)
.padding(.bottom, 18)
}
}
.background(Tj.Palette.sand.ignoresSafeArea())
}
private var header: some View {
HStack(spacing: 6) {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
}
Text("核对识别结果")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("识别用时 0.4s · 本地")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.sand2))
}
.padding(.horizontal, 12)
.padding(.top, 4)
.padding(.bottom, 8)
}
private var croppedPhoto: some View {
ZStack(alignment: .topTrailing) {
Text("低密度脂蛋白 3.84 mmol/L ↑")
.font(.system(size: 13, design: .monospaced))
.fontWeight(.semibold)
.tracking(0.3)
.foregroundStyle(Tj.Palette.text)
.padding(.vertical, 14)
.padding(.horizontal, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.92))
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06),
radius: 2, x: 0, y: 1)
Text("已裁剪")
.font(.system(size: 9))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
.padding(.top, 8)
.padding(.trailing, 10)
}
}
private var resultCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("指标名 · 可编辑")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Text("低密度脂蛋白胆固醇")
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("LDL-C")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
TjBadge(text: String(appLoc: "偏高"), style: .brick)
}
HStack(spacing: 12) {
FieldBox(label: String(appLoc: "数值")) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("3.84")
.font(.system(size: 30, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text("mmol/L")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
FieldBox(label: String(appLoc: "参考范围")) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("< 3.40")
.font(.system(size: 14, design: .monospaced))
.foregroundStyle(Tj.Palette.text2)
Text("mmol/L")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
}
Button { withAnimation { expanded.toggle() } } label: {
HStack(alignment: .top, spacing: 10) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Tj.Palette.brick)
.frame(width: 4)
Text(expanded
? "超过参考上限 0.44属轻度偏高。建议关注饮食结构减少动物脂肪摄入3 个月内复查。若家族有心血管病史,可与医生沟通是否需要药物干预。"
: "超过参考上限 0.44,属轻度偏高。点击展开详细解读 ")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text2)
.lineSpacing(5)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand)
)
}
.buttonStyle(.plain)
}
.padding(18)
.tjCard()
}
private var actions: some View {
VStack(spacing: 10) {
Button(action: onSave) {
Text("保存到记录")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
Button(action: onNext) {
HStack(spacing: 8) {
Image(systemName: "camera.fill").font(.system(size: 14))
Text("继续拍下一项")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(TjGhostButton())
}
}
}
private struct FieldBox<Content: View>: View {
let label: String
@ViewBuilder var content: Content
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.system(size: 10))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
content
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
.padding(.horizontal, 12)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
}

View File

@@ -1,124 +0,0 @@
import SwiftUI
struct A3BatchItem {
let name: String
let value: String
let unit: String
let range: String
let status: IndicatorStatus
}
struct A3BatchView: View {
var onAddMore: () -> Void
var onFinish: () -> Void
var onBack: () -> Void
let items: [A3BatchItem] = [
.init(name: String(appLoc: "低密度脂蛋白胆固醇"), value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high),
.init(name: String(appLoc: "甘油三酯 TG"), value: "1.78", unit: "mmol/L", range: "< 1.70", status: .high),
.init(name: String(appLoc: "空腹血糖 GLU"), value: "5.4", unit: "mmol/L", range: "3.96.1", status: .normal),
]
var body: some View {
VStack(spacing: 0) {
header
ScrollView(showsIndicators: false) {
VStack(spacing: 10) {
ForEach(Array(items.enumerated()), id: \.offset) { idx, it in
BatchRow(index: idx + 1, item: it)
}
addRow
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
HStack(spacing: 10) {
Button {
onFinish()
} label: {
Text("全部保存(\(items.count)").frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
}
.padding(.horizontal, 16)
.padding(.bottom, 14)
}
.background(Tj.Palette.sand.ignoresSafeArea())
}
private var header: some View {
HStack(spacing: 6) {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
}
VStack(alignment: .leading, spacing: 2) {
Text("本次已记录 \(items.count)")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("核对后一次保存")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Text("· · ·")
.font(.system(size: 14, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
.padding(.trailing, 12)
}
.padding(.horizontal, 12)
.padding(.top, 4)
.padding(.bottom, 12)
}
private var addRow: some View {
Button(action: onAddMore) {
HStack(spacing: 8) {
Image(systemName: "camera").font(.system(size: 14))
Text("再拍一项")
.font(.system(size: 13))
}
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.line, style: StrokeStyle(lineWidth: 1.5, dash: [4, 4]))
)
}
.buttonStyle(.plain)
}
}
private struct BatchRow: View {
let index: Int
let item: A3BatchItem
var body: some View {
HStack(spacing: 12) {
TjPlaceholder(label: "#\(index)")
.frame(width: 60, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text(item.name)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Text("范围 \(item.range) \(item.unit)")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(item.value)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(item.status == .high ? Tj.Palette.brick : Tj.Palette.text)
TjBadge(text: item.status == .high ? String(appLoc: "偏高") : String(appLoc: "正常"),
style: item.status == .high ? .brick : .leaf)
}
}
.padding(12)
.tjCard()
}
}

View File

@@ -1,60 +0,0 @@
import SwiftUI
private enum QuickStep: Hashable {
case viewfinder
case confirm
case batch
}
struct QuickCaptureFlow: View {
var onClose: () -> Void
@State private var step: QuickStep = .viewfinder
@State private var snapCount = 0
var body: some View {
ZStack {
switch step {
case .viewfinder:
A1ViewfinderView(
onShoot: {
snapCount += 1
withAnimation(.easeInOut(duration: 0.25)) { step = .confirm }
},
onClose: onClose
)
.transition(.opacity)
case .confirm:
A2ConfirmView(
onSave: {
if snapCount >= 2 {
withAnimation { step = .batch }
} else {
onClose()
}
},
onNext: {
withAnimation { step = .viewfinder }
},
onBack: {
withAnimation { step = .viewfinder }
}
)
.transition(.opacity)
case .batch:
A3BatchView(
onAddMore: {
withAnimation { step = .viewfinder }
},
onFinish: onClose,
onBack: {
withAnimation { step = .confirm }
}
)
.transition(.opacity)
}
}
}
}

View File

@@ -0,0 +1,254 @@
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 }
}
}

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
}

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)
}
}
}

View File

@@ -1,100 +0,0 @@
import SwiftUI
struct SmartFramer: View {
var radius: CGFloat = 10
var height: CGFloat = 56
@State private var breathing = false
var body: some View {
GeometryReader { geo in
ZStack {
Color.black.opacity(0.32)
.mask(
Rectangle()
.overlay(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.frame(height: height)
.padding(.horizontal, geo.size.width * 0.08)
.blendMode(.destinationOut)
)
.compositingGroup()
)
RoundedRectangle(cornerRadius: radius + 4, style: .continuous)
.stroke(Color(red: 0.95, green: 0.78, blue: 0.45), lineWidth: 1.5)
.shadow(color: Color(red: 0.95, green: 0.78, blue: 0.45).opacity(0.5), radius: 8)
.frame(height: height + 8)
.padding(.horizontal, geo.size.width * 0.08 - 4)
.opacity(breathing ? 1 : 0.35)
cornerMarks(in: geo.size)
}
.frame(width: geo.size.width, height: geo.size.height)
.onAppear {
withAnimation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true)) {
breathing.toggle()
}
}
}
}
private func cornerMarks(in size: CGSize) -> some View {
let inset = size.width * 0.08
return ZStack {
ForEach(Corner.allCases, id: \.self) { corner in
CornerMark(corner: corner, radius: radius)
.frame(width: 18, height: 18)
.position(corner.position(in: size, inset: inset, frameHeight: height))
}
}
}
}
private enum Corner: CaseIterable {
case tl, tr, bl, br
func position(in size: CGSize, inset: CGFloat, frameHeight: CGFloat) -> CGPoint {
let centerY = size.height / 2
let top = centerY - frameHeight / 2
let bottom = centerY + frameHeight / 2
switch self {
case .tl: return CGPoint(x: inset, y: top)
case .tr: return CGPoint(x: size.width - inset, y: top)
case .bl: return CGPoint(x: inset, y: bottom)
case .br: return CGPoint(x: size.width - inset, y: bottom)
}
}
}
private struct CornerMark: View {
let corner: Corner
let radius: CGFloat
var body: some View {
Path { p in
let r = min(radius, 8)
switch corner {
case .tl:
p.move(to: CGPoint(x: 0, y: 18))
p.addLine(to: CGPoint(x: 0, y: r))
p.addQuadCurve(to: CGPoint(x: r, y: 0), control: CGPoint(x: 0, y: 0))
p.addLine(to: CGPoint(x: 18, y: 0))
case .tr:
p.move(to: CGPoint(x: 0, y: 0))
p.addLine(to: CGPoint(x: 18 - r, y: 0))
p.addQuadCurve(to: CGPoint(x: 18, y: r), control: CGPoint(x: 18, y: 0))
p.addLine(to: CGPoint(x: 18, y: 18))
case .bl:
p.move(to: CGPoint(x: 0, y: 0))
p.addLine(to: CGPoint(x: 0, y: 18 - r))
p.addQuadCurve(to: CGPoint(x: r, y: 18), control: CGPoint(x: 0, y: 18))
p.addLine(to: CGPoint(x: 18, y: 18))
case .br:
p.move(to: CGPoint(x: 0, y: 18))
p.addLine(to: CGPoint(x: 18 - r, y: 18))
p.addQuadCurve(to: CGPoint(x: 18, y: 18 - r), control: CGPoint(x: 18, y: 18))
p.addLine(to: CGPoint(x: 18, y: 0))
}
}
.stroke(Tj.Palette.paper, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
}
}