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,使用信号量闸门控制显存占用 - 更新文档中的技术栈说明、模块边界和周次交付计划 ```
183 lines
6.5 KiB
Swift
183 lines
6.5 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
|
|
/// 趋势 Tab。日历已迁至主页;此页专注「时间序列」:
|
|
/// 任何出现 ≥2 次的指标都能成趋势,分「长期监测」(seriesKey)与「化验指标」(按名归并)两段。
|
|
struct TrendsView: View {
|
|
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
|
private var indicators: [Indicator]
|
|
|
|
@Query private var profiles: [UserProfile]
|
|
@Query private var customMetrics: [CustomMonitorMetric]
|
|
|
|
private var profile: UserProfile? { profiles.first }
|
|
|
|
/// 顶部搜索:点放大镜展开搜索框,按指标名(bucket.title)实时过滤两段列表。
|
|
@State private var searching = false
|
|
@State private var query = ""
|
|
|
|
private var seriesBuckets: [SeriesBucket] {
|
|
SeriesBucket.build(from: indicators,
|
|
profile: profile,
|
|
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 {
|
|
ScrollView(showsIndicators: false) {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
header.padding(.top, 4)
|
|
if seriesBuckets.isEmpty {
|
|
emptyState
|
|
} else if filteredMonitor.isEmpty && filteredLab.isEmpty {
|
|
noMatchState
|
|
} else {
|
|
if !filteredMonitor.isEmpty {
|
|
section(title: String(appLoc: "长期监测"), buckets: filteredMonitor)
|
|
}
|
|
if !filteredLab.isEmpty {
|
|
section(title: String(appLoc: "化验指标趋势"), buckets: filteredLab)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 24)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
|
.navigationBarHidden(true)
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(alignment: .lastTextBaseline) {
|
|
Text("趋势")
|
|
.font(.tjTitle(26))
|
|
.foregroundStyle(Tj.Palette.text)
|
|
Spacer()
|
|
searchToggle
|
|
}
|
|
if searching { searchField }
|
|
}
|
|
}
|
|
|
|
private var searchToggle: some View {
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.18)) {
|
|
searching.toggle()
|
|
if !searching { query = "" }
|
|
}
|
|
} label: {
|
|
Image(systemName: searching ? "xmark" : "magnifyingglass")
|
|
.font(.tjScaled( 15, weight: .semibold))
|
|
.foregroundStyle(Tj.Palette.text)
|
|
.frame(width: 36, height: 36)
|
|
.background(Circle().fill(Tj.Palette.sand2))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(searching ? String(appLoc: "关闭搜索") : String(appLoc: "搜索指标"))
|
|
}
|
|
|
|
private var searchField: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.tjScaled( 13))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
TextField(String(appLoc: "搜索指标名"), text: $query)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.foregroundStyle(Tj.Palette.text)
|
|
.tint(Tj.Palette.ink)
|
|
if !query.isEmpty {
|
|
Button { query = "" } label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.fill(Tj.Palette.paper)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private var noMatchState: some View {
|
|
VStack(spacing: 12) {
|
|
TjPlaceholder(label: String(appLoc: "没有匹配「\(query)」的指标"))
|
|
.frame(height: 120)
|
|
.frame(maxWidth: 260)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 60)
|
|
}
|
|
|
|
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(.tjScaled( 12))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
Spacer()
|
|
}
|
|
|
|
VStack(spacing: 12) {
|
|
ForEach(buckets) { bucket in
|
|
NavigationLink {
|
|
TrendDetailView(bucket: bucket)
|
|
} label: {
|
|
TrendRow(bucket: bucket)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 12) {
|
|
TjPlaceholder(label: String(appLoc: "还没有可成趋势的指标"))
|
|
.frame(height: 120)
|
|
.frame(maxWidth: 260)
|
|
Text("同一指标记录满 2 次后,会在这里出现时间序列")
|
|
.font(.tjScaled( 12))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 60)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
TrendsView()
|
|
.modelContainer(for: [
|
|
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self
|
|
], inMemory: true)
|
|
}
|