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以扩大点击区域 - 修复提醒行展开按钮尺寸,保证不同提醒类型的垂直对齐 ```
183 lines
6.6 KiB
Swift
183 lines
6.6 KiB
Swift
import SwiftUI
|
|
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) } }
|
|
}
|
|
|
|
private var dateDomain: ClosedRange<Date>? {
|
|
let dates = allPoints.map(\.point.date)
|
|
guard let lo = dates.min(), let hi = dates.max() else { return nil }
|
|
if lo == hi {
|
|
// 只有一个点的极端情况:扩 1 天显示
|
|
let cal = Calendar.current
|
|
let earlier = cal.date(byAdding: .hour, value: -12, to: lo) ?? lo
|
|
let later = cal.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 allPoints {
|
|
lo = min(lo, p.value)
|
|
hi = max(hi, p.value)
|
|
}
|
|
for line in bucket.lines {
|
|
if let r = line.referenceRange {
|
|
lo = min(lo, r.lowerBound)
|
|
hi = max(hi, r.upperBound)
|
|
}
|
|
}
|
|
// 无数据时 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)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
header
|
|
chart
|
|
.frame(height: 120)
|
|
if bucket.lines.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 header: some View {
|
|
HStack(alignment: .lastTextBaseline, spacing: 10) {
|
|
Text(bucket.title)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(Tj.Palette.text)
|
|
Text("\(allPoints.count) 条 · 近 \(daysSpanLabel)")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
Spacer()
|
|
latestValueBadge
|
|
}
|
|
}
|
|
|
|
private var latestValueBadge: some View {
|
|
let parts = bucket.lines.compactMap { line -> String? in
|
|
guard let p = line.latestPoint else { return nil }
|
|
return formatValue(p.value)
|
|
}
|
|
let joined = parts.joined(separator: " / ")
|
|
let anyAbnormal = bucket.lines.contains { line in
|
|
(line.latestPoint?.status ?? .normal) != .normal
|
|
}
|
|
return HStack(spacing: 4) {
|
|
Text(joined)
|
|
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
|
.foregroundStyle(anyAbnormal ? Tj.Palette.brick : Tj.Palette.text)
|
|
Text(bucket.unit)
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
}
|
|
}
|
|
|
|
private var chart: some View {
|
|
Chart {
|
|
// 参考范围带
|
|
ForEach(bucket.lines) { 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(bucket.lines) { line in
|
|
ForEach(line.points) { p in
|
|
LineMark(
|
|
x: .value("时间", p.date),
|
|
y: .value(line.label ?? bucket.title, p.value)
|
|
)
|
|
.foregroundStyle(line.color)
|
|
.interpolationMethod(.catmullRom)
|
|
.lineStyle(StrokeStyle(lineWidth: 2))
|
|
}
|
|
.symbol {
|
|
Circle()
|
|
.fill(line.color)
|
|
.frame(width: 6, height: 6)
|
|
}
|
|
}
|
|
}
|
|
.chartXAxis {
|
|
AxisMarks(values: .automatic(desiredCount: 4)) { _ in
|
|
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
|
|
AxisValueLabel(format: .dateTime.month(.abbreviated).day(),
|
|
centered: false)
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
}
|
|
}
|
|
.chartYAxis {
|
|
AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) { _ 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: 12) {
|
|
ForEach(bucket.lines) { line in
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(line.color)
|
|
.frame(width: 8, height: 8)
|
|
Text(line.label ?? line.seriesKey)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Tj.Palette.text2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var daysSpanLabel: String {
|
|
guard let dom = dateDomain else { return "—" }
|
|
let days = Calendar.current.dateComponents([.day],
|
|
from: dom.lowerBound,
|
|
to: dom.upperBound).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 func formatValue(_ v: Double) -> String {
|
|
v.truncatingRemainder(dividingBy: 1) == 0
|
|
? String(format: "%.0f", v)
|
|
: String(format: "%.1f", v)
|
|
}
|
|
}
|