From adb589af16bbac818b477117a65423907df6a48c Mon Sep 17 00:00:00 2001 From: link2026 Date: Sun, 31 May 2026 17:12:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(quick):=20=E5=BC=82=E5=B8=B8=E9=A1=B9?= =?UTF-8?q?=E5=BF=AB=E6=8B=8D=E6=94=B9=E4=B8=BA=E5=B1=80=E9=83=A8=E5=B0=8F?= =?UTF-8?q?=E6=A1=86=20+=20VL=20=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将「异常项快拍」从复用整页报告归档流程,改造成独立的局部识别路径: 小框拍局部 → 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) --- ...026-05-31-abnormal-quick-capture-design.md | 87 +++++ 康康/AI/Prompts/VLPrompts.swift | 51 +++ 康康/Features/Quick/A1ViewfinderView.swift | 159 -------- 康康/Features/Quick/A2ConfirmView.swift | 180 --------- 康康/Features/Quick/A3BatchView.swift | 124 ------- 康康/Features/Quick/QuickCaptureFlow.swift | 60 --- .../Features/Quick/QuickRegionCaptureFlow.swift | 254 +++++++++++++ .../Features/Quick/QuickRegionConfirmView.swift | 305 +++++++++++++++ 康康/Features/Quick/RegionCameraView.swift | 349 ++++++++++++++++++ 康康/Features/Quick/SmartFramer.swift | 100 ----- 康康/RootView.swift | 4 +- 康康/Services/CaptureService.swift | 115 ++++++ 12 files changed, 1163 insertions(+), 625 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-31-abnormal-quick-capture-design.md delete mode 100644 康康/Features/Quick/A1ViewfinderView.swift delete mode 100644 康康/Features/Quick/A2ConfirmView.swift delete mode 100644 康康/Features/Quick/A3BatchView.swift delete mode 100644 康康/Features/Quick/QuickCaptureFlow.swift create mode 100644 康康/Features/Quick/QuickRegionCaptureFlow.swift create mode 100644 康康/Features/Quick/QuickRegionConfirmView.swift create mode 100644 康康/Features/Quick/RegionCameraView.swift delete mode 100644 康康/Features/Quick/SmartFramer.swift diff --git a/docs/superpowers/specs/2026-05-31-abnormal-quick-capture-design.md b/docs/superpowers/specs/2026-05-31-abnormal-quick-capture-design.md new file mode 100644 index 0000000..3e37271 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-abnormal-quick-capture-design.md @@ -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` 这一条路径。 diff --git a/康康/AI/Prompts/VLPrompts.swift b/康康/AI/Prompts/VLPrompts.swift index 59bdb25..1be6249 100644 --- a/康康/AI/Prompts/VLPrompts.swift +++ b/康康/AI/Prompts/VLPrompts.swift @@ -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"}]} 现在请识别图片并输出 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: """# } diff --git a/康康/Features/Quick/A1ViewfinderView.swift b/康康/Features/Quick/A1ViewfinderView.swift deleted file mode 100644 index df4083f..0000000 --- a/康康/Features/Quick/A1ViewfinderView.swift +++ /dev/null @@ -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) - } -} diff --git a/康康/Features/Quick/A2ConfirmView.swift b/康康/Features/Quick/A2ConfirmView.swift deleted file mode 100644 index 522487a..0000000 --- a/康康/Features/Quick/A2ConfirmView.swift +++ /dev/null @@ -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: 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) - ) - } -} diff --git a/康康/Features/Quick/A3BatchView.swift b/康康/Features/Quick/A3BatchView.swift deleted file mode 100644 index fb61544..0000000 --- a/康康/Features/Quick/A3BatchView.swift +++ /dev/null @@ -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() - } -} diff --git a/康康/Features/Quick/QuickCaptureFlow.swift b/康康/Features/Quick/QuickCaptureFlow.swift deleted file mode 100644 index 12ab0ba..0000000 --- a/康康/Features/Quick/QuickCaptureFlow.swift +++ /dev/null @@ -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) - } - } - } -} diff --git a/康康/Features/Quick/QuickRegionCaptureFlow.swift b/康康/Features/Quick/QuickRegionCaptureFlow.swift new file mode 100644 index 0000000..1222b8d --- /dev/null +++ b/康康/Features/Quick/QuickRegionCaptureFlow.swift @@ -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? = 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 } + } +} diff --git a/康康/Features/Quick/QuickRegionConfirmView.swift b/康康/Features/Quick/QuickRegionConfirmView.swift new file mode 100644 index 0000000..45114a3 --- /dev/null +++ b/康康/Features/Quick/QuickRegionConfirmView.swift @@ -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) -> 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, 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) -> 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 +} diff --git a/康康/Features/Quick/RegionCameraView.swift b/康康/Features/Quick/RegionCameraView.swift new file mode 100644 index 0000000..b147752 --- /dev/null +++ b/康康/Features/Quick/RegionCameraView.swift @@ -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) + } + } +} diff --git a/康康/Features/Quick/SmartFramer.swift b/康康/Features/Quick/SmartFramer.swift deleted file mode 100644 index dd7c169..0000000 --- a/康康/Features/Quick/SmartFramer.swift +++ /dev/null @@ -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)) - } -} diff --git a/康康/RootView.swift b/康康/RootView.swift index 2cddbc8..6ac4eb4 100644 --- a/康康/RootView.swift +++ b/康康/RootView.swift @@ -104,7 +104,7 @@ struct RootView: View { .fullScreenCover(item: $activeFlow) { flow in switch flow { case .quick: - UnifiedCaptureFlow(onClose: { activeFlow = nil }) + QuickRegionCaptureFlow(onClose: { activeFlow = nil }) case .archive: UnifiedCaptureFlow(onClose: { activeFlow = nil }) } @@ -113,7 +113,7 @@ struct RootView: View { .sheet(item: $activeFlow) { flow in switch flow { case .quick: - UnifiedCaptureFlow(onClose: { activeFlow = nil }) + QuickRegionCaptureFlow(onClose: { activeFlow = nil }) case .archive: UnifiedCaptureFlow(onClose: { activeFlow = nil }) } diff --git a/康康/Services/CaptureService.swift b/康康/Services/CaptureService.swift index 33efdd3..4e566f5 100644 --- a/康康/Services/CaptureService.swift +++ b/康康/Services/CaptureService.swift @@ -70,6 +70,45 @@ actor CaptureService { 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。 private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport { 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 围栏、前后乱码。 /// 失败返回原字符串(后续 JSONSerialization 报错)。 static func extractJSONObject(from raw: String) -> String { @@ -186,6 +251,56 @@ actor CaptureService { 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[.. String { guard let raw = raw?.lowercased() else { return ReportType.other.rawValue } return ReportType(rawValue: raw)?.rawValue ?? ReportType.other.rawValue