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:
250
康康/Features/Capture/CaptureReviewForm.swift
Normal file
250
康康/Features/Capture/CaptureReviewForm.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
68
康康/Features/Capture/DocumentScanner.swift
Normal file
68
康康/Features/Capture/DocumentScanner.swift
Normal 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
|
||||
68
康康/Features/Capture/PhotoPickerSheet.swift
Normal file
68
康康/Features/Capture/PhotoPickerSheet.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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