缺少代码差异信息,无法生成具体的commit message。

请提供 "code differences" 的具体内容,以便我能够根据代码变更情况生成符合 Angular 规范的中文 commit message。
This commit is contained in:
link2026
2026-06-07 09:40:59 +08:00
parent 675c33bea1
commit 60b6ad6d65
18 changed files with 1552 additions and 299 deletions

View File

@@ -2,6 +2,11 @@ import SwiftUI
import SwiftData
import Foundation
///
/// - `.monitor`: / / ( seriesKey )
/// - `.lab`: 2 //( name+unit , seriesKey)
enum SeriesKind { case monitor, lab }
/// Trends 线
/// (//...)= 1 SeriesLine; = + 2 线
struct SeriesBucket: Identifiable {
@@ -10,6 +15,7 @@ struct SeriesBucket: Identifiable {
let unit: String
let lines: [SeriesLine]
let latestDate: Date
let kind: SeriesKind
struct SeriesLine: Identifiable {
let id: String
@@ -68,9 +74,79 @@ extension SeriesBucket {
}
}
// lab : seriesKey , name+unit ; minPoints
var labBuckets: [String: [Indicator]] = [:]
for i in indicators {
if let key = i.seriesKey, !key.isEmpty { continue } // seriesKey monitor
let nk = normalizedKey(name: i.name, unit: i.unit)
guard !nk.isEmpty else { continue }
labBuckets[nk, default: []].append(i)
}
for (_, items) in labBuckets {
guard items.count >= minPoints else { continue }
if let bucket = buildLab(items: items) {
results.append(bucket)
}
}
return results.sorted { $0.latestDate > $1.latestDate }
}
/// name+unit :trim + + ()
static func normalizedKey(name: String, unit: String) -> String {
func norm(_ s: String) -> String {
s.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
}
let n = norm(name)
guard !n.isEmpty else { return "" }
return n + "|" + norm(unit)
}
/// ClosedRange "3.9-6.1" / "3.9~6.1" / "3.9 - 6.1"
/// ("<5.2" / ">40" / "120") nil(,)
static func parseRange(_ raw: String) -> ClosedRange<Double>? {
let s = raw.replacingOccurrences(of: "", with: "~")
.replacingOccurrences(of: "~", with: "-")
guard let regex = try? NSRegularExpression(
pattern: #"(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)"#
) else { return nil }
let range = NSRange(s.startIndex..<s.endIndex, in: s)
guard let m = regex.firstMatch(in: s, range: range),
let r1 = Range(m.range(at: 1), in: s),
let r2 = Range(m.range(at: 2), in: s),
let lo = Double(s[r1]), let hi = Double(s[r2]),
lo <= hi else { return nil }
return lo...hi
}
private static func buildLab(items: [Indicator]) -> SeriesBucket? {
let sorted = items.sorted { $0.capturedAt < $1.capturedAt }
guard let latest = sorted.last else { return nil }
let points = sorted.compactMap { point(from: $0) }
guard points.count >= 2 else { return nil } // , 2
let line = SeriesLine(
id: "lab:\(latest.name)",
seriesKey: "lab:\(latest.name)",
label: nil,
color: Tj.Palette.ink,
points: points,
referenceRange: parseRange(latest.range)
)
return SeriesBucket(
id: "lab:\(normalizedKey(name: latest.name, unit: latest.unit))",
title: latest.name,
unit: latest.unit,
lines: [line],
latestDate: latest.capturedAt,
kind: .lab
)
}
private static func buildSingle(key: String,
items: [Indicator],
profile: UserProfile?,
@@ -106,7 +182,8 @@ extension SeriesBucket {
title: title,
unit: unit,
lines: [line],
latestDate: latest.capturedAt
latestDate: latest.capturedAt,
kind: .monitor
)
}
@@ -148,7 +225,8 @@ extension SeriesBucket {
title: String(appLoc: "血压"),
unit: "mmHg",
lines: lines,
latestDate: latest
latestDate: latest,
kind: .monitor
)
}

View File

