```
feat(AI): 优化AIRuntime任务取消机制并增强安全保护 - 在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以扩大点击区域 - 修复提醒行展开按钮尺寸,保证不同提醒类型的垂直对齐 ```
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) // 挪不动就删,至少保证能启动
|
||||
}
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 形如 `<kind>-<persistentModelID>` / `bp-<sys>-<dia>`)。
|
||||
/// 把时间线条目反查回源记录。逻辑统一收敛到 `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-<sysID>-<diaID>
|
||||
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) {
|
||||
|
||||
@@ -125,6 +125,7 @@ struct HealthExportDetailView: View {
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 44)
|
||||
.background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1))
|
||||
.contentShape(Capsule()) // 纯描边胶囊:内边距区也可点
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -293,6 +293,8 @@ struct DiaryQuickSheet: View {
|
||||
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
|
||||
)
|
||||
)
|
||||
// 纯描边背景、内部透明:补 contentShape 让整框可点(否则只有图标+文字本体能点)。
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
|
||||
@@ -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,8 +122,19 @@ struct HomeView: View {
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
VStack(spacing: 10) {
|
||||
ForEach(group.items) { entry in
|
||||
Button {
|
||||
if TimelineDetail.resolve(
|
||||
for: entry,
|
||||
indicators: indicators, reports: reports,
|
||||
diaries: diaries, symptoms: symptoms
|
||||
) != nil {
|
||||
selectedEntry = entry
|
||||
}
|
||||
} label: {
|
||||
TimelineRow(entry: entry)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -10,19 +10,56 @@ enum TimelineDetail {
|
||||
case report(Report)
|
||||
case diary(DiaryEntry)
|
||||
case symptom(Symptom)
|
||||
|
||||
/// 把时间线条目反查回源记录(id 形如 `<kind>-<persistentModelID>` / `bp-<sysID>-<diaID>`)。
|
||||
/// 主页「最近记录」与档案库 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-<sysID>-<diaID>
|
||||
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
|
||||
|
||||
@@ -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..<count).compactMap { calendar.date(byAdding: .day, value: $0, to: interval.start) }
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,7 @@ extension SeriesBucket {
|
||||
|
||||
// 合并血压
|
||||
let bpKeys: Set<String> = ["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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ struct HealthExportService {
|
||||
inferredTimeFromDate: snapshot.fromDate,
|
||||
inferredTimeToDate: snapshot.toDate,
|
||||
inferredIntent: intent.intent,
|
||||
inferredLabelCN: intent.labelCN,
|
||||
decodeRate: lastRate
|
||||
)
|
||||
modelContext.insert(export)
|
||||
|
||||
Reference in New Issue
Block a user