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:
link2026
2026-05-26 11:18:00 +08:00
parent 39edc25dc1
commit 1b01923c8e
27 changed files with 3128 additions and 29 deletions

View File

@@ -0,0 +1,250 @@
import SwiftUI
/// VL
/// title / type / reportDate / institution / summary / indicator;
/// indicator,
/// SwiftData + Vault assets
struct CaptureReviewForm: View {
@State var parsed: ParsedReport
let assets: [FileVault.SavedAsset]
let warning: String?
let onSave: (ParsedReport) -> Void
let onCancel: () -> Void
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if let warning {
warningBanner(warning)
}
if !assets.isEmpty {
pageThumbnails
}
metaSection
indicatorSection
Spacer(minLength: 8)
actions
}
.padding(.horizontal, 18)
.padding(.bottom, 24)
}
}
// MARK: - warning
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: 12))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.5))
)
}
// MARK: -
private var pageThumbnails: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("已保存 \(assets.count) 页(端侧加密)")
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) {
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: 84, height: 110)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
}
}
}
}
}
// MARK: - meta(title / type / date / institution / summary)
private var metaSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionLabel("基本信息")
VStack(spacing: 10) {
labeledField("标题") {
TextField("如:春季年度体检", text: $parsed.title)
.textFieldStyle(.plain)
}
labeledField("类型") {
Picker("", selection: $parsed.typeRaw) {
ForEach(ReportType.allCases, id: \.rawValue) { t in
Text(t.label).tag(t.rawValue)
}
}
.pickerStyle(.segmented)
}
labeledField("报告日期") {
DatePicker("", selection: $parsed.reportDate,
in: ...Date.now,
displayedComponents: .date)
.datePickerStyle(.compact)
.labelsHidden()
.environment(\.locale, Locale(identifier: "zh_CN"))
}
labeledField("机构(可选)") {
TextField("如:协和医院", text: $parsed.institution)
}
labeledField("摘要(可选)") {
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
.lineLimit(1...3)
}
}
.padding(12)
.background(fieldBg)
.overlay(fieldBorder)
}
}
private func labeledField<C: View>(_ label: String, @ViewBuilder content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
content()
}
}
// MARK: - indicators
private var indicatorSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
sectionLabel("指标(\(parsed.indicators.count) 项)")
Spacer()
Button {
parsed.indicators.append(
.init(name: "", value: "", unit: "", range: "", status: .normal)
)
} label: {
Label("加一项", systemImage: "plus.circle")
.font(.system(size: 12, weight: .medium))
}
.buttonStyle(.plain)
.foregroundStyle(Tj.Palette.ink)
}
if parsed.indicators.isEmpty {
Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.padding(.vertical, 8)
} else {
VStack(spacing: 10) {
ForEach(parsed.indicators.indices, id: \.self) { idx in
indicatorRow(idx)
}
}
}
}
}
private func indicatorRow(_ idx: Int) -> some View {
let binding = $parsed.indicators[idx]
return VStack(spacing: 8) {
HStack(spacing: 8) {
TextField("指标名", text: binding.name)
.font(.system(size: 14, weight: .medium))
Button(role: .destructive) {
parsed.indicators.remove(at: idx)
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
HStack(spacing: 8) {
TextField("数值", text: binding.value)
.keyboardType(.decimalPad)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.frame(maxWidth: 90)
TextField("单位", text: binding.unit)
.frame(maxWidth: 80)
.autocorrectionDisabled()
TextField("参考", text: binding.range)
.autocorrectionDisabled()
}
Picker("", selection: binding.status) {
Text("正常").tag(IndicatorStatus.normal)
Text("偏高 ↑").tag(IndicatorStatus.high)
Text("偏低 ↓").tag(IndicatorStatus.low)
}
.pickerStyle(.segmented)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(statusColor(binding.status.wrappedValue).opacity(0.4),
lineWidth: 1)
)
}
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
}
}
// MARK: - actions
private var actions: some View {
VStack(spacing: 10) {
Button {
onSave(parsed)
} label: {
Text("保存到记录")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
Button(action: onCancel) {
Text("取消(图片不保留)")
.frame(maxWidth: .infinity)
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
// MARK: - helpers
private func sectionLabel(_ t: String) -> some View {
Text(t)
.font(.system(size: 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
private var fieldBg: some View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
}
private var fieldBorder: some View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
}
}

View File

@@ -0,0 +1,68 @@
import SwiftUI
import VisionKit
import UIKit
#if canImport(VisionKit) && os(iOS)
/// VisionKit SwiftUI
/// - :,
/// - :`VNDocumentCameraViewController.isSupported == false`,
/// View present , PhotosPicker 退( PhotoPickerSheet)
struct DocumentScannerView: UIViewControllerRepresentable {
let onFinish: ([UIImage]) -> Void
let onCancel: () -> Void
func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
let vc = VNDocumentCameraViewController()
vc.delegate = context.coordinator
return vc
}
func updateUIViewController(_ uiViewController: VNDocumentCameraViewController,
context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onFinish: onFinish, onCancel: onCancel)
}
final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
let onFinish: ([UIImage]) -> Void
let onCancel: () -> Void
init(onFinish: @escaping ([UIImage]) -> Void,
onCancel: @escaping () -> Void) {
self.onFinish = onFinish
self.onCancel = onCancel
}
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFinishWith scan: VNDocumentCameraScan
) {
var images: [UIImage] = []
for i in 0..<scan.pageCount {
images.append(scan.imageOfPage(at: i))
}
onFinish(images)
}
func documentCameraViewControllerDidCancel(
_ controller: VNDocumentCameraViewController
) {
onCancel()
}
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFailWithError error: Error
) {
onCancel()
}
}
static var isSupported: Bool {
VNDocumentCameraViewController.isSupported
}
}
#endif

View File

@@ -0,0 +1,68 @@
import SwiftUI
import PhotosUI
/// VisionKit ,demo / PhotosPicker 退
/// DocumentScannerView
struct PhotoPickerSheet: View {
let onFinish: ([UIImage]) -> Void
let onCancel: () -> Void
@State private var selection: [PhotosPickerItem] = []
@State private var loading = false
var body: some View {
VStack(spacing: 20) {
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 56))
.foregroundStyle(Tj.Palette.text3)
Text("模拟器没有摄像头,从相册选一张化验单/体检报告")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
.multilineTextAlignment(.center)
PhotosPicker(selection: $selection,
maxSelectionCount: 5,
matching: .images) {
Text("从相册选 ≤5 张")
.font(.system(size: 14, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Tj.Palette.ink)
.foregroundStyle(Tj.Palette.paper)
.clipShape(Capsule())
}
Button("取消", action: onCancel)
.foregroundStyle(Tj.Palette.text3)
if loading {
ProgressView().tint(Tj.Palette.ink)
}
}
.padding(28)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Tj.Palette.sand.ignoresSafeArea())
.onChange(of: selection) { _, newValue in
guard !newValue.isEmpty else { return }
loadImages(from: newValue)
}
}
private func loadImages(from items: [PhotosPickerItem]) {
loading = true
Task {
var images: [UIImage] = []
for item in items {
if let data = try? await item.loadTransferable(type: Data.self),
let img = UIImage(data: data) {
images.append(img)
}
}
await MainActor.run {
loading = false
if images.isEmpty { onCancel() }
else { onFinish(images) }
}
}
}
}

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