Files
kangkang/康康/Features/Profile/MedicationLibraryView.swift
link2026 9d856fcfc4 ```
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,使用信号量闸门控制显存占用
- 更新文档中的技术栈说明、模块边界和周次交付计划
```
2026-06-15 09:24:59 +08:00

384 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import SwiftData
/// · : master ( / / / )
/// ; · ( `DiaryEntry.medicationTag` , + )
/// / `CustomMetricsListView`; `CustomReminderEditSheet`
struct MedicationLibraryView: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@Query(sort: \Medication.updatedAt, order: .reverse)
private var medications: [Medication]
/// sheet ();push ,
var presentedAsSheet: Bool = false
@State private var editingTarget: MedicationEditTarget?
@State private var showScan = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
hintBanner
if medications.isEmpty {
emptyState
} else {
ForEach(medications) { m in
Button {
editingTarget = MedicationEditTarget(medication: m)
} label: {
row(m)
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 32)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("药品库")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if presentedAsSheet {
ToolbarItem(placement: .topBarLeading) {
Button(String(appLoc: "完成")) { dismiss() }
}
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
Button { showScan = true } label: {
Image(systemName: "camera")
.font(.tjScaled( 16, weight: .semibold))
}
.accessibilityLabel(String(appLoc: "拍药盒添加"))
Button { editingTarget = MedicationEditTarget(medication: nil) } label: {
Image(systemName: "plus")
.font(.tjScaled( 16, weight: .semibold))
}
.accessibilityLabel(String(appLoc: "手动添加"))
}
}
}
.sheet(item: $editingTarget) { target in
MedicationEditSheet(existing: target.medication)
}
.fullScreenCover(isPresented: $showScan) {
// OCR + LLM ()
MedicationScanFlow(
onSave: { meds, images in
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
},
onClose: { showScan = false }
)
}
}
// MARK: - subviews
private var hintBanner: some View {
HStack(spacing: 10) {
Image(systemName: "info.circle.fill")
.foregroundStyle(Tj.Palette.text3)
Text("药品库是你的常用药清单。记录某次服用请到「写日记 · 用药」,可填剂量和时间。")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2.opacity(0.5))
)
}
private var emptyState: some View {
VStack(spacing: 14) {
Spacer(minLength: 40)
TjPlaceholder(label: String(appLoc: "药品库还是空的"))
.frame(width: 220, height: 130)
Text("右上角拍药盒或 + 手动添加")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.frame(maxWidth: .infinity)
}
private func row(_ m: Medication) -> some View {
HStack(spacing: 12) {
ZStack {
Circle().fill(Tj.Palette.leafSoft)
Image(systemName: "pills.fill")
.font(.tjScaled( 17, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 3) {
Text(m.name)
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
if !m.detailLine.isEmpty {
Text(m.detailLine)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
}
Spacer(minLength: 8)
if !m.assets.isEmpty {
Text("📷 \(m.assets.count)")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Image(systemName: "chevron.right")
.font(.tjScaled( 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.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)
)
}
}
/// `medication == nil` ;`id` UUID sheet
private struct MedicationEditTarget: Identifiable {
let id = UUID()
let medication: Medication?
}
/// / ( `CustomReminderEditSheet`: @State ,)
private struct MedicationEditSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
/// nil =
let existing: Medication?
@State private var name = ""
@State private var strength = ""
@State private var usage = ""
@State private var note = ""
@State private var hydrated = false
/// ;nil =
@State private var viewerStart: PhotoIndex?
private var isEditing: Bool { existing != nil }
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
NavigationStack {
Form {
if let m = existing, !m.assets.isEmpty {
Section {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Array(m.assets.enumerated()), id: \.offset) { idx, asset in
Button {
viewerStart = PhotoIndex(index: idx)
} label: {
MedicationAssetThumb(asset: asset)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
.listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12))
} header: {
Text(String(appLoc: "原图\(m.assets.count)"))
} footer: {
Text("点图片可放大查看。原图均存在本机加密目录,不上传。")
}
}
Section {
TextField(String(appLoc: "药名,如:缬沙坦胶囊"), text: $name)
.foregroundStyle(Tj.Palette.text)
TextField(String(appLoc: "规格,如:80mg×7粒"), text: $strength)
.foregroundStyle(Tj.Palette.text2)
TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $usage)
.foregroundStyle(Tj.Palette.text2)
} footer: {
Text("仅作清单记录,不提供任何用药或剂量建议。")
}
Section {
TextField(String(appLoc: "备注(可选)"), text: $note, axis: .vertical)
.lineLimit(1...3)
.foregroundStyle(Tj.Palette.text2)
}
if isEditing {
Section {
Button(role: .destructive) { deleteMedication() } label: {
Label(String(appLoc: "从药品库删除"), systemImage: "trash")
}
}
}
}
.scrollContentBackground(.hidden)
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle(isEditing ? String(appLoc: "编辑药品") : String(appLoc: "添加药品"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(String(appLoc: "取消")) { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button(String(appLoc: "保存")) { save() }
.fontWeight(.semibold)
.disabled(!canSave)
}
}
.onAppear(perform: hydrate)
.fullScreenCover(item: $viewerStart) { start in
if let m = existing {
MedicationPhotoViewer(assets: m.assets, startIndex: start.index)
}
}
}
}
private func hydrate() {
guard !hydrated else { return }
hydrated = true
if let m = existing {
name = m.name
strength = m.strength
usage = m.usage
note = m.note ?? ""
}
}
private func save() {
guard canSave else { return }
let n = name.trimmingCharacters(in: .whitespacesAndNewlines)
let s = strength.trimmingCharacters(in: .whitespacesAndNewlines)
let u = usage.trimmingCharacters(in: .whitespacesAndNewlines)
let nt = note.trimmingCharacters(in: .whitespacesAndNewlines)
if let m = existing {
m.name = n
m.strength = s
m.usage = u
m.note = nt.isEmpty ? nil : nt
m.updatedAt = .now
} else {
let med = Medication(name: n, strength: s, usage: u, note: nt.isEmpty ? nil : nt)
ctx.insert(med)
}
try? ctx.save()
dismiss()
}
private func deleteMedication() {
guard let m = existing else { return }
// Vault JPEG(cascade Asset , unlink,§6 )
for a in m.assets {
try? FileVault.shared.remove(relativePath: a.relativePath)
}
ctx.delete(m)
try? ctx.save()
dismiss()
}
}
// MARK: -
/// (`.fullScreenCover(item:)` Identifiable)
private struct PhotoIndex: Identifiable {
let id = UUID()
let index: Int
}
/// / Vault (, EvidenceImagePage )
private struct MedicationAssetThumb: View {
let asset: Asset
var body: some View {
VaultImage(relativePath: asset.relativePath, maxPixel: 500) { img in
Image(uiImage: img).resizable().scaledToFill()
} placeholder: { isLoading in
if isLoading {
Tj.Palette.paper
} else {
TjPlaceholder(label: String(appLoc: "原图无法读取"))
}
}
.frame(width: 150, height: 150)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
}
/// ()
private struct MedicationPhotoViewer: View {
@Environment(\.dismiss) private var dismiss
let assets: [Asset]
@State private var selection: Int
init(assets: [Asset], startIndex: Int) {
self.assets = assets
_selection = State(initialValue: min(max(startIndex, 0), max(assets.count - 1, 0)))
}
var body: some View {
ZStack(alignment: .topTrailing) {
Color.black.ignoresSafeArea()
TabView(selection: $selection) {
ForEach(Array(assets.enumerated()), id: \.offset) { idx, asset in
VaultImage(relativePath: asset.relativePath, maxPixel: 2000) { img in
Image(uiImage: img).resizable().scaledToFit()
} placeholder: { isLoading in
if isLoading {
ProgressView().tint(.white)
} else {
TjPlaceholder(label: String(appLoc: "原图无法读取"))
}
}
.tag(idx)
}
}
.tabViewStyle(.page(indexDisplayMode: assets.count > 1 ? .automatic : .never))
.ignoresSafeArea()
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(Circle().fill(.black.opacity(0.4)))
}
.padding(.trailing, 18)
.padding(.top, 14)
}
}
}
#Preview {
NavigationStack {
MedicationLibraryView(presentedAsSheet: true)
}
.modelContainer(for: [Medication.self, Asset.self], inMemory: true)
}