fix(core): 代码审查修复 AI 并发/隐私/解析等多处缺陷

- AIRuntime 加 actor 内串行推理闸门,封死 LLM/VL in-flight 并发解码窄口(jetsam OOM 根因)
- prepare 的 .loading 改轮询等待消除假就绪竞态;就绪判据 isReady→isComplete 防半下载崩溃
- applyReanalyzed 重新解读时 unlink 旧 Asset,消除 Vault 孤儿图片(§6 隐私承诺)
- parseReportJSON 改 extractBalancedJSON + 裸数组兜底,防 VL 多项输出被静默截断丢指标
- 临时文件改 completeUnlessOpen 修锁屏写失败;parseDate 支持多格式防归档年份错位
- TimelineEntry/DayDetailSheet 修「偏高」文案与血压箭头方向(偏低指标不再显示相反结论)
- FileVault.wipe 容错;HealthExportSheet 异常关键词排除否定句;modelTag 取实际枚举值
- 删除 B1-B5 + ArchiveFlow 死代码(含违反 §6 的 AES 加密文案)
- 补 3 个回归测试,编译 + 测试全部通过
This commit is contained in:
link2026
2026-06-01 07:43:49 +08:00
parent 32e7c25ed7
commit bff7cfd4b6
16 changed files with 185 additions and 1204 deletions

View File

@@ -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<Void, Never>] = []
private func acquireGate() async {
if !gateBusy {
gateBusy = true
return
}
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) 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,

View File

@@ -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)
}
}
}
}

View File

@@ -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("跳过") }
)
}

View File

@@ -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.105.18"),
(String(appLoc: "甘油三酯"), "1.78", "0.451.70"),
(String(appLoc: "低密度脂蛋白"), "3.84↑", "<3.40"),
(String(appLoc: "高密度脂蛋白"), "1.21", ">1.04"),
(String(appLoc: "载脂蛋白 A1"), "1.42", "1.001.60"),
(String(appLoc: "载脂蛋白 B"), "1.04", "0.551.05"),
(String(appLoc: "谷丙转氨酶"), "28", "950"),
(String(appLoc: "谷草转氨酶"), "24", "1540"),
(String(appLoc: "空腹血糖"), "5.4", "3.96.1"),
(String(appLoc: "糖化血红蛋白"), "5.7", "4.06.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)
}
}

View File

@@ -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("预计耗时 58 秒 · 端侧 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)
}
}

View File

