diff --git a/康康/AI/AIRuntime.swift b/康康/AI/AIRuntime.swift index ccb9cc8..6363a3f 100644 --- a/康康/AI/AIRuntime.swift +++ b/康康/AI/AIRuntime.swift @@ -32,6 +32,40 @@ actor AIRuntime { private var llmSession: LLMSession? private var vlSession: VLSession? + // MARK: - 串行推理闸门(§3.1 OOM 防护的真正落地) + // + // actor 只串行化「方法入口」,但 generate() 同步返回流、真正解码在内部 Task; + // analyzeReport 也在 await 期间让出 actor。若不加闸门,LLM 流正在解码时触发 VL, + // 两个模型会同时在 GPU 上解码 → 冲过单 App 内存上限被 jetsam 杀 + //(MEMORY 记录的「in-flight 流并发窄口」)。 + // + // 这里用 actor 内信号量(count = 1):所有「会占显存的重活」(解码 + 模型加载) + // 进入前先 await acquireGate(),结束后 releaseGate()。actor 串行执行保证 + // gateBusy / gateWaiters 的读写天然无并发。 + private var gateBusy = false + private var gateWaiters: [CheckedContinuation] = [] + + private func acquireGate() async { + if !gateBusy { + gateBusy = true + return + } + await withCheckedContinuation { (cont: CheckedContinuation) in + gateWaiters.append(cont) + } + // 被 releaseGate 唤醒时即已持有闸门(gateBusy 保持 true)。 + } + + private func releaseGate() { + if gateWaiters.isEmpty { + gateBusy = false + } else { + // 把闸门直接交给队首等待者,gateBusy 维持 true,不留空窗。 + let next = gateWaiters.removeFirst() + next.resume() + } + } + private init() {} /// App 启动时调用一次:给 MLX 的 GPU 缓冲池设上限,避免 reuse cache 在大模型常驻之上 @@ -46,25 +80,30 @@ actor AIRuntime { /// 加载模型。首次调用会真正加载,后续幂等。 func prepare() async throws { - switch status { - case .ready: - return - case .loading: - // 已有其他调用方在加载;本次 prepare 直接返回, - // 调用方需稍后 await prepare() 再判 status,或自行轮询 / 显示加载 UI。 - // W3 引入 prepare 队列时优化。 - return - case .error, .notReady: - break + // 已有其他调用方在加载时,轮询等其结束再判定结果。 + // 不能像旧实现那样裸 return:那会让调用方误以为已 ready,随后 generate 的 + // `guard status == .ready` 失败 → 用户撞上「假错误屏」(模型其实正常加载中)。 + while status == .loading { + try await Task.sleep(nanoseconds: 80_000_000) } + if status == .ready { return } - guard ModelStore.shared.isReady(.llm) else { + // 用 isComplete(逐文件字节校验)而非 isReady(只看 config.json):config.json 最小最先下完, + // 半下载时 isReady 仍 true 会让加载在残缺 safetensors 上崩溃。与 ModelDownloadService 的 + // 完成判据保持一致(它也用 isComplete)。 + guard ModelStore.shared.isComplete(for: .llm) else { status = .error("LLM 模型未就绪") throw AIRuntimeError.notReady } + // 进闸门:等所有在跑的推理(可能是 VL 解码)结束,再卸 VL + 载 LLM, + // 避免「VL 解码 + LLM 加载」内存峰值叠加 OOM。 + await acquireGate() + defer { releaseGate() } + // 拿到闸门后复查:排队期间可能已被别的调用方加载好,避免重复 load。 + if status == .ready { return } + // OOM 闸门(§3.1):LLM(~1GB)与 VL(~3GB)不可同时常驻,叠加会冲过单 App 内存上限被 jetsam 杀。 - // 加载 LLM 前先卸 VL,释放其 ModelContainer + MLX 显存缓存。 unloadVL() status = .loading @@ -93,6 +132,8 @@ actor AIRuntime { continuation.finish(throwing: AIRuntimeError.notReady) return } + // 进闸门:保证本次 LLM 解码与任何 VL 解码 / 模型加载串行,绝不并发占显存。 + await self.acquireGate() do { // session.generate 跨 actor 边界,需要 await let stream = await session.generate(prompt: prompt, maxTokens: maxTokens) @@ -109,6 +150,9 @@ actor AIRuntime { } catch { continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)")) } + // 正常结束 / 异常 / 取消(checkCancellation 抛出后被上面 catch 吞掉)都会走到这, + // 闸门一定释放,不会死锁后续推理。 + self.releaseGate() } // 消费者取消/流终止时取消内部 Task(与 LLMSession / HealthExportService 一致)。 continuation.onTermination = { _ in task.cancel() } @@ -123,20 +167,24 @@ actor AIRuntime { /// 加载 VL 模型。幂等,首调真正 load。 func prepareVL() async throws { - switch vlStatus { - case .ready, .loading: - return - case .error, .notReady: - break + while vlStatus == .loading { + try await Task.sleep(nanoseconds: 80_000_000) } + if vlStatus == .ready { return } - guard ModelStore.shared.isReady(.vl) else { + // 同 prepare():用 isComplete 排除半下载(避免在残缺权重上崩溃),与下载服务判据一致。 + guard ModelStore.shared.isComplete(for: .vl) else { vlStatus = .error("VL 模型未就绪") throw AIRuntimeError.notReady } - // OOM 闸门(§3.1):加载 VL(~3GB)前先卸 LLM(~1GB),否则两者常驻叠加冲过内存上限被 jetsam 杀 - // —— 这正是「异常项快拍识别时 App 自动退出」的主因。 + // 进闸门:等所有在跑的推理(可能是 LLM 文本流)结束,再卸 LLM + 载 VL。 + // —— 这正是「异常项快拍识别时 App 自动退出」的主因防护。 + await acquireGate() + defer { releaseGate() } + if vlStatus == .ready { return } + + // OOM 闸门(§3.1):加载 VL(~3GB)前先卸 LLM(~1GB),否则两者常驻叠加冲过内存上限被 jetsam 杀。 unloadLLM() vlStatus = .loading @@ -155,8 +203,7 @@ actor AIRuntime { // MARK: - 卸载(OOM 闸门) /// 卸载 LLM,释放 ModelContainer 引用并清 MLX 显存缓存。幂等。 - /// 注:若此刻有 generate() 的流仍在跑,它持有 session 快照,真正释放要等流结束; - /// 但快拍/归档场景下没有并发文本流,卸载即时生效。 + /// 注:只在持有推理闸门时调用(prepareVL 内),此刻不会有 LLM 流在解码,卸载即时生效。 private func unloadLLM() { guard llmSession != nil else { return } llmSession = nil @@ -174,13 +221,15 @@ actor AIRuntime { /// 图像 → JSON 字符串(由 VLPrompts.reportExtraction 引导)。 /// 调用方负责解析 + 失败回退(§3.2)。 - /// AIRuntime 是 actor,本调用与 LLM.generate() 自然串行,不会 OOM。 + /// 推理闸门保证本调用与 LLM.generate() 的解码串行,不会同时占显存 OOM。 func analyzeReport(imageURLs: [URL], prompt: String, maxTokens: Int = 512) async throws -> String { guard vlStatus == .ready, let session = vlSession else { throw AIRuntimeError.notReady } + await acquireGate() + defer { releaseGate() } do { return try await session.analyze( imageURLs: imageURLs, diff --git a/康康/Features/Archive/ArchiveFlow.swift b/康康/Features/Archive/ArchiveFlow.swift deleted file mode 100644 index 0573a02..0000000 --- a/康康/Features/Archive/ArchiveFlow.swift +++ /dev/null @@ -1,61 +0,0 @@ -import SwiftUI - -private enum ArchiveStep: Hashable { - case guide - case scan - case meta - case progress - case result -} - -struct ArchiveFlow: View { - var onClose: () -> Void - - @State private var step: ArchiveStep = .guide - @State private var capturedPages: Int = 1 - @State private var totalPages: Int = 3 - - var body: some View { - ZStack { - switch step { - case .guide: - B1GuideView( - onSingle: { withAnimation { totalPages = 1; step = .scan } }, - onMulti: { withAnimation { totalPages = 3; step = .scan } }, - onSkip: onClose - ) - .transition(.opacity) - - case .scan: - B2ScanView( - onShoot: { capturedPages = min(capturedPages + 1, totalPages) }, - onDone: { withAnimation { step = .meta } }, - onClose: onClose, - page: capturedPages, - total: totalPages - ) - .transition(.opacity) - - case .meta: - B3MetaView( - onAnalyze: { withAnimation { step = .progress } }, - onBack: { withAnimation { step = .scan } } - ) - .transition(.opacity) - - case .progress: - B4ProgressView(onComplete: { - withAnimation { step = .result } - }) - .transition(.opacity) - - case .result: - B5ResultView( - onSave: onClose, - onBack: { withAnimation { step = .meta } } - ) - .transition(.opacity) - } - } - } -} diff --git a/康康/Features/Archive/B1GuideView.swift b/康康/Features/Archive/B1GuideView.swift deleted file mode 100644 index 3a57764..0000000 --- a/康康/Features/Archive/B1GuideView.swift +++ /dev/null @@ -1,131 +0,0 @@ -import SwiftUI - -struct B1GuideView: View { - var onSingle: () -> Void - var onMulti: () -> Void - var onSkip: () -> Void - - var body: some View { - VStack(spacing: 0) { - HStack { - Button(action: onSkip) { - Image(systemName: "xmark") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(Tj.Palette.text) - .frame(width: 36, height: 36) - } - Spacer() - Button(action: onSkip) { - Text("跳过") - .font(.system(size: 12)) - .foregroundStyle(Tj.Palette.text3) - .padding(8) - } - } - .padding(.horizontal, 12) - - VStack(alignment: .leading, spacing: 0) { - ZStack { - RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) - .fill(Tj.Palette.ink) - Image(systemName: "doc.text.fill") - .font(.system(size: 26, weight: .medium)) - .foregroundStyle(Tj.Palette.paper) - } - .frame(width: 60, height: 60) - .padding(.bottom, 18) - - Text("归档一份\n关键报告") - .font(.system(size: 30, weight: .bold)) - .lineSpacing(6) - .foregroundStyle(Tj.Palette.text) - .padding(.bottom, 12) - - Text("推荐拍清晰的\(Text("整张图").underline()),多页报告可一次完成扫描。原图与解读全部本地加密保存,永不上传。") - .font(.system(size: 13)) - .foregroundStyle(Tj.Palette.text2) - .lineSpacing(6) - .padding(.bottom, 26) - - VStack(spacing: 12) { - OptCard(title: String(appLoc: "单张报告"), sub: String(appLoc: "一张图,几秒搞定"), hint: String(appLoc: "化验单 · 处方"), badge: nil, action: onSingle) - OptCard(title: String(appLoc: "多页报告"), sub: String(appLoc: "像扫描文档一样翻页拍摄"), hint: String(appLoc: "体检报告 · 影像报告"), badge: String(appLoc: "推荐"), action: onMulti) - } - - Spacer(minLength: 18) - - HStack(alignment: .top, spacing: 10) { - Image(systemName: "lock.fill") - .font(.system(size: 12)) - .foregroundStyle(Tj.Palette.text2) - .padding(.top, 2) - Text("所有照片以 AES 加密存于本机沙盒。康康 服务端无法访问。") - .font(.system(size: 11)) - .foregroundStyle(Tj.Palette.text2) - .lineSpacing(4) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) - .fill(Tj.Palette.sand2) - ) - } - .padding(.horizontal, 24) - .padding(.top, 20) - .padding(.bottom, 20) - } - .background(Tj.Palette.sand.ignoresSafeArea()) - } -} - -private struct OptCard: View { - let title: String - let sub: String - let hint: String - let badge: String? - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 14) { - ZStack { - RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) - .fill(Tj.Palette.sand2) - Image(systemName: "doc.text") - .font(.system(size: 18, weight: .regular)) - .foregroundStyle(Tj.Palette.ink) - } - .frame(width: 44, height: 44) - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 8) { - Text(title) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Tj.Palette.text) - if let badge { - TjBadge(text: badge, style: .ink) - } - } - Text("\(sub) · \(hint)") - .font(.system(size: 11)) - .foregroundStyle(Tj.Palette.text3) - } - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(Tj.Palette.text3) - } - .padding(16) - .tjCard(bordered: true) - } - .buttonStyle(.plain) - } -} - -#Preview { - B1GuideView( - onSingle: { print("单张报告") }, - onMulti: { print("多页报告") }, - onSkip: { print("跳过") } - ) -} diff --git a/康康/Features/Archive/B2ScanView.swift b/康康/Features/Archive/B2ScanView.swift deleted file mode 100644 index 7cfff94..0000000 --- a/康康/Features/Archive/B2ScanView.swift +++ /dev/null @@ -1,198 +0,0 @@ -import SwiftUI - -struct B2ScanView: View { - var onShoot: () -> Void - var onDone: () -> Void - var onClose: () -> Void - var page: Int = 2 - var total: Int = 3 - - var body: some View { - ZStack { - Color(red: 0.04, green: 0.047, blue: 0.04).ignoresSafeArea() - - mockPaper - - DetectedEdge() - .stroke(Color(red: 0.95, green: 0.78, blue: 0.45), - style: StrokeStyle(lineWidth: 2, dash: [6, 4])) - .opacity(0.95) - .padding(.horizontal, 30) - .padding(.top, 140) - .padding(.bottom, 200) - .allowsHitTesting(false) - - VStack(spacing: 0) { - topBar - Spacer() - detectedBadge - Spacer() - thumbnails - bottomControls - } - } - .preferredColorScheme(.dark) - } - - private var mockPaper: some View { - VStack(spacing: 2) { - Text("体 检 报 告 (第 \(page) 页)") - .font(.system(size: 12, weight: .bold)) - .padding(.bottom, 4) - ForEach(reportRows, id: \.0) { row in - HStack { - Text(row.0).frame(maxWidth: .infinity, alignment: .leading) - Text(row.1) - Text(row.2).foregroundStyle(Tj.Palette.text3) - } - .font(.system(size: 9, design: .monospaced)) - } - } - .padding(16) - .foregroundStyle(Tj.Palette.text) - .frame(maxWidth: .infinity) - .background(Color(red: 0.97, green: 0.95, blue: 0.89).opacity(0.95)) - .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) - .rotation3DEffect(.degrees(8), axis: (x: 1, y: 0, z: 0)) - .rotationEffect(.degrees(-1)) - .shadow(color: .black.opacity(0.6), radius: 20, x: 0, y: 12) - .padding(.horizontal, 40) - .padding(.top, 160) - .padding(.bottom, 220) - } - - private var reportRows: [(String, String, String)] { - [ - (String(appLoc: "总胆固醇"), "5.42", "3.10–5.18"), - (String(appLoc: "甘油三酯"), "1.78", "0.45–1.70"), - (String(appLoc: "低密度脂蛋白"), "3.84↑", "<3.40"), - (String(appLoc: "高密度脂蛋白"), "1.21", ">1.04"), - (String(appLoc: "载脂蛋白 A1"), "1.42", "1.00–1.60"), - (String(appLoc: "载脂蛋白 B"), "1.04", "0.55–1.05"), - (String(appLoc: "谷丙转氨酶"), "28", "9–50"), - (String(appLoc: "谷草转氨酶"), "24", "15–40"), - (String(appLoc: "空腹血糖"), "5.4", "3.9–6.1"), - (String(appLoc: "糖化血红蛋白"), "5.7", "4.0–6.0"), - ] - } - - private var topBar: some View { - HStack { - Button(action: onClose) { - Image(systemName: "xmark") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(Color.white) - .frame(width: 36, height: 36) - } - Spacer() - HStack(spacing: 4) { - Text("\(page)").font(.system(size: 12, design: .monospaced)) - Text(" / \(total) · 像扫描文档一样对准") - .font(.system(size: 12)) - } - .foregroundStyle(Color.white) - .padding(.horizontal, 14) - .padding(.vertical, 6) - .background(Capsule().fill(Color(red: 0.08, green: 0.11, blue: 0.094).opacity(0.7))) - Spacer() - Color.clear.frame(width: 36, height: 36) - } - .padding(.horizontal, 6) - .padding(.top, 50) - } - - private var detectedBadge: some View { - Text("已识别边框 · 将自动透视校正") - .font(.system(size: 10, weight: .semibold)) - .tracking(0.4) - .foregroundStyle(Color(red: 0.10, green: 0.115, blue: 0.094)) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Capsule().fill(Color(red: 0.95, green: 0.78, blue: 0.45))) - .padding(.top, 140) - } - - private var thumbnails: some View { - HStack { - PageThumbStack(index: 1) - Spacer() - Text("已拍 1 页") - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(Color.white.opacity(0.7)) - } - .padding(.horizontal, 18) - .padding(.bottom, 24) - } - - private var bottomControls: some View { - HStack { - Color.clear.frame(width: 60, height: 60) - Spacer() - Button(action: onShoot) { - ZStack { - Circle().fill(Tj.Palette.paper) - Circle().strokeBorder(Color.white.opacity(0.4), lineWidth: 4) - } - .frame(width: 72, height: 72) - } - .buttonStyle(.plain) - Spacer() - Button(action: onDone) { - Text("完成") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Tj.Palette.paper) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Capsule().fill(Color.white.opacity(0.1))) - } - .buttonStyle(.plain) - } - .padding(.horizontal, 32) - .padding(.bottom, 40) - } -} - -private struct DetectedEdge: Shape { - func path(in rect: CGRect) -> Path { - var p = Path() - let w = rect.width - let h = rect.height - p.move(to: CGPoint(x: w * 0.04, y: h * 0.05)) - p.addLine(to: CGPoint(x: w * 0.92, y: h * 0.02)) - p.addLine(to: CGPoint(x: w * 0.96, y: h * 0.96)) - p.addLine(to: CGPoint(x: 0, y: h * 1.0)) - p.closeSubpath() - return p - } -} - -struct PageThumbStack: View { - let index: Int - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.7)) - .frame(width: 56, height: 76) - .rotationEffect(.degrees(2)) - .offset(x: 4, y: 4) - .shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2) - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color(red: 0.97, green: 0.95, blue: 0.89).opacity(0.85)) - .frame(width: 56, height: 76) - .rotationEffect(.degrees(-1)) - .offset(x: 2, y: 2) - .shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2) - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Tj.Palette.paper) - .frame(width: 56, height: 76) - .overlay( - Text("p.\(index)") - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(Tj.Palette.text3) - ) - .shadow(color: .black.opacity(0.4), radius: 4, x: 0, y: 2) - } - .frame(width: 64, height: 84, alignment: .topLeading) - } -} diff --git a/康康/Features/Archive/B3MetaView.swift b/康康/Features/Archive/B3MetaView.swift deleted file mode 100644 index e2b0677..0000000 --- a/康康/Features/Archive/B3MetaView.swift +++ /dev/null @@ -1,143 +0,0 @@ -import SwiftUI - -struct B3MetaView: View { - var onAnalyze: () -> Void - var onBack: () -> Void - - @State private var selectedType = 0 - private let types = [ - String(appLoc: "体检报告"), - String(appLoc: "化验单"), - String(appLoc: "影像报告"), - String(appLoc: "处方"), - String(appLoc: "其他"), - ] - - var body: some View { - VStack(spacing: 0) { - header - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - Text("报告类型") - .font(.system(size: 11)) - .tracking(0.5) - .foregroundStyle(Tj.Palette.text3) - .padding(.bottom, 8) - - typeChips.padding(.bottom, 20) - - FormRow(label: "报告日期", value: "2026 / 05 / 25", subtle: false) - FormRow(label: "出具机构", value: "协和医院体检中心", subtle: true) - FormRow(label: "备注", value: "春季年度体检", subtle: true) - - Text("已拍页面(3 页)") - .font(.system(size: 11)) - .tracking(0.5) - .foregroundStyle(Tj.Palette.text3) - .padding(.top, 20) - .padding(.bottom, 10) - - HStack(spacing: 10) { - ForEach(1...3, id: \.self) { n in - PageCard(index: n) - } - } - } - .padding(.horizontal, 18) - .padding(.bottom, 18) - } - - VStack(spacing: 8) { - Button(action: onAnalyze) { - Text("开始 AI 解读").frame(maxWidth: .infinity) - } - .buttonStyle(TjPrimaryButton()) - - Text("预计耗时 5–8 秒 · 端侧 SME2 加速") - .font(.system(size: 11)) - .foregroundStyle(Tj.Palette.text3) - } - .padding(.horizontal, 18) - .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) - } - Text("归档信息") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Tj.Palette.text) - Spacer() - } - .padding(.horizontal, 12) - .padding(.top, 4) - .padding(.bottom, 8) - } - - private var typeChips: some View { - let columns = [GridItem(.adaptive(minimum: 60, maximum: 200), spacing: 8)] - return LazyVGrid(columns: columns, alignment: .leading, spacing: 8) { - ForEach(Array(types.enumerated()), id: \.offset) { idx, t in - Button { selectedType = idx } label: { - Text(t) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(idx == selectedType ? Tj.Palette.paper : Tj.Palette.text2) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - Capsule().fill(idx == selectedType ? Tj.Palette.ink : Tj.Palette.sand2) - ) - } - .buttonStyle(.plain) - } - } - } -} - -private struct FormRow: View { - let label: String - let value: String - let subtle: Bool - - var body: some View { - HStack { - Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text2) - Spacer() - HStack(spacing: 6) { - Text(value) - .font(.system(size: 13)) - .foregroundStyle(subtle ? Tj.Palette.text3 : Tj.Palette.text) - Image(systemName: "chevron.right") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Tj.Palette.text3) - } - } - .padding(.vertical, 12) - .overlay(alignment: .top) { - Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) - } - } -} - -private struct PageCard: View { - let index: Int - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) - .fill(Tj.Palette.paper) - .shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06), - radius: 2, x: 0, y: 1) - TjPlaceholder(label: "p.\(index)", radius: 4) - .padding(6) - } - .aspectRatio(0.72, contentMode: .fit) - } -} diff --git a/康康/Features/Archive/B4ProgressView.swift b/康康/Features/Archive/B4ProgressView.swift deleted file mode 100644 index cb16616..0000000 --- a/康康/Features/Archive/B4ProgressView.swift +++ /dev/null @@ -1,293 +0,0 @@ -import SwiftUI - -struct B4ProgressView: View { - var onComplete: () -> Void - - @State private var step: Int = 1 - @State private var pulse = false - @State private var glow = false - @State private var rotate: Double = 0 - @State private var elapsed: Double = 0.2 - - private let lineLabels = [ - String(appLoc: "正在本地识别第 1 / 3 页…"), - String(appLoc: "正在本地识别第 2 / 3 页…"), - String(appLoc: "正在本地识别第 3 / 3 页…"), - String(appLoc: "提取指标 · 共 28 项"), - String(appLoc: "生成整体摘要…"), - ] - - var body: some View { - ZStack { - backgroundGradient.ignoresSafeArea() - - VStack(spacing: 0) { - Spacer() - chip.padding(.bottom, 36) - - Text("本地 AI · 正在解读") - .font(.system(size: 22, weight: .semibold)) - .tracking(1) - .foregroundStyle(Color.white.opacity(0.95)) - .padding(.bottom, 6) - - Text("QWEN2.5-VL · ON-DEVICE · SME2") - .font(.system(size: 11, design: .monospaced)) - .tracking(0.5) - .foregroundStyle(Color.white.opacity(0.55)) - .padding(.bottom, 30) - - lineList - .padding(.horizontal, 28) - - speedBadge.padding(.top, 32) - Spacer() - - Text("本地处理中 · 不会上传任何内容") - .font(.system(size: 10, design: .monospaced)) - .tracking(0.5) - .foregroundStyle(Color.white.opacity(0.45)) - .padding(.bottom, 30) - } - .padding(.horizontal, 28) - } - .preferredColorScheme(.dark) - .onAppear { startAnimations() } - } - - private var backgroundGradient: some View { - RadialGradient( - colors: [ - Color(red: 0.22, green: 0.21, blue: 0.18), - Color(red: 0.13, green: 0.12, blue: 0.10), - Color(red: 0.08, green: 0.075, blue: 0.06), - ], - center: .init(x: 0.5, y: 0.3), - startRadius: 60, - endRadius: 700 - ) - } - - private var chip: some View { - ZStack { - Circle() - .fill(Color(red: 0.93, green: 0.75, blue: 0.40).opacity(glow ? 0.18 : 0.0)) - .frame(width: 176, height: 176) - .blur(radius: 30) - - Circle() - .strokeBorder(Color.white.opacity(0.18), - style: StrokeStyle(lineWidth: 1, dash: [4, 4])) - .frame(width: 140, height: 140) - .rotationEffect(.degrees(rotate)) - - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill( - LinearGradient( - colors: [Color(red: 0.36, green: 0.34, blue: 0.30), - Color(red: 0.22, green: 0.21, blue: 0.18)], - startPoint: .topLeading, endPoint: .bottomTrailing - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(Color.white.opacity(0.10), lineWidth: 1) - ) - .frame(width: 96, height: 96) - .shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 12) - .overlay(ChipGlyph()) - .overlay(alignment: .topTrailing) { - Circle() - .fill(Color(red: 0.95, green: 0.78, blue: 0.40)) - .frame(width: 6, height: 6) - .opacity(pulse ? 1 : 0.35) - .shadow(color: Color(red: 0.95, green: 0.78, blue: 0.40), radius: 6) - .padding(10) - } - .scaleEffect(pulse ? 1.06 : 1.0) - .opacity(pulse ? 0.92 : 1.0) - } - } - - private var lineList: some View { - VStack(alignment: .leading, spacing: 10) { - ForEach(Array(lineLabels.enumerated()), id: \.offset) { idx, label in - LineRow( - text: label, - done: step > idx + 1, - active: step == idx + 1, - isLast: idx == lineLabels.count - 1 - ) - .opacity(step >= idx + 1 ? 1 : 0) - .offset(y: step >= idx + 1 ? 0 : 6) - .animation(.easeOut(duration: 0.4).delay(Double(idx) * 0.05), value: step) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var speedBadge: some View { - Text(String(format: String(appLoc: "已处理 %.1fs · 比云端快 4.2×"), elapsed)) - .font(.system(size: 10, design: .monospaced)) - .tracking(0.6) - .foregroundStyle(Color.white.opacity(0.75)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Capsule().fill(Color.white.opacity(0.08))) - } - - private func startAnimations() { - withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) { - pulse.toggle() - } - withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) { - glow.toggle() - } - withAnimation(.linear(duration: 14).repeatForever(autoreverses: false)) { - rotate = 360 - } - - Task { - for _ in 0.. Void - var onBack: () -> Void - - @State private var expandedIndex: Int? = 0 - @State private var normalsExpanded = false - - let abnormal: [B5IndicatorData] = [ - .init(name: String(appLoc: "低密度脂蛋白胆固醇"), value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high, - note: String(appLoc: "超过参考上限 0.44。建议关注饮食结构,3 个月内复查。")), - .init(name: String(appLoc: "甘油三酯 TG"), value: "1.78", unit: "mmol/L", range: "0.45–1.70", status: .high, note: nil), - .init(name: String(appLoc: "尿酸 UA"), value: "428", unit: "μmol/L", range: "150–420", status: .high, note: nil), - .init(name: String(appLoc: "维生素 D"), value: "18", unit: "ng/mL", range: "30–100", status: .low, note: nil), - ] - let normalCount = 24 - - var body: some View { - VStack(spacing: 0) { - header - - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - reportMeta.padding(.bottom, 16) - summaryCard.padding(.bottom, 18) - SectionLabel(String(appLoc: "异常项"), count: abnormal.count, accent: .brick) - .padding(.bottom, 10) - VStack(spacing: 8) { - ForEach(Array(abnormal.enumerated()), id: \.offset) { idx, it in - IndicatorRow(item: it, expanded: expandedIndex == idx) { - withAnimation { expandedIndex = (expandedIndex == idx) ? nil : idx } - } - } - } - .padding(.bottom, 18) - - SectionLabel(String(appLoc: "正常项"), count: normalCount, accent: .leaf) - .padding(.bottom, 10) - normalCollapsed - } - .padding(.horizontal, 18) - .padding(.bottom, 16) - } - - HStack(spacing: 10) { - Button(action: onSave) { - Text("保存归档").frame(maxWidth: .infinity) - } - .buttonStyle(TjPrimaryButton()) - - Button { } label: { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 16, weight: .semibold)) - } - .buttonStyle(TjGhostButton(horizontalPadding: 16)) - } - .padding(.horizontal, 18) - .padding(.bottom, 14) - .padding(.top, 10) - } - .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) - } - Spacer() - Button { } label: { - HStack(spacing: 4) { - Image(systemName: "photo") - Text("查看原图") - } - .font(.system(size: 12)) - .foregroundStyle(Tj.Palette.text3) - .padding(8) - } - } - .padding(.horizontal, 12) - .padding(.top, 4) - } - - private var reportMeta: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - TjBadge(text: String(appLoc: "体检报告"), style: .ink) - Text("3 页") - .font(.system(size: 11)) - .foregroundStyle(Tj.Palette.text3) - Spacer() - TjLockChip() - } - Text("2026 春季年度体检") - .font(.system(size: 22, weight: .bold)) - .foregroundStyle(Tj.Palette.text) - Text("2026 / 05 / 25 · 协和医院体检中心") - .font(.system(size: 12)) - .foregroundStyle(Tj.Palette.text3) - } - } - - private var summaryCard: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 10) { - Text("整体摘记") - .font(.system(size: 12, weight: .semibold)) - .tracking(0.3) - .foregroundStyle(Tj.Palette.brick) - .fixedSize() - Rectangle().fill(Tj.Palette.line).frame(height: 1) - Text("本机摘要") - .font(.system(size: 11)) - .foregroundStyle(Tj.Palette.text3) - .fixedSize() - } - .padding(.bottom, 12) - - HStack(spacing: 14) { - Stat(n: "28", label: String(appLoc: "总项")) - Stat(n: "3", label: String(appLoc: "偏高"), tone: .brick) - Stat(n: "1", label: String(appLoc: "偏低"), tone: .amber) - Stat(n: "24", label: String(appLoc: "正常"), tone: .leaf) - } - .padding(.bottom, 14) - - Text("本次共检测 28 项,\(Text("3 项偏高").fontWeight(.semibold).underline(color: Tj.Palette.brick))(血脂相关 2 项 + 尿酸)、\(Text("1 项偏低").fontWeight(.semibold).underline(color: Tj.Palette.amber))(维生素 D)。整体趋势提示代谢风险有所抬升,建议优化饮食并复查血脂。") - .font(.system(size: 14)) - .foregroundStyle(Tj.Palette.text) - .lineSpacing(6) - .padding(.bottom, 12) - - TjDashedDivider().padding(.bottom, 10) - - Text("仅供参考,不构成医疗建议") - .font(.system(size: 11)) - .italic() - .foregroundStyle(Tj.Palette.text3) - } - .padding(.leading, 20) - .padding(.trailing, 20) - .padding(.vertical, 20) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - Tj.Palette.paper - .overlay(alignment: .leading) { - Tj.Palette.brick.frame(width: 3) - } - ) - .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) - .shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06), radius: 0, x: 0, y: 1) - } - - private var normalCollapsed: some View { - Button { withAnimation { normalsExpanded.toggle() } } label: { - HStack(spacing: 10) { - TjBadge(text: "\(normalCount)", style: .leaf) - Text("谷丙转氨酶、空腹血糖、糖化血红蛋白…") - .font(.system(size: 13)) - .foregroundStyle(Tj.Palette.text2) - .lineLimit(1) - Spacer() - Image(systemName: normalsExpanded ? "chevron.up" : "chevron.down") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Tj.Palette.text3) - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .tjCard(bordered: true) - } - .buttonStyle(.plain) - } -} - -private struct Stat: View { - let n: String - let label: String - var tone: Tone = .ink - - enum Tone { case ink, brick, amber, leaf } - - var color: Color { - switch tone { - case .ink: return Tj.Palette.text - case .brick: return Tj.Palette.brick - case .amber: return Color(red: 0.59, green: 0.45, blue: 0.27) - case .leaf: return Tj.Palette.leaf - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(n) - .font(.system(size: 24, weight: .semibold)) - .foregroundStyle(color) - Text(label) - .font(.system(size: 10)) - .tracking(0.5) - .foregroundStyle(Tj.Palette.text3) - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -private struct SectionLabel: View { - let title: String - let count: Int - let accent: AccentKind - - enum AccentKind { case brick, leaf } - - init(_ title: String, count: Int, accent: AccentKind) { - self.title = title - self.count = count - self.accent = accent - } - - var body: some View { - HStack(spacing: 8) { - RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(accent == .brick ? Tj.Palette.brick : Tj.Palette.leaf) - .frame(width: 4, height: 14) - Text(title).font(.system(size: 13, weight: .semibold)).foregroundStyle(Tj.Palette.text) - Text("· \(count)").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3) - } - } -} - -private struct IndicatorRow: View { - let item: B5IndicatorData - let expanded: Bool - let onTap: () -> Void - - var statusBadge: TjBadgeStyle { - switch item.status { - case .high: return .brick - case .low: return .amber - case .normal: return .leaf - } - } - var statusWord: String { - switch item.status { - case .high: return String(appLoc: "偏高") - case .low: return String(appLoc: "偏低") - case .normal: return String(appLoc: "正常") - } - } - var valueColor: Color { - switch item.status { - case .high: return Tj.Palette.brick - case .low: return Color(red: 0.55, green: 0.45, blue: 0.32) - case .normal: return Tj.Palette.text - } - } - - var body: some View { - Button(action: onTap) { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Text(item.name) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Tj.Palette.text) - .lineLimit(1) - TjBadge(text: statusWord, style: statusBadge) - } - Text("范围 \(item.range) \(item.unit)") - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(Tj.Palette.text3) - } - Spacer(minLength: 8) - VStack(alignment: .trailing, spacing: 2) { - Text(item.value) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(valueColor) - Text(item.unit) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(Tj.Palette.text3) - } - } - - if expanded, let note = item.note { - TjDashedDivider() - Text(note) - .font(.system(size: 12)) - .foregroundStyle(Tj.Palette.text2) - .lineSpacing(5) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .background( - RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) - .fill(Tj.Palette.paper) - ) - .overlay( - RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) - .strokeBorder( - item.status != .normal - ? Color(red: 0.78, green: 0.68, blue: 0.48).opacity(0.5) - : Tj.Palette.lineSoft, - lineWidth: 1 - ) - ) - } - .buttonStyle(.plain) - } -} diff --git a/康康/Features/Archive/HealthExportSheet.swift b/康康/Features/Archive/HealthExportSheet.swift index 91df3fc..3b28f86 100644 --- a/康康/Features/Archive/HealthExportSheet.swift +++ b/康康/Features/Archive/HealthExportSheet.swift @@ -458,9 +458,14 @@ struct MarkdownView: View { return trimmed.replacingOccurrences(of: "⚠️", with: "") .trimmingCharacters(in: .whitespaces) } - // 一些常见 LLM 表达,也当异常项高亮 + // 关键词兜底高亮,但排除否定语境(「无异常」「未见偏高」「没有偏低」等), + // 否则正常结论会被误标红。判断:信号词前最近 4 字内出现否定词即视为否定。 + let negations = ["无", "未", "没"] let abnormalSignals = ["偏高", "偏低", "异常", "过高", "过低"] - for sig in abnormalSignals where trimmed.contains(sig) { + for sig in abnormalSignals { + guard let r = trimmed.range(of: sig) else { continue } + let window = String(trimmed[.. some View { - let binding = $parsed.indicators[idx] + private func indicatorRow(_ binding: Binding) -> some View { + let id = binding.wrappedValue.id return VStack(spacing: 8) { HStack(spacing: 8) { TextField("指标名", text: binding.name) .font(.system(size: 14, weight: .medium)) Button(role: .destructive) { - parsed.indicators.remove(at: idx) + parsed.indicators.removeAll { $0.id == id } } label: { Image(systemName: "minus.circle.fill") .foregroundStyle(Tj.Palette.text3) diff --git a/康康/Features/Indicator/IndicatorQuickSheet.swift b/康康/Features/Indicator/IndicatorQuickSheet.swift index 2a04661..666f2b3 100644 --- a/康康/Features/Indicator/IndicatorQuickSheet.swift +++ b/康康/Features/Indicator/IndicatorQuickSheet.swift @@ -246,13 +246,10 @@ struct IndicatorQuickSheet: View { } .buttonStyle(.plain) .contextMenu { + // 单一入口:进编辑器既能改也能删(编辑器内含删除按钮)。 + // 旧实现两项 action 完全相同,第二项却标红 trash「编辑/删除」,看似直接删除实则打开编辑器,误导。 Button { editingCustom = CustomMetricEditTarget(metric: cm) } label: { - Label("编辑", systemImage: "pencil") - } - Button(role: .destructive) { - editingCustom = CustomMetricEditTarget(metric: cm) - } label: { - Label("编辑/删除", systemImage: "trash") + Label("编辑 / 删除", systemImage: "pencil") } } } diff --git a/康康/Features/Timeline/TimelineEntry.swift b/康康/Features/Timeline/TimelineEntry.swift index 6e3eb0b..cdd9187 100644 --- a/康康/Features/Timeline/TimelineEntry.swift +++ b/康康/Features/Timeline/TimelineEntry.swift @@ -86,32 +86,52 @@ struct TimelineEntry: Identifiable, Hashable { private static func mergedBP(systolic sys: Indicator, diastolic dia: Indicator) -> TimelineEntry { let abnormal = sys.status != .normal || dia.status != .normal + // 方向箭头按实际 status 给:两值同向才标 ↑/↓;一高一低只标红不给方向 + // (旧实现异常一律 ↑,低血压 85/55 会错误显示 ↑)。 + let arrow: String + switch (sys.status, dia.status) { + case (.high, .high), (.high, .normal), (.normal, .high): arrow = " ↑" + case (.low, .low), (.low, .normal), (.normal, .low): arrow = " ↓" + default: arrow = "" + } return TimelineEntry( id: "bp-\(sys.persistentModelID)-\(dia.persistentModelID)", kind: .indicator, date: sys.capturedAt, title: String(appLoc: "血压"), subtitle: String(appLoc: "长期监测"), - trailing: "\(sys.value)/\(dia.value) mmHg" + (abnormal ? " ↑" : ""), + trailing: "\(sys.value)/\(dia.value) mmHg" + arrow, trailingIsAlert: abnormal, isOngoing: false ) } static func from(report r: Report) -> TimelineEntry { - let abnormal = r.indicators.filter { $0.status != .normal }.count + let highCount = r.indicators.filter { $0.status == .high }.count + let lowCount = r.indicators.filter { $0.status == .low }.count return TimelineEntry( id: "report-\(r.persistentModelID)", kind: .report, date: r.reportDate, title: r.title, subtitle: "\(r.type.label) · " + String(appLoc: "共 \(r.pageCount) 页"), - trailing: abnormal > 0 ? String(appLoc: "\(abnormal) 项偏高") : nil, - trailingIsAlert: abnormal > 0, + trailing: abnormalSummary(high: highCount, low: lowCount), + trailingIsAlert: highCount + lowCount > 0, isOngoing: false ) } + /// 异常计数 → trailing 文案。只高→「N 项偏高」、只低→「N 项偏低」、混合→「N 项异常」、无→nil。 + /// 旧实现一律写「N 项偏高」,只含偏低指标的报告会显示与事实相反的结论(demo 翻车点)。 + static func abnormalSummary(high: Int, low: Int) -> String? { + switch (high, low) { + case (0, 0): return nil + case (let h, 0): return String(appLoc: "\(h) 项偏高") + case (0, let l): return String(appLoc: "\(l) 项偏低") + case (let h, let l): return String(appLoc: "\(h + l) 项异常") + } + } + static func from(diary d: DiaryEntry) -> TimelineEntry { TimelineEntry( id: "diary-\(d.persistentModelID)", diff --git a/康康/Features/Trends/DayDetailSheet.swift b/康康/Features/Trends/DayDetailSheet.swift index 8ef2f7b..d1b3412 100644 --- a/康康/Features/Trends/DayDetailSheet.swift +++ b/康康/Features/Trends/DayDetailSheet.swift @@ -227,7 +227,9 @@ struct DayDetailContent: View { } private func reportRow(_ r: Report) -> some View { - let abnormal = r.indicators.filter { $0.status != .normal }.count + let highCount = r.indicators.filter { $0.status == .high }.count + let lowCount = r.indicators.filter { $0.status == .low }.count + let summary = TimelineEntry.abnormalSummary(high: highCount, low: lowCount) return HStack(spacing: 12) { ZStack { RoundedRectangle(cornerRadius: 8, style: .continuous) @@ -247,8 +249,8 @@ struct DayDetailContent: View { .foregroundStyle(Tj.Palette.text3) } Spacer(minLength: 6) - if abnormal > 0 { - Text("\(abnormal) 项偏高") + if let summary { + Text(summary) .font(.system(size: 11, weight: .semibold, design: .monospaced)) .foregroundStyle(Tj.Palette.brick) } diff --git a/康康/Persistence/FileVault.swift b/康康/Persistence/FileVault.swift index 223217b..4031228 100644 --- a/康康/Persistence/FileVault.swift +++ b/康康/Persistence/FileVault.swift @@ -90,11 +90,17 @@ final class FileVault: @unchecked Sendable { } } + /// 清空 Vault 全部文件。单个文件删除失败(被占用/权限)不中断,继续删其余; + /// 最后复查仍有残留才抛错 ——「永久删除 / 全清」语义下不能因一个文件卡住而留下隐私残留。 nonisolated func wipe() throws { let fm = FileManager.default - let contents = try fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil) + let contents = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? [] for url in contents { - try fm.removeItem(at: url) + try? fm.removeItem(at: url) + } + let remaining = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? [] + if !remaining.isEmpty { + throw FileVaultError.removeFailed } } } diff --git a/康康/Services/CaptureService.swift b/康康/Services/CaptureService.swift index 4e566f5..37819ee 100644 --- a/康康/Services/CaptureService.swift +++ b/康康/Services/CaptureService.swift @@ -13,7 +13,9 @@ struct ParsedReport: Sendable { var pageCount: Int var indicators: [ParsedIndicator] - struct ParsedIndicator: Sendable { + struct ParsedIndicator: Sendable, Identifiable { + // 稳定身份:供可编辑列表 ForEach 用,避免按 indices 作 id 在增删时错配输入。 + let id = UUID() var name: String var value: String var unit: String @@ -71,8 +73,8 @@ actor CaptureService { } /// 异常项快拍:对一张**局部照片**(JPEG data)跑 VL,只抽 indicators,不建 Report、不留图。 - /// - 临时文件落 `NSTemporaryDirectory`(`.completeFileProtection`),推理后 `defer` 删除 —— 符合 - /// 「最后只存参数和异常值」(§ 需求)与隐私基线(§6),全程不写 Vault、不建 Asset。 + /// - 临时文件落 `NSTemporaryDirectory`(`.completeFileProtectionUnlessOpen`),推理后 `defer` 删除 —— + /// 符合「最后只存参数和异常值」(§ 需求)与隐私基线(§6),全程不写 Vault、不建 Asset。 /// - 失败抛 `CaptureError`,UI 回退手动录入(§3.2 失败回退红线)。 /// 调用方(MainActor)负责把识别结果落成独立 Indicator。 func recognizeRegion(imageData: Data) async throws -> [ParsedReport.ParsedIndicator] { @@ -85,7 +87,10 @@ actor CaptureService { let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("region-\(UUID().uuidString).jpg") do { - try imageData.write(to: tmpURL, options: [.completeFileProtection, .atomic]) + // 用 .completeFileProtectionUnlessOpen 而非 .complete:VL 推理可能持续数秒, + // 期间设备若锁屏,.complete 会让读/写抛 EPERM 使快拍在锁屏下必失败; + // unlessOpen 允许已打开句柄继续访问,与 Vault(completeUnlessOpen)一致。 + try imageData.write(to: tmpURL, options: [.completeFileProtectionUnlessOpen, .atomic]) } catch { throw CaptureError.inferenceFailed("临时图片写入失败:\(error.localizedDescription)") } @@ -145,7 +150,10 @@ actor CaptureService { /// 解析不到任何 indicator 也算成功,但 ParsedReport.isEmpty = true, /// UI 走「手动录入」分支。 static func parseReportJSON(_ raw: String, pageCount: Int = 1) throws -> ParsedReport { - let jsonString = extractJSONObject(from: raw) + // 用 extractBalancedJSON(而非只认 {} 的 extractJSONObject):VL 多项时偶尔直接吐 + // 裸数组 [{...},{...}],只认对象会从第一个 { 配对,只截出第一个 indicator、静默丢掉 + // 其余 —— 这是影像档案核心卖点上的数据丢失。顶层是数组时整体视作 indicators。 + let jsonString = extractBalancedJSON(from: raw) guard let data = jsonString.data(using: .utf8) else { throw CaptureError.parseFailed("非 UTF-8 输出") } @@ -155,8 +163,13 @@ actor CaptureService { } catch { throw CaptureError.parseFailed("JSON 不合法:\(error.localizedDescription)") } - guard let dict = obj as? [String: Any] else { - throw CaptureError.parseFailed("根节点不是对象") + let dict: [String: Any] + if let d = obj as? [String: Any] { + dict = d + } else if let arr = obj as? [[String: Any]] { + dict = ["indicators": arr] + } else { + throw CaptureError.parseFailed("根节点既不是对象也不是数组") } let title = (dict["title"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "" @@ -310,8 +323,15 @@ actor CaptureService { guard let s = raw?.trimmingCharacters(in: .whitespaces), !s.isEmpty else { return nil } let f = DateFormatter() f.locale = Locale(identifier: "en_US_POSIX") - f.dateFormat = "yyyy-MM-dd" - return f.date(from: s) + // VL 不同来源会吐多种日期格式;逐一尝试,避免解析失败回退到「今天」(parseReportJSON 里 + // ?? .now)导致归档按 reportDate 分年份时错位(C1)。 + let patterns = ["yyyy-MM-dd", "yyyy/MM/dd", "yyyy.MM.dd", + "yyyy年MM月dd日", "yyyy年M月d日", "yyyy年MM月", "yyyy-MM", "yyyy/MM"] + for p in patterns { + f.dateFormat = p + if let d = f.date(from: s) { return d } + } + return nil } private static func parseIndicator(_ d: [String: Any]) -> ParsedReport.ParsedIndicator? { @@ -357,8 +377,14 @@ extension Report { if !parsed.institution.isEmpty { self.institution = parsed.institution } - // 旧 indicators 全删(cascade 会一起清) + // 旧 indicators 全删。各自挂的 Asset(若有局部快拍图)关系是 nullify 不 cascade, + // 必须手动 unlink Vault 文件 + 删 Asset 记录,否则留下孤儿图片(违反 §6 隐私承诺)。 + // 对照正确写法见 TimelineEntryDetailView.deleteIndicator。 for old in indicators { + if let asset = old.asset { + try? FileVault.shared.remove(relativePath: asset.relativePath) + ctx.delete(asset) + } ctx.delete(old) } indicators.removeAll() diff --git a/康康/Services/HealthExportService.swift b/康康/Services/HealthExportService.swift index 484b94b..babeb93 100644 --- a/康康/Services/HealthExportService.swift +++ b/康康/Services/HealthExportService.swift @@ -147,6 +147,7 @@ struct HealthExportService { inferredTimeToDate: snapshot.toDate, inferredIntent: intent.intent, inferredLabelCN: intent.labelCN, + modelTag: ModelKind.llm.rawValue, // 取实际加载的 LLM tag,而非写死默认值(本地推理凭证 §12#6) decodeRate: lastRate ) modelContext.insert(export) diff --git a/康康Tests/CaptureServiceJSONTests.swift b/康康Tests/CaptureServiceJSONTests.swift index c750a60..02adf26 100644 --- a/康康Tests/CaptureServiceJSONTests.swift +++ b/康康Tests/CaptureServiceJSONTests.swift @@ -109,4 +109,28 @@ struct CaptureServiceJSONTests { let diff = abs(parsed.reportDate.timeIntervalSince(now)) #expect(diff < 5) // 5 秒内算 .now } + + /// VL 多项时偶尔直接吐裸数组 [{...},{...}],旧实现(只认 {})会只截第一项静默丢其余。 + @Test func parsesTopLevelArrayAsIndicators() throws { + let raw = """ + [{"name":"A","value":"1","unit":"","range":"","status":"high"}, + {"name":"B","value":"2","unit":"","range":"","status":"low"}] + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.indicators.count == 2) + #expect(parsed.indicators.first?.name == "A") + #expect(parsed.indicators.last?.status == .low) + } + + /// VL 不同来源会吐 yyyy/MM/dd 等格式,不应回退到「今天」导致归档年份错位。 + @Test func parsesSlashAndCJKDateFormats() throws { + for ds in ["2026/04/12", "2026.04.12", "2026年04月12日"] { + let raw = """ + {"title":"t","type":"lab","report_date":"\(ds)","institution":"","page_count":1,"summary":"","indicators":[]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + let c = Calendar(identifier: .gregorian).dateComponents([.year, .month, .day], from: parsed.reportDate) + #expect(c.year == 2026 && c.month == 4 && c.day == 12, "格式 \(ds) 解析失败") + } + } }