@@ -0,0 +1,448 @@
import SwiftUI
import SwiftData
import Charts
/// : + + + ()
struct TrendDetailView: View {
let bucket: SeriesBucket
@Query(sort: \Indicator.capturedAt, order: .reverse)
private var indicators: [Indicator]
@Query(sort: \Report.reportDate, order: .reverse)
private var reports: [Report]
@Query(sort: \DiaryEntry.createdAt, order: .reverse)
private var diaries: [DiaryEntry]
@Query(sort: \Symptom.startedAt, order: .reverse)
private var symptoms: [Symptom]
@State private var range: TrendRange = .all
@State private var openDay: SelectedDay?
private let calendar = Calendar.current
// MARK:
/// = (,"3")
private var anchorDate: Date {
bucket.lines.flatMap(\.points).map(\.date).max() ?? .now
}
private var fullSpanDays: Int {
let dates = bucket.lines.flatMap(\.points).map(\.date)
guard let lo = dates.min(), let hi = dates.max() else { return 0 }
return calendar.dateComponents([.day], from: lo, to: hi).day ?? 0
}
private var availableRanges: [TrendRange] {
TrendRange.allCases.filter { r in
guard let d = r.days else { return true } // .all
return d < fullSpanDays
}
}
private func filtered(_ line: SeriesBucket.SeriesLine) -> [SeriesBucket.Point] {
guard let days = range.days,
let cutoff = calendar.date(byAdding: .day, value: -days, to: anchorDate) else {
return line.points
}
return line.points.filter { $0.date >= cutoff }
}
private var filteredLines: [SeriesBucket.SeriesLine] {
bucket.lines.map { line in
SeriesBucket.SeriesLine(
id: line.id,
seriesKey: line.seriesKey,
label: line.label,
color: line.color,
points: filtered(line),
referenceRange: line.referenceRange
)
}
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
if availableRanges.count > 1 {
rangePicker
}
chartCard
statsCard
aiPlaceholder
pointsList
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle(bucket.title)
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $openDay) { day in
DayDetailSheet(
date: day.date,
indicators: indicators,
reports: reports,
diaries: diaries,
symptoms: symptoms
)
}
}
// MARK:
private var rangePicker: some View {
HStack(spacing: 0) {
ForEach(availableRanges) { r in
Button {
withAnimation(.snappy(duration: 0.2)) { range = r }
} label: {
Text(r.label)
.font(.system(size: 12, weight: range == r ? .semibold : .regular))
.foregroundStyle(range == r ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity)
.padding(.vertical, 7)
.background(Capsule().fill(range == r ? Tj.Palette.ink : Color.clear))
}
.buttonStyle(.plain)
}
}
.padding(3)
.background(Capsule().fill(Tj.Palette.paper))
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
}
// MARK:
private var allFilteredPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)] {
filteredLines.flatMap { line in line.points.map { (line, $0) } }
}
private var dateDomain: ClosedRange<Date>? {
let dates = allFilteredPoints.map(\.point.date)
guard let lo = dates.min(), let hi = dates.max() else { return nil }
if lo == hi {
let earlier = calendar.date(byAdding: .hour, value: -12, to: lo) ?? lo
let later = calendar.date(byAdding: .hour, value: 12, to: hi) ?? hi
return earlier...later
}
return lo...hi
}
private var valueDomain: ClosedRange<Double>? {
var lo = Double.greatestFiniteMagnitude
var hi = -Double.greatestFiniteMagnitude
for (_, p) in allFilteredPoints {
lo = min(lo, p.value); hi = max(hi, p.value)
}
for line in filteredLines {
if let r = line.referenceRange {
lo = min(lo, r.lowerBound); hi = max(hi, r.upperBound)
}
}
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)
}
private var chartCard: some View {
VStack(alignment: .leading, spacing: 12) {
chart.frame(height: 220)
if filteredLines.count > 1 {
legendLine
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
private var chart: some View {
Chart {
ForEach(filteredLines) { line in
if let r = line.referenceRange, let dom = dateDomain {
RectangleMark(
xStart: .value("start", dom.lowerBound),
xEnd: .value("end", dom.upperBound),
yStart: .value("lo", r.lowerBound),
yEnd: .value("hi", r.upperBound)
)
.foregroundStyle(line.color.opacity(0.08))
}
}
ForEach(filteredLines) { line in
ForEach(line.points) { p in
LineMark(
x: .value("时间", p.date),
y: .value(line.label ?? bucket.title, p.value),
series: .value("series", line.id)
)
.foregroundStyle(line.color)
.interpolationMethod(.catmullRom)
.lineStyle(StrokeStyle(lineWidth: 2))
PointMark(
x: .value("时间", p.date),
y: .value(line.label ?? bucket.title, p.value)
)
.foregroundStyle(p.status == .normal ? line.color : Tj.Palette.brick)
.symbolSize(p.status == .normal ? 26 : 44)
}
}
}
.chartXAxis {
AxisMarks(values: .automatic(desiredCount: 4)) { _ in
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
AxisValueLabel(format: .dateTime.month(.abbreviated).day())
.foregroundStyle(Tj.Palette.text3)
}
}
.chartYAxis {
AxisMarks(position: .leading, values: .automatic(desiredCount: 4)) { _ in
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
AxisValueLabel()
.foregroundStyle(Tj.Palette.text3)
.font(.system(size: 10, design: .monospaced))
}
}
.chartYScale(domain: valueDomain ?? 0...1)
}
private var legendLine: some View {
HStack(spacing: 14) {
ForEach(filteredLines) { line in
HStack(spacing: 5) {
Circle().fill(line.color).frame(width: 8, height: 8)
Text(line.label ?? line.seriesKey)
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text2)
}
}
}
}
// MARK:
private var statsCard: some View {
VStack(alignment: .leading, spacing: 14) {
ForEach(filteredLines) { line in
lineStats(line)
if line.id != filteredLines.last?.id {
Divider().overlay(Tj.Palette.lineSoft)
}
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
@ViewBuilder
private func lineStats(_ line: SeriesBucket.SeriesLine) -> some View {
let pts = line.points
let values = pts.map(\.value)
let latest = pts.last
let prev = pts.count >= 2 ? pts[pts.count - 2] : nil
let minV = values.min() ?? 0
let maxV = values.max() ?? 0
let avg = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
VStack(alignment: .leading, spacing: 10) {
if filteredLines.count > 1, let label = line.label {
Text(label)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
}
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(latest.map { fmt($0.value) } ?? "")
.font(.system(size: 28, weight: .bold, design: .monospaced))
.foregroundStyle((latest?.status ?? .normal) == .normal ? Tj.Palette.text : Tj.Palette.brick)
Text(bucket.unit)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
if let delta = deltaText(latest: latest, prev: prev) {
Text(delta.text)
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundStyle(delta.color)
}
}
HStack(spacing: 0) {
statCell(String(appLoc: "最低"), fmt(minV))
statCell(String(appLoc: "最高"), fmt(maxV))
statCell(String(appLoc: "平均"), fmt(avg))
statCell(String(appLoc: "记录"), "\(pts.count)")
}
}
}
private func statCell(_ label: String, _ value: String) -> some View {
VStack(spacing: 3) {
Text(value)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundStyle(Tj.Palette.text)
Text(label)
.font(.system(size: 10))
.foregroundStyle(Tj.Palette.text3)
}
.frame(maxWidth: .infinity)
}
/// :Δ + + ;
private func deltaText(latest: SeriesBucket.Point?,
prev: SeriesBucket.Point?) -> (text: String, color: Color)? {
guard let latest, let prev else { return nil }
let d = latest.value - prev.value
let arrow = d > 0 ? "" : (d < 0 ? "" : "")
let pct = prev.value != 0 ? abs(d / prev.value) * 100 : 0
let abnormalShift = (prev.status == .normal) != (latest.status == .normal)
let color: Color = abnormalShift
? Tj.Palette.brick
: (d == 0 ? Tj.Palette.text3 : Tj.Palette.text2)
let pctStr = pct > 0 ? String(format: " (%.0f%%)", pct) : ""
return ("\(arrow) \(fmt(abs(d)))\(pctStr)", color)
}
// MARK: AI
private var aiPlaceholder: some View {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Text("AI 趋势解读即将上线")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2.opacity(0.6))
)
}
// MARK:
/// 线:,线
private var pointRows: [PointRow] {
var byDay: [Date: [String: SeriesBucket.Point]] = [:]
for line in filteredLines {
for p in line.points {
let day = calendar.startOfDay(for: p.date)
byDay[day, default: [:]][line.id] = p
}
}
return byDay.keys.sorted(by: >).map { day in
PointRow(day: day, byLine: byDay[day] ?? [:])
}
}
private struct PointRow: Identifiable {
let day: Date
let byLine: [String: SeriesBucket.Point]
var id: TimeInterval { day.timeIntervalSince1970 }
}
private var pointsList: some View {
VStack(alignment: .leading, spacing: 10) {
Text("全部记录")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
VStack(spacing: 8) {
ForEach(pointRows) { row in
Button {
openDay = SelectedDay(date: row.day)
} label: {
pointRowView(row)
}
.buttonStyle(.plain)
}
}
}
}
private func pointRowView(_ row: PointRow) -> some View {
HStack(spacing: 12) {
Text(row.day.formatted(.dateTime.year().month(.abbreviated).day()))
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 8)
HStack(spacing: 10) {
ForEach(filteredLines) { line in
if let p = row.byLine[line.id] {
HStack(spacing: 3) {
if filteredLines.count > 1 {
Circle().fill(line.color).frame(width: 6, height: 6)
}
Text(fmt(p.value) + arrow(p.status))
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundStyle(p.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
}
}
}
}
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(12)
.frame(maxWidth: .infinity)
.tjCard(bordered: true)
}
private func arrow(_ status: IndicatorStatus) -> String {
switch status {
case .high: return ""
case .low: return ""
case .normal: return ""
}
}
private func fmt(_ v: Double) -> String {
v.truncatingRemainder(dividingBy: 1) == 0
? String(format: "%.0f", v)
: String(format: "%.1f", v)
}
}
enum TrendRange: String, CaseIterable, Identifiable {
case all, year, sixMonths, threeMonths
var id: String { rawValue }
var label: String {
switch self {
case .all: return String(appLoc: "全部")
case .year: return String(appLoc: "近1年")
case .sixMonths: return String(appLoc: "近6月")
case .threeMonths: return String(appLoc: "近3月")
}
}
/// nil =
var days: Int? {
switch self {
case .all: return nil
case .year: return 365
case .sixMonths: return 182
case .threeMonths: return 91
}
}
}

View File

@@ -0,0 +1,113 @@
import SwiftUI
import Charts
/// : + / + mini sparkline +
struct TrendRow: View {
let bucket: SeriesBucket
private var allPoints: [SeriesBucket.Point] {
bucket.lines.flatMap(\.points)
}
private var pointCount: Int { allPoints.count }
private var anyLatestAbnormal: Bool {
bucket.lines.contains { ($0.latestPoint?.status ?? .normal) != .normal }
}
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(bucket.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Text(subtitle)
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 8)
sparkline
.frame(width: 76, height: 34)
VStack(alignment: .trailing, spacing: 2) {
Text(latestValue)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundStyle(anyLatestAbnormal ? Tj.Palette.brick : Tj.Palette.text)
.lineLimit(1)
Text(bucket.unit)
.font(.system(size: 9, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
.fixedSize()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.frame(maxWidth: .infinity)
.tjCard(bordered: true)
}
private var sparkline: some View {
Chart {
ForEach(bucket.lines) { line in
ForEach(line.points) { p in
LineMark(
x: .value("t", p.date),
y: .value(line.label ?? bucket.title, p.value),
series: .value("s", line.id)
)
.foregroundStyle(line.color)
.interpolationMethod(.catmullRom)
.lineStyle(StrokeStyle(lineWidth: 1.6))
}
}
//
ForEach(bucket.lines) { line in
if let p = line.latestPoint {
PointMark(
x: .value("t", p.date),
y: .value("v", p.value)
)
.foregroundStyle(p.status == .normal ? line.color : Tj.Palette.brick)
.symbolSize(28)
}
}
}
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.chartLegend(.hidden)
}
private var subtitle: String {
"\(pointCount) 条 · 近 \(spanLabel)"
}
private var spanLabel: String {
let dates = allPoints.map(\.date)
guard let lo = dates.min(), let hi = dates.max() else { return "" }
let days = Calendar.current.dateComponents([.day], from: lo, to: hi).day ?? 0
if days <= 0 { return String(appLoc: "今天") }
if days < 30 { return String(appLoc: "\(days)") }
if days < 365 { return String(appLoc: "\(days / 30) 个月") }
return String(appLoc: "\(days / 365)")
}
private var latestValue: String {
let parts = bucket.lines.compactMap { line -> String? in
guard let p = line.latestPoint else { return nil }
return formatValue(p.value)
}
return parts.joined(separator: "/")
}
private func formatValue(_ v: Double) -> String {
v.truncatingRemainder(dividingBy: 1) == 0
? String(format: "%.0f", v)
: String(format: "%.1f", v)
}
}

View File

@@ -1,39 +1,15 @@
import SwiftUI
import SwiftData
enum CalendarMode: String, CaseIterable, Identifiable {
case month, year
var id: String { rawValue }
var label: String {
switch self {
case .month: return String(appLoc: "")
case .year: return String(appLoc: "")
}
}
}
/// Tab;:
/// 2 ,(seriesKey)()
struct TrendsView: View {
@Query(sort: \Indicator.capturedAt, order: .reverse)
private var indicators: [Indicator]
@Query(sort: \Report.reportDate, order: .reverse)
private var reports: [Report]
@Query(sort: \DiaryEntry.createdAt, order: .reverse)
private var diaries: [DiaryEntry]
@Query(sort: \Symptom.startedAt, order: .reverse)
private var symptoms: [Symptom]
@Query private var profiles: [UserProfile]
@Query private var customMetrics: [CustomMonitorMetric]
@State private var mode: CalendarMode = .month
@State private var anchor: Date = .now
/// , inline
@State private var selectedDate: Date = .now
private var profile: UserProfile? { profiles.first }
private var seriesBuckets: [SeriesBucket] {
@@ -42,267 +18,81 @@ struct TrendsView: View {
customMetrics: customMetrics)
}
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.firstWeekday = 2
c.locale = Locale.current
return c
}()
@MainActor
private var data: CalendarData {
CalendarData.build(
indicators: indicators,
reports: reports,
diaries: diaries,
symptoms: symptoms
)
private var monitorBuckets: [SeriesBucket] {
seriesBuckets.filter { $0.kind == .monitor }
}
private var labBuckets: [SeriesBucket] {
seriesBuckets.filter { $0.kind == .lab }
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
header.padding(.top, 4)
modeSwitch
anchorBar
calendarBody
legend
if mode == .month {
dayDetailInline
NavigationStack {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
header.padding(.top, 4)
if seriesBuckets.isEmpty {
emptyState
} else {
if !monitorBuckets.isEmpty {
section(title: String(appLoc: "长期监测"), buckets: monitorBuckets)
}
if !labBuckets.isEmpty {
section(title: String(appLoc: "化验指标趋势"), buckets: labBuckets)
}
}
}
seriesSection
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
.padding(.horizontal, 20)
.padding(.bottom, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationBarHidden(true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
}
/// inline (symptoms / indicators / reports / diaries)
private var dayDetailInline: some View {
VStack(alignment: .leading, spacing: 0) {
DayDetailContent(
date: selectedDate,
indicators: indicators,
reports: reports,
diaries: diaries,
symptoms: symptoms,
showHeader: true
)
.padding(14)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
.animation(.snappy(duration: 0.2), value: selectedDate)
}
private var header: some View {
HStack(alignment: .lastTextBaseline) {
Text("趋势")
.font(.tjTitle(26))
.foregroundStyle(Tj.Palette.text)
Spacer()
Button {
withAnimation(.snappy(duration: 0.2)) {
anchor = .now
selectedDate = .now
}
} label: {
Text("回到今天")
Text("趋势")
.font(.tjTitle(26))
.foregroundStyle(Tj.Palette.text)
}
private func section(title: String, buckets: [SeriesBucket]) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .lastTextBaseline) {
Text(title)
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(buckets.count)")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.buttonStyle(.plain)
}
}
private var modeSwitch: some View {
HStack(spacing: 0) {
ForEach(CalendarMode.allCases) { m in
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
mode = m
VStack(spacing: 12) {
ForEach(buckets) { bucket in
NavigationLink {
TrendDetailView(bucket: bucket)
} label: {
TrendRow(bucket: bucket)
}
} label: {
Text(m.label)
.font(.system(size: 13, weight: mode == m ? .semibold : .regular))
.foregroundStyle(mode == m ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(
Capsule().fill(mode == m ? Tj.Palette.ink : Color.clear)
)
}
.buttonStyle(.plain)
}
}
.padding(3)
.background(Capsule().fill(Tj.Palette.paper))
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
.frame(maxWidth: 220)
}
private var anchorBar: some View {
HStack {
Button { shiftAnchor(-1) } label: {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
.background(Circle().fill(Tj.Palette.paper))
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
}
.buttonStyle(.plain)
Spacer()
Text(anchorTitle)
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
.contentTransition(.numericText())
.animation(.snappy, value: anchor)
Spacer()
Button { shiftAnchor(1) } label: {
Image(systemName: "chevron.right")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
.background(Circle().fill(Tj.Palette.paper))
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
}
.buttonStyle(.plain)
.disabled(isAnchorAtFuture)
.opacity(isAnchorAtFuture ? 0.4 : 1)
}
}
private var anchorTitle: String {
let style: Date.FormatStyle = mode == .month
? .dateTime.year().month()
: .dateTime.year()
return anchor.formatted(style)
}
@ViewBuilder
private var calendarBody: some View {
switch mode {
case .month:
CalendarMonthGrid(monthAnchor: anchor, data: data, selectedDate: selectedDate) { day in
withAnimation(.snappy(duration: 0.2)) {
selectedDate = day
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
case .year:
CalendarYearGrid(
year: calendar.component(.year, from: anchor),
data: data
) { tappedMonth in
anchor = tappedMonth
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
mode = .month
.buttonStyle(.plain)
}
}
}
}
@ViewBuilder
private var seriesSection: some View {
let buckets = seriesBuckets
if !buckets.isEmpty {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .lastTextBaseline) {
Text("长期监测")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(buckets.count)")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.padding(.top, 8)
VStack(spacing: 12) {
ForEach(buckets) { bucket in
SeriesChartCard(bucket: bucket)
}
}
}
}
}
private var legend: some View {
VStack(alignment: .leading, spacing: 8) {
Text("图例")
.font(.system(size: 11, weight: .semibold))
.tracking(0.5)
private var emptyState: some View {
VStack(spacing: 12) {
TjPlaceholder(label: String(appLoc: "还没有可成趋势的指标"))
.frame(height: 120)
.frame(maxWidth: 260)
Text("同一指标记录满 2 次后,会在这里出现时间序列")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
HStack(spacing: 14) {
legendItem(color: Tj.Palette.brick, label: String(appLoc: "指标异常"))
legendItem(color: Tj.Palette.amber, label: String(appLoc: "症状持续中"))
legendItem(color: Tj.Palette.ink2, label: String(appLoc: "报告归档"))
legendItem(color: Tj.Palette.leaf, label: String(appLoc: "正常"))
}
}
.padding(.top, 4)
}
private func legendItem(color: Color, label: String) -> some View {
HStack(spacing: 5) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(color)
.frame(width: 14, height: 6)
Text(label)
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text2)
}
}
private var isAnchorAtFuture: Bool {
switch mode {
case .month:
return calendar.isDate(anchor, equalTo: .now, toGranularity: .month) ||
anchor > .now
case .year:
let nowYear = calendar.component(.year, from: .now)
let anchorYear = calendar.component(.year, from: anchor)
return anchorYear >= nowYear
}
}
private func shiftAnchor(_ delta: Int) {
let component: Calendar.Component = (mode == .month) ? .month : .year
if let next = calendar.date(byAdding: component, value: delta, to: anchor) {
withAnimation(.snappy) {
anchor = next
// selection :() 1
if mode == .month {
if calendar.isDate(next, equalTo: .now, toGranularity: .month) {
selectedDate = .now
} else if let first = calendar.dateInterval(of: .month, for: next)?.start {
selectedDate = first
}
}
}
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.top, 60)
}
}