- 替换 QuickCaptureFlow 和 ArchiveFlow 为 UnifiedCaptureFlow 统一流程 - 新增 VLSession 封装 Qwen2.5-VL 模型进行图像文本推理 - 实现 AIRuntime 中 VL 模型的准备和分析功能 - 添加 VLPrompts 定义体检化验单识别的 JSON 输出模板 - 创建 CaptureReviewForm 提供 VL 解析结果的可编辑表单界面 - 集成 VisionKit 文档扫描器支持真机多页文档扫描 - 为模拟器实现 PhotosPicker 回退方案选择已有照片 - 在 RootView 中统一使用 UnifiedCaptureFlow 处理快速和归档流程 - 添加 CustomMetricEditor 支持自定义监测指标的创建编辑删除 - 扩展 KangkangApp 模型配置以支持新数据类型 - 实现档案列表中症状结束功能通过时间线行点击触发
229 lines
7.6 KiB
Swift
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)
|
|
}
|
|
}
|