feat(quick): 异常项快拍改为局部小框 + VL 识别
将「异常项快拍」从复用整页报告归档流程,改造成独立的局部识别路径: 小框拍局部 → Qwen-VL 只抽 indicators → 用户确认逐项编辑 → 存成独立 Indicator(不建 Report、不留原图,与「记录指标」统一落库)。 - RegionCameraView: AVFoundation 实时预览 + 居中小框,快门后按 metadataOutputRectConverted 裁剪到框内区域;含裁剪纯函数与权限态。 - VLPrompts.regionExtraction(): 局部识别 prompt,严格 JSON 只要 indicators。 - CaptureService.recognizeRegion(): 临时文件推理后即删,不写 Vault; 新增 parseIndicatorsJSON / extractBalancedJSON 解析容错。 - QuickRegionConfirmView: 异常项高亮置顶、默认勾选,可编辑/增删/选纳入。 - QuickRegionCaptureFlow: 状态机 idle→analyzing→confirm,30s 超时回退手动。 - RootView: .quick 路由改指向新流程(.archive 仍走 UnifiedCaptureFlow)。 - 删除 5 个无引用的旧 mockup(A1/A2/A3/SmartFramer/QuickCaptureFlow)。 模拟器无相机退化为相册整图;小框裁剪坐标需真机验证。 设计见 docs/superpowers/specs/2026-05-31-abnormal-quick-capture-design.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
# 异常项快拍(局部小框 + VL 识别)— 设计
|
||||||
|
|
||||||
|
> 日期:2026-05-31 · 分支:feat/w2-ai-foundation
|
||||||
|
> 需求:异常项快拍要拍摄局部,采用小框拍局部,用 Qwen-VL 识别被拍区域→检测项目结构化数据;
|
||||||
|
> 存储前用户确认;最后只存参数和异常值,可和「记录指标」统一保存。
|
||||||
|
|
||||||
|
## 1. 现状与缺口
|
||||||
|
|
||||||
|
- `RecordSheet.quick`(标题「异常项快拍」)已存在,但 `RootView.recordFlow(.quick)` 当前直接路由到
|
||||||
|
`UnifiedCaptureFlow` —— 与「体检报告归档」(`.archive`)完全一样,走的是整页文档扫描,**没有局部小框**,
|
||||||
|
也会把整份当 `Report` + 原图存档。这与需求(局部 / 只存数值 / 不留图 / 并入指标)不符。
|
||||||
|
- `Features/Quick/` 下 `A1ViewfinderView` / `A2ConfirmView` / `SmartFramer` / `QuickCaptureFlow` /
|
||||||
|
`A3BatchView` 均为早期 mockup,全树无外部引用(纯孤儿)。`A1ViewfinderView` 有小框引导和 AVFoundation
|
||||||
|
预览,但**快门未接线**(`capturePhoto()` 从不触发)、**不裁剪**。
|
||||||
|
|
||||||
|
## 2. 目标流程
|
||||||
|
|
||||||
|
```
|
||||||
|
RecordSheet(.quick)
|
||||||
|
→ QuickRegionCaptureFlow(状态机)
|
||||||
|
├ 真机: RegionCameraView(实时预览 + 居中小框 + 快门 → 裁剪到小框的 UIImage)
|
||||||
|
└ 模拟器: PhotoPickerSheet(无小框,整图送 VL)
|
||||||
|
→ CaptureService.recognizeRegion(imageData:) ──actor──► AIRuntime.analyzeReport ─► VLSession
|
||||||
|
↑ VLPrompts.regionExtraction()
|
||||||
|
→ QuickRegionConfirmView(逐项可编辑 + 勾选纳入 + 测量时间;异常项高亮置顶)
|
||||||
|
→ 保存:勾选项各插入一条独立 Indicator(无 Report、无 Asset);ctx.save()
|
||||||
|
```
|
||||||
|
|
||||||
|
红线遵守:UI 不直接调 `AIRuntime`,经 `CaptureService`(§3.1);`AIRuntime` actor 串行(复用既有 VL 路径,
|
||||||
|
不新增并发);无新增 `@Model`,不触发 SwiftData 迁移。
|
||||||
|
|
||||||
|
## 3. 组件
|
||||||
|
|
||||||
|
### 3.1 RegionCameraView.swift(新建,取代 A1ViewfinderView)
|
||||||
|
- AVFoundation 实时预览,`videoGravity = .resizeAspectFill`。
|
||||||
|
- 居中**局部小框**(屏宽 ~84% × 高 ~140pt,虚线框 + 半透明遮罩挖空),提示「把异常项放进框里 · 对准一两行」。
|
||||||
|
- 底部快门键、顶部取消键。
|
||||||
|
- 拍照后:`previewLayer.metadataOutputRectConverted(fromLayerRect: 小框rect)` → 归一化裁剪 rect;
|
||||||
|
先把照片方向 bake 成 `.up`,再按归一化 rect 裁 `CGImage`,回调裁剪后的 `UIImage`。
|
||||||
|
- 相机权限:被拒时显示「去设置开启相机」态。
|
||||||
|
- 纯函数 `RegionImageCropper.crop(_:normalizedRect:)` + `UIImage.normalizedUp()`,与 View 解耦便于推理/复用。
|
||||||
|
|
||||||
|
### 3.2 VLPrompts.regionExtraction()(加进 VLPrompts.swift)
|
||||||
|
- 说明「这是报告的局部照片,可能只有一两行指标」。
|
||||||
|
- 严格 JSON,只要 `{"indicators":[{name,value,unit,range,status}]}`,**不要**报告元信息。
|
||||||
|
- status 由 value 与 range 自判;range 保留原文;不发明指标,看不清整行跳过。
|
||||||
|
- 2 个 few-shot(单行 / 两行)。
|
||||||
|
|
||||||
|
### 3.3 CaptureService.recognizeRegion(imageData: Data)(加进 CaptureService.swift)
|
||||||
|
- 把 JPEG 写临时文件(`NSTemporaryDirectory`,`.completeFileProtection`),`defer` 删除。
|
||||||
|
- `prepareVL()` → `analyzeReport(imageURLs:[temp], prompt: regionExtraction())`。
|
||||||
|
- 新增 `parseIndicatorsJSON(_:)`:复用 `extractJSONObject` + `parseIndicator`,抽出 `indicators` 数组,
|
||||||
|
返回 `[ParsedReport.ParsedIndicator]`。失败抛 `CaptureError`(UI 回退手动录入)。
|
||||||
|
|
||||||
|
### 3.4 QuickRegionCaptureFlow.swift(新建,状态机)
|
||||||
|
- `Phase { idle, analyzing(UIImage), confirm(items, warning) }`。
|
||||||
|
- 裁剪图 → analyzing → Task:JPEG 编码 → `recognizeRegion` → confirm。
|
||||||
|
- 30s 超时哨兵 → confirm(空 + warning);各类错误 → confirm(空 + warning)。
|
||||||
|
- 无 Vault 资产需清理(临时文件已在 service 内删除);取消即关闭。
|
||||||
|
|
||||||
|
### 3.5 QuickRegionConfirmView.swift(新建,确认 UI)
|
||||||
|
- 头部「核对异常项 · 只存数值,不保留照片」+ 内存中的裁剪缩略图(仅核对用,**不持久化**)。
|
||||||
|
- 测量时间 DatePicker(默认 now)。
|
||||||
|
- 指标列表:逐项可编辑(name/value/unit/range/status)+ 勾选「纳入保存」。
|
||||||
|
异常(high/low)项红色高亮、置顶、默认勾选;正常项默认也勾选(用户可取消),体现「只存参数和异常值」由用户掌控。
|
||||||
|
- 「加一项」手动补充(VL 空结果回退)。
|
||||||
|
- 底栏:取消 / 保存到记录(N 项)。
|
||||||
|
|
||||||
|
### 3.6 RootView 路由
|
||||||
|
- `.quick → QuickRegionCaptureFlow(onClose:)`(原为 `UnifiedCaptureFlow`)。
|
||||||
|
|
||||||
|
### 3.7 清理
|
||||||
|
- 删除 5 个孤儿 mockup:A1ViewfinderView / A2ConfirmView / SmartFramer / QuickCaptureFlow / A3BatchView。
|
||||||
|
|
||||||
|
## 4. 数据落库
|
||||||
|
|
||||||
|
- 每个勾选项 → 一条 `Indicator(name,value,unit,range,status,capturedAt,note=nil,pinned=false,seriesKey=nil)`。
|
||||||
|
- 不建 `Report`,不存 `Asset`(原图丢弃)→ 符合「最后只存参数和异常值」。
|
||||||
|
- 与「记录指标」自由输入路径落库一致(同一 Indicator 表,进记录时间线;不带 seriesKey 不强制进趋势)。
|
||||||
|
|
||||||
|
## 5. 取舍
|
||||||
|
|
||||||
|
- **裁剪 vs 整图**:需求明确「小框拍局部 / 识别被拍区域」,故真机裁剪到小框(也提升小目标 VL 准确率、降 token)。
|
||||||
|
模拟器无实时小框 → 退化为整图(与既有 UnifiedCaptureFlow 模拟器退化一致)。
|
||||||
|
- **不留图**:遵循「只存参数和异常值」与隐私基线,临时文件推理后即删,不写 Vault、不建 Asset。
|
||||||
|
- **正常项是否保存**:默认全部勾选、异常项高亮,正常项可手动取消 —— 不静默丢弃用户可能想留的读数。
|
||||||
|
- **不动既有归档流程**:UnifiedCaptureFlow / B3 / C2 不变;本功能只重写 `.quick` 这一条路径。
|
||||||
@@ -80,5 +80,56 @@ JSON schema(严格):
|
|||||||
{"title":"春季年度体检","type":"checkup","report_date":"2026-04-12","institution":"协和医院","page_count":1,"summary":"血脂偏高、其他正常","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"},{"name":"谷丙转氨酶","value":"32","unit":"U/L","range":"9 - 50","status":"normal"},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]}
|
{"title":"春季年度体检","type":"checkup","report_date":"2026-04-12","institution":"协和医院","page_count":1,"summary":"血脂偏高、其他正常","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"},{"name":"谷丙转氨酶","value":"32","unit":"U/L","range":"9 - 50","status":"normal"},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]}
|
||||||
|
|
||||||
现在请识别图片并输出 JSON:
|
现在请识别图片并输出 JSON:
|
||||||
|
"""#
|
||||||
|
|
||||||
|
// MARK: - 局部小框识别(异常项快拍)
|
||||||
|
|
||||||
|
/// 异常项快拍专用:输入是报告/化验单的**局部照片**(常常只有一两行指标)。
|
||||||
|
/// 只要 indicators 数组,不要报告标题/机构/日期等元信息 —— 这条路径只存数值,不建 Report。
|
||||||
|
static func regionExtraction(today: Date = .now) -> String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
let todayStr = f.string(from: today)
|
||||||
|
return regionExtractionTemplate.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let regionExtractionTemplate: String = #"""
|
||||||
|
你是一个医学化验单识别助手。下面给你的是一张化验单/体检报告的**局部照片**,通常只框住了一两行指标。
|
||||||
|
请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||||
|
|
||||||
|
今天的日期是 {{TODAY}}。
|
||||||
|
|
||||||
|
JSON schema(严格):
|
||||||
|
{
|
||||||
|
"indicators": [
|
||||||
|
{
|
||||||
|
"name": string,
|
||||||
|
"value": string,
|
||||||
|
"unit": string,
|
||||||
|
"range": string,
|
||||||
|
"status": "high" | "low" | "normal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 只识别框内清楚可读的指标行,通常 1-3 行;看不清的整行跳过,绝不发明指标。
|
||||||
|
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"。
|
||||||
|
- range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象。
|
||||||
|
- 识别不出单位/范围就填空字符串,不要编造。
|
||||||
|
- 不要输出 title / institution / date / summary 等任何报告级字段,只输出 indicators 数组。
|
||||||
|
|
||||||
|
示例 1(单行):
|
||||||
|
输入: 局部照片,清楚可读「低密度脂蛋白 3.84 mmol/L 参考 <3.40 ↑」
|
||||||
|
输出:
|
||||||
|
{"indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]}
|
||||||
|
|
||||||
|
示例 2(两行):
|
||||||
|
输入: 局部照片,清楚可读「尿酸 486 μmol/L 208-428」与「空腹血糖 5.2 mmol/L 3.9-6.1」
|
||||||
|
输出:
|
||||||
|
{"indicators":[{"name":"尿酸","value":"486","unit":"μmol/L","range":"208 - 428","status":"high"},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]}
|
||||||
|
|
||||||
|
现在请识别这张局部照片并输出 JSON:
|
||||||
"""#
|
"""#
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct A1ViewfinderView: View {
|
|
||||||
var onShoot: () -> Void
|
|
||||||
var onClose: () -> Void
|
|
||||||
|
|
||||||
@State private var dotPulse = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { geometry in
|
|
||||||
ZStack {
|
|
||||||
Color(red: 0.04, green: 0.047, blue: 0.04).ignoresSafeArea()
|
|
||||||
|
|
||||||
mockCameraPreview(screenHeight: geometry.size.height)
|
|
||||||
|
|
||||||
VStack {
|
|
||||||
HStack {
|
|
||||||
Button(action: onClose) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.system(size: 18, weight: .semibold))
|
|
||||||
.foregroundStyle(Color.white)
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.top, 50)
|
|
||||||
|
|
||||||
topHint
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
SmartFramer()
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
identifiedPill
|
|
||||||
.padding(.top, geometry.size.height * 0.62 - 20)
|
|
||||||
|
|
||||||
VStack {
|
|
||||||
Spacer()
|
|
||||||
bottomControls
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
|
||||||
.statusBarHidden(false)
|
|
||||||
#endif
|
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mockCameraPreview(screenHeight: CGFloat) -> some View {
|
|
||||||
RadialGradient(
|
|
||||||
colors: [Color.white.opacity(0.05), Color.clear],
|
|
||||||
center: .init(x: 0.5, y: 0.3),
|
|
||||||
startRadius: 20,
|
|
||||||
endRadius: 400
|
|
||||||
)
|
|
||||||
.overlay(alignment: .center) {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("总胆固醇 TC 5.42 mmol/L").opacity(0.65)
|
|
||||||
Text("甘油三酯 TG 1.78 mmol/L").opacity(0.65)
|
|
||||||
Text("低密度脂蛋白 3.84 mmol/L ↑").fontWeight(.semibold).opacity(1)
|
|
||||||
Text("高密度脂蛋白 1.21 mmol/L").opacity(0.65)
|
|
||||||
Text("载脂蛋白 A1 1.42 g/L").opacity(0.45)
|
|
||||||
Text("载脂蛋白 B 1.04 g/L").opacity(0.45)
|
|
||||||
}
|
|
||||||
.font(.system(size: 11, design: .monospaced))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
.padding(.vertical, 20)
|
|
||||||
.padding(.horizontal, 18)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.92))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
|
|
||||||
.rotationEffect(.degrees(-1.2))
|
|
||||||
.shadow(color: .black.opacity(0.45), radius: 15, x: 0, y: 12)
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.vertical, screenHeight * 0.20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var topHint: some View {
|
|
||||||
Text("对准异常的那一行就好 · 不用拍整张")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.tracking(0.5)
|
|
||||||
.foregroundStyle(Color.white.opacity(0.92))
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 7)
|
|
||||||
.background(Capsule().fill(Color(red: 0.08, green: 0.11, blue: 0.094).opacity(0.7)))
|
|
||||||
.padding(.top, 6)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var identifiedPill: some View {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Circle()
|
|
||||||
.fill(Tj.Palette.paper)
|
|
||||||
.frame(width: 6, height: 6)
|
|
||||||
.opacity(dotPulse ? 1 : 0.35)
|
|
||||||
Text("AI 已识别到 1 项指标")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.tracking(0.5)
|
|
||||||
}
|
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Capsule().fill(Color(red: 0.37, green: 0.47, blue: 0.31).opacity(0.85)))
|
|
||||||
.onAppear {
|
|
||||||
withAnimation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true)) {
|
|
||||||
dotPulse.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var bottomControls: some View {
|
|
||||||
HStack {
|
|
||||||
CircleIconButton(icon: "bolt.fill", size: 44) { }
|
|
||||||
Spacer()
|
|
||||||
Button(action: onShoot) {
|
|
||||||
ZStack {
|
|
||||||
Circle().fill(Tj.Palette.ink)
|
|
||||||
Circle().strokeBorder(Tj.Palette.paper, lineWidth: 4)
|
|
||||||
}
|
|
||||||
.frame(width: 72, height: 72)
|
|
||||||
.overlay(
|
|
||||||
Circle().strokeBorder(Color.white.opacity(0.2), lineWidth: 1)
|
|
||||||
.frame(width: 76, height: 76)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
Spacer()
|
|
||||||
CircleIconButton(icon: "photo.on.rectangle", size: 44) { }
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 32)
|
|
||||||
.padding(.bottom, 40)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct CircleIconButton: View {
|
|
||||||
let icon: String
|
|
||||||
let size: CGFloat
|
|
||||||
let action: () -> Void
|
|
||||||
var body: some View {
|
|
||||||
Button(action: action) {
|
|
||||||
ZStack {
|
|
||||||
Circle().fill(Color.white.opacity(0.12))
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.system(size: 18, weight: .medium))
|
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
|
||||||
}
|
|
||||||
.frame(width: size, height: size)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct A2ConfirmView: View {
|
|
||||||
var onSave: () -> Void
|
|
||||||
var onNext: () -> Void
|
|
||||||
var onBack: () -> Void
|
|
||||||
|
|
||||||
@State private var expanded = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
header
|
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
croppedPhoto.padding(.bottom, 14)
|
|
||||||
resultCard.padding(.bottom, 16)
|
|
||||||
actions
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 18)
|
|
||||||
.padding(.bottom, 18)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
|
||||||
}
|
|
||||||
|
|
||||||
private var header: some View {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Button(action: onBack) {
|
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
.font(.system(size: 18, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
}
|
|
||||||
Text("核对识别结果")
|
|
||||||
.font(.system(size: 15, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
Spacer()
|
|
||||||
Text("识别用时 0.4s · 本地")
|
|
||||||
.font(.system(size: 10, design: .monospaced))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Capsule().fill(Tj.Palette.sand2))
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.top, 4)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var croppedPhoto: some View {
|
|
||||||
ZStack(alignment: .topTrailing) {
|
|
||||||
Text("低密度脂蛋白 3.84 mmol/L ↑")
|
|
||||||
.font(.system(size: 13, design: .monospaced))
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.tracking(0.3)
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.92))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
|
||||||
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06),
|
|
||||||
radius: 2, x: 0, y: 1)
|
|
||||||
Text("已裁剪")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.tracking(0.5)
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
.padding(.top, 8)
|
|
||||||
.padding(.trailing, 10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var resultCard: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
|
||||||
HStack(alignment: .top) {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text("指标名 · 可编辑")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
Text("低密度脂蛋白胆固醇")
|
|
||||||
.font(.system(size: 19, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
Text("LDL-C")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
TjBadge(text: String(appLoc: "偏高"), style: .brick)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
FieldBox(label: String(appLoc: "数值")) {
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
|
||||||
Text("3.84")
|
|
||||||
.font(.system(size: 30, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
|
||||||
Text("mmol/L")
|
|
||||||
.font(.system(size: 11, design: .monospaced))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FieldBox(label: String(appLoc: "参考范围")) {
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
|
||||||
Text("< 3.40")
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
|
||||||
Text("mmol/L")
|
|
||||||
.font(.system(size: 11, design: .monospaced))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button { withAnimation { expanded.toggle() } } label: {
|
|
||||||
HStack(alignment: .top, spacing: 10) {
|
|
||||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
|
||||||
.fill(Tj.Palette.brick)
|
|
||||||
.frame(width: 4)
|
|
||||||
Text(expanded
|
|
||||||
? "超过参考上限 0.44,属轻度偏高。建议关注饮食结构(减少动物脂肪摄入),3 个月内复查。若家族有心血管病史,可与医生沟通是否需要药物干预。"
|
|
||||||
: "超过参考上限 0.44,属轻度偏高。点击展开详细解读 ›")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
|
||||||
.lineSpacing(5)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
||||||
.fill(Tj.Palette.sand)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
.padding(18)
|
|
||||||
.tjCard()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var actions: some View {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Button(action: onSave) {
|
|
||||||
Text("保存到记录")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(TjPrimaryButton())
|
|
||||||
|
|
||||||
Button(action: onNext) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "camera.fill").font(.system(size: 14))
|
|
||||||
Text("继续拍下一项")
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(TjGhostButton())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct FieldBox<Content: View>: View {
|
|
||||||
let label: String
|
|
||||||
@ViewBuilder var content: Content
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(label)
|
|
||||||
.font(.system(size: 10))
|
|
||||||
.tracking(0.5)
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
content
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
||||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct A3BatchItem {
|
|
||||||
let name: String
|
|
||||||
let value: String
|
|
||||||
let unit: String
|
|
||||||
let range: String
|
|
||||||
let status: IndicatorStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
struct A3BatchView: View {
|
|
||||||
var onAddMore: () -> Void
|
|
||||||
var onFinish: () -> Void
|
|
||||||
var onBack: () -> Void
|
|
||||||
|
|
||||||
let items: [A3BatchItem] = [
|
|
||||||
.init(name: String(appLoc: "低密度脂蛋白胆固醇"), value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high),
|
|
||||||
.init(name: String(appLoc: "甘油三酯 TG"), value: "1.78", unit: "mmol/L", range: "< 1.70", status: .high),
|
|
||||||
.init(name: String(appLoc: "空腹血糖 GLU"), value: "5.4", unit: "mmol/L", range: "3.9–6.1", status: .normal),
|
|
||||||
]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
header
|
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
ForEach(Array(items.enumerated()), id: \.offset) { idx, it in
|
|
||||||
BatchRow(index: idx + 1, item: it)
|
|
||||||
}
|
|
||||||
addRow
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
}
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Button {
|
|
||||||
onFinish()
|
|
||||||
} label: {
|
|
||||||
Text("全部保存(\(items.count))").frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(TjPrimaryButton())
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.bottom, 14)
|
|
||||||
}
|
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
|
||||||
}
|
|
||||||
|
|
||||||
private var header: some View {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Button(action: onBack) {
|
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
.font(.system(size: 18, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
}
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("本次已记录 \(items.count) 项")
|
|
||||||
.font(.system(size: 15, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
Text("核对后一次保存")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Text("· · ·")
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
.padding(.trailing, 12)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.top, 4)
|
|
||||||
.padding(.bottom, 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var addRow: some View {
|
|
||||||
Button(action: onAddMore) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "camera").font(.system(size: 14))
|
|
||||||
Text("再拍一项")
|
|
||||||
.font(.system(size: 13))
|
|
||||||
}
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
|
||||||
.strokeBorder(Tj.Palette.line, style: StrokeStyle(lineWidth: 1.5, dash: [4, 4]))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct BatchRow: View {
|
|
||||||
let index: Int
|
|
||||||
let item: A3BatchItem
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
TjPlaceholder(label: "#\(index)")
|
|
||||||
.frame(width: 60, height: 44)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(item.name)
|
|
||||||
.font(.system(size: 13, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.text)
|
|
||||||
.lineLimit(1)
|
|
||||||
Text("范围 \(item.range) \(item.unit)")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
Spacer(minLength: 8)
|
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
|
||||||
Text(item.value)
|
|
||||||
.font(.system(size: 17, weight: .semibold))
|
|
||||||
.foregroundStyle(item.status == .high ? Tj.Palette.brick : Tj.Palette.text)
|
|
||||||
TjBadge(text: item.status == .high ? String(appLoc: "偏高") : String(appLoc: "正常"),
|
|
||||||
style: item.status == .high ? .brick : .leaf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.tjCard()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
private enum QuickStep: Hashable {
|
|
||||||
case viewfinder
|
|
||||||
case confirm
|
|
||||||
case batch
|
|
||||||
}
|
|
||||||
|
|
||||||
struct QuickCaptureFlow: View {
|
|
||||||
var onClose: () -> Void
|
|
||||||
|
|
||||||
@State private var step: QuickStep = .viewfinder
|
|
||||||
@State private var snapCount = 0
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
switch step {
|
|
||||||
case .viewfinder:
|
|
||||||
A1ViewfinderView(
|
|
||||||
onShoot: {
|
|
||||||
snapCount += 1
|
|
||||||
withAnimation(.easeInOut(duration: 0.25)) { step = .confirm }
|
|
||||||
},
|
|
||||||
onClose: onClose
|
|
||||||
)
|
|
||||||
.transition(.opacity)
|
|
||||||
|
|
||||||
case .confirm:
|
|
||||||
A2ConfirmView(
|
|
||||||
onSave: {
|
|
||||||
if snapCount >= 2 {
|
|
||||||
withAnimation { step = .batch }
|
|
||||||
} else {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNext: {
|
|
||||||
withAnimation { step = .viewfinder }
|
|
||||||
},
|
|
||||||
onBack: {
|
|
||||||
withAnimation { step = .viewfinder }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.transition(.opacity)
|
|
||||||
|
|
||||||
case .batch:
|
|
||||||
A3BatchView(
|
|
||||||
onAddMore: {
|
|
||||||
withAnimation { step = .viewfinder }
|
|
||||||
},
|
|
||||||
onFinish: onClose,
|
|
||||||
onBack: {
|
|
||||||
withAnimation { step = .confirm }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
254
康康/Features/Quick/QuickRegionCaptureFlow.swift
Normal file
254
康康/Features/Quick/QuickRegionCaptureFlow.swift
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// 异常项快拍 · 统一流程。
|
||||||
|
/// 局部小框拍摄 → VL 识别(只抽 indicators)→ 确认 → 存成独立 Indicator(不建 Report、不留图)。
|
||||||
|
///
|
||||||
|
/// 状态机:
|
||||||
|
/// ```
|
||||||
|
/// idle(相机/相册) → analyzing(croppedImage) → confirm(items)
|
||||||
|
/// ↓ 失败/超时
|
||||||
|
/// confirm(空 + warning)
|
||||||
|
/// confirm → save → dismiss · confirm → 重拍 → idle
|
||||||
|
/// ```
|
||||||
|
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 转手动录入。
|
||||||
|
private let analyzeTimeoutSeconds: Int = 30
|
||||||
|
|
||||||
|
enum Phase {
|
||||||
|
case idle
|
||||||
|
case analyzing(image: UIImage)
|
||||||
|
case confirm(image: UIImage?, items: [QuickRegionItem], warning: String?)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
switch phase {
|
||||||
|
case .idle:
|
||||||
|
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 .confirm(let image, let items, let warning):
|
||||||
|
NavigationStack {
|
||||||
|
QuickRegionConfirmView(
|
||||||
|
image: image,
|
||||||
|
items: items,
|
||||||
|
warning: warning,
|
||||||
|
onSave: { finalItems, capturedAt in save(items: finalItems, capturedAt: capturedAt) },
|
||||||
|
onCancel: cancelAll,
|
||||||
|
onRetake: { phase = .idle }
|
||||||
|
)
|
||||||
|
.navigationTitle(String(appLoc: "核对异常项"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("取消") { cancelAll() }
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 入口:相机(真机)/ 相册(模拟器)
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var captureEntry: some View {
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
PhotoPickerSheet(
|
||||||
|
onFinish: { imgs in if let first = imgs.first { startAnalyze(image: first) } },
|
||||||
|
onCancel: onClose
|
||||||
|
)
|
||||||
|
#else
|
||||||
|
RegionCameraView(
|
||||||
|
onCapture: { startAnalyze(image: $0) },
|
||||||
|
onCancel: onClose
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 识别
|
||||||
|
|
||||||
|
private func startAnalyze(image: UIImage) {
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
phase = .analyzing(image: image)
|
||||||
|
let timeout = analyzeTimeoutSeconds
|
||||||
|
// 本类型默认 MainActor 隔离,Task{} 继承之,故内部 phase 写入都在主线程,直接赋值即可。
|
||||||
|
analyzeTask = Task {
|
||||||
|
guard let data = image.jpegData(compressionQuality: 0.9) else {
|
||||||
|
phase = .confirm(image: image, items: [],
|
||||||
|
warning: String(appLoc: "图片编码失败,手动补充或重拍"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let watchdog = Task {
|
||||||
|
try? await Task.sleep(for: .seconds(timeout))
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
}
|
||||||
|
defer { watchdog.cancel() }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let parsed = try await CaptureService.shared.recognizeRegion(imageData: data)
|
||||||
|
if Task.isCancelled {
|
||||||
|
phase = .confirm(image: image, items: [],
|
||||||
|
warning: String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍"))
|
||||||
|
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: "VL 模型未就绪,手动补充"))
|
||||||
|
} catch let CaptureError.parseFailed(msg) {
|
||||||
|
phase = .confirm(image: image, items: [],
|
||||||
|
warning: String(appLoc: "VL 输出无法解析:\(msg)"))
|
||||||
|
} catch let CaptureError.inferenceFailed(msg) {
|
||||||
|
phase = .confirm(image: image, items: [],
|
||||||
|
warning: Task.isCancelled
|
||||||
|
? String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍")
|
||||||
|
: String(appLoc: "推理失败:\(msg)"))
|
||||||
|
} catch {
|
||||||
|
phase = .confirm(image: image, items: [],
|
||||||
|
warning: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VL 结果 → 可编辑行,异常项(high/low)置顶、默认勾选。
|
||||||
|
private static func buildItems(from parsed: [ParsedReport.ParsedIndicator]) -> [QuickRegionItem] {
|
||||||
|
let mapped = parsed.map {
|
||||||
|
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
|
||||||
|
if aAbn != bAbn { return aAbn && !bAbn }
|
||||||
|
return a.offset < b.offset
|
||||||
|
}.map { $0.element }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 取消 / 保存
|
||||||
|
|
||||||
|
private func cancelAll() {
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
analyzeTask = nil
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 勾选项各存一条独立 Indicator(与「记录指标」自由输入一致):无 Report、无 Asset、无 seriesKey。
|
||||||
|
private func save(items: [QuickRegionItem], capturedAt: Date) {
|
||||||
|
let selected = items.filter {
|
||||||
|
$0.include
|
||||||
|
&& !$0.name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& !$0.value.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
for item in selected {
|
||||||
|
let indicator = Indicator(
|
||||||
|
name: item.name.trimmingCharacters(in: .whitespaces),
|
||||||
|
value: item.value.trimmingCharacters(in: .whitespaces),
|
||||||
|
unit: item.unit.trimmingCharacters(in: .whitespaces),
|
||||||
|
range: item.range.trimmingCharacters(in: .whitespaces),
|
||||||
|
status: item.status,
|
||||||
|
capturedAt: capturedAt
|
||||||
|
)
|
||||||
|
ctx.insert(indicator)
|
||||||
|
}
|
||||||
|
try? ctx.save()
|
||||||
|
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(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
if elapsed >= timeoutSeconds - 5 {
|
||||||
|
Text("快超时了,>\(timeoutSeconds)s 会自动转手动录入")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("取消识别 · 改为手动录入", action: onCancel)
|
||||||
|
.font(.system(size: 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
305
康康/Features/Quick/QuickRegionConfirmView.swift
Normal file
305
康康/Features/Quick/QuickRegionConfirmView.swift
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 异常项快拍 · 确认页。VL 识别结果逐项可编辑 + 勾选纳入,确认后只存数值(不留图)。
|
||||||
|
/// 与「记录指标」自由输入落库一致 —— 每个勾选项 = 一条独立 Indicator。
|
||||||
|
struct QuickRegionConfirmView: View {
|
||||||
|
let image: UIImage?
|
||||||
|
let warning: String?
|
||||||
|
let onSave: ([QuickRegionItem], Date) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
let onRetake: () -> Void
|
||||||
|
|
||||||
|
@State private var items: [QuickRegionItem]
|
||||||
|
@State private var capturedAt: Date
|
||||||
|
|
||||||
|
init(image: UIImage?,
|
||||||
|
items: [QuickRegionItem],
|
||||||
|
warning: String?,
|
||||||
|
capturedAt: Date = .now,
|
||||||
|
onSave: @escaping ([QuickRegionItem], Date) -> Void,
|
||||||
|
onCancel: @escaping () -> Void,
|
||||||
|
onRetake: @escaping () -> Void) {
|
||||||
|
self.image = image
|
||||||
|
self.warning = warning
|
||||||
|
self.onSave = onSave
|
||||||
|
self.onCancel = onCancel
|
||||||
|
self.onRetake = onRetake
|
||||||
|
_items = State(initialValue: items)
|
||||||
|
_capturedAt = State(initialValue: capturedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedCount: Int {
|
||||||
|
items.filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& !$0.value.trimmingCharacters(in: .whitespaces).isEmpty }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
if let warning { warningBanner(warning) }
|
||||||
|
if let image { thumbnailCard(image) }
|
||||||
|
timeCard
|
||||||
|
itemsCard
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) { bottomBar }
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 区块
|
||||||
|
|
||||||
|
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: 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.amber.opacity(0.12))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func thumbnailCard(_ image: UIImage) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("拍到的局部")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer()
|
||||||
|
Text("仅核对用 · 不保存照片")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(maxHeight: 180)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
Button {
|
||||||
|
onRetake()
|
||||||
|
} label: {
|
||||||
|
Label("重拍", systemImage: "camera.rotate")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("测量时间")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
DatePicker("", selection: $capturedAt, in: ...Date.now)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var itemsCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack {
|
||||||
|
Text("识别到的指标 (\(items.count))")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
items.append(QuickRegionItem(name: "", value: "", unit: "", range: "",
|
||||||
|
status: .high, include: true))
|
||||||
|
} label: {
|
||||||
|
Label("加一项", systemImage: "plus.circle.fill")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if items.isEmpty {
|
||||||
|
Text("没有识别到指标,点「加一项」手动补充,或返回重拍")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
} else {
|
||||||
|
ForEach($items) { $item in
|
||||||
|
itemRow($item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func itemRow(_ item: Binding<QuickRegionItem>) -> some View {
|
||||||
|
let abnormal = item.wrappedValue.status != .normal
|
||||||
|
return VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
item.wrappedValue.include.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: item.wrappedValue.include ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundStyle(item.wrappedValue.include ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
TextField(String(appLoc: "指标名"), text: item.name)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
|
||||||
|
if abnormal {
|
||||||
|
Text(statusLabel(item.wrappedValue.status))
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(statusColor(item.wrappedValue.status))
|
||||||
|
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||||
|
.background(Capsule().fill(statusColor(item.wrappedValue.status).opacity(0.16)))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if let idx = items.firstIndex(where: { $0.id == item.wrappedValue.id }) {
|
||||||
|
items.remove(at: idx)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
fieldCol(String(appLoc: "数值"), item.value, width: 80, mono: true)
|
||||||
|
fieldCol(String(appLoc: "单位"), item.unit, width: 80)
|
||||||
|
fieldCol(String(appLoc: "范围"), item.range)
|
||||||
|
}
|
||||||
|
statusPicker(item)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.opacity(item.wrappedValue.include ? 1 : 0.5)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(abnormal ? statusColor(item.wrappedValue.status).opacity(0.6) : Tj.Palette.line,
|
||||||
|
lineWidth: abnormal ? 1.5 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fieldCol(_ label: String, _ text: Binding<String>, width: CGFloat? = nil,
|
||||||
|
mono: Bool = false) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
TextField("", text: text)
|
||||||
|
.font(.system(size: 14, weight: mono ? .semibold : .regular,
|
||||||
|
design: mono ? .monospaced : .default))
|
||||||
|
.keyboardType(mono ? .decimalPad : .default)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.sand)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.frame(width: width)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: width == nil ? .infinity : nil, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusPicker(_ item: Binding<QuickRegionItem>) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(IndicatorStatus.allCases, id: \.self) { st in
|
||||||
|
let selected = item.wrappedValue.status == st
|
||||||
|
Button {
|
||||||
|
item.wrappedValue.status = st
|
||||||
|
} label: {
|
||||||
|
Text(statusLabel(st))
|
||||||
|
.font(.system(size: 12, weight: selected ? .semibold : .regular))
|
||||||
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text2)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Capsule().fill(selected ? statusColor(st) : Tj.Palette.paper))
|
||||||
|
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusLabel(_ s: IndicatorStatus) -> String {
|
||||||
|
switch s {
|
||||||
|
case .normal: return String(appLoc: "正常")
|
||||||
|
case .high: return String(appLoc: "偏高 ↑")
|
||||||
|
case .low: return String(appLoc: "偏低 ↓")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bottomBar: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Text("取消")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(TjGhostButton())
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onSave(items, capturedAt)
|
||||||
|
} label: {
|
||||||
|
Text(selectedCount > 0 ? "\(String(appLoc: "保存到记录"))(\(selectedCount))"
|
||||||
|
: String(appLoc: "保存到记录"))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(TjPrimaryButton())
|
||||||
|
.disabled(selectedCount == 0)
|
||||||
|
.opacity(selectedCount == 0 ? 0.4 : 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(
|
||||||
|
Tj.Palette.sand
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确认页可编辑行模型。`include` 控制是否落库(异常项默认勾选,正常项也默认勾选但可取消)。
|
||||||
|
struct QuickRegionItem: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var name: String
|
||||||
|
var value: String
|
||||||
|
var unit: String
|
||||||
|
var range: String
|
||||||
|
var status: IndicatorStatus
|
||||||
|
var include: Bool
|
||||||
|
}
|
||||||
349
康康/Features/Quick/RegionCameraView.swift
Normal file
349
康康/Features/Quick/RegionCameraView.swift
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// 异常项快拍 · 局部相机。
|
||||||
|
/// 实时预览 + 居中小框 + 快门 → **裁剪到小框区域**的 UIImage 回调。
|
||||||
|
/// 只在真机可用(模拟器无相机,QuickRegionCaptureFlow 退化到 PhotoPicker)。
|
||||||
|
///
|
||||||
|
/// 裁剪原理:`previewLayer.metadataOutputRectConverted(fromLayerRect:)` 把屏上小框换算成
|
||||||
|
/// 归一化(0-1)裁剪 rect;先把拍到的照片方向 bake 成 `.up`,再按归一化 rect 裁 CGImage。
|
||||||
|
struct RegionCameraView: View {
|
||||||
|
let onCapture: (UIImage) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
@StateObject private var controller = RegionCameraController()
|
||||||
|
@State private var authState: AuthState = .checking
|
||||||
|
@State private var isCapturing = false
|
||||||
|
@State private var flash = false
|
||||||
|
|
||||||
|
enum AuthState { case checking, authorized, denied }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
|
||||||
|
switch authState {
|
||||||
|
case .checking:
|
||||||
|
ProgressView().tint(.white)
|
||||||
|
case .denied:
|
||||||
|
deniedView
|
||||||
|
case .authorized:
|
||||||
|
cameraStack
|
||||||
|
}
|
||||||
|
|
||||||
|
if flash {
|
||||||
|
Color.white.ignoresSafeArea().transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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(.system(size: 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 {
|
||||||
|
Button {
|
||||||
|
onCancel()
|
||||||
|
} label: {
|
||||||
|
Text("取消")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Capsule().fill(.black.opacity(0.35)))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
shutterButton
|
||||||
|
.padding(.bottom, 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shutterButton: some View {
|
||||||
|
Button {
|
||||||
|
capture()
|
||||||
|
} label: {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(.white).frame(width: 72, height: 72)
|
||||||
|
Circle().strokeBorder(.white.opacity(0.6), lineWidth: 3).frame(width: 84, height: 84)
|
||||||
|
if isCapturing {
|
||||||
|
ProgressView().tint(.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isCapturing)
|
||||||
|
.accessibilityLabel("拍摄异常项")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deniedView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "camera.fill")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
Text("相机权限未开启")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 36)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("取消") { onCancel() }
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 18).padding(.vertical, 10)
|
||||||
|
.background(Capsule().strokeBorder(.white.opacity(0.5), lineWidth: 1))
|
||||||
|
Button("去设置") {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
.padding(.horizontal, 18).padding(.vertical, 10)
|
||||||
|
.background(Capsule().fill(.white))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 行为
|
||||||
|
|
||||||
|
private func capture() {
|
||||||
|
guard !isCapturing else { return }
|
||||||
|
isCapturing = true
|
||||||
|
withAnimation(.easeOut(duration: 0.08)) { flash = true }
|
||||||
|
controller.capture { image in
|
||||||
|
withAnimation(.easeIn(duration: 0.15)) { flash = false }
|
||||||
|
isCapturing = false
|
||||||
|
guard let image else { return }
|
||||||
|
onCapture(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveAuth() async {
|
||||||
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||||
|
case .authorized:
|
||||||
|
authState = .authorized
|
||||||
|
case .notDetermined:
|
||||||
|
let granted = await AVCaptureDevice.requestAccess(for: .video)
|
||||||
|
authState = granted ? .authorized : .denied
|
||||||
|
default:
|
||||||
|
authState = .denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
/// 按归一化 rect(原点左上、范围 0-1、与显示方向一致)裁剪 `.up` 方向的图。
|
||||||
|
/// 入参 image 须已 bake 成 `.up`(见 `UIImage.normalizedUp()`)。越界自动夹紧;失败回退原图。
|
||||||
|
static func crop(_ image: UIImage, normalizedRect: CGRect) -> UIImage {
|
||||||
|
guard let cg = image.cgImage else { return image }
|
||||||
|
let w = CGFloat(cg.width), h = CGFloat(cg.height)
|
||||||
|
let nx = max(0, min(1, normalizedRect.origin.x))
|
||||||
|
let ny = max(0, min(1, normalizedRect.origin.y))
|
||||||
|
let nw = max(0, min(1 - nx, normalizedRect.size.width))
|
||||||
|
let nh = max(0, min(1 - ny, normalizedRect.size.height))
|
||||||
|
let rect = CGRect(x: nx * w, y: ny * h, width: nw * w, height: nh * h).integral
|
||||||
|
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)。
|
||||||
|
final class RegionCameraController: ObservableObject {
|
||||||
|
weak var view: RegionPreviewUIView?
|
||||||
|
func capture(_ completion: @escaping (UIImage?) -> Void) {
|
||||||
|
guard let view else { completion(nil); return }
|
||||||
|
view.capture(completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RegionCameraPreview: UIViewRepresentable {
|
||||||
|
let controller: RegionCameraController
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> RegionPreviewUIView {
|
||||||
|
let v = RegionPreviewUIView()
|
||||||
|
controller.view = v
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: RegionPreviewUIView, context: Context) {}
|
||||||
|
|
||||||
|
static func dismantleUIView(_ uiView: RegionPreviewUIView, coordinator: ()) {
|
||||||
|
uiView.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实时预览 + 单张拍摄,拍完按小框裁剪。
|
||||||
|
final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
|
||||||
|
private let session = AVCaptureSession()
|
||||||
|
private let output = AVCapturePhotoOutput()
|
||||||
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
private var setupDone = false
|
||||||
|
private var captureCompletion: ((UIImage?) -> Void)?
|
||||||
|
|
||||||
|
override func didMoveToWindow() {
|
||||||
|
super.didMoveToWindow()
|
||||||
|
guard !setupDone, window != nil else { return }
|
||||||
|
setupDone = true
|
||||||
|
configure()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configure() {
|
||||||
|
session.beginConfiguration()
|
||||||
|
session.sessionPreset = .photo
|
||||||
|
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
|
||||||
|
let input = try? AVCaptureDeviceInput(device: device),
|
||||||
|
session.canAddInput(input) else {
|
||||||
|
session.commitConfiguration()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.addInput(input)
|
||||||
|
if session.canAddOutput(output) { session.addOutput(output) }
|
||||||
|
session.commitConfiguration()
|
||||||
|
|
||||||
|
let preview = AVCaptureVideoPreviewLayer(session: session)
|
||||||
|
preview.videoGravity = .resizeAspectFill
|
||||||
|
preview.frame = bounds
|
||||||
|
layer.addSublayer(preview)
|
||||||
|
self.previewLayer = preview
|
||||||
|
applyPortrait(preview.connection)
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
|
self?.session.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 锁竖屏(iOS 17+ 用 videoRotationAngle,避免 videoOrientation 弃用告警)。
|
||||||
|
private func applyPortrait(_ connection: AVCaptureConnection?) {
|
||||||
|
guard let connection else { return }
|
||||||
|
if connection.isVideoRotationAngleSupported(90) {
|
||||||
|
connection.videoRotationAngle = 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
previewLayer?.frame = bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
func capture(completion: @escaping (UIImage?) -> Void) {
|
||||||
|
guard session.isRunning else { completion(nil); return }
|
||||||
|
captureCompletion = completion
|
||||||
|
applyPortrait(output.connection(with: .video))
|
||||||
|
output.capturePhoto(with: AVCapturePhotoSettings(), delegate: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard session.isRunning else { return }
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
|
self?.session.stopRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func photoOutput(_ output: AVCapturePhotoOutput,
|
||||||
|
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||||
|
error: Error?) {
|
||||||
|
let completion = captureCompletion
|
||||||
|
captureCompletion = nil
|
||||||
|
// 代理回调在 AVFoundation 私有队列,SwiftUI 状态更新必须切回主线程。
|
||||||
|
let deliver: (UIImage?) -> Void = { result in
|
||||||
|
DispatchQueue.main.async { completion?(result) }
|
||||||
|
}
|
||||||
|
guard error == nil,
|
||||||
|
let data = photo.fileDataRepresentation(),
|
||||||
|
let image = UIImage(data: data) else {
|
||||||
|
deliver(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let upright = image.normalizedUp()
|
||||||
|
guard let preview = previewLayer else {
|
||||||
|
deliver(upright)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// metadataOutputRectConverted 读 previewLayer 几何,回主线程算更稳。
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let box = RegionFraming.box(in: self.bounds.size)
|
||||||
|
let normalized = preview.metadataOutputRectConverted(fromLayerRect: box)
|
||||||
|
let cropped = RegionImageCropper.crop(upright, normalizedRect: normalized)
|
||||||
|
completion?(cropped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct SmartFramer: View {
|
|
||||||
var radius: CGFloat = 10
|
|
||||||
var height: CGFloat = 56
|
|
||||||
@State private var breathing = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { geo in
|
|
||||||
ZStack {
|
|
||||||
Color.black.opacity(0.32)
|
|
||||||
.mask(
|
|
||||||
Rectangle()
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
|
||||||
.frame(height: height)
|
|
||||||
.padding(.horizontal, geo.size.width * 0.08)
|
|
||||||
.blendMode(.destinationOut)
|
|
||||||
)
|
|
||||||
.compositingGroup()
|
|
||||||
)
|
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: radius + 4, style: .continuous)
|
|
||||||
.stroke(Color(red: 0.95, green: 0.78, blue: 0.45), lineWidth: 1.5)
|
|
||||||
.shadow(color: Color(red: 0.95, green: 0.78, blue: 0.45).opacity(0.5), radius: 8)
|
|
||||||
.frame(height: height + 8)
|
|
||||||
.padding(.horizontal, geo.size.width * 0.08 - 4)
|
|
||||||
.opacity(breathing ? 1 : 0.35)
|
|
||||||
|
|
||||||
cornerMarks(in: geo.size)
|
|
||||||
}
|
|
||||||
.frame(width: geo.size.width, height: geo.size.height)
|
|
||||||
.onAppear {
|
|
||||||
withAnimation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true)) {
|
|
||||||
breathing.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cornerMarks(in size: CGSize) -> some View {
|
|
||||||
let inset = size.width * 0.08
|
|
||||||
return ZStack {
|
|
||||||
ForEach(Corner.allCases, id: \.self) { corner in
|
|
||||||
CornerMark(corner: corner, radius: radius)
|
|
||||||
.frame(width: 18, height: 18)
|
|
||||||
.position(corner.position(in: size, inset: inset, frameHeight: height))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum Corner: CaseIterable {
|
|
||||||
case tl, tr, bl, br
|
|
||||||
func position(in size: CGSize, inset: CGFloat, frameHeight: CGFloat) -> CGPoint {
|
|
||||||
let centerY = size.height / 2
|
|
||||||
let top = centerY - frameHeight / 2
|
|
||||||
let bottom = centerY + frameHeight / 2
|
|
||||||
switch self {
|
|
||||||
case .tl: return CGPoint(x: inset, y: top)
|
|
||||||
case .tr: return CGPoint(x: size.width - inset, y: top)
|
|
||||||
case .bl: return CGPoint(x: inset, y: bottom)
|
|
||||||
case .br: return CGPoint(x: size.width - inset, y: bottom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct CornerMark: View {
|
|
||||||
let corner: Corner
|
|
||||||
let radius: CGFloat
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Path { p in
|
|
||||||
let r = min(radius, 8)
|
|
||||||
switch corner {
|
|
||||||
case .tl:
|
|
||||||
p.move(to: CGPoint(x: 0, y: 18))
|
|
||||||
p.addLine(to: CGPoint(x: 0, y: r))
|
|
||||||
p.addQuadCurve(to: CGPoint(x: r, y: 0), control: CGPoint(x: 0, y: 0))
|
|
||||||
p.addLine(to: CGPoint(x: 18, y: 0))
|
|
||||||
case .tr:
|
|
||||||
p.move(to: CGPoint(x: 0, y: 0))
|
|
||||||
p.addLine(to: CGPoint(x: 18 - r, y: 0))
|
|
||||||
p.addQuadCurve(to: CGPoint(x: 18, y: r), control: CGPoint(x: 18, y: 0))
|
|
||||||
p.addLine(to: CGPoint(x: 18, y: 18))
|
|
||||||
case .bl:
|
|
||||||
p.move(to: CGPoint(x: 0, y: 0))
|
|
||||||
p.addLine(to: CGPoint(x: 0, y: 18 - r))
|
|
||||||
p.addQuadCurve(to: CGPoint(x: r, y: 18), control: CGPoint(x: 0, y: 18))
|
|
||||||
p.addLine(to: CGPoint(x: 18, y: 18))
|
|
||||||
case .br:
|
|
||||||
p.move(to: CGPoint(x: 0, y: 18))
|
|
||||||
p.addLine(to: CGPoint(x: 18 - r, y: 18))
|
|
||||||
p.addQuadCurve(to: CGPoint(x: 18, y: 18 - r), control: CGPoint(x: 18, y: 18))
|
|
||||||
p.addLine(to: CGPoint(x: 18, y: 0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.stroke(Tj.Palette.paper, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -104,7 +104,7 @@ struct RootView: View {
|
|||||||
.fullScreenCover(item: $activeFlow) { flow in
|
.fullScreenCover(item: $activeFlow) { flow in
|
||||||
switch flow {
|
switch flow {
|
||||||
case .quick:
|
case .quick:
|
||||||
UnifiedCaptureFlow(onClose: { activeFlow = nil })
|
QuickRegionCaptureFlow(onClose: { activeFlow = nil })
|
||||||
case .archive:
|
case .archive:
|
||||||
UnifiedCaptureFlow(onClose: { activeFlow = nil })
|
UnifiedCaptureFlow(onClose: { activeFlow = nil })
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ struct RootView: View {
|
|||||||
.sheet(item: $activeFlow) { flow in
|
.sheet(item: $activeFlow) { flow in
|
||||||
switch flow {
|
switch flow {
|
||||||
case .quick:
|
case .quick:
|
||||||
UnifiedCaptureFlow(onClose: { activeFlow = nil })
|
QuickRegionCaptureFlow(onClose: { activeFlow = nil })
|
||||||
case .archive:
|
case .archive:
|
||||||
UnifiedCaptureFlow(onClose: { activeFlow = nil })
|
UnifiedCaptureFlow(onClose: { activeFlow = nil })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,45 @@ actor CaptureService {
|
|||||||
try await runVL(on: assets)
|
try await runVL(on: assets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 异常项快拍:对一张**局部照片**(JPEG data)跑 VL,只抽 indicators,不建 Report、不留图。
|
||||||
|
/// - 临时文件落 `NSTemporaryDirectory`(`.completeFileProtection`),推理后 `defer` 删除 —— 符合
|
||||||
|
/// 「最后只存参数和异常值」(§ 需求)与隐私基线(§6),全程不写 Vault、不建 Asset。
|
||||||
|
/// - 失败抛 `CaptureError`,UI 回退手动录入(§3.2 失败回退红线)。
|
||||||
|
/// 调用方(MainActor)负责把识别结果落成独立 Indicator。
|
||||||
|
func recognizeRegion(imageData: Data) async throws -> [ParsedReport.ParsedIndicator] {
|
||||||
|
do {
|
||||||
|
try await AIRuntime.shared.prepareVL()
|
||||||
|
} catch {
|
||||||
|
throw CaptureError.modelNotReady
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||||
|
.appendingPathComponent("region-\(UUID().uuidString).jpg")
|
||||||
|
do {
|
||||||
|
try imageData.write(to: tmpURL, options: [.completeFileProtection, .atomic])
|
||||||
|
} catch {
|
||||||
|
throw CaptureError.inferenceFailed("临时图片写入失败:\(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
defer { try? FileManager.default.removeItem(at: tmpURL) }
|
||||||
|
|
||||||
|
let raw: String
|
||||||
|
do {
|
||||||
|
raw = try await AIRuntime.shared.analyzeReport(
|
||||||
|
imageURLs: [tmpURL],
|
||||||
|
prompt: VLPrompts.regionExtraction()
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw CaptureError.inferenceFailed("\(error)")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try CaptureService.parseIndicatorsJSON(raw)
|
||||||
|
} catch let CaptureError.parseFailed(msg) {
|
||||||
|
throw CaptureError.parseFailed(msg)
|
||||||
|
} catch {
|
||||||
|
throw CaptureError.parseFailed("\(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// VL 推理 + JSON 解析的纯阶段。assets 必须已写入 Vault。
|
/// VL 推理 + JSON 解析的纯阶段。assets 必须已写入 Vault。
|
||||||
private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
|
private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
|
||||||
do {
|
do {
|
||||||
@@ -143,6 +182,32 @@ actor CaptureService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 局部识别解析:VL 输出 `{"indicators":[...]}`,只抠 indicators 数组。
|
||||||
|
/// 复用 `extractJSONObject` + `parseIndicator`。解析不到任何 indicator 返回空数组(不抛),
|
||||||
|
/// UI 据此走「没读出指标,手动补充」分支。JSON 本身不合法才抛 `parseFailed`。
|
||||||
|
static func parseIndicatorsJSON(_ raw: String) throws -> [ParsedReport.ParsedIndicator] {
|
||||||
|
let jsonString = extractBalancedJSON(from: raw)
|
||||||
|
guard let data = jsonString.data(using: .utf8) else {
|
||||||
|
throw CaptureError.parseFailed("非 UTF-8 输出")
|
||||||
|
}
|
||||||
|
let obj: Any
|
||||||
|
do {
|
||||||
|
obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
|
||||||
|
} catch {
|
||||||
|
throw CaptureError.parseFailed("JSON 不合法:\(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
// 兼容两种形态:{"indicators":[...]} 或直接 [...](模型偶尔省外层 key)
|
||||||
|
let indicatorsRaw: [[String: Any]]
|
||||||
|
if let dict = obj as? [String: Any] {
|
||||||
|
indicatorsRaw = (dict["indicators"] as? [[String: Any]]) ?? []
|
||||||
|
} else if let arr = obj as? [[String: Any]] {
|
||||||
|
indicatorsRaw = arr
|
||||||
|
} else {
|
||||||
|
throw CaptureError.parseFailed("根节点既不是对象也不是数组")
|
||||||
|
}
|
||||||
|
return indicatorsRaw.compactMap { parseIndicator($0) }
|
||||||
|
}
|
||||||
|
|
||||||
/// 从字符串里抠出第一段平衡的 {...}。处理 markdown 围栏、前后乱码。
|
/// 从字符串里抠出第一段平衡的 {...}。处理 markdown 围栏、前后乱码。
|
||||||
/// 失败返回原字符串(后续 JSONSerialization 报错)。
|
/// 失败返回原字符串(后续 JSONSerialization 报错)。
|
||||||
static func extractJSONObject(from raw: String) -> String {
|
static func extractJSONObject(from raw: String) -> String {
|
||||||
@@ -186,6 +251,56 @@ actor CaptureService {
|
|||||||
return String(s[start...])
|
return String(s[start...])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 抠出第一段平衡的 JSON 值,`{...}` 或 `[...]` 以先出现者为准。
|
||||||
|
/// 用于局部识别(模型可能输出 `{"indicators":[...]}` 或裸 `[...]`)。
|
||||||
|
/// 失败返回去围栏后的原串(后续 JSONSerialization 报错)。
|
||||||
|
static func extractBalancedJSON(from raw: String) -> String {
|
||||||
|
var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if s.hasPrefix("```") {
|
||||||
|
if let firstNewline = s.firstIndex(of: "\n") {
|
||||||
|
s = String(s[s.index(after: firstNewline)...])
|
||||||
|
}
|
||||||
|
if let endRange = s.range(of: "```", options: .backwards) {
|
||||||
|
s = String(s[..<endRange.lowerBound])
|
||||||
|
}
|
||||||
|
s = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstBrace = s.firstIndex(of: "{")
|
||||||
|
let firstBracket = s.firstIndex(of: "[")
|
||||||
|
let start: String.Index
|
||||||
|
let open: Character
|
||||||
|
let close: Character
|
||||||
|
switch (firstBrace, firstBracket) {
|
||||||
|
case let (b?, k?):
|
||||||
|
if b < k { start = b; open = "{"; close = "}" }
|
||||||
|
else { start = k; open = "["; close = "]" }
|
||||||
|
case let (b?, nil): start = b; open = "{"; close = "}"
|
||||||
|
case let (nil, k?): start = k; open = "["; close = "]"
|
||||||
|
default: return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var depth = 0
|
||||||
|
var inString = false
|
||||||
|
var escape = false
|
||||||
|
var idx = start
|
||||||
|
while idx < s.endIndex {
|
||||||
|
let ch = s[idx]
|
||||||
|
if escape { escape = false }
|
||||||
|
else if ch == "\\" { escape = true }
|
||||||
|
else if ch == "\"" { inString.toggle() }
|
||||||
|
else if !inString {
|
||||||
|
if ch == open { depth += 1 }
|
||||||
|
else if ch == close {
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0 { return String(s[start...idx]) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx = s.index(after: idx)
|
||||||
|
}
|
||||||
|
return String(s[start...])
|
||||||
|
}
|
||||||
|
|
||||||
private static func parseReportType(_ raw: String?) -> String {
|
private static func parseReportType(_ raw: String?) -> String {
|
||||||
guard let raw = raw?.lowercased() else { return ReportType.other.rawValue }
|
guard let raw = raw?.lowercased() else { return ReportType.other.rawValue }
|
||||||
return ReportType(rawValue: raw)?.rawValue ?? ReportType.other.rawValue
|
return ReportType(rawValue: raw)?.rawValue ?? ReportType.other.rawValue
|
||||||
|
|||||||
Reference in New Issue
Block a user