```
feat(iOS): 更新MNN后端模型配置优化性能 将MNN主模型从Qwen3.5-4B(~2.64GiB)降级为Qwen3.5-2B(~1.1GiB),因为4B版本 实测运行过慢,影响用户体验。iPhone17+/SME2设备使用2B模型,保留MLX 兜底方案用于模拟器和备用场景,确保AI推理性能和存储效率的平衡。 ```
This commit is contained in:
457
康康/Features/Timeline/IndicatorSeriesDetailView.swift
Normal file
457
康康/Features/Timeline/IndicatorSeriesDetailView.swift
Normal file
@@ -0,0 +1,457 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 一个指标的「同类组」标识。决定聚合详情里收哪些历次记录、跳哪个趋势 bucket。
|
||||
/// - `.series`:有 seriesKey 的长期监测指标(血糖/体重/...)。
|
||||
/// - `.bloodPressure`:血压(bp.systolic + bp.diastolic 合并成一对)。
|
||||
/// - `.lab`:无 seriesKey 的化验/手动指标,按 name+unit 归一化 key 聚合。
|
||||
enum IndicatorGroup: Identifiable, Hashable {
|
||||
case series(key: String)
|
||||
case bloodPressure
|
||||
case lab(key: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .series(let k): return "series:\(k)"
|
||||
case .bloodPressure: return "bp"
|
||||
case .lab(let k): return "lab:\(k)"
|
||||
}
|
||||
}
|
||||
|
||||
/// 从单条指标推断其所属同类组(与趋势页 SeriesBucket 的分组语义一致)。
|
||||
static func of(_ i: Indicator) -> IndicatorGroup {
|
||||
if let key = i.seriesKey, !key.isEmpty {
|
||||
return key.hasPrefix("bp.") ? .bloodPressure : .series(key: key)
|
||||
}
|
||||
return .lab(key: SeriesBucket.normalizedKey(name: i.name, unit: i.unit))
|
||||
}
|
||||
}
|
||||
|
||||
/// 同类指标聚合详情:横向翻页看该指标的历次记录,底部可跳趋势图。
|
||||
/// 从「记录」页点指标条目进入。数据全部 @Query 实时,删除后翻页列表自动更新。
|
||||
struct IndicatorSeriesDetailView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var ctx
|
||||
|
||||
let group: IndicatorGroup
|
||||
|
||||
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
||||
private var indicators: [Indicator]
|
||||
@Query private var profiles: [UserProfile]
|
||||
@Query private var customMetrics: [CustomMonitorMetric]
|
||||
|
||||
@State private var selection: String?
|
||||
@State private var showTrend = false
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var evidenceTarget: Indicator?
|
||||
|
||||
// MARK: - 数据
|
||||
|
||||
/// 聚合详情里的一页:单值指标一条;血压一对。
|
||||
private enum Record: Identifiable {
|
||||
case single(Indicator)
|
||||
case bp(sys: Indicator, dia: Indicator?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .single(let i): return "\(i.persistentModelID)"
|
||||
case .bp(let s, _): return "bp-\(s.persistentModelID)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 历次血压对:以 bp.systolic 为锚,按 ±5s 配 bp.diastolic(同 TimelineEntry 合并规则)。
|
||||
private var bloodPressureRecords: [Record] {
|
||||
let sysList = indicators
|
||||
.filter { $0.seriesKey == "bp.systolic" }
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
var usedDia = Set<PersistentIdentifier>()
|
||||
return sysList.map { sys in
|
||||
let dia = indicators.first {
|
||||
$0.seriesKey == "bp.diastolic" &&
|
||||
!usedDia.contains($0.persistentModelID) &&
|
||||
abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5
|
||||
}
|
||||
if let dia { usedDia.insert(dia.persistentModelID) }
|
||||
return .bp(sys: sys, dia: dia)
|
||||
}
|
||||
}
|
||||
|
||||
private var records: [Record] {
|
||||
switch group {
|
||||
case .bloodPressure:
|
||||
return bloodPressureRecords
|
||||
case .series(let key):
|
||||
return indicators
|
||||
.filter { $0.seriesKey == key }
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
.map(Record.single)
|
||||
case .lab(let nk):
|
||||
return indicators
|
||||
.filter {
|
||||
($0.seriesKey ?? "").isEmpty &&
|
||||
SeriesBucket.normalizedKey(name: $0.name, unit: $0.unit) == nk
|
||||
}
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
.map(Record.single)
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch group {
|
||||
case .bloodPressure:
|
||||
return String(appLoc: "血压")
|
||||
case .series, .lab:
|
||||
if case let .single(i)? = records.first { return i.name }
|
||||
return String(appLoc: "指标详情")
|
||||
}
|
||||
}
|
||||
|
||||
/// 对应的趋势 bucket(需 ≥2 个可解析数值点才存在);nil 时隐藏「查看趋势图」。
|
||||
private var bucket: SeriesBucket? {
|
||||
let all = SeriesBucket.build(from: indicators,
|
||||
profile: profiles.first,
|
||||
customMetrics: customMetrics)
|
||||
switch group {
|
||||
case .bloodPressure:
|
||||
return all.first { $0.id == "bp" }
|
||||
case .series(let key):
|
||||
return all.first { b in b.lines.contains { $0.seriesKey == key } }
|
||||
case .lab(let nk):
|
||||
return all.first { $0.kind == .lab && $0.id == "lab:\(nk)" }
|
||||
}
|
||||
}
|
||||
|
||||
private var currentIndex: Int {
|
||||
records.firstIndex { $0.id == selection } ?? 0
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
if records.isEmpty {
|
||||
Spacer()
|
||||
TjPlaceholder(label: String(appLoc: "记录已不存在"))
|
||||
.frame(width: 200, height: 120)
|
||||
Spacer()
|
||||
} else {
|
||||
pages
|
||||
pager
|
||||
if bucket != nil { trendButton }
|
||||
}
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationDestination(isPresented: $showTrend) {
|
||||
if let bucket { TrendDetailView(bucket: bucket) }
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
.presentationCornerRadius(Tj.Radius.xl)
|
||||
.onAppear { if selection == nil { selection = records.first?.id } }
|
||||
.alert(String(appLoc: "永久删除这条记录?"), isPresented: $showDeleteConfirm) {
|
||||
Button(String(appLoc: "删除"), role: .destructive) { deleteCurrent() }
|
||||
Button(String(appLoc: "取消"), role: .cancel) { }
|
||||
} message: {
|
||||
Text("删除后无法恢复。")
|
||||
}
|
||||
.sheet(item: $evidenceTarget) { indicator in
|
||||
if let report = indicator.report {
|
||||
EvidenceImagePreview(report: report, indicator: indicator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.tjScaled(16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(Tj.Palette.sand2))
|
||||
}
|
||||
Text(title)
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
if records.count > 1 {
|
||||
Text("\(records.count) 条")
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
TjLockChip()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
.background(Tj.Palette.sand)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 翻页内容
|
||||
|
||||
private var pages: some View {
|
||||
TabView(selection: $selection) {
|
||||
ForEach(records) { rec in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
recordCard(rec)
|
||||
deleteButton
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.tag(Optional(rec.id))
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func recordCard(_ rec: Record) -> some View {
|
||||
switch rec {
|
||||
case .single(let i): singleCard(i)
|
||||
case .bp(let sys, let dia): bpCard(sys: sys, dia: dia)
|
||||
}
|
||||
}
|
||||
|
||||
private func singleCard(_ i: Indicator) -> some View {
|
||||
card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
statusChip(i.status)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(i.value)
|
||||
.font(.tjScaled(30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
if !i.unit.isEmpty {
|
||||
Text(i.unit).font(.tjScaled(14)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
divider
|
||||
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
|
||||
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
|
||||
field(String(appLoc: "来源"), i.report?.title ?? i.source.label)
|
||||
if i.report != nil { evidenceButton(for: i) }
|
||||
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
}
|
||||
}
|
||||
|
||||
private func bpCard(sys: Indicator, dia: Indicator?) -> some View {
|
||||
let combined: IndicatorStatus = sys.status != .normal ? sys.status : (dia?.status ?? .normal)
|
||||
return card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
statusChip(combined)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(sys.value)/\(dia?.value ?? "—")")
|
||||
.font(.tjScaled(30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
Text("mmHg").font(.tjScaled(14)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
divider
|
||||
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
|
||||
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 翻页器
|
||||
|
||||
private var pager: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 20) {
|
||||
pagerArrow("chevron.left", enabled: currentIndex > 0) {
|
||||
if currentIndex > 0 { selection = records[currentIndex - 1].id }
|
||||
}
|
||||
if records.count <= 7 {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array(records.enumerated()), id: \.offset) { idx, _ in
|
||||
Circle()
|
||||
.fill(idx == currentIndex ? Tj.Palette.ink : Tj.Palette.line)
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
pagerArrow("chevron.right", enabled: currentIndex < records.count - 1) {
|
||||
if currentIndex < records.count - 1 { selection = records[currentIndex + 1].id }
|
||||
}
|
||||
}
|
||||
Text("第 \(currentIndex + 1) / 共 \(records.count) 条")
|
||||
.font(.tjScaled(11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func pagerArrow(_ system: String, enabled: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: system)
|
||||
.font(.tjScaled(13, weight: .semibold))
|
||||
.foregroundStyle(enabled ? Tj.Palette.text : Tj.Palette.text3.opacity(0.4))
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Circle().fill(Tj.Palette.sand2))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
}
|
||||
|
||||
// MARK: - 趋势 / 删除
|
||||
|
||||
private var trendButton: some View {
|
||||
Button { showTrend = true } label: {
|
||||
Label(String(appLoc: "查看趋势图"), systemImage: "chart.xyaxis.line")
|
||||
.font(.tjScaled(15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.ink)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
|
||||
private var deleteButton: some View {
|
||||
Button(role: .destructive) { showDeleteConfirm = true } label: {
|
||||
Label(String(appLoc: "永久删除"), systemImage: "trash")
|
||||
.font(.tjScaled(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(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
/// 删当前页记录(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6)。
|
||||
/// 删后把 selection 落到相邻一条;删空则关闭。
|
||||
private func deleteCurrent() {
|
||||
guard records.indices.contains(currentIndex) else { return }
|
||||
let removingIndex = currentIndex
|
||||
switch records[removingIndex] {
|
||||
case .single(let i):
|
||||
deleteIndicator(i)
|
||||
case .bp(let sys, let dia):
|
||||
deleteIndicator(sys)
|
||||
if let dia { deleteIndicator(dia) }
|
||||
}
|
||||
try? ctx.save()
|
||||
|
||||
let remaining = records
|
||||
if remaining.isEmpty {
|
||||
dismiss()
|
||||
} else {
|
||||
let next = min(removingIndex, remaining.count - 1)
|
||||
selection = remaining[next].id
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteIndicator(_ i: Indicator) {
|
||||
if let asset = i.asset {
|
||||
try? FileVault.shared.remove(relativePath: asset.relativePath)
|
||||
ctx.delete(asset)
|
||||
}
|
||||
ctx.delete(i)
|
||||
}
|
||||
|
||||
// MARK: - 复用件
|
||||
|
||||
@ViewBuilder
|
||||
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) { content() }
|
||||
.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 func field(_ label: String, _ value: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Text(label).font(.tjScaled(13)).foregroundStyle(Tj.Palette.text3)
|
||||
Spacer(minLength: 12)
|
||||
Text(value)
|
||||
.font(.tjScaled(14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func evidenceButton(for indicator: Indicator) -> some View {
|
||||
if indicator.hasEvidenceBox,
|
||||
let page = indicator.sourcePageIndex,
|
||||
let assets = indicator.report?.assets,
|
||||
assets.indices.contains(page) {
|
||||
Button {
|
||||
evidenceTarget = indicator
|
||||
} label: {
|
||||
Label(String(appLoc: "查看原图位置"), systemImage: "viewfinder")
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Tj.Palette.leaf.opacity(0.14)))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
|
||||
private func statusChip(_ s: IndicatorStatus) -> some View {
|
||||
let text: String
|
||||
let color: Color
|
||||
let arrow: String
|
||||
switch s {
|
||||
case .high: text = String(appLoc: "偏高"); color = Tj.Palette.brick; arrow = "↑"
|
||||
case .low: text = String(appLoc: "偏低"); color = Tj.Palette.brick; arrow = "↓"
|
||||
case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = ""
|
||||
}
|
||||
return HStack(spacing: 3) {
|
||||
if !arrow.isEmpty { Text(arrow).font(.tjScaled(11, weight: .bold)) }
|
||||
Text(text).font(.tjScaled(12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(color.opacity(0.14)))
|
||||
}
|
||||
|
||||
private nonisolated static func dateTimeText(_ d: Date) -> String {
|
||||
d.formatted(.dateTime.year().month().day().hour().minute())
|
||||
}
|
||||
}
|
||||
@@ -420,7 +420,8 @@ struct TimelineEntryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct EvidenceImagePreview: View {
|
||||
/// 原图证据预览(翻页 + 高亮框)。指标详情与同类聚合详情共用,故为模块内可见。
|
||||
struct EvidenceImagePreview: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let report: Report
|
||||
let indicator: Indicator
|
||||
|
||||
Reference in New Issue
Block a user