Files
kangkang/康康/Features/Capture/UnifiedCaptureFlow.swift
link2026 1b01923c8e feat(capture): 统一报告捕获流程并集成视觉语言模型识别
- 替换 QuickCaptureFlow 和 ArchiveFlow 为 UnifiedCaptureFlow 统一流程
- 新增 VLSession 封装 Qwen2.5-VL 模型进行图像文本推理
- 实现 AIRuntime 中 VL 模型的准备和分析功能
- 添加 VLPrompts 定义体检化验单识别的 JSON 输出模板
- 创建 CaptureReviewForm 提供 VL 解析结果的可编辑表单界面
- 集成 VisionKit 文档扫描器支持真机多页文档扫描
- 为模拟器实现 PhotosPicker 回退方案选择已有照片
- 在 RootView 中统一使用 UnifiedCaptureFlow 处理快速和归档流程
- 添加 CustomMetricEditor 支持自定义监测指标的创建编辑删除
- 扩展 KangkangApp 模型配置以支持新数据类型
- 实现档案列表中症状结束功能通过时间线行点击触发
2026-05-26 11:18:00 +08:00

229 lines
7.6 KiB
Swift

import SwiftUI
import SwiftData
import UIKit
/// VL ( + )
/// , A1-A3 / B1-B5 mockup
///
/// :
/// ```
/// idle captured(images) analyzing editing(parsed, assets)
///
/// editing(empty, assets)
/// editing saved dismiss
/// ```
struct UnifiedCaptureFlow: View {
@Environment(\.modelContext) private var ctx
let onClose: () -> Void
@State private var phase: Phase = .idle
enum Phase {
case idle
case analyzing(images: [UIImage])
case editing(parsed: ParsedReport,
assets: [FileVault.SavedAsset],
warning: String?)
}
var body: some View {
NavigationStack {
content
.background(Tj.Palette.sand.ignoresSafeArea())
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { onClose() }
.foregroundStyle(Tj.Palette.text)
}
}
.navigationTitle(phaseTitle)
.navigationBarTitleDisplayMode(.inline)
}
}
private var phaseTitle: String {
switch phase {
case .idle: return "拍摄报告"
case .analyzing: return "本地识别中…"
case .editing: return "核对识别结果"
}
}
@ViewBuilder
private var content: some View {
switch phase {
case .idle:
captureEntry
case .analyzing(let images):
AnalyzingView(images: images)
case .editing(let parsed, let assets, let warning):
CaptureReviewForm(
parsed: parsed,
assets: assets,
warning: warning,
onSave: { final in saveAll(parsed: final, assets: assets) },
onCancel: onClose
)
}
}
// MARK: - : /
private var captureEntry: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
onFinish: { startAnalyze(images: $0) },
onCancel: onClose
)
#else
if DocumentScannerView.isSupported {
DocumentScannerView(
onFinish: { startAnalyze(images: $0) },
onCancel: onClose
)
.ignoresSafeArea()
} else {
PhotoPickerSheet(
onFinish: { startAnalyze(images: $0) },
onCancel: onClose
)
}
#endif
}
// MARK: -
private func startAnalyze(images: [UIImage]) {
guard !images.isEmpty else { onClose(); return }
phase = .analyzing(images: images)
Task {
do {
let result = try await CaptureService.shared.analyze(images: images)
await MainActor.run {
phase = .editing(
parsed: result.parsed,
assets: result.assets,
warning: result.parsed.isEmpty
? "识别没有读出指标,请手动补充"
: nil
)
}
} catch let CaptureError.parseFailed(msg) {
// :, indicators ,assets
await fallbackToManual(images: images, msg: "VL 输出无法解析:\(msg)")
} catch let CaptureError.inferenceFailed(msg) {
await fallbackToManual(images: images, msg: "推理失败:\(msg)")
} catch let CaptureError.modelNotReady {
await fallbackToManual(images: images, msg: "VL 模型未就绪,先手动录入")
} catch CaptureError.writeAssetFailed {
await MainActor.run {
phase = .editing(
parsed: .empty(),
assets: [],
warning: "图片保存失败,手动录入并保留文本"
)
}
} catch {
await fallbackToManual(images: images, msg: "未知错误:\(error.localizedDescription)")
}
}
}
private func fallbackToManual(images: [UIImage], msg: String) async {
// 便 VL , Vault( CaptureService.analyze 1 )
// writeAsset (modelNotReady / inferenceFailed),
// ,
var assets: [FileVault.SavedAsset] = []
for img in images {
if let a = try? FileVault.shared.writeJPEG(img) { assets.append(a) }
}
await MainActor.run {
phase = .editing(
parsed: .empty(),
assets: assets,
warning: msg
)
}
}
// MARK: -
private func saveAll(parsed final: ParsedReport,
assets: [FileVault.SavedAsset]) {
let report = Report(
title: final.title.isEmpty ? "拍摄识别" : final.title,
type: ReportType(rawValue: final.typeRaw) ?? .other,
reportDate: final.reportDate,
institution: final.institution.isEmpty ? nil : final.institution,
summary: final.summary.isEmpty ? nil : final.summary,
pageCount: final.pageCount
)
ctx.insert(report)
// Asset
for a in assets {
let asset = Asset(relativePath: a.relativePath, bytes: a.bytes)
ctx.insert(asset)
report.assets.append(asset)
}
// Indicator
for ind in final.indicators {
let i = Indicator(
name: ind.name,
value: ind.value,
unit: ind.unit,
range: ind.range,
status: ind.status,
capturedAt: final.reportDate,
report: report
)
ctx.insert(i)
}
try? ctx.save()
onClose()
}
}
// MARK: -
private struct AnalyzingView: View {
let images: [UIImage]
var body: some View {
VStack(spacing: 20) {
Spacer()
if let first = images.first {
Image(uiImage: first)
.resizable()
.scaledToFit()
.frame(maxHeight: 240)
.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.4)
)
)
}
VStack(spacing: 6) {
Text("本地识别中")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(images.count) 页 · 100% 本地推理")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
}
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}