```
feat(Quick): 异常项快拍流程重构为静态图框选识别模式 重构异常项快拍功能,将原有的局部小框拍摄改为整幅单拍后静态框选模式。 新流程为:整幅单拍/相册选择 → 静态图手动框选 → 框内OCR+LLM提取指标 → 核对 → 存储独立Indicator。 主要变更包括: - 移除实时预览小框拍摄模式,改为整幅拍摄后手动框选 - 新增RegionAdjustView组件用于静态图框选和识别 - 更新状态机流程:idle → adjust(静态图框选) → confirm → save - 修改识别逻辑,对框选区域进行OCR+LLM处理 - 更新相机组件为SingleShotCameraView,支持整幅拍摄 - 调整错误处理策略,识别失败时可挪框重试而非强制手动录入 - 优化本地化字符串,更新用户界面提示文案 ```
This commit is contained in:
@@ -1,32 +1,25 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
/// 异常项快拍 · 统一流程。
|
||||
/// 局部小框拍摄 → VL 识别(只抽 indicators)→ 确认 → 存成独立 Indicator(不建 Report、不留图)。
|
||||
/// 整幅单拍(真机)/ 相册(模拟器)→ 静态图手动框选 → 框内 OCR+LLM 抽指标 → 核对 → 存独立 Indicator。
|
||||
///
|
||||
/// 状态机:
|
||||
/// ```
|
||||
/// idle(相机/相册) → analyzing(croppedImage) → confirm(items)
|
||||
/// ↓ 失败/超时
|
||||
/// confirm(空 + warning)
|
||||
/// confirm → save → dismiss · confirm → 重拍 → idle
|
||||
/// idle(相机/相册) → adjust(静态图框选,可反复识别) → confirm(核对) → save → dismiss
|
||||
/// confirm → 重拍 → idle
|
||||
/// ```
|
||||
/// 识别失败/超时不卡死:停在 adjust 提示挪框重试,或直接进 confirm 手动补(§3.2 失败回退红线)。
|
||||
struct QuickRegionCaptureFlow: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
let onClose: () -> Void
|
||||
|
||||
@State private var phase: Phase = .idle
|
||||
@State private var analyzeTask: Task<Void, Never>? = nil
|
||||
|
||||
/// VL 单次推理超时(防卡死);超时后 cancel 子任务,UI 转手动录入。
|
||||
/// 整页化验单指标多、生成 token 多,30s 偏紧,放宽到 60s。
|
||||
private let analyzeTimeoutSeconds: Int = 60
|
||||
|
||||
enum Phase {
|
||||
case idle
|
||||
case analyzing(image: UIImage)
|
||||
case adjust(image: UIImage)
|
||||
case confirm(image: UIImage?, items: [QuickRegionItem], warning: String?)
|
||||
}
|
||||
|
||||
@@ -42,28 +35,17 @@ struct QuickRegionCaptureFlow: View {
|
||||
captureEntry
|
||||
.ignoresSafeArea()
|
||||
|
||||
case .analyzing(let image):
|
||||
NavigationStack {
|
||||
AnalyzingRegionView(
|
||||
image: image,
|
||||
timeoutSeconds: analyzeTimeoutSeconds,
|
||||
onCancel: {
|
||||
analyzeTask?.cancel()
|
||||
analyzeTask = nil
|
||||
// 取消识别 → 直接进确认页手动补充(图仍在内存,可重拍)
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: String(appLoc: "已取消识别,手动补充或重拍"))
|
||||
}
|
||||
)
|
||||
.navigationTitle(String(appLoc: "本地识别中…"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("取消") { cancelAll() }
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
case .adjust(let image):
|
||||
RegionAdjustView(
|
||||
image: image,
|
||||
recognize: { await recognizeRegion($0) },
|
||||
onProceed: { items in
|
||||
phase = .confirm(image: image, items: items, warning: nil)
|
||||
},
|
||||
onRetake: { phase = .idle },
|
||||
onCancel: { onClose() }
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
case .confirm(let image, let items, let warning):
|
||||
NavigationStack {
|
||||
@@ -72,14 +54,14 @@ struct QuickRegionCaptureFlow: View {
|
||||
items: items,
|
||||
warning: warning,
|
||||
onSave: { finalItems, capturedAt in save(items: finalItems, capturedAt: capturedAt) },
|
||||
onCancel: cancelAll,
|
||||
onCancel: { onClose() },
|
||||
onRetake: { phase = .idle }
|
||||
)
|
||||
.navigationTitle(String(appLoc: "核对异常项"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("取消") { cancelAll() }
|
||||
Button("取消") { onClose() }
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
}
|
||||
@@ -87,100 +69,59 @@ struct QuickRegionCaptureFlow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 入口:整页文档扫描(真机)/ 相册(模拟器或不支持)
|
||||
// MARK: - 入口:整幅单拍(真机)/ 相册(模拟器或无相机)
|
||||
|
||||
// 旧实现用 RegionCameraView 的「细条小框」(为 1-2 行异常项设计);并入「记录指标 · 拍照识别」后
|
||||
// 用户会拍整张化验单,塞进细条须离远拍 → 小字像素过低,VL 读不出。改用 VisionKit 整页扫描:
|
||||
// 全分辨率 + 自动透视校正,VL 能读清整表。模拟器 / 不支持时回退相册选图。
|
||||
@ViewBuilder
|
||||
private var captureEntry: some View {
|
||||
#if targetEnvironment(simulator)
|
||||
PhotoPickerSheet(
|
||||
onFinish: { imgs in handleScanned(imgs) },
|
||||
onFinish: { handlePicked($0) },
|
||||
onCancel: onClose
|
||||
)
|
||||
#else
|
||||
if DocumentScannerView.isSupported {
|
||||
DocumentScannerView(
|
||||
onFinish: { imgs in handleScanned(imgs) },
|
||||
onCancel: onClose
|
||||
)
|
||||
} else {
|
||||
PhotoPickerSheet(
|
||||
onFinish: { imgs in handleScanned(imgs) },
|
||||
onCancel: onClose
|
||||
)
|
||||
}
|
||||
SingleShotCameraView(
|
||||
onCapture: { phase = .adjust(image: $0) },
|
||||
onCancel: onClose
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 扫描/选图回来:取首页跑识别(单张化验单通常一页);无图则关闭。
|
||||
private func handleScanned(_ images: [UIImage]) {
|
||||
/// 拍/选回来:取首张进框选;无图则关闭(「只能拍一张」=只用第一张)。
|
||||
private func handlePicked(_ images: [UIImage]) {
|
||||
if let first = images.first {
|
||||
startAnalyze(image: first)
|
||||
phase = .adjust(image: first)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 识别
|
||||
// MARK: - 识别(框内子图 → OCR → LLM)
|
||||
|
||||
private func startAnalyze(image: UIImage) {
|
||||
analyzeTask?.cancel()
|
||||
phase = .analyzing(image: image)
|
||||
let timeout = analyzeTimeoutSeconds
|
||||
// 本类型默认 MainActor 隔离,Task{} 继承之,故内部 phase 写入都在主线程,直接赋值即可。
|
||||
// 新链路:Vision 端侧 OCR 取文本 → Qwen3-1.7B LLM 结构化抽指标(替代 3B VL 直读图)。
|
||||
analyzeTask = Task {
|
||||
let timeoutWarn = String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍")
|
||||
|
||||
let watchdog = Task {
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
analyzeTask?.cancel()
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
|
||||
do {
|
||||
// 1. 端侧 OCR
|
||||
let text = try await OCRService.recognizeText(in: image)
|
||||
if Task.isCancelled {
|
||||
phase = .confirm(image: image, items: [], warning: timeoutWarn); return
|
||||
}
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#if DEBUG
|
||||
print("🔤 [OCR] recognized text:\n\(trimmed)\n--- end OCR ---")
|
||||
#endif
|
||||
if trimmed.isEmpty {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: String(appLoc: "没识别到文字,手动补充或重拍"))
|
||||
return
|
||||
}
|
||||
// 2. LLM 解析文本 → 指标
|
||||
let parsed = try await CaptureService.shared.recognizeIndicators(fromOCRText: trimmed)
|
||||
if Task.isCancelled {
|
||||
phase = .confirm(image: image, items: [], warning: timeoutWarn); return
|
||||
}
|
||||
let items = Self.buildItems(from: parsed)
|
||||
phase = .confirm(
|
||||
image: image,
|
||||
items: items,
|
||||
warning: items.isEmpty ? String(appLoc: "没读出指标,手动补充或重拍") : nil
|
||||
)
|
||||
} catch CaptureError.modelNotReady {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: String(appLoc: "AI 模型未就绪,手动补充"))
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: String(appLoc: "解析失败:\(msg)"))
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: Task.isCancelled ? timeoutWarn
|
||||
: String(appLoc: "识别失败:\(msg)"))
|
||||
} catch {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: Task.isCancelled ? timeoutWarn
|
||||
: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
/// 对已裁好的框内子图跑识别。失败/超时返回提示文案,绝不抛出(由 RegionAdjustView 展示)。
|
||||
/// 链路:Vision 端侧 OCR 取文本 → Qwen3-1.7B 结构化抽指标(对齐 indicator-capture-ocr-llm)。
|
||||
private func recognizeRegion(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) {
|
||||
do {
|
||||
let text = try await OCRService.recognizeText(in: image)
|
||||
if Task.isCancelled { return ([], nil) } // 超时:文案由调用方给
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#if DEBUG
|
||||
print("🔤 [OCR · region] recognized text:\n\(trimmed)\n--- end OCR ---")
|
||||
#endif
|
||||
if trimmed.isEmpty {
|
||||
return ([], String(appLoc: "没识别到文字,挪一下框再试"))
|
||||
}
|
||||
let parsed = try await CaptureService.shared.recognizeIndicators(fromOCRText: trimmed)
|
||||
if Task.isCancelled { return ([], nil) }
|
||||
let items = Self.buildItems(from: parsed)
|
||||
return (items, items.isEmpty ? String(appLoc: "没读出指标,挪一下框再试") : nil)
|
||||
} catch CaptureError.modelNotReady {
|
||||
return ([], String(appLoc: "AI 模型未就绪,手动补充"))
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
return ([], String(appLoc: "解析失败:\(msg)"))
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
return ([], Task.isCancelled ? nil : String(appLoc: "识别失败:\(msg)"))
|
||||
} catch {
|
||||
return ([], Task.isCancelled ? nil : String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +131,6 @@ struct QuickRegionCaptureFlow: View {
|
||||
QuickRegionItem(name: $0.name, value: $0.value, unit: $0.unit,
|
||||
range: $0.range, status: $0.status, include: true)
|
||||
}
|
||||
// 异常优先(stable):high/low 在前,normal 在后
|
||||
return mapped.enumerated().sorted { a, b in
|
||||
let aAbn = a.element.status != .normal
|
||||
let bAbn = b.element.status != .normal
|
||||
@@ -199,13 +139,7 @@ struct QuickRegionCaptureFlow: View {
|
||||
}.map { $0.element }
|
||||
}
|
||||
|
||||
// MARK: - 取消 / 保存
|
||||
|
||||
private func cancelAll() {
|
||||
analyzeTask?.cancel()
|
||||
analyzeTask = nil
|
||||
onClose()
|
||||
}
|
||||
// MARK: - 保存
|
||||
|
||||
/// 勾选项各存一条独立 Indicator(与「记录指标」自由输入一致):无 Report、无 Asset、无 seriesKey。
|
||||
private func save(items: [QuickRegionItem], capturedAt: Date) {
|
||||
@@ -230,56 +164,3 @@ struct QuickRegionCaptureFlow: View {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 识别中视图
|
||||
|
||||
private struct AnalyzingRegionView: View {
|
||||
let image: UIImage
|
||||
let timeoutSeconds: Int
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var elapsed: Int = 0
|
||||
private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 200)
|
||||
.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.3))
|
||||
)
|
||||
VStack(spacing: 6) {
|
||||
Text("识别框内指标")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("100% 本地推理 · 已用 \(elapsed)s")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
if elapsed >= timeoutSeconds - 5 {
|
||||
Text("快超时了,>\(timeoutSeconds)s 会自动转手动录入")
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
}
|
||||
}
|
||||
Button("取消识别 · 改为手动录入", action: onCancel)
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.top, 4)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Tj.Palette.sand)
|
||||
.onReceive(tick) { _ in elapsed += 1 }
|
||||
}
|
||||
}
|
||||
|
||||
261
康康/Features/Quick/RegionAdjustView.swift
Normal file
261
康康/Features/Quick/RegionAdjustView.swift
Normal file
@@ -0,0 +1,261 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
/// 异常项快拍 · 静态图框选识别。
|
||||
/// 拍/选一张后,在静态照片上手动拖动 + 缩放一个方框,点「识别」只对框内做 OCR+LLM。
|
||||
/// 可反复挪框重识别,满意后进入核对页;0 项也能进核对手动补(失败回退红线)。
|
||||
struct RegionAdjustView: View {
|
||||
let image: UIImage
|
||||
/// 对裁好的子图跑 OCR+LLM,返回(识别项, 提示文案?)。几何裁剪由本视图负责。
|
||||
let recognize: (UIImage) async -> (items: [QuickRegionItem], warning: String?)
|
||||
let onProceed: ([QuickRegionItem]) -> Void
|
||||
let onRetake: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
/// 单次识别超时(秒)。超时取消并提示挪框重试。
|
||||
let timeoutSeconds: Int = 60
|
||||
|
||||
@State private var box: CGRect = .zero
|
||||
@State private var fittedRect: CGRect = .zero
|
||||
@State private var boxInited = false
|
||||
@State private var dragStartBox: CGRect? = nil
|
||||
@State private var resizeStartBox: CGRect? = nil
|
||||
|
||||
@State private var isRecognizing = false
|
||||
@State private var items: [QuickRegionItem] = []
|
||||
@State private var statusText: String? = nil
|
||||
@State private var recognizeTask: Task<Void, Never>? = nil
|
||||
|
||||
private let handleSize: CGFloat = 30
|
||||
private let minBox: CGFloat = 56
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
canvas
|
||||
controls
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
}
|
||||
|
||||
// MARK: - 顶栏
|
||||
|
||||
private var topBar: some View {
|
||||
HStack {
|
||||
Button {
|
||||
recognizeTask?.cancel()
|
||||
onCancel()
|
||||
} label: {
|
||||
Text("取消")
|
||||
.font(.tjScaled( 16, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
Spacer()
|
||||
Text("框住异常指标")
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
Spacer()
|
||||
Button {
|
||||
recognizeTask?.cancel()
|
||||
onRetake()
|
||||
} label: {
|
||||
Text("重拍")
|
||||
.font(.tjScaled( 16, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
// MARK: - 图 + 选框
|
||||
|
||||
private var canvas: some View {
|
||||
GeometryReader { proxy in
|
||||
let fitted = AVMakeRect(
|
||||
aspectRatio: image.size == .zero ? CGSize(width: 1, height: 1) : image.size,
|
||||
insideRect: CGRect(origin: .zero, size: proxy.size)
|
||||
)
|
||||
ZStack {
|
||||
Color.black
|
||||
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: proxy.size.width, height: proxy.size.height)
|
||||
|
||||
// 框外压暗,突出框内
|
||||
Canvas { ctx, size in
|
||||
var path = Path(CGRect(origin: .zero, size: size))
|
||||
path.addPath(Path(roundedRect: box, cornerRadius: 10))
|
||||
ctx.fill(path, with: .color(.black.opacity(0.5)), style: FillStyle(eoFill: true))
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
|
||||
// 选框边框 + 拖动
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(Color.white, style: StrokeStyle(lineWidth: 2, dash: [7, 5]))
|
||||
.frame(width: max(box.width, 1), height: max(box.height, 1))
|
||||
.position(x: box.midX, y: box.midY)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(moveGesture(in: fitted))
|
||||
|
||||
// 右下角缩放手柄
|
||||
Circle()
|
||||
.fill(.white)
|
||||
.frame(width: handleSize, height: handleSize)
|
||||
.overlay(
|
||||
Image(systemName: "arrow.down.right")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(.black)
|
||||
)
|
||||
.position(x: box.maxX, y: box.maxY)
|
||||
.gesture(resizeGesture(in: fitted))
|
||||
}
|
||||
.onAppear {
|
||||
fittedRect = fitted
|
||||
if !boxInited {
|
||||
box = defaultBox(in: fitted)
|
||||
boxInited = true
|
||||
}
|
||||
}
|
||||
.onChange(of: proxy.size) { _, _ in
|
||||
fittedRect = fitted
|
||||
box = clampSize(clampOrigin(box, in: fitted), in: fitted)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
// MARK: - 底部控件
|
||||
|
||||
private var controls: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text(statusText ?? String(appLoc: "拖动方框对准要识别的指标,可拖右下角缩放"))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 34)
|
||||
|
||||
Button {
|
||||
runRecognize()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isRecognizing { ProgressView().tint(.black) }
|
||||
Text(isRecognizing ? String(appLoc: "本地识别中…") : String(appLoc: "识别框内指标"))
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Capsule().fill(.white))
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.disabled(isRecognizing)
|
||||
|
||||
Button {
|
||||
recognizeTask?.cancel()
|
||||
onProceed(items)
|
||||
} label: {
|
||||
Text(items.isEmpty
|
||||
? String(appLoc: "跳过 · 手动录入")
|
||||
: String(appLoc: "进入核对(\(items.count))"))
|
||||
.font(.tjScaled( 15, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(Capsule().strokeBorder(.white.opacity(0.6), lineWidth: 1))
|
||||
}
|
||||
.disabled(isRecognizing)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 28)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
// MARK: - 选框默认值 / 夹紧
|
||||
|
||||
private func defaultBox(in fitted: CGRect) -> CGRect {
|
||||
guard fitted.width > 0, fitted.height > 0 else { return .zero }
|
||||
let w = fitted.width * 0.8
|
||||
let h = min(fitted.height * 0.3, max(minBox, fitted.height * 0.18))
|
||||
let x = fitted.minX + (fitted.width - w) / 2
|
||||
let y = fitted.minY + (fitted.height - h) / 2
|
||||
return CGRect(x: x, y: y, width: w, height: h)
|
||||
}
|
||||
|
||||
/// 移动时夹紧原点(尺寸不变),保证框不超出图片显示区。
|
||||
private func clampOrigin(_ b: CGRect, in fitted: CGRect) -> CGRect {
|
||||
guard fitted.width > 0 else { return b }
|
||||
let w = min(b.width, fitted.width)
|
||||
let h = min(b.height, fitted.height)
|
||||
let x = min(max(b.minX, fitted.minX), fitted.maxX - w)
|
||||
let y = min(max(b.minY, fitted.minY), fitted.maxY - h)
|
||||
return CGRect(x: x, y: y, width: w, height: h)
|
||||
}
|
||||
|
||||
/// 缩放时夹紧尺寸,保证不超出图片显示区。
|
||||
private func clampSize(_ b: CGRect, in fitted: CGRect) -> CGRect {
|
||||
guard fitted.width > 0 else { return b }
|
||||
let w = max(minBox, min(b.width, fitted.maxX - b.minX))
|
||||
let h = max(minBox, min(b.height, fitted.maxY - b.minY))
|
||||
return CGRect(x: b.minX, y: b.minY, width: w, height: h)
|
||||
}
|
||||
|
||||
private func moveGesture(in fitted: CGRect) -> some Gesture {
|
||||
DragGesture()
|
||||
.onChanged { v in
|
||||
if dragStartBox == nil { dragStartBox = box }
|
||||
let start = dragStartBox ?? box
|
||||
let moved = start.offsetBy(dx: v.translation.width, dy: v.translation.height)
|
||||
box = clampOrigin(moved, in: fitted)
|
||||
}
|
||||
.onEnded { _ in dragStartBox = nil }
|
||||
}
|
||||
|
||||
private func resizeGesture(in fitted: CGRect) -> some Gesture {
|
||||
DragGesture()
|
||||
.onChanged { v in
|
||||
if resizeStartBox == nil { resizeStartBox = box }
|
||||
let start = resizeStartBox ?? box
|
||||
let grown = CGRect(
|
||||
x: start.minX, y: start.minY,
|
||||
width: start.width + v.translation.width,
|
||||
height: start.height + v.translation.height
|
||||
)
|
||||
box = clampSize(grown, in: fitted)
|
||||
}
|
||||
.onEnded { _ in resizeStartBox = nil }
|
||||
}
|
||||
|
||||
// MARK: - 识别
|
||||
|
||||
private func runRecognize() {
|
||||
guard !isRecognizing, fittedRect.width > 1, box.width > 1, box.height > 1 else { return }
|
||||
let cropped = RegionImageCropper.cropAspectFit(image, box: box, imageFrame: fittedRect)
|
||||
recognizeTask?.cancel()
|
||||
isRecognizing = true
|
||||
statusText = String(appLoc: "本地识别中…")
|
||||
recognizeTask = Task {
|
||||
let watchdog = Task {
|
||||
try? await Task.sleep(for: .seconds(timeoutSeconds))
|
||||
recognizeTask?.cancel()
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
|
||||
let result = await recognize(cropped)
|
||||
isRecognizing = false
|
||||
if Task.isCancelled {
|
||||
statusText = String(appLoc: "识别超时,挪一下框再试或手动补充")
|
||||
return
|
||||
}
|
||||
items = result.items
|
||||
statusText = result.warning
|
||||
?? String(appLoc: "识别到 \(result.items.count) 项,可继续挪框或进入核对")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,11 @@ import AVFoundation
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
/// 异常项快拍 · 局部相机。
|
||||
/// 实时预览 + 居中小框 + 快门 → **裁剪到小框区域**的 UIImage 回调。
|
||||
/// 只在真机可用(模拟器无相机,QuickRegionCaptureFlow 退化到 PhotoPicker)。
|
||||
///
|
||||
/// 裁剪原理:先把拍到的照片 bake 成 `.up`(竖屏),再用纯几何 aspect-fill 反算把屏上小框
|
||||
/// (view 点坐标)映射到照片像素 rect(见 `RegionImageCropper`)。
|
||||
/// 不用 `metadataOutputRectConverted` —— 它返回传感器横向坐标,套到竖屏照片会轴对调裁出竖条。
|
||||
struct RegionCameraView: View {
|
||||
/// 异常项快拍 · 整幅单拍相机。
|
||||
/// 全屏实时预览 + 一个快门 → 返回**整幅** upright UIImage(不裁剪)。
|
||||
/// 拍完后由 `RegionAdjustView` 在静态图上手动框选识别区域。
|
||||
/// 只在真机可用(模拟器无相机,`QuickRegionCaptureFlow` 退化到 PhotoPicker)。
|
||||
struct SingleShotCameraView: View {
|
||||
let onCapture: (UIImage) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@@ -31,7 +28,9 @@ struct RegionCameraView: View {
|
||||
case .denied:
|
||||
deniedView
|
||||
case .authorized:
|
||||
cameraStack
|
||||
RegionCameraPreview(controller: controller, cropsToBox: false)
|
||||
.ignoresSafeArea()
|
||||
controlsOverlay
|
||||
}
|
||||
|
||||
if flash {
|
||||
@@ -41,47 +40,6 @@ struct RegionCameraView: View {
|
||||
.task { await resolveAuth() }
|
||||
}
|
||||
|
||||
// MARK: - 相机 + 小框 + 控件
|
||||
|
||||
private var cameraStack: some View {
|
||||
GeometryReader { proxy in
|
||||
let box = RegionFraming.box(in: proxy.size)
|
||||
ZStack {
|
||||
RegionCameraPreview(controller: controller)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// 框外压暗(even-odd 挖空),只突出小框内
|
||||
Canvas { ctx, size in
|
||||
var path = Path(CGRect(origin: .zero, size: size))
|
||||
path.addPath(Path(roundedRect: box, cornerRadius: Tj.Radius.md))
|
||||
ctx.fill(path, with: .color(.black.opacity(0.5)), style: FillStyle(eoFill: true))
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
|
||||
// 小框边
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.95),
|
||||
style: StrokeStyle(lineWidth: 2, dash: [8, 6]))
|
||||
.frame(width: box.width, height: box.height)
|
||||
.position(x: box.midX, y: box.midY)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
// 提示
|
||||
Text("把异常项放进框里 · 对准一两行")
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(.black.opacity(0.4)))
|
||||
.position(x: box.midX, y: box.minY - 22)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
controlsOverlay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var controlsOverlay: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
@@ -102,6 +60,14 @@ struct RegionCameraView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("拍一张含异常指标的照片 · 拍完再框选")
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(.black.opacity(0.4)))
|
||||
.padding(.bottom, 14)
|
||||
|
||||
shutterButton
|
||||
.padding(.bottom, 36)
|
||||
}
|
||||
@@ -120,7 +86,7 @@ struct RegionCameraView: View {
|
||||
}
|
||||
}
|
||||
.disabled(isCapturing)
|
||||
.accessibilityLabel("拍摄异常项")
|
||||
.accessibilityLabel("拍摄照片")
|
||||
}
|
||||
|
||||
private var deniedView: some View {
|
||||
@@ -155,8 +121,6 @@ struct RegionCameraView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 行为
|
||||
|
||||
private func capture() {
|
||||
guard !isCapturing else { return }
|
||||
isCapturing = true
|
||||
@@ -182,70 +146,6 @@ struct RegionCameraView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 小框几何(UIView 与 SwiftUI 覆盖层共用,保证坐标一致)
|
||||
|
||||
enum RegionFraming {
|
||||
/// 居中、略高于中心的小框。宽 84% 屏宽,高取 160 与 28% 屏高的较小值。
|
||||
static func box(in size: CGSize) -> CGRect {
|
||||
guard size.width > 0, size.height > 0 else { return .zero }
|
||||
let w = size.width * 0.84
|
||||
let h = min(160, size.height * 0.28)
|
||||
let x = (size.width - w) / 2
|
||||
let y = (size.height - h) / 2 - size.height * 0.06
|
||||
return CGRect(x: x, y: y, width: w, height: h)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 裁剪纯函数
|
||||
|
||||
enum RegionImageCropper {
|
||||
/// 把屏上小框(view 点坐标)按 `.resizeAspectFill` 反算到 `.up` 照片的像素裁剪 rect。
|
||||
/// 前提:预览以 aspect-fill 铺满 viewSize,照片与预览同源、同为竖屏方向。
|
||||
/// 纯几何、方向自洽 —— 不用 `metadataOutputRectConverted`(它返回传感器**横向**坐标,
|
||||
/// 套到竖屏照片会 x/y 轴对调,把宽框裁成竖窄条,见 RegionImageCropperTests)。越界自动夹紧。
|
||||
static func cropRect(photoPixelSize p: CGSize, box: CGRect, in viewSize: CGSize) -> CGRect {
|
||||
guard p.width > 0, p.height > 0, viewSize.width > 0, viewSize.height > 0 else { return .zero }
|
||||
// aspect-fill:取较大缩放系数让照片铺满视图,溢出部分被裁。
|
||||
let scale = max(viewSize.width / p.width, viewSize.height / p.height)
|
||||
let scaledW = p.width * scale
|
||||
let scaledH = p.height * scale
|
||||
// 缩放后照片相对视图居中,溢出维度的原点为负。
|
||||
let ox = (viewSize.width - scaledW) / 2
|
||||
let oy = (viewSize.height - scaledH) / 2
|
||||
// 视图点 → 照片像素:先减去居中偏移,再除以缩放系数。
|
||||
var x = (box.minX - ox) / scale
|
||||
var y = (box.minY - oy) / scale
|
||||
var w = box.width / scale
|
||||
var h = box.height / scale
|
||||
// 夹紧到照片范围内。
|
||||
x = max(0, min(p.width, x))
|
||||
y = max(0, min(p.height, y))
|
||||
w = max(0, min(p.width - x, w))
|
||||
h = max(0, min(p.height - y, h))
|
||||
return CGRect(x: x, y: y, width: w, height: h).integral
|
||||
}
|
||||
|
||||
/// 按屏上小框裁 `.up` 照片(`box` / `viewSize` 同为 view 点坐标);失败回退原图。
|
||||
static func crop(_ image: UIImage, box: CGRect, viewSize: CGSize) -> UIImage {
|
||||
guard let cg = image.cgImage else { return image }
|
||||
let rect = cropRect(photoPixelSize: CGSize(width: cg.width, height: cg.height),
|
||||
box: box, in: viewSize)
|
||||
guard rect.width >= 1, rect.height >= 1, let cropped = cg.cropping(to: rect) else { return image }
|
||||
return UIImage(cgImage: cropped, scale: image.scale, orientation: .up)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
/// 把 EXIF 方向 bake 进像素,返回 `.up` 方向图,便于按归一化 rect 直接裁 CGImage。
|
||||
func normalizedUp() -> UIImage {
|
||||
if imageOrientation == .up { return self }
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = scale
|
||||
let renderer = UIGraphicsImageRenderer(size: size, format: format)
|
||||
return renderer.image { _ in draw(in: CGRect(origin: .zero, size: size)) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVFoundation 桥接
|
||||
|
||||
/// SwiftUI 持有,作为快门触发的句柄(weak 指向真正的 UIView)。
|
||||
@@ -259,9 +159,12 @@ final class RegionCameraController: ObservableObject {
|
||||
|
||||
struct RegionCameraPreview: UIViewRepresentable {
|
||||
let controller: RegionCameraController
|
||||
/// 是否在拍摄后裁到居中小框。整幅单拍传 false(返回整图)。
|
||||
var cropsToBox: Bool = false
|
||||
|
||||
func makeUIView(context: Context) -> RegionPreviewUIView {
|
||||
let v = RegionPreviewUIView()
|
||||
v.cropsToBox = cropsToBox
|
||||
controller.view = v
|
||||
return v
|
||||
}
|
||||
@@ -273,8 +176,10 @@ struct RegionCameraPreview: UIViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 实时预览 + 单张拍摄,拍完按小框裁剪。
|
||||
/// 实时预览 + 单张拍摄。`cropsToBox` 为真时按居中小框裁剪,否则返回整幅 upright 图。
|
||||
final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
|
||||
var cropsToBox = false
|
||||
|
||||
private let session = AVCaptureSession()
|
||||
private let output = AVCapturePhotoOutput()
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
@@ -356,12 +261,11 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
|
||||
return
|
||||
}
|
||||
let upright = image.normalizedUp()
|
||||
guard previewLayer != nil else {
|
||||
// 整幅单拍:直接返回整图,框选在静态图阶段做。
|
||||
guard cropsToBox, previewLayer != nil else {
|
||||
deliver(upright)
|
||||
return
|
||||
}
|
||||
// 裁剪走纯几何映射:预览以 .resizeAspectFill 铺满 bounds,照片与预览同源同为竖屏,
|
||||
// 故屏上小框可按 aspect-fill 反算到照片像素 rect。读 bounds 几何回主线程更稳。
|
||||
DispatchQueue.main.async {
|
||||
let viewSize = self.bounds.size
|
||||
let box = RegionFraming.box(in: viewSize)
|
||||
@@ -370,3 +274,93 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 小框几何(旧 fill 裁剪路径保留,供 cropsToBox 用)
|
||||
|
||||
enum RegionFraming {
|
||||
/// 居中、略高于中心的小框。宽 84% 屏宽,高取 160 与 28% 屏高的较小值。
|
||||
static func box(in size: CGSize) -> CGRect {
|
||||
guard size.width > 0, size.height > 0 else { return .zero }
|
||||
let w = size.width * 0.84
|
||||
let h = min(160, size.height * 0.28)
|
||||
let x = (size.width - w) / 2
|
||||
let y = (size.height - h) / 2 - size.height * 0.06
|
||||
return CGRect(x: x, y: y, width: w, height: h)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 裁剪纯函数
|
||||
|
||||
enum RegionImageCropper {
|
||||
/// 把屏上小框(view 点坐标)按 `.resizeAspectFill` 反算到 `.up` 照片的像素裁剪 rect。
|
||||
/// 前提:预览以 aspect-fill 铺满 viewSize,照片与预览同源、同为竖屏方向。
|
||||
/// 单边越界自动夹紧。仅 cropsToBox 实时相机路径用。
|
||||
static func cropRect(photoPixelSize p: CGSize, box: CGRect, in viewSize: CGSize) -> CGRect {
|
||||
guard p.width > 0, p.height > 0, viewSize.width > 0, viewSize.height > 0 else { return .zero }
|
||||
let scale = max(viewSize.width / p.width, viewSize.height / p.height)
|
||||
let scaledW = p.width * scale
|
||||
let scaledH = p.height * scale
|
||||
let ox = (viewSize.width - scaledW) / 2
|
||||
let oy = (viewSize.height - scaledH) / 2
|
||||
var x = (box.minX - ox) / scale
|
||||
var y = (box.minY - oy) / scale
|
||||
var w = box.width / scale
|
||||
var h = box.height / scale
|
||||
x = max(0, min(p.width, x))
|
||||
y = max(0, min(p.height, y))
|
||||
w = max(0, min(p.width - x, w))
|
||||
h = max(0, min(p.height - y, h))
|
||||
return CGRect(x: x, y: y, width: w, height: h).integral
|
||||
}
|
||||
|
||||
/// 按屏上小框裁 `.up` 照片(aspect-fill 路径);失败回退原图。
|
||||
static func crop(_ image: UIImage, box: CGRect, viewSize: CGSize) -> UIImage {
|
||||
guard let cg = image.cgImage else { return image }
|
||||
let rect = cropRect(photoPixelSize: CGSize(width: cg.width, height: cg.height),
|
||||
box: box, in: viewSize)
|
||||
guard rect.width >= 1, rect.height >= 1, let cropped = cg.cropping(to: rect) else { return image }
|
||||
return UIImage(cgImage: cropped, scale: image.scale, orientation: .up)
|
||||
}
|
||||
|
||||
/// aspect-FIT 版:静态图以 `.scaledToFit` 显示在 `imageFrame`(view 点坐标,通常用
|
||||
/// `AVMakeRect(aspectRatio:insideRect:)` 算得)内,把屏上选框反算到照片像素 rect。
|
||||
/// `RegionAdjustView` 框选识别用。越界自动夹紧。
|
||||
static func cropRectAspectFit(photoPixelSize p: CGSize, box: CGRect, imageFrame f: CGRect) -> CGRect {
|
||||
guard p.width > 0, p.height > 0, f.width > 0, f.height > 0 else { return .zero }
|
||||
// aspect-fit:照片完整显示在 imageFrame 内,缩放系数两轴一致。
|
||||
let scale = f.width / p.width
|
||||
guard scale > 0 else { return .zero }
|
||||
var x = (box.minX - f.minX) / scale
|
||||
var y = (box.minY - f.minY) / scale
|
||||
var w = box.width / scale
|
||||
var h = box.height / scale
|
||||
x = max(0, min(p.width, x))
|
||||
y = max(0, min(p.height, y))
|
||||
w = max(0, min(p.width - x, w))
|
||||
h = max(0, min(p.height - y, h))
|
||||
return CGRect(x: x, y: y, width: w, height: h).integral
|
||||
}
|
||||
|
||||
/// 按静态图上的选框(aspect-fit)裁子图;失败回退原图。
|
||||
static func cropAspectFit(_ image: UIImage, box: CGRect, imageFrame: CGRect) -> UIImage {
|
||||
let up = image.normalizedUp()
|
||||
guard let cg = up.cgImage else { return image }
|
||||
let rect = cropRectAspectFit(
|
||||
photoPixelSize: CGSize(width: cg.width, height: cg.height),
|
||||
box: box, imageFrame: imageFrame
|
||||
)
|
||||
guard rect.width >= 1, rect.height >= 1, let cropped = cg.cropping(to: rect) else { return up }
|
||||
return UIImage(cgImage: cropped, scale: up.scale, orientation: .up)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
/// 把 EXIF 方向 bake 进像素,返回 `.up` 方向图,便于按归一化 rect 直接裁 CGImage。
|
||||
func normalizedUp() -> UIImage {
|
||||
if imageOrientation == .up { return self }
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = scale
|
||||
let renderer = UIGraphicsImageRenderer(size: size, format: format)
|
||||
return renderer.image { _ in draw(in: CGRect(origin: .zero, size: size)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,9 +997,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"100%% 本地推理 · 已用 %llds" : {
|
||||
|
||||
},
|
||||
"2026 / 05 / 25 · 协和医院体检中心" : {
|
||||
"extractionState" : "stale",
|
||||
@@ -5244,9 +5241,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"已取消识别,手动补充或重拍" : {
|
||||
|
||||
},
|
||||
"已处理 %.1fs · 比云端快 4.2×" : {
|
||||
"extractionState" : "stale",
|
||||
@@ -6170,9 +6164,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"快超时了,>%llds 会自动转手动录入" : {
|
||||
|
||||
},
|
||||
"性别" : {
|
||||
"localizations" : {
|
||||
@@ -6543,9 +6534,6 @@
|
||||
},
|
||||
"手动记录" : {
|
||||
|
||||
},
|
||||
"把异常项放进框里 · 对准一两行" : {
|
||||
|
||||
},
|
||||
"抑郁/焦虑" : {
|
||||
"localizations" : {
|
||||
@@ -6682,6 +6670,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"拍一张含异常指标的照片 · 拍完再框选" : {
|
||||
|
||||
},
|
||||
"拍到的局部" : {
|
||||
|
||||
@@ -6710,9 +6701,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"拍摄异常项" : {
|
||||
|
||||
},
|
||||
"拍摄报告" : {
|
||||
"localizations" : {
|
||||
@@ -6735,6 +6723,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"拍摄照片" : {
|
||||
|
||||
},
|
||||
"拍摄识别" : {
|
||||
"localizations" : {
|
||||
@@ -6804,6 +6795,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"拖动方框对准要识别的指标,可拖右下角缩放" : {
|
||||
|
||||
},
|
||||
"持续" : {
|
||||
"localizations" : {
|
||||
@@ -8797,6 +8791,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"框住异常指标" : {
|
||||
|
||||
},
|
||||
"档案 · %lld" : {
|
||||
"localizations" : {
|
||||
@@ -9229,10 +9226,10 @@
|
||||
"没有识别到指标,点「加一项」手动补充,或返回重拍" : {
|
||||
|
||||
},
|
||||
"没识别到文字,手动补充或重拍" : {
|
||||
"没识别到文字,挪一下框再试" : {
|
||||
|
||||
},
|
||||
"没读出指标,手动补充或重拍" : {
|
||||
"没读出指标,挪一下框再试" : {
|
||||
|
||||
},
|
||||
"测试 PROMPT" : {
|
||||
@@ -10972,6 +10969,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"识别到 %lld 项,可继续挪框或进入核对" : {
|
||||
|
||||
},
|
||||
"识别到的指标 (%lld)" : {
|
||||
|
||||
@@ -11045,6 +11045,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"识别超时,挪一下框再试或手动补充" : {
|
||||
|
||||
},
|
||||
"识别超时(>%llds)" : {
|
||||
"localizations" : {
|
||||
@@ -11111,9 +11114,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"识别超时(>%llds),手动补充或重拍" : {
|
||||
|
||||
},
|
||||
"该测%@了" : {
|
||||
"localizations" : {
|
||||
@@ -11414,6 +11414,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"跳过 · 手动录入" : {
|
||||
|
||||
},
|
||||
"身体档案" : {
|
||||
"localizations" : {
|
||||
@@ -11918,6 +11921,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"进入核对(%lld)" : {
|
||||
|
||||
},
|
||||
"进行中" : {
|
||||
"localizations" : {
|
||||
|
||||
Reference in New Issue
Block a user