From 40155de7098da77fcb5528227c16f6ce5d38c7d9 Mon Sep 17 00:00:00 2001 From: link2026 Date: Sun, 31 May 2026 09:25:49 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(AI):=20=E4=BC=98=E5=8C=96AIRuntime?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=8F=96=E6=B6=88=E6=9C=BA=E5=88=B6=E5=B9=B6?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=AE=89=E5=85=A8=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在AI推理流中添加Task.checkCancellation()检查,使消费者取消时能快速退出 - 为异步流添加onTermination回调以取消内部Task,与LLMSession一致 - 实现SwiftData store的completeUnlessOpen文件保护,提升数据安全性 - 在store备份过程中同样应用加密保护 feat(home): 优化主页交互体验并统一详情查看功能 - 在主页"最近记录"中点击任意条目可打开只读详情sheet - 将时间线详情解析逻辑统一收敛到TimelineDetail.resolve方法 - 修复血压条目的精确反查逻辑,避免时间窗匹配错误 feat(archive): 新增提醒任务汇总卡并完善档案库功能 - 在档案库页面新增提醒任务汇总卡,显示总数和启用状态 - 添加按更新时间倒序合并的提醒标题预览功能 - 实现RemindersListView导航路由,统一管理提醒任务 - 优化导出列表显示,优先使用中文标签展示 feat(me): 优化个人中心界面并改进语言设置体验 - 将个人中心标题改为内容文字渲染,解决导航栏背景问题 - 为语言选择器添加个性化图标,使用本族语代表字区分 - 修复语言设置视图的图标显示逻辑 feat(timeline): 新增记录详情页删除功能并优化图表显示 - 在时间线详情页添加永久删除按钮和确认弹窗 - 实现完整的删除逻辑,包括SwiftData硬删和Vault原图unlink - 修复系列图表的数值范围计算,处理同值数据的对称留白 - 优化血压图表合并逻辑,只保留有数据点的线条 refactor(calendar): 修复DST切换导致的月份天数计算错误 - 使用calendar.range(of:.day,in:.month)替代日期间隔计算 - 避免在夏令时切换月份出现天数偏差问题 fix(ui): 修复多个UI组件的交互响应区域问题 - 为纯描边按钮和胶囊添加contentShape以扩大点击区域 - 修复提醒行展开按钮尺寸,保证不同提醒类型的垂直对齐 ``` --- 康康/AI/AIRuntime.swift | 7 +- 康康/App/KangkangApp.swift | 29 +++- 康康/App/Localization.swift | 17 +++ 康康/Features/Archive/ArchiveListView.swift | 124 +++++++++++++----- .../Archive/HealthExportDetailView.swift | 1 + .../Features/Archive/HealthExportListView.swift | 2 +- 康康/Features/Archive/HealthExportSheet.swift | 2 + 康康/Features/Diary/DiaryQuickSheet.swift | 2 + 康康/Features/Home/HomeView.swift | 25 +++- 康康/Features/Me/LanguageSettingsView.swift | 20 ++- 康康/Features/Me/MeView.swift | 13 +- 康康/Features/Me/RemindersListView.swift | 2 + .../Timeline/TimelineEntryDetailView.swift | 94 +++++++++++++ 康康/Features/Trends/CalendarYearGrid.swift | 3 +- 康康/Features/Trends/SeriesBucket.swift | 7 +- 康康/Features/Trends/SeriesChartCard.swift | 7 +- 康康/Localizable.xcstrings | 111 ++++++++++++++++ 康康/Models/HealthExport.swift | 4 + 康康/Services/HealthExportService.swift | 1 + 19 files changed, 424 insertions(+), 47 deletions(-) diff --git a/康康/AI/AIRuntime.swift b/康康/AI/AIRuntime.swift index 3ec5392..262f72f 100644 --- a/康康/AI/AIRuntime.swift +++ b/康康/AI/AIRuntime.swift @@ -73,7 +73,7 @@ actor AIRuntime { let snapshotSession = llmSession return AsyncThrowingStream { continuation in - Task { + let task = Task { guard snapshotStatus == .ready, let session = snapshotSession else { continuation.finish(throwing: AIRuntimeError.notReady) return @@ -82,6 +82,9 @@ actor AIRuntime { // session.generate 跨 actor 边界,需要 await let stream = await session.generate(prompt: prompt, maxTokens: maxTokens) for try await chunk in stream { + // 消费者(UI)提前关闭/取消时,下面的 checkCancellation 让本 Task 尽快退出, + // 连带丢弃 session 流并触发其 onTermination,停止底层 MLX 解码,不空耗 GPU。 + try Task.checkCancellation() // Task 闭包在 generate() 内启动,继承 AIRuntime 的 actor 隔离; // 调用同 actor 的 recordRate 不需要 await self.recordRate(chunk.decodeRate) @@ -92,6 +95,8 @@ actor AIRuntime { continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)")) } } + // 消费者取消/流终止时取消内部 Task(与 LLMSession / HealthExportService 一致)。 + continuation.onTermination = { _ in task.cancel() } } } diff --git a/康康/App/KangkangApp.swift b/康康/App/KangkangApp.swift index 7dffe7a..f3b7cd6 100644 --- a/康康/App/KangkangApp.swift +++ b/康康/App/KangkangApp.swift @@ -20,8 +20,14 @@ struct KangkangApp: App { CustomReminder.self, ]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + // 建库后给 store 文件补 .completeUnlessOpen 保护(§6),两条创建路径共用。 + func makeContainer() throws -> ModelContainer { + let container = try ModelContainer(for: schema, configurations: [config]) + KangkangApp.protectStore(at: config.url) + return container + } do { - return try ModelContainer(for: schema, configurations: [config]) + return try makeContainer() } catch { // Demo 阶段 schema 仍在演进:某次改动若超出 SwiftData 自动轻量迁移能力 // (最常见:给已存在的 @Model 新增「非可选且无内联默认值」的属性),自动迁移会抛错。 @@ -32,13 +38,27 @@ struct KangkangApp: App { print("⚠️ ModelContainer 创建失败,备份旧 store 后重建: \(error)") KangkangApp.backupIncompatibleStore(at: config.url) do { - return try ModelContainer(for: schema, configurations: [config]) + return try makeContainer() } catch { fatalError("Could not create ModelContainer even after store reset: \(error)") } } }() + /// 给 SwiftData store(含 `-wal`/`-shm`)补 `.completeUnlessOpen` 文件保护: + /// 设备锁屏时静态加密,但已打开的库仍可读写——对运行中的 SQLite 安全, + /// 不用 `.complete` 以免锁屏时后台/Live Activity 访问 store 崩溃。对应 CLAUDE.md §6。 + /// (默认未指定保护类时 iOS 仅给 CompleteUntilFirstUserAuthentication,这里升级一档。) + private static func protectStore(at storeURL: URL) { + let fm = FileManager.default + for suffix in ["", "-wal", "-shm"] { + let path = storeURL.path + suffix + guard fm.fileExists(atPath: path) else { continue } + try? fm.setAttributes([.protectionKey: FileProtectionType.completeUnlessOpen], + ofItemAtPath: path) + } + } + /// 把与新 schema 不兼容的旧 store(含 `-wal` / `-shm`)挪到 /// `Application Support/StoreBackups/<时间戳>/`,而不是直接删除。 /// 既清出路径让新库能建起来,又把旧数据留作可手动恢复的备份;挪不动时才降级为删除。 @@ -51,12 +71,17 @@ struct KangkangApp: App { let backupDir = storeURL.deletingLastPathComponent() .appendingPathComponent("StoreBackups/\(stamp)", isDirectory: true) try? fm.createDirectory(at: backupDir, withIntermediateDirectories: true) + // 备份副本同样要加密(否则等于把全量健康数据明文留在低保护目录)。 + try? fm.setAttributes([.protectionKey: FileProtectionType.completeUnlessOpen], + ofItemAtPath: backupDir.path) for suffix in ["", "-wal", "-shm"] { let src = URL(fileURLWithPath: storeURL.path + suffix) guard fm.fileExists(atPath: src.path) else { continue } let dst = backupDir.appendingPathComponent(src.lastPathComponent) do { try fm.moveItem(at: src, to: dst) + try? fm.setAttributes([.protectionKey: FileProtectionType.completeUnlessOpen], + ofItemAtPath: dst.path) } catch { try? fm.removeItem(at: src) // 挪不动就删,至少保证能启动 } diff --git a/康康/App/Localization.swift b/康康/App/Localization.swift index 6e3e22b..ba5e81e 100644 --- a/康康/App/Localization.swift +++ b/康康/App/Localization.swift @@ -26,6 +26,23 @@ enum AppLanguage: String, CaseIterable, Identifiable { var localeIdentifier: String? { self == .system ? nil : rawValue } + + /// 语言选择器图标。各语言用本族语代表字区分(中 / A / あ / 가), + /// 「跟随系统」非具体语言,用地球符号。代表字与 `displayName` 一样不本地化。 + enum PickerIcon: Equatable { + case symbol(String) // SF Symbol 名 + case glyph(String) // 本族语代表字 + } + + var pickerIcon: PickerIcon { + switch self { + case .system: return .symbol("globe") + case .zhHans: return .glyph("中") + case .en: return .glyph("A") + case .ja: return .glyph("あ") + case .ko: return .glyph("가") + } + } } /// 全 App 单例。负责:持久化选择、维护当前语言的 lproj bundle 与 locale。 diff --git a/康康/Features/Archive/ArchiveListView.swift b/康康/Features/Archive/ArchiveListView.swift index 4819792..5cef318 100644 --- a/康康/Features/Archive/ArchiveListView.swift +++ b/康康/Features/Archive/ArchiveListView.swift @@ -17,11 +17,21 @@ struct ArchiveListView: View { @Query(sort: \HealthExport.createdAt, order: .reverse) private var exports: [HealthExport] + @Query(sort: \CustomReminder.updatedAt, order: .reverse) + private var customReminders: [CustomReminder] + + @Query(sort: \MetricReminder.updatedAt, order: .reverse) + private var metricReminders: [MetricReminder] + + /// 记录页内的 push 目的地。用单个 `navigationDestination(item:)` 驱动—— + /// 多个 `navigationDestination(isPresented:)` 并存时 SwiftUI 行为未定义(会误触发)。 + private enum Route: Hashable { case exports, reminders } + @State private var filter: TimelineKind? = nil @State private var endingSymptom: Symptom? @State private var selectedEntry: TimelineEntry? @State private var showExportSheet = false - @State private var showExportList = false + @State private var route: Route? @MainActor private var allEntries: [TimelineEntry] { @@ -43,8 +53,11 @@ struct ArchiveListView: View { var body: some View { NavigationStack { content - .navigationDestination(isPresented: $showExportList) { - HealthExportListView() + .navigationDestination(item: $route) { route in + switch route { + case .exports: HealthExportListView() + case .reminders: RemindersListView() + } } } } @@ -56,6 +69,12 @@ struct ArchiveListView: View { .padding(.top, 8) .padding(.bottom, 14) + if reminderTotal > 0 { + reminderBoard + .padding(.horizontal, 20) + .padding(.bottom, 14) + } + filterChips .padding(.bottom, 14) @@ -118,33 +137,11 @@ struct ArchiveListView: View { } } - /// 把时间线条目反查回源记录(id 形如 `-` / `bp--`)。 + /// 把时间线条目反查回源记录。逻辑统一收敛到 `TimelineDetail.resolve`(主页/档案库共用)。 private func detail(for entry: TimelineEntry) -> TimelineDetail? { - switch entry.kind { - case .report: - return reports.first { "report-\($0.persistentModelID)" == entry.id } - .map(TimelineDetail.report) - case .diary: - return diaries.first { "diary-\($0.persistentModelID)" == entry.id } - .map(TimelineDetail.diary) - case .symptom: - return symptoms.first { "symptom-\($0.persistentModelID)" == entry.id } - .map(TimelineDetail.symptom) - case .indicator: - if let i = indicators.first(where: { "indicator-\($0.persistentModelID)" == entry.id }) { - return .indicator(i) - } - // 合并血压条目:bp-- - if entry.id.hasPrefix("bp-"), - let sys = indicators.first(where: { entry.id.hasPrefix("bp-\($0.persistentModelID)-") }) { - let dia = indicators.first { - $0.seriesKey == "bp.diastolic" && - abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5 - } - return .bloodPressure(sys: sys, dia: dia) - } - return nil - } + TimelineDetail.resolve(for: entry, + indicators: indicators, reports: reports, + diaries: diaries, symptoms: symptoms) } private var header: some View { @@ -164,7 +161,7 @@ struct ArchiveListView: View { } if !exports.isEmpty { Button { - showExportList = true + route = .exports } label: { Label("我的导出 · \(exports.count) 份", systemImage: "clock.arrow.circlepath") } @@ -173,7 +170,7 @@ struct ArchiveListView: View { HStack(spacing: 6) { Image(systemName: "doc.text.below.ecg") .font(.system(size: 12, weight: .semibold)) - Text("导出") + Text("导出身体档案") .font(.system(size: 13, weight: .semibold)) Image(systemName: "chevron.down") .font(.system(size: 9, weight: .semibold)) @@ -186,6 +183,71 @@ struct ArchiveListView: View { } } + // MARK: - 提醒任务汇总卡 + + /// 两类提醒(自由 + 指标记录)合计,含已关闭。 + private var reminderTotal: Int { customReminders.count + metricReminders.count } + private var reminderEnabledCount: Int { + customReminders.filter(\.enabled).count + metricReminders.filter(\.enabled).count + } + + /// 按 updatedAt 倒序合并,取前 3 条标题做预览(标题是用户数据,不本地化)。 + private var reminderTitlePreview: [String] { + let merged: [(title: String, at: Date)] = + customReminders.map { ($0.title, $0.updatedAt) } + + metricReminders.map { ($0.displayName, $0.updatedAt) } + return merged.sorted { $0.at > $1.at }.prefix(3).map(\.title) + } + + private var reminderCountLabel: String { + reminderEnabledCount == reminderTotal + ? String(appLoc: "\(reminderTotal) 个提醒任务") + : String(appLoc: "\(reminderTotal) 个提醒任务 · \(reminderEnabledCount) 个开启中") + } + + private var reminderTitleLine: String { + let joined = reminderTitlePreview.joined(separator: " · ") + return reminderTotal > reminderTitlePreview.count ? joined + " …" : joined + } + + /// 点击进提醒中心(RemindersListView)统一管理;卡片本身只展示。 + private var reminderBoard: some View { + Button { route = .reminders } label: { + HStack(spacing: 12) { + ZStack { + Circle().fill(reminderEnabledCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2) + Image(systemName: "bell.fill") + .font(.system(size: 16)) + .foregroundStyle(reminderEnabledCount > 0 ? Tj.Palette.ink : Tj.Palette.text3) + } + .frame(width: 36, height: 36) + + VStack(alignment: .leading, spacing: 2) { + Text(reminderCountLabel) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .lineLimit(1) + if !reminderTitlePreview.isEmpty { + Text(reminderTitleLine) + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + .lineLimit(1) + } + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(14) + .contentShape(Rectangle()) + .tjCard() + } + .buttonStyle(.plain) + } + private var filterChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { diff --git a/康康/Features/Archive/HealthExportDetailView.swift b/康康/Features/Archive/HealthExportDetailView.swift index e98a9a0..7321e32 100644 --- a/康康/Features/Archive/HealthExportDetailView.swift +++ b/康康/Features/Archive/HealthExportDetailView.swift @@ -125,6 +125,7 @@ struct HealthExportDetailView: View { .padding(.horizontal, 14) .frame(height: 44) .background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1)) + .contentShape(Capsule()) // 纯描边胶囊:内边距区也可点 } Spacer() diff --git a/康康/Features/Archive/HealthExportListView.swift b/康康/Features/Archive/HealthExportListView.swift index b32c485..b6807ed 100644 --- a/康康/Features/Archive/HealthExportListView.swift +++ b/康康/Features/Archive/HealthExportListView.swift @@ -107,7 +107,7 @@ struct HealthExportRow: View { .foregroundStyle(Tj.Palette.leaf) } Spacer() - if let label = export.inferredIntent { + if let label = export.inferredLabelCN ?? export.inferredIntent { TjBadge(text: label, style: .neutral) } } diff --git a/康康/Features/Archive/HealthExportSheet.swift b/康康/Features/Archive/HealthExportSheet.swift index 7396462..91df3fc 100644 --- a/康康/Features/Archive/HealthExportSheet.swift +++ b/康康/Features/Archive/HealthExportSheet.swift @@ -274,6 +274,7 @@ struct HealthExportSheet: View { .padding(.horizontal, 14) .frame(height: 44) .background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1)) + .contentShape(Capsule()) // 纯描边胶囊:内边距区也可点 } Spacer() @@ -297,6 +298,7 @@ struct HealthExportSheet: View { guard !p.isEmpty else { return } promptFocused = false content = "" + rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示 error = nil completed = false phase = .extractingIntent diff --git a/康康/Features/Diary/DiaryQuickSheet.swift b/康康/Features/Diary/DiaryQuickSheet.swift index 1d48464..a20e3d7 100644 --- a/康康/Features/Diary/DiaryQuickSheet.swift +++ b/康康/Features/Diary/DiaryQuickSheet.swift @@ -293,6 +293,8 @@ struct DiaryQuickSheet: View { style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3]) ) ) + // 纯描边背景、内部透明:补 contentShape 让整框可点(否则只有图标+文字本体能点)。 + .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(!enabled) diff --git a/康康/Features/Home/HomeView.swift b/康康/Features/Home/HomeView.swift index 8d6ef42..0420edf 100644 --- a/康康/Features/Home/HomeView.swift +++ b/康康/Features/Home/HomeView.swift @@ -16,6 +16,9 @@ struct HomeView: View { @Query(sort: \Symptom.startedAt, order: .reverse) private var symptoms: [Symptom] + /// 点「最近记录」某行 → 打开只读详情 sheet(与档案库 C1 同款交互)。 + @State private var selectedEntry: TimelineEntry? + @MainActor private var recentEntries: [TimelineEntry] { let all = @@ -51,6 +54,15 @@ struct HomeView: View { .padding(.bottom, 20) } .background(Tj.Palette.sand.ignoresSafeArea()) + .sheet(item: $selectedEntry) { entry in + if let d = TimelineDetail.resolve( + for: entry, + indicators: indicators, reports: reports, + diaries: diaries, symptoms: symptoms + ) { + TimelineEntryDetailView(detail: d) + } + } } private var greeting: some View { @@ -110,7 +122,18 @@ struct HomeView: View { .foregroundStyle(Tj.Palette.text3) VStack(spacing: 10) { ForEach(group.items) { entry in - TimelineRow(entry: entry) + Button { + if TimelineDetail.resolve( + for: entry, + indicators: indicators, reports: reports, + diaries: diaries, symptoms: symptoms + ) != nil { + selectedEntry = entry + } + } label: { + TimelineRow(entry: entry) + } + .buttonStyle(.plain) } } } diff --git a/康康/Features/Me/LanguageSettingsView.swift b/康康/Features/Me/LanguageSettingsView.swift index f95db1c..ac96f16 100644 --- a/康康/Features/Me/LanguageSettingsView.swift +++ b/康康/Features/Me/LanguageSettingsView.swift @@ -35,9 +35,7 @@ struct LanguageSettingsView: View { HStack(spacing: 12) { ZStack { Circle().fill(selected ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2) - Image(systemName: "character.bubble") - .font(.system(size: 16)) - .foregroundStyle(selected ? Tj.Palette.ink : Tj.Palette.text2) + icon(option, selected: selected) } .frame(width: 40, height: 40) @@ -58,6 +56,22 @@ struct LanguageSettingsView: View { } .buttonStyle(.plain) } + + /// 每个语言的个性化图标:本族语代表字(中/A/あ/가),跟随系统用地球符号。 + @ViewBuilder + private func icon(_ option: AppLanguage, selected: Bool) -> some View { + let fg = selected ? Tj.Palette.ink : Tj.Palette.text2 + switch option.pickerIcon { + case .symbol(let name): + Image(systemName: name) + .font(.system(size: 16)) + .foregroundStyle(fg) + case .glyph(let g): + Text(verbatim: g) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(fg) + } + } } #Preview { diff --git a/康康/Features/Me/MeView.swift b/康康/Features/Me/MeView.swift index d3f8661..5876f90 100644 --- a/康康/Features/Me/MeView.swift +++ b/康康/Features/Me/MeView.swift @@ -24,6 +24,15 @@ struct MeView: View { NavigationStack { ScrollView { VStack(spacing: 12) { + HStack { + Text("我的") + .font(.tjTitle()) + .foregroundStyle(Tj.Palette.text) + Spacer() + } + .padding(.top, 4) + .padding(.bottom, 6) + profileCard customMetricsCard modelManagementCard @@ -42,8 +51,8 @@ struct MeView: View { .padding(.vertical, 20) } .background(Tj.Palette.sand.ignoresSafeArea()) - .navigationTitle("我的") - .navigationBarTitleDisplayMode(.large) + // 标题改用内容文字渲染(与 主页/记录/趋势 一致),不走 .navigationTitle: + // 大标题导航栏在真机状态栏区域会露出系统白底,破坏全 App 的沙色背景。 .onAppear { if profiles.isEmpty { _ = UserProfileStore.loadOrCreate(in: ctx) diff --git a/康康/Features/Me/RemindersListView.swift b/康康/Features/Me/RemindersListView.swift index 9cc986c..6151b2d 100644 --- a/康康/Features/Me/RemindersListView.swift +++ b/康康/Features/Me/RemindersListView.swift @@ -171,9 +171,11 @@ private struct CustomReminderRow: View { .tint(Tj.Palette.ink) .onChange(of: reminder.enabled) { _, _ in onToggle() } + // 与指标提醒行的 28×28 展开按钮等宽,保证两类行的 Toggle 纵向对齐。 Image(systemName: "chevron.right") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) + .frame(width: 28, height: 28) } .padding(14) .background( diff --git a/康康/Features/Timeline/TimelineEntryDetailView.swift b/康康/Features/Timeline/TimelineEntryDetailView.swift index a7d5530..de663ce 100644 --- a/康康/Features/Timeline/TimelineEntryDetailView.swift +++ b/康康/Features/Timeline/TimelineEntryDetailView.swift @@ -10,19 +10,56 @@ enum TimelineDetail { case report(Report) case diary(DiaryEntry) case symptom(Symptom) + + /// 把时间线条目反查回源记录(id 形如 `-` / `bp--`)。 + /// 主页「最近记录」与档案库 C1 共用同一套反查,避免逻辑重复。无法定位源记录时返回 nil。 + static func resolve(for entry: TimelineEntry, + indicators: [Indicator], + reports: [Report], + diaries: [DiaryEntry], + symptoms: [Symptom]) -> TimelineDetail? { + switch entry.kind { + case .report: + return reports.first { "report-\($0.persistentModelID)" == entry.id } + .map(TimelineDetail.report) + case .diary: + return diaries.first { "diary-\($0.persistentModelID)" == entry.id } + .map(TimelineDetail.diary) + case .symptom: + return symptoms.first { "symptom-\($0.persistentModelID)" == entry.id } + .map(TimelineDetail.symptom) + case .indicator: + if let i = indicators.first(where: { "indicator-\($0.persistentModelID)" == entry.id }) { + return .indicator(i) + } + // 合并血压条目:bp-- + if entry.id.hasPrefix("bp-"), + let sys = indicators.first(where: { entry.id.hasPrefix("bp-\($0.persistentModelID)-") }) { + // 用 id 里编码的 diaID 精确反查,不再用 ±5s 时间窗近似匹配 + //(后者在同日多次量血压时会把详情配到错误的舒张读数)。 + let dia = indicators.first { entry.id.hasSuffix("-\($0.persistentModelID)") } + return .bloodPressure(sys: sys, dia: dia) + } + return nil + } + } } /// 时间线条目的只读详情:展示该记录的完整字段。各类型一屏看完,不可编辑。 struct TimelineEntryDetailView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var ctx let detail: TimelineDetail + @State private var showDeleteConfirm = false + var body: some View { VStack(spacing: 0) { header ScrollView { VStack(alignment: .leading, spacing: 16) { bodyContent + deleteButton } .padding(.horizontal, 20) .padding(.vertical, 16) @@ -34,6 +71,63 @@ struct TimelineEntryDetailView: View { .presentationDragIndicator(.visible) .presentationBackground(Tj.Palette.sand) .presentationCornerRadius(Tj.Radius.xl) + .alert(String(appLoc: "永久删除这条记录?"), isPresented: $showDeleteConfirm) { + Button(String(appLoc: "删除"), role: .destructive) { performDelete() } + Button(String(appLoc: "取消"), role: .cancel) { } + } message: { + Text("删除后无法恢复。") + } + } + + // MARK: - 删除(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6) + + private var deleteButton: some View { + Button(role: .destructive) { showDeleteConfirm = true } label: { + Label(String(appLoc: "永久删除"), systemImage: "trash") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Tj.Palette.brick.opacity(0.8)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(Tj.Palette.brick.opacity(0.3), lineWidth: 1) + ) + // 纯描边按钮:补 contentShape 让整框可点(否则中间透明区点不到)。 + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.top, 8) + } + + private func performDelete() { + switch detail { + case .indicator(let i): + deleteIndicator(i) + case .bloodPressure(let sys, let dia): + deleteIndicator(sys) + if let dia { deleteIndicator(dia) } + case .report(let r): + // cascade 只删 Asset/Indicator 记录,Vault 里的 JPEG 要手动 unlink。 + var paths = Set(r.assets.map(\.relativePath)) + paths.formUnion(r.indicators.compactMap { $0.asset?.relativePath }) + for p in paths { try? FileVault.shared.remove(relativePath: p) } + ctx.delete(r) + case .diary(let d): + ctx.delete(d) + case .symptom(let s): + ctx.delete(s) + } + try? ctx.save() + dismiss() + } + + /// 删一条指标:先 unlink 其原图文件 + Asset 记录(关系默认 nullify,不会自动级联),再删指标本身。 + private func deleteIndicator(_ i: Indicator) { + if let asset = i.asset { + try? FileVault.shared.remove(relativePath: asset.relativePath) + ctx.delete(asset) + } + ctx.delete(i) } // MARK: - Header diff --git a/康康/Features/Trends/CalendarYearGrid.swift b/康康/Features/Trends/CalendarYearGrid.swift index 97d5d95..0d0ce82 100644 --- a/康康/Features/Trends/CalendarYearGrid.swift +++ b/康康/Features/Trends/CalendarYearGrid.swift @@ -47,7 +47,8 @@ private struct MiniMonth: View { private var days: [Date] { guard let interval = calendar.dateInterval(of: .month, for: anchor) else { return [] } - let count = calendar.dateComponents([.day], from: interval.start, to: interval.end).day ?? 30 + // 用 range(of:.day,in:.month) 求当月天数,避免按秒差折算在 DST 切换月份偏 1 天。 + let count = calendar.range(of: .day, in: .month, for: anchor)?.count ?? 30 return (0.. = ["bp.systolic", "bp.diastolic"] - let bpIndicators = bpKeys.flatMap { buckets[$0] ?? [] } - let bpHasEnoughPoints = bpIndicators.filter { $0.seriesKey == "bp.systolic" }.count >= minPoints + let bpHasEnoughPoints = (buckets["bp.systolic"]?.count ?? 0) >= minPoints var results: [SeriesBucket] = [] @@ -142,11 +141,13 @@ extension SeriesBucket { diaItems.last?.capturedAt ?? .distantPast ) + // 只保留有数据点的线:某一路(收缩/舒张)为空时不画空线 + 残缺图例。 + let lines = [sysLine, diaLine].filter { !$0.points.isEmpty } return SeriesBucket( id: "bp", title: String(appLoc: "血压"), unit: "mmHg", - lines: [sysLine, diaLine], + lines: lines, latestDate: latest ) } diff --git a/康康/Features/Trends/SeriesChartCard.swift b/康康/Features/Trends/SeriesChartCard.swift index 93bc961..e331bca 100644 --- a/康康/Features/Trends/SeriesChartCard.swift +++ b/康康/Features/Trends/SeriesChartCard.swift @@ -34,8 +34,11 @@ struct SeriesChartCard: View { hi = max(hi, r.upperBound) } } - guard lo < hi else { return nil } - let pad = max(1, (hi - lo) * 0.12) + // 无数据时 lo>hi → nil;所有点同值(lo==hi)时按值本身对称留白, + // 否则会落到 0...1 把数据点挤出可视域。 + guard lo <= hi else { return nil } + let span = hi - lo + let pad = span > 0 ? max(1, span * 0.12) : max(1, abs(lo) * 0.1) return (lo - pad)...(hi + pad) } diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings index 265f282..168d58a 100644 --- a/康康/Localizable.xcstrings +++ b/康康/Localizable.xcstrings @@ -395,6 +395,50 @@ } } }, + "%lld 个提醒任务" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld reminders" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 件のリマインダー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리마인더 %lld개" + } + } + } + }, + "%lld 个提醒任务 · %lld 个开启中" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld reminders · %lld on" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 件のリマインダー · %lld 件オン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리마인더 %lld개 · %lld개 켜짐" + } + } + } + }, "%lld 个建议" : { "localizations" : { "en" : { @@ -3402,6 +3446,28 @@ } } }, + "删除后无法恢复。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This can't be undone." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "削除すると元に戻せません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제하면 복구할 수 없습니다." + } + } + } + }, "删除后无法恢复。源记录(指标、症状等)不受影响。" : { "localizations" : { "en" : { @@ -4867,6 +4933,7 @@ } }, "导出" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -8873,6 +8940,28 @@ }, "每月%lld日" : { + }, + "永久删除" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Permanently" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "完全に削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "영구 삭제" + } + } + } }, "永久删除这份导出?" : { "localizations" : { @@ -8896,6 +8985,28 @@ } } }, + "永久删除这条记录?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete this record permanently?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この記録を完全に削除しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기록을 영구 삭제할까요?" + } + } + } + }, "没有指标 — 点上方「加一项」补一行,或直接保存只存图片" : { "localizations" : { "en" : { diff --git a/康康/Models/HealthExport.swift b/康康/Models/HealthExport.swift index 77b9310..cee32cb 100644 --- a/康康/Models/HealthExport.swift +++ b/康康/Models/HealthExport.swift @@ -24,6 +24,8 @@ final class HealthExport { var inferredTimeFromDate: Date? var inferredTimeToDate: Date? var inferredIntent: String? + /// 意图的中文标签(如「感冒就诊」),供「我的导出」列表 badge 展示;可选,旧库走轻量迁移。 + var inferredLabelCN: String? // demo 卖点凭证 /// 模型 tag,如 "Qwen3-1.7B-4bit"。截图能证明本地推理。 @@ -41,6 +43,7 @@ final class HealthExport { inferredTimeFromDate: Date? = nil, inferredTimeToDate: Date? = nil, inferredIntent: String? = nil, + inferredLabelCN: String? = nil, modelTag: String = "Qwen3-1.7B-4bit", decodeRate: Double = 0) { self.prompt = prompt @@ -53,6 +56,7 @@ final class HealthExport { self.inferredTimeFromDate = inferredTimeFromDate self.inferredTimeToDate = inferredTimeToDate self.inferredIntent = inferredIntent + self.inferredLabelCN = inferredLabelCN self.modelTag = modelTag self.decodeRate = decodeRate } diff --git a/康康/Services/HealthExportService.swift b/康康/Services/HealthExportService.swift index c92ad36..484b94b 100644 --- a/康康/Services/HealthExportService.swift +++ b/康康/Services/HealthExportService.swift @@ -146,6 +146,7 @@ struct HealthExportService { inferredTimeFromDate: snapshot.fromDate, inferredTimeToDate: snapshot.toDate, inferredIntent: intent.intent, + inferredLabelCN: intent.labelCN, decodeRate: lastRate ) modelContext.insert(export)