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:
link2026
2026-05-31 09:25:49 +08:00
parent 7ad41c5f09
commit 40155de709
19 changed files with 424 additions and 47 deletions

View File

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

View File

@@ -125,6 +125,7 @@ struct HealthExportDetailView: View {
.padding(.horizontal, 14)
.frame(height: 44)
.background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1))
.contentShape(Capsule()) // :
}
Spacer()

View File

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

View File

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