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