根据提供的code differences信息,由于没有具体的代码变更内容,我将生成一个通用的commit message模板:

```
docs(readme): 更新文档说明

- 添加了项目使用指南
- 完善了API接口说明
- 修正了一些文字错误
```

注:由于未提供具体的代码差异信息,以上为示例格式。请提供具体的代码变更内容以便生成准确的commit message。
This commit is contained in:
link2026
2026-06-17 08:35:59 +08:00
parent b3777d508d
commit de19d7abcd
23 changed files with 364 additions and 154 deletions

View File

@@ -117,11 +117,17 @@ struct HealthExportRow: View {
.tjCard()
}
static func relativeDate(_ d: Date) -> String {
/// formatter:RelativeDateTimeFormatter , new
/// Locale.current(),,
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.locale = Locale.current
f.unitsStyle = .full
return f.localizedString(for: d, relativeTo: .now)
return f
}()
static func relativeDate(_ d: Date) -> String {
relativeFormatter.localizedString(for: d, relativeTo: .now)
}
}

View File

@@ -19,6 +19,7 @@ struct HealthExportSheet: View {
@State private var error: Error?
@State private var completed: Bool = false
@State private var copiedFlash: Bool = false
@State private var lastScrollAt: Date = .distantPast //
@State private var answeringTurnID: UUID?
@State private var retrieval: HealthExportService.RetrievalSummary?
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
@@ -57,7 +58,7 @@ struct HealthExportSheet: View {
header
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 18) {
LazyVStack(alignment: .leading, spacing: 18) {
introSection
ForEach(turns) { turn in
@@ -76,15 +77,15 @@ struct HealthExportSheet: View {
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
.onChange(of: content) { _, _ in
withAnimation(.easeOut(duration: 0.12)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
// content / turns token ,;
// ~8Hz,
.onChange(of: content) { _, _ in throttledScrollToBottom(proxy) }
.onChange(of: turns) { _, _ in throttledScrollToBottom(proxy) }
.onChange(of: completed) { _, done in
if done { scrollToBottom(proxy) }
}
.onChange(of: turns) { _, _ in
withAnimation(.easeOut(duration: 0.12)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
.onChange(of: answeringTurnID) { _, id in
if id == nil { scrollToBottom(proxy) }
}
}
if completed {
@@ -358,7 +359,7 @@ struct HealthExportSheet: View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.brick)
Text(err.localizedDescription)
Text(friendlyMessage(for: err))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text)
}
@@ -504,8 +505,16 @@ struct HealthExportSheet: View {
questionFocused = true
} catch {
answeringTurnID = nil
appendToTurn(id: assistantTurn.id, text: error.localizedDescription)
questionFocused = true
if error is CancellationError { return }
#if DEBUG
print("[HealthExport] answer failed: \(error)")
#endif
// ;, AI
if let idx = turns.firstIndex(where: { $0.id == assistantTurn.id }),
turns[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
turns[idx].text = "这次没能回答上来,请换个说法再试一次。"
}
}
}
}
@@ -552,6 +561,10 @@ struct HealthExportSheet: View {
}
}
} catch {
if error is CancellationError { return }
#if DEBUG
print("[HealthExport] export failed: \(error)")
#endif
self.error = error
self.phase = nil
}
@@ -600,6 +613,27 @@ struct HealthExportSheet: View {
task?.cancel()
dismiss()
}
// MARK: -
private func scrollToBottom(_ proxy: ScrollViewProxy) {
lastScrollAt = Date()
withAnimation(.easeOut(duration: 0.12)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
/// : token , 8Hz, token
private func throttledScrollToBottom(_ proxy: ScrollViewProxy) {
guard Date().timeIntervalSince(lastScrollAt) > 0.12 else { return }
scrollToBottom(proxy)
}
/// ()
private func friendlyMessage(for error: Error) -> String {
if error is CancellationError { return "" }
return "这次没能生成成功,请稍后重试。"
}
}
// MARK: - chips( RAG )
@@ -757,15 +791,30 @@ struct MarkdownView: View {
return nil
}
/// :,
/// markdown ,,
private final class AttrBox { let value: AttributedString; init(_ v: AttributedString) { value = v } }
private static let inlineCache: NSCache<NSString, AttrBox> = {
let c = NSCache<NSString, AttrBox>()
c.countLimit = 256
return c
}()
private func inline(_ s: String) -> AttributedString {
// :(),
// , AttributedString(markdown:)
if !s.contains(where: { $0 == "*" || $0 == "_" || $0 == "[" || $0 == "`" }) {
return AttributedString(s)
}
let key = s as NSString
if let hit = Self.inlineCache.object(forKey: key) { return hit.value }
// **bold** / *italic* / [text](url) AttributedString markdown
if let attr = try? AttributedString(
let attr = (try? AttributedString(
markdown: s,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
) {
return attr
}
return AttributedString(s)
)) ?? AttributedString(s)
Self.inlineCache.setObject(AttrBox(attr), forKey: key)
return attr
}
// MARK: -

View File

@@ -153,8 +153,21 @@ struct UnifiedCaptureFlow: View {
phase = .analyzing(images: images, assets: nil)
let timeout = analyzeTimeoutSeconds
analyzeTask = Task {
// Step 1: Vault(,)
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
// Step 1: Vault(:),
// 线,JPEG autoreleasepool Data /
// 线,,穿(jetsam )
let inputBox = UncheckedImageBox(images: images)
let written: (assets: [FileVault.SavedAsset], thumbs: UncheckedImageBox) =
await Task.detached(priority: .userInitiated) {
let assets = inputBox.images.compactMap { img in
autoreleasepool { try? FileVault.shared.writeJPEG(img) }
}
let thumbs = assets.compactMap {
try? FileVault.shared.loadDownsampledImage(relativePath: $0.relativePath, maxPixelSize: 600)
}
return (assets, UncheckedImageBox(images: thumbs))
}.value
let assets = written.assets
// :,View dismisscancelAll
// phase .analyzing(_, nil),
if Task.isCancelled {
@@ -171,11 +184,9 @@ struct UnifiedCaptureFlow: View {
}
return
}
// assets phase,使
// :phase 600px (), assets
await MainActor.run {
if case .analyzing(let imgs, _) = phase {
phase = .analyzing(images: imgs, assets: assets)
}
phase = .analyzing(images: written.thumbs.images, assets: assets)
}
// Step 2: meta (OCR + LLM,///)
@@ -287,6 +298,12 @@ struct UnifiedCaptureFlow: View {
}
}
/// detached UIImage :, Sendable
/// ( MNNBackend.MNNUncheckedBox )
private struct UncheckedImageBox: @unchecked Sendable {
let images: [UIImage]
}
// MARK: -
private struct AnalyzingView: View {

View File

@@ -204,12 +204,12 @@ struct DiaryQuickSheet: View {
if let note = voiceNote {
HStack(spacing: 6) {
Image(systemName: "info.circle")
Image(systemName: "exclamationmark.circle.fill")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
.foregroundStyle(Tj.Palette.amber)
Text(note)
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 0)
}
}
@@ -575,7 +575,10 @@ struct DiaryQuickSheet: View {
}
}
} catch {
voiceNote = error.localizedDescription
#if DEBUG
print("[DiaryVoice] dictation start failed: \(error)")
#endif
voiceNote = String(appLoc: "无法开始录音,请检查麦克风 / 语音识别权限")
voicePhase = .idle
}
}
@@ -612,7 +615,7 @@ struct DiaryQuickSheet: View {
guard !Task.isCancelled else { return }
appendToContent(transcript) // 线 #5:退,
organizedAppended = nil
voiceNote = String(appLoc: "AI 整理失败,已填入原话")
voiceNote = String(appLoc: "AI 整理没成功,已填入未整理的原文")
}
withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
}

View File

@@ -46,8 +46,8 @@ struct HomeCalendarCard: View {
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
}
/// (///)
private var daysWithRecordsThisMonth: Int {
/// (///) data,
private func daysWithRecordsThisMonth(_ data: CalendarData) -> Int {
guard let interval = calendar.dateInterval(of: .month, for: .now) else { return 0 }
let count = calendar.range(of: .day, in: .month, for: .now)?.count ?? 30
var n = 0
@@ -62,9 +62,11 @@ struct HomeCalendarCard: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
weekStrip
// CalendarData , + (7)+ (~30)
let calData = data
return VStack(alignment: .leading, spacing: 12) {
header(calData)
weekStrip(calData)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -77,14 +79,14 @@ struct HomeCalendarCard: View {
}
}
private var header: some View {
private func header(_ data: CalendarData) -> some View {
HStack(alignment: .firstTextBaseline) {
Text("健康日历")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
HStack(spacing: 3) {
Text(summaryLine)
Text(summaryLine(data))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Image(systemName: "chevron.right")
@@ -94,20 +96,20 @@ struct HomeCalendarCard: View {
}
}
private var summaryLine: String {
let n = daysWithRecordsThisMonth
private func summaryLine(_ data: CalendarData) -> String {
let n = daysWithRecordsThisMonth(data)
return n > 0 ? String(appLoc: "本月 \(n) 天有记录") : String(appLoc: "本月暂无记录")
}
private var weekStrip: some View {
private func weekStrip(_ data: CalendarData) -> some View {
HStack(spacing: 6) {
ForEach(weekDays, id: \.self) { day in
dayCell(day)
dayCell(day, data)
}
}
}
private func dayCell(_ day: Date) -> some View {
private func dayCell(_ day: Date, _ data: CalendarData) -> some View {
let marks = data.marks(for: day, calendar: calendar)
let ranges = data.ranges(touching: day, calendar: calendar)
let isToday = calendar.isDateInToday(day)

View File

@@ -64,6 +64,7 @@ struct CustomMetricEditor: View {
@State private var upper: String = ""
@State private var icon: String = "circle.fill"
@State private var hydrated = false
@State private var showDeleteConfirm = false
private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
private var trimmedUnit: String { unit.trimmingCharacters(in: .whitespaces) }
@@ -227,13 +228,7 @@ struct CustomMetricEditor: View {
private var deleteButton: some View {
Button(role: .destructive) {
if let m = existing {
ReminderService.cancel(metricId: m.seriesKey)
ctx.delete(m)
try? ctx.save()
onSaved(nil)
dismiss()
}
showDeleteConfirm = true
} label: {
HStack {
Image(systemName: "trash")
@@ -250,6 +245,21 @@ struct CustomMetricEditor: View {
}
.buttonStyle(.plain)
.padding(.top, 8)
.alert(String(appLoc: "删除这项自定义指标?"), isPresented: $showDeleteConfirm) {
Button(String(appLoc: "删除"), role: .destructive) { deleteMetric() }
Button(String(appLoc: "取消"), role: .cancel) { }
} message: {
Text("删除后不再监测该指标,已记录的历史数据仍保留。")
}
}
private func deleteMetric() {
guard let m = existing else { return }
ReminderService.cancel(metricId: m.seriesKey)
ctx.delete(m)
try? ctx.save()
onSaved(nil)
dismiss()
}
private var footer: some View {

View File

@@ -129,7 +129,29 @@ struct IndicatorQuickSheet: View {
selectedMonitor?.displayName ?? selectedCustom?.name
}
/// ( / / );
/// / (++)
/// nil = ;,
private var numericValidationError: String? {
func check(_ s: String, min: Double, max: Double, field: String) -> String? {
let t = s.trimmingCharacters(in: .whitespaces)
guard !t.isEmpty else { return nil } //
guard let v = Double(t), v.isFinite else { return String(appLoc: "\(field)请填数字") }
guard v >= min, v <= max else { return String(appLoc: "\(field)数值超出合理范围") }
return nil
}
if isBP {
return check(systolic, min: 30, max: 350, field: String(appLoc: "收缩压"))
?? check(diastolic, min: 20, max: 250, field: String(appLoc: "舒张压"))
}
if isLongTermMetric { // / :
return check(value, min: 0.0001, max: 1_000_000, field: String(appLoc: "数值"))
}
return nil // ( / )
}
private var canSubmit: Bool {
guard numericValidationError == nil else { return false }
if isBP {
return !systolic.trimmingCharacters(in: .whitespaces).isEmpty &&
!diastolic.trimmingCharacters(in: .whitespaces).isEmpty
@@ -162,6 +184,9 @@ struct IndicatorQuickSheet: View {
statusSection
}
}
if let validationError = numericValidationError {
validationHint(validationError)
}
timeSection
noteSection
@@ -1152,6 +1177,21 @@ struct IndicatorQuickSheet: View {
return (s.label, s.color)
}
// MARK: -
private func validationHint(_ text: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.circle.fill")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.brick)
Text(text)
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.brick)
Spacer(minLength: 0)
}
.transition(.opacity)
}
// MARK: - submit
private func submit() {

View File

@@ -18,11 +18,16 @@ struct CustomMetricsListView: View {
if metrics.isEmpty {
emptyState
} else {
// seriesKey 使(O(indicators)), O(1) ,
// O(metrics × indicators) N+1
let usageCounts: [String: Int] = indicators.reduce(into: [:]) { acc, ind in
if let key = ind.seriesKey, !key.isEmpty { acc[key, default: 0] += 1 }
}
ForEach(metrics) { m in
Button {
editingTarget = CustomMetricEditTarget(metric: m)
} label: {
row(m)
row(m, usage: usageCounts[m.seriesKey] ?? 0)
}
.buttonStyle(.plain)
}
@@ -82,9 +87,8 @@ struct CustomMetricsListView: View {
.frame(maxWidth: .infinity)
}
private func row(_ m: CustomMonitorMetric) -> some View {
let count = usageCount(for: m)
return HStack(spacing: 12) {
private func row(_ m: CustomMonitorMetric, usage count: Int) -> some View {
HStack(spacing: 12) {
ZStack {
Circle().fill(Tj.Palette.leafSoft)
Image(systemName: m.icon)
@@ -137,9 +141,6 @@ struct CustomMetricsListView: View {
)
}
private func usageCount(for m: CustomMonitorMetric) -> Int {
indicators.filter { $0.seriesKey == m.seriesKey }.count
}
}
#Preview {

View File

@@ -27,6 +27,7 @@ struct CustomReminderEditSheet: View {
@State private var month = 1
@State private var hydrated = false
@State private var showAuthDeniedAlert = false
@State private var showDeleteConfirm = false
/// (, ): / / /
private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)]
@@ -79,7 +80,7 @@ struct CustomReminderEditSheet: View {
if isEditing {
Section {
Button(role: .destructive) { deleteReminder() } label: {
Button(role: .destructive) { showDeleteConfirm = true } label: {
Label(String(appLoc: "删除提醒"), systemImage: "trash")
}
}
@@ -110,6 +111,12 @@ struct CustomReminderEditSheet: View {
} message: {
Text("提醒已保存,但系统通知权限未开启,到点不会弹出。请在「设置 · 通知 · 康康」中允许。")
}
.alert(String(appLoc: "删除这条提醒?"), isPresented: $showDeleteConfirm) {
Button(String(appLoc: "删除"), role: .destructive) { deleteReminder() }
Button(String(appLoc: "取消"), role: .cancel) { }
} message: {
Text("删除后该提醒不再触发。")
}
}
}

View File

@@ -177,6 +177,7 @@ private struct MedicationEditSheet: View {
@State private var viewerStart: PhotoIndex?
/// : MedicationLogSheet,
@State private var showLog = false
@State private var showDeleteConfirm = false
private var isEditing: Bool { existing != nil }
private var canSave: Bool {
@@ -251,7 +252,7 @@ private struct MedicationEditSheet: View {
if isEditing {
Section {
Button(role: .destructive) { deleteMedication() } label: {
Button(role: .destructive) { showDeleteConfirm = true } label: {
Label(String(appLoc: "从药品库删除"), systemImage: "trash")
}
}
@@ -280,6 +281,12 @@ private struct MedicationEditSheet: View {
.sheet(isPresented: $showLog) {
MedicationLogSheet(preselected: existing)
}
.alert(String(appLoc: "从药品库删除这种药?"), isPresented: $showDeleteConfirm) {
Button(String(appLoc: "删除"), role: .destructive) { deleteMedication() }
Button(String(appLoc: "取消"), role: .cancel) { }
} message: {
Text("关联的原图会一并永久删除,无法恢复。")
}
}
}

View File

@@ -316,12 +316,13 @@ struct MedicationScanFlow: View {
return (items, items.isEmpty ? String(appLoc: "没读出药品,可以手动填写") : nil)
} catch CaptureError.modelNotReady {
return ([], String(appLoc: "AI 模型未就绪,可以手动填写"))
} catch let CaptureError.parseFailed(msg) {
return ([], String(appLoc: "解析失败:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
return ([], String(appLoc: "识别失败:\(msg)"))
} catch CaptureError.parseFailed {
// 退,; DEBUG ,
return ([], String(appLoc: "没认出药品信息,可检查照片清晰度后重拍,或手动填写"))
} catch CaptureError.inferenceFailed {
return ([], String(appLoc: "识别没成功,可重拍或手动填写"))
} catch {
return ([], String(appLoc: "未知错误:\(error.localizedDescription)"))
return ([], String(appLoc: "识别没成功,可重拍或手动填写"))
}
}
@@ -354,9 +355,13 @@ enum MedicationArchiver {
// Vault(§5/§6: Application Support/Vault,)
// , JPEG Asset
// cascade
// autoreleasepool: JPEG Data / ,
// 5 (,,线)
let savedAssets = images
.prefix(MedicationScanFlow.maxImages)
.compactMap { try? FileVault.shared.writeJPEG($0) }
.compactMap { img in
autoreleasepool { try? FileVault.shared.writeJPEG(img) }
}
let existing = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
var attachedImages = false

View File

@@ -117,12 +117,13 @@ struct QuickRegionCaptureFlow: View {
return (items, items.isEmpty ? String(appLoc: "没读出指标,挪一下框再试") : nil)
} catch CaptureError.modelNotReady {
return ([], String(appLoc: "AI 模型未就绪,手动补充"))
} catch let CaptureError.parseFailed(msg) {
return ([], String(appLoc: "解析失败:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
return ([], Task.isCancelled ? nil : String(appLoc: "识别失败:\(msg)"))
} catch CaptureError.parseFailed {
// 退,; CaptureService DEBUG
return ([], String(appLoc: "没自动认出指标,挪一下框再试,或手动填写"))
} catch CaptureError.inferenceFailed {
return ([], Task.isCancelled ? nil : String(appLoc: "识别没成功,挪一下框再试,或手动填写"))
} catch {
return ([], Task.isCancelled ? nil : String(appLoc: "未知错误:\(error.localizedDescription)"))
return ([], Task.isCancelled ? nil : String(appLoc: "识别没成功,请手动填写"))
}
}

View File

@@ -62,13 +62,15 @@ struct IndicatorSeriesDetailView: View {
/// : bp.systolic , ±5s bp.diastolic( TimelineEntry )
private var bloodPressureRecords: [Record] {
// (O(n)),,
// O(n²)
let sysList = indicators
.filter { $0.seriesKey == "bp.systolic" }
.sorted { $0.capturedAt > $1.capturedAt }
let diaList = indicators.filter { $0.seriesKey == "bp.diastolic" }
var usedDia = Set<PersistentIdentifier>()
return sysList.map { sys in
let dia = indicators.first {
$0.seriesKey == "bp.diastolic" &&
let dia = diaList.first {
!usedDia.contains($0.persistentModelID) &&
abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5
}

View File

@@ -4,11 +4,23 @@ import Charts
struct SeriesChartCard: View {
let bucket: SeriesBucket
private var allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)] {
bucket.lines.flatMap { line in line.points.map { (line, $0) } }
// bucket , init let, body
// header / chart / daysSpanLabel 访 flatMap / min / max
private let allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)]
private let dateDomain: ClosedRange<Date>?
private let valueDomain: ClosedRange<Double>?
init(bucket: SeriesBucket) {
self.bucket = bucket
let pts = bucket.lines.flatMap { line in line.points.map { (line, $0) } }
self.allPoints = pts
self.dateDomain = Self.makeDateDomain(pts)
self.valueDomain = Self.makeValueDomain(pts, lines: bucket.lines)
}
private var dateDomain: ClosedRange<Date>? {
private static func makeDateDomain(
_ allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)]
) -> ClosedRange<Date>? {
let dates = allPoints.map(\.point.date)
guard let lo = dates.min(), let hi = dates.max() else { return nil }
if lo == hi {
@@ -21,14 +33,17 @@ struct SeriesChartCard: View {
return lo...hi
}
private var valueDomain: ClosedRange<Double>? {
private static func makeValueDomain(
_ allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)],
lines: [SeriesBucket.SeriesLine]
) -> ClosedRange<Double>? {
var lo = Double.greatestFiniteMagnitude
var hi = -Double.greatestFiniteMagnitude
for (_, p) in allPoints {
lo = min(lo, p.value)
hi = max(hi, p.value)
}
for line in bucket.lines {
for line in lines {
if let r = line.referenceRange {
lo = min(lo, r.lowerBound)
hi = max(hi, r.upperBound)

View File

@@ -506,7 +506,11 @@ private struct TrendInsightCard: View {
do {
text = try await TrendInsightService.shared.generate(for: bucket)
} catch {
failedMessage = String(appLoc: "AI 解读暂不可用(模型未就绪或繁忙)")
// ,(CLAUDE.md §4)
let downloaded = ModelStore.shared.isComplete(for: .mnnLLM) || ModelStore.shared.isComplete(for: .llm)
failedMessage = downloaded
? String(appLoc: "本地推理这次没成功,点右上「解读」重试")
: String(appLoc: "AI 解读需先在「我的 · 模型管理」下载模型")
}
running = false
}

View File

@@ -22,36 +22,32 @@ struct TrendsView: View {
customMetrics: customMetrics)
}
private var monitorBuckets: [SeriesBucket] {
seriesBuckets.filter { $0.kind == .monitor }
}
private var labBuckets: [SeriesBucket] {
seriesBuckets.filter { $0.kind == .lab }
}
private func filtered(_ buckets: [SeriesBucket]) -> [SeriesBucket] {
let q = query.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return buckets }
return buckets.filter { $0.title.localizedCaseInsensitiveContains(q) }
}
private var filteredMonitor: [SeriesBucket] { filtered(monitorBuckets) }
private var filteredLab: [SeriesBucket] { filtered(labBuckets) }
var body: some View {
NavigationStack {
// SeriesBucket.build ,monitor / lab /
// () build ~7
let series = seriesBuckets
let monitor = filtered(series.filter { $0.kind == .monitor })
let lab = filtered(series.filter { $0.kind == .lab })
return NavigationStack {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
header.padding(.top, 4)
if seriesBuckets.isEmpty {
if series.isEmpty {
emptyState
} else if filteredMonitor.isEmpty && filteredLab.isEmpty {
} else if monitor.isEmpty && lab.isEmpty {
noMatchState
} else {
if !filteredMonitor.isEmpty {
section(title: String(appLoc: "长期监测"), buckets: filteredMonitor)
if !monitor.isEmpty {
section(title: String(appLoc: "长期监测"), buckets: monitor)
}
if !filteredLab.isEmpty {
section(title: String(appLoc: "化验指标趋势"), buckets: filteredLab)
if !lab.isEmpty {
section(title: String(appLoc: "化验指标趋势"), buckets: lab)
}
}
}