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,使用信号量闸门控制显存占用 - 更新文档中的技术栈说明、模块边界和周次交付计划 ```
384 lines
14 KiB
Swift
384 lines
14 KiB
Swift
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)
|
||
}
|