feat(AI): 集成MNN推理引擎替换MLX作为主AI运行时 - 引入MNN(alibaba) + Arm SME2 + CPU作为主AI运行时,支持A19/iPhone17的 SME2和A17的NEON加速 - 添加MLX Swift作为兜底GPU推理方案,实现双后端切换机制 - 使用单一Qwen3.5-2B多模态模型(1.2GB),替代原有的LLM+VL分离架构 - 实现InferenceEngine.current引擎选择逻辑,真机默认MNN,模拟器回退MLX - 更新AIAgent架构,通过MNNLLMBridge(ObjC++) → MNNBackend进行推理 - 修改队列机制防止并发推理导致OOM,使用信号量闸门控制显存占用 - 更新文档中的技术栈说明、模块边界和周次交付计划 ```
483 lines
18 KiB
Swift
483 lines
18 KiB
Swift
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
|
|
recordAnotherRow
|
|
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: - 再记一条(与指标详情共用 RecordAnotherButton 组件)
|
|
|
|
/// 按当前翻到的那一页指标预选「再记一条」:血压走双字段,其余按 name/unit/range/seriesKey。
|
|
@ViewBuilder
|
|
private var recordAnotherRow: some View {
|
|
if records.indices.contains(currentIndex) {
|
|
switch records[currentIndex] {
|
|
case .single(let i):
|
|
RecordAnotherButton(name: i.name, prefill: .init(indicator: i))
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, bucket == nil ? 20 : 10)
|
|
case .bp(let sys, _):
|
|
RecordAnotherButton(
|
|
name: String(appLoc: "血压"),
|
|
prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic",
|
|
name: String(appLoc: "血压"),
|
|
unit: "mmHg", range: sys.range)
|
|
)
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, bucket == nil ? 20 : 10)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|