```
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:
@@ -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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user