@@ -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..<lineLabels.count {
try? await Task.sleep(nanoseconds: 900_000_000)
await MainActor.run {
withAnimation { step += 1 }
elapsed += 0.9
}
}
try? await Task.sleep(nanoseconds: 600_000_000)
await MainActor.run { onComplete() }
}
}
}
private struct LineRow: View {
let text: String
let done: Bool
let active: Bool
let isLast: Bool
@State private var dotPulse = false
var body: some View {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(done
? Color(red: 0.95, green: 0.78, blue: 0.40)
: Color.white.opacity(0.12))
if done {
Image(systemName: "checkmark")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(Color(red: 0.10, green: 0.115, blue: 0.094))
}
}
.frame(width: 14, height: 14)
Text(text)
.font(.system(size: 13))
.foregroundStyle(done ? Color.white.opacity(0.95) : Color.white.opacity(0.45))
if active {
Spacer()
Text("···")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Color.white.opacity(dotPulse ? 0.9 : 0.4))
.onAppear {
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
dotPulse.toggle()
}
}
}
}
}
}
private struct ChipGlyph: View {
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 5, style: .continuous)
.strokeBorder(Color.white.opacity(0.8), lineWidth: 1.4)
.frame(width: 28, height: 28)
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Color(red: 0.95, green: 0.78, blue: 0.40).opacity(0.35))
.overlay(
RoundedRectangle(cornerRadius: 2, style: .continuous)
.strokeBorder(Color(red: 0.95, green: 0.78, blue: 0.40), lineWidth: 1)
)
.frame(width: 16, height: 16)
innerCross
outerPins
}
.frame(width: 56, height: 56)
}
private var innerCross: some View {
Canvas { ctx, size in
let amber = Color(red: 0.95, green: 0.78, blue: 0.40)
let stroke = GraphicsContext.Shading.color(amber)
let cx = size.width / 2
let cy = size.height / 2
let pairs: [(CGPoint, CGPoint)] = [
(CGPoint(x: cx, y: cy - 8), CGPoint(x: cx, y: cy - 4)),
(CGPoint(x: cx, y: cy + 4), CGPoint(x: cx, y: cy + 8)),
(CGPoint(x: cx - 8, y: cy), CGPoint(x: cx - 4, y: cy)),
(CGPoint(x: cx + 4, y: cy), CGPoint(x: cx + 8, y: cy)),
]
for (s, e) in pairs {
var p = Path()
p.move(to: s)
p.addLine(to: e)
ctx.stroke(p, with: stroke, style: StrokeStyle(lineWidth: 1, lineCap: .round))
}
}
.frame(width: 56, height: 56)
}
private var outerPins: some View {
Canvas { ctx, size in
let pinColor = GraphicsContext.Shading.color(Color.white.opacity(0.45))
let cx = size.width / 2
let cy = size.height / 2
let halfChip: CGFloat = 14
let outsideStart: CGFloat = 20
let outsideEnd: CGFloat = 26
let positions: [CGFloat] = [-8, 0, 8]
for offset in positions {
// top
var p = Path()
p.move(to: CGPoint(x: cx + offset, y: cy - outsideEnd))
p.addLine(to: CGPoint(x: cx + offset, y: cy - halfChip))
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
// bottom
p = Path()
p.move(to: CGPoint(x: cx + offset, y: cy + halfChip))
p.addLine(to: CGPoint(x: cx + offset, y: cy + outsideEnd))
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
// left
p = Path()
p.move(to: CGPoint(x: cx - outsideEnd, y: cy + offset))
p.addLine(to: CGPoint(x: cx - halfChip, y: cy + offset))
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
// right
p = Path()
p.move(to: CGPoint(x: cx + halfChip, y: cy + offset))
p.addLine(to: CGPoint(x: cx + outsideStart + 2, y: cy + offset))
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
}
}
.frame(width: 56, height: 56)
}
}
#Preview {
B4ProgressView(onComplete: {})
}

View File

@@ -1,323 +0,0 @@
import SwiftUI
struct B5IndicatorData {
let name: String
let value: String
let unit: String
let range: String
let status: IndicatorStatus
let note: String?
}
struct B5ResultView: View {
var onSave: () -> 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.451.70", status: .high, note: nil),
.init(name: String(appLoc: "尿酸 UA"), value: "428", unit: "μmol/L", range: "150420", status: .high, note: nil),
.init(name: String(appLoc: "维生素 D"), value: "18", unit: "ng/mL", range: "30100", 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)
}
}

View File

@@ -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[..<r.lowerBound].suffix(4))
if negations.contains(where: { window.contains($0) }) { continue }
return trimmed
}
return nil

View File

@@ -162,22 +162,22 @@ struct CaptureReviewForm: View {
.padding(.vertical, 8)
} else {
VStack(spacing: 10) {
ForEach(parsed.indicators.indices, id: \.self) { idx in
indicatorRow(idx)
ForEach($parsed.indicators) { $indicator in
indicatorRow($indicator)
}
}
}
}
}
private func indicatorRow(_ idx: Int) -> some View {
let binding = $parsed.indicators[idx]
private func indicatorRow(_ binding: Binding<ParsedReport.ParsedIndicator>) -> 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)

View File

@@ -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")
}
}
}

View File

@@ -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)",

View File

@@ -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)
}

View File

@@ -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
}
}
}

View File

@@ -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()

View File

@@ -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)