```
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,使用信号量闸门控制显存占用 - 更新文档中的技术栈说明、模块边界和周次交付计划 ```
This commit is contained in:
@@ -23,9 +23,12 @@ struct ArchiveListView: View {
|
||||
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
|
||||
private var metricReminders: [MetricReminder]
|
||||
|
||||
@Query(sort: \Medication.updatedAt, order: .reverse)
|
||||
private var medications: [Medication]
|
||||
|
||||
/// 记录页内的 push 目的地。用单个 `navigationDestination(item:)` 驱动——
|
||||
/// 多个 `navigationDestination(isPresented:)` 并存时 SwiftUI 行为未定义(会误触发)。
|
||||
private enum Route: Hashable { case exports, reminders }
|
||||
private enum Route: Hashable { case exports, reminders, medicationLibrary }
|
||||
|
||||
@State private var filter: TimelineKind? = nil
|
||||
@State private var endingSymptom: Symptom?
|
||||
@@ -33,57 +36,73 @@ struct ArchiveListView: View {
|
||||
@State private var selectedGroup: IndicatorGroup?
|
||||
@State private var route: Route?
|
||||
|
||||
/// 顶部搜索:点放大镜展开搜索框,按条目标题(指标/报告/症状/日记名)实时过滤,与分类 chip 叠加。
|
||||
@State private var searching = false
|
||||
@State private var query = ""
|
||||
|
||||
@MainActor
|
||||
private var allEntries: [TimelineEntry] {
|
||||
let mapped =
|
||||
TimelineEntry.from(indicators: indicators) +
|
||||
TimelineEntry.aggregatedIndicators(indicators) +
|
||||
reports.map(TimelineEntry.from(report:)) +
|
||||
diaries.map(TimelineEntry.from(diary:)) +
|
||||
symptoms.map(TimelineEntry.from(symptom:))
|
||||
let filtered = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
|
||||
return filtered.sorted { $0.date > $1.date }
|
||||
let byKind = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
|
||||
let q = query.trimmingCharacters(in: .whitespaces)
|
||||
let byQuery = q.isEmpty ? byKind : byKind.filter { $0.title.localizedCaseInsensitiveContains(q) }
|
||||
return byQuery.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
private var grouped: [(section: DateSection, items: [TimelineEntry])] {
|
||||
TimelineGrouping.group(allEntries)
|
||||
}
|
||||
|
||||
private var totalCount: Int { allEntries.count }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
content
|
||||
.navigationDestination(item: $route) { route in
|
||||
switch route {
|
||||
case .exports: HealthExportListView()
|
||||
case .reminders: RemindersListView()
|
||||
case .exports: HealthExportListView()
|
||||
case .reminders: RemindersListView()
|
||||
case .medicationLibrary: MedicationLibraryView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
// 聚合(含血压配对 O(m²))+ 分类/搜索过滤在一次 body 内只算一次。原先 .isEmpty、分组、
|
||||
// 计数各调一遍 allEntries,等于全表聚合三次;搜索时每次按键都翻三倍,这里收敛成一次。
|
||||
let entries = allEntries
|
||||
let groups = TimelineGrouping.group(entries)
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
header(total: entries.count)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 14)
|
||||
|
||||
if reminderTotal > 0 {
|
||||
reminderBoard
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
// 药品库入口:始终显示——它是「管理常用药」的浏览/管理目的地,空库时也要能找到来添加。
|
||||
medicationBoard
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 14)
|
||||
|
||||
filterChips
|
||||
.padding(.bottom, searching ? 10 : 14)
|
||||
|
||||
if searching {
|
||||
searchField
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
|
||||
filterChips
|
||||
.padding(.bottom, 14)
|
||||
|
||||
if allEntries.isEmpty {
|
||||
if entries.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView(showsIndicators: false) {
|
||||
LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) {
|
||||
ForEach(grouped, id: \.section) { group in
|
||||
ForEach(groups, id: \.section) { group in
|
||||
Section {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(group.items) { entry in
|
||||
@@ -149,12 +168,12 @@ struct ArchiveListView: View {
|
||||
diaries: diaries, symptoms: symptoms)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
private func header(total: Int) -> some View {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text("记录")
|
||||
.font(.tjTitle(26))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount) 条"))
|
||||
Text(total == 0 ? "" : String(appLoc: "\(total) 条"))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
@@ -173,9 +192,57 @@ struct ArchiveListView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
searchToggle
|
||||
}
|
||||
}
|
||||
|
||||
private var searchToggle: some View {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
searching.toggle()
|
||||
if !searching { query = "" }
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: searching ? "xmark" : "magnifyingglass")
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 32, height: 32)
|
||||
.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)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 提醒任务汇总卡
|
||||
|
||||
/// 两类提醒(自由 + 指标记录)合计,含已关闭。
|
||||
@@ -241,6 +308,58 @@ struct ArchiveListView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - 药品库入口卡
|
||||
|
||||
/// 主标题:空库「药品库」,有药「药品库 · N 种常用药」。
|
||||
private var medicationCountLabel: String {
|
||||
medications.isEmpty
|
||||
? String(appLoc: "药品库")
|
||||
: String(appLoc: "药品库 · \(medications.count) 种常用药")
|
||||
}
|
||||
|
||||
/// 副标题:空库给引导文案;有药取前 3 个药名预览(药名是用户数据,不本地化)。
|
||||
private var medicationPreviewLine: String {
|
||||
if medications.isEmpty { return String(appLoc: "拍药盒或手动添加常用药") }
|
||||
let names = medications.prefix(3).map(\.name).joined(separator: " · ")
|
||||
return medications.count > 3 ? names + " …" : names
|
||||
}
|
||||
|
||||
/// 点击进药品库(MedicationLibraryView,push 形态)统一管理;卡片本身只展示。
|
||||
private var medicationBoard: some View {
|
||||
Button { route = .medicationLibrary } label: {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle().fill(medications.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft)
|
||||
Image(systemName: "pills.fill")
|
||||
.font(.tjScaled( 16))
|
||||
.foregroundStyle(medications.isEmpty ? Tj.Palette.text3 : Tj.Palette.ink)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(medicationCountLabel)
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Text(medicationPreviewLine)
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
.contentShape(Rectangle())
|
||||
.tjCard()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var filterChips: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
@@ -291,13 +410,19 @@ struct ArchiveListView: View {
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 14) {
|
||||
let q = query.trimmingCharacters(in: .whitespaces)
|
||||
let isSearchMiss = !q.isEmpty
|
||||
return VStack(spacing: 14) {
|
||||
Spacer()
|
||||
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
|
||||
TjPlaceholder(label: isSearchMiss
|
||||
? String(appLoc: "没有匹配「\(q)」的记录")
|
||||
: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
|
||||
.frame(width: 240, height: 140)
|
||||
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
if !isSearchMiss {
|
||||
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
@@ -8,9 +8,12 @@ struct CaptureReviewForm: View {
|
||||
@State var parsed: ParsedReport
|
||||
let assets: [FileVault.SavedAsset]
|
||||
let warning: String?
|
||||
/// 归档模式:只存原图 + 基本信息(标题/类型/日期/机构),隐藏指标区与摘要。
|
||||
/// 报告归档不再逐项识别(逐项多模态在 2B 上易 OOM 卡死),见 CaptureService.extractReportMeta。
|
||||
var metaOnly: Bool = false
|
||||
let onSave: (ParsedReport) -> Void
|
||||
let onCancel: () -> Void
|
||||
/// 「重新识别」回调。assets 为空(写图失败)时传 nil,banner 上不显示该按钮。
|
||||
/// 「重新识别信息」回调。assets 为空(写图失败)时传 nil,banner 上不显示该按钮。
|
||||
var onReanalyze: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
@@ -23,7 +26,9 @@ struct CaptureReviewForm: View {
|
||||
pageThumbnails
|
||||
}
|
||||
metaSection
|
||||
indicatorSection
|
||||
if !metaOnly {
|
||||
indicatorSection
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
actions
|
||||
}
|
||||
@@ -68,20 +73,26 @@ struct CaptureReviewForm: View {
|
||||
private var pageThumbnails: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
|
||||
if metaOnly {
|
||||
Text("原图已加密保存,详情页随时可翻看放大。系统只识别报告日期与机构作为标签,不逐项录入数值。")
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
|
||||
if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 84, height: 110)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||
)
|
||||
VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in
|
||||
Image(uiImage: img).resizable().scaledToFill()
|
||||
} placeholder: { _ in
|
||||
Tj.Palette.paper
|
||||
}
|
||||
.frame(width: 84, height: 110)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,9 +128,11 @@ struct CaptureReviewForm: View {
|
||||
labeledField(String(appLoc: "机构(可选)")) {
|
||||
TextField("如:协和医院", text: $parsed.institution)
|
||||
}
|
||||
labeledField(String(appLoc: "摘要(可选)")) {
|
||||
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
|
||||
.lineLimit(1...3)
|
||||
if !metaOnly {
|
||||
labeledField(String(appLoc: "摘要(可选)")) {
|
||||
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
|
||||
.lineLimit(1...3)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
@@ -62,7 +62,7 @@ struct UnifiedCaptureFlow: View {
|
||||
switch phase {
|
||||
case .idle: return String(appLoc: "拍摄报告")
|
||||
case .analyzing: return String(appLoc: "本地识别中…")
|
||||
case .editing: return String(appLoc: "核对识别结果")
|
||||
case .editing: return String(appLoc: "核对报告信息")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ struct UnifiedCaptureFlow: View {
|
||||
parsed: parsed,
|
||||
assets: assets,
|
||||
warning: warning,
|
||||
metaOnly: true, // 归档只存原图 + meta,不逐项识别(§见 CaptureService.extractReportMeta)
|
||||
onSave: { final in saveAll(parsed: final, assets: assets) },
|
||||
onCancel: cancelAll,
|
||||
onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) }
|
||||
@@ -152,9 +153,7 @@ struct UnifiedCaptureFlow: View {
|
||||
phase = .analyzing(images: images, assets: nil)
|
||||
let timeout = analyzeTimeoutSeconds
|
||||
analyzeTask = Task {
|
||||
// Step 1: 先把图写进 Vault。
|
||||
// 在 UI 这一层写,而不是塞进 CaptureService.analyze —— 这样取消/失败回退时,
|
||||
// assets 已经在 phase 里,cancelAll 能清理孤儿,editingFallback 也不必再补写。
|
||||
// Step 1: 先把图写进 Vault(归档的核心价值就是「把原图存下来」,先保证它)。
|
||||
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
|
||||
// 极端情况:用户在写图过程中按了「取消」,View 已 dismiss、cancelAll 看到的
|
||||
// phase 还是 .analyzing(_, nil),清不到这批刚写完的图 — 这里手动收尾。
|
||||
@@ -167,7 +166,7 @@ struct UnifiedCaptureFlow: View {
|
||||
phase = .editing(
|
||||
parsed: .empty(),
|
||||
assets: [],
|
||||
warning: String(appLoc: "图片保存失败,手动录入并保留文本")
|
||||
warning: String(appLoc: "图片保存失败,请重试")
|
||||
)
|
||||
}
|
||||
return
|
||||
@@ -179,49 +178,40 @@ struct UnifiedCaptureFlow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: VL 推理(timeout 哨兵到点 cancel 父任务,VLSession 在下一个 token break)。
|
||||
// Step 2: 轻量 meta 提取(OCR + 文本 LLM,只抽日期/机构/类型/标题)。
|
||||
// 不再跑多模态逐项识别 —— 那在 2B 上又慢又会 OOM 卡死。watchdog 到点 cancel。
|
||||
let watchdog = Task {
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
analyzeTask?.cancel()
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
|
||||
do {
|
||||
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
|
||||
if Task.isCancelled {
|
||||
await editingFallback(assets: assets,
|
||||
msg: String(appLoc: "识别超时(>\(timeout)s),先手动录入"))
|
||||
return
|
||||
}
|
||||
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
|
||||
if Task.isCancelled {
|
||||
await MainActor.run {
|
||||
phase = .editing(
|
||||
parsed: parsed,
|
||||
assets: assets,
|
||||
warning: parsed.isEmpty ? String(appLoc: "识别没有读出指标,请手动补充") : nil
|
||||
)
|
||||
phase = .editing(parsed: .empty(), assets: assets,
|
||||
warning: String(appLoc: "识别超时,已保存原图,请手动填写信息"))
|
||||
}
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
await editingFallback(assets: assets,
|
||||
msg: Task.isCancelled
|
||||
? String(appLoc: "识别超时(>\(timeout)s),先手动录入")
|
||||
: String(appLoc: "推理失败:\(msg)"))
|
||||
} catch CaptureError.modelNotReady {
|
||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪,先手动录入"))
|
||||
} catch {
|
||||
await editingFallback(assets: assets,
|
||||
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
phase = .editing(
|
||||
parsed: meta,
|
||||
assets: assets,
|
||||
warning: recognized ? nil
|
||||
: String(appLoc: "未能自动识别报告信息,已保存原图,可手动填写日期 / 机构")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「重新识别」:复用已存 assets,不再写图,只重跑 VL。
|
||||
/// 「重新识别信息」:复用已存 assets,不再写图,只重跑一次轻量 meta 提取。
|
||||
private func reanalyze(assets: [FileVault.SavedAsset]) {
|
||||
analyzeTask?.cancel()
|
||||
// 这里没有原始 UIImage,AnalyzingView 显示首张缩略图即可
|
||||
// 这里没有原始 UIImage,AnalyzingView 只把首张缩略图模糊后当背景,降采样到 600px 足够,
|
||||
// 避免为「重新识别」把整页全分辨率原图(数十 MB)载进内存。
|
||||
let thumbnails: [UIImage] = assets.compactMap {
|
||||
try? FileVault.shared.loadImage(relativePath: $0.relativePath)
|
||||
try? FileVault.shared.loadDownsampledImage(relativePath: $0.relativePath, maxPixelSize: 600)
|
||||
}
|
||||
phase = .analyzing(images: thumbnails, assets: assets)
|
||||
let timeout = analyzeTimeoutSeconds
|
||||
@@ -232,40 +222,19 @@ struct UnifiedCaptureFlow: View {
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
|
||||
do {
|
||||
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
|
||||
if Task.isCancelled {
|
||||
await editingFallback(assets: assets,
|
||||
msg: String(appLoc: "识别超时(>\(timeout)s),保留旧编辑"))
|
||||
return
|
||||
}
|
||||
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
|
||||
if Task.isCancelled {
|
||||
await MainActor.run {
|
||||
phase = .editing(
|
||||
parsed: parsed,
|
||||
assets: assets,
|
||||
warning: parsed.isEmpty ? String(appLoc: "重新识别没有读出新指标") : nil
|
||||
)
|
||||
phase = .editing(parsed: .empty(), assets: assets,
|
||||
warning: String(appLoc: "识别超时,已保留原图"))
|
||||
}
|
||||
} catch CaptureError.modelNotReady {
|
||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪"))
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
await editingFallback(assets: assets,
|
||||
msg: Task.isCancelled
|
||||
? String(appLoc: "识别超时(>\(timeout)s)")
|
||||
: String(appLoc: "推理失败:\(msg)"))
|
||||
} catch {
|
||||
await editingFallback(assets: assets,
|
||||
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
phase = .editing(parsed: meta, assets: assets,
|
||||
warning: recognized ? nil
|
||||
: String(appLoc: "未能自动识别报告信息,可手动填写"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// reanalyze 失败时回到 editing,保留 assets 但清空 parsed。
|
||||
private func editingFallback(assets: [FileVault.SavedAsset], msg: String) async {
|
||||
await MainActor.run {
|
||||
phase = .editing(parsed: .empty(), assets: assets, warning: msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ struct DiaryQuickSheet: View {
|
||||
|
||||
@State private var content: String = ""
|
||||
@State private var createdAt: Date = .now
|
||||
/// 「拍药盒」分支:全屏扫描流程,确认后存为带「用药」tag 的日记。
|
||||
/// 「拍药盒」分支:全屏扫描流程,识别后入药品库。
|
||||
@State private var showMedicationScan = false
|
||||
/// 「用药」分支:记一次服用(选药 + 剂量 + 时间),存为带「用药」tag 的日记。
|
||||
@State private var showMedicationLog = false
|
||||
/// 「记症状」分支:嵌套弹出 SymptomStartSheet(自带保存/取消,关闭后回到本页)。
|
||||
@State private var showSymptomStart = false
|
||||
|
||||
@@ -98,14 +100,20 @@ struct DiaryQuickSheet: View {
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
// 入口三选一:写日记(本页)/ 拍药盒(存「用药」日记)/ 记症状(SymptomStartSheet)
|
||||
HStack(spacing: 10) {
|
||||
// 入口四选(2×2):写日记(本页)/ 用药(MedicationLogSheet,记剂量+时间)/
|
||||
// 拍药盒(识别入药品库)/ 记症状(SymptomStartSheet)。
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10),
|
||||
GridItem(.flexible(), spacing: 10)], spacing: 10) {
|
||||
modeCard(icon: "pencil", title: String(appLoc: "写日记"),
|
||||
subtitle: String(appLoc: "文字或语音"), active: true) {
|
||||
contentFocused = true
|
||||
}
|
||||
modeCard(icon: "pills.fill", title: String(appLoc: "拍药盒"),
|
||||
subtitle: String(appLoc: "识别用药"), active: false) {
|
||||
modeCard(icon: "pills.fill", title: String(appLoc: "用药"),
|
||||
subtitle: String(appLoc: "记剂量与时间"), active: false) {
|
||||
showMedicationLog = true
|
||||
}
|
||||
modeCard(icon: "camera.viewfinder", title: String(appLoc: "拍药盒"),
|
||||
subtitle: String(appLoc: "识别入药品库"), active: false) {
|
||||
showMedicationScan = true
|
||||
}
|
||||
modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"),
|
||||
@@ -252,9 +260,9 @@ struct DiaryQuickSheet: View {
|
||||
.presentationCornerRadius(Tj.Radius.xl)
|
||||
.fullScreenCover(isPresented: $showMedicationScan) {
|
||||
MedicationScanFlow(
|
||||
onSave: { entries in
|
||||
// 落库:「用药」日记(进记录时间线)+ 同步个人资料·当前用药。
|
||||
MedicationArchiver.archive(entries: entries, in: ctx)
|
||||
onSave: { meds, images in
|
||||
// 识别后入药品库(含原图),不再写日记。服用流水走「写日记 · 用药」模式。
|
||||
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
|
||||
dismiss()
|
||||
},
|
||||
onClose: { showMedicationScan = false }
|
||||
@@ -264,6 +272,10 @@ struct DiaryQuickSheet: View {
|
||||
// 嵌套 sheet:症状表单自带保存/取消;取消回到日记,不强行关闭。
|
||||
SymptomStartSheet()
|
||||
}
|
||||
.sheet(isPresented: $showMedicationLog) {
|
||||
// 嵌套 sheet:用药记录表单自带保存/取消;保存后回到日记(不强行关闭)。
|
||||
MedicationLogSheet()
|
||||
}
|
||||
.onDisappear {
|
||||
suggestTask?.cancel()
|
||||
voiceFlowTask?.cancel()
|
||||
|
||||
133
康康/Features/Diary/MedicationLogSheet.swift
Normal file
133
康康/Features/Diary/MedicationLogSheet.swift
Normal file
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 「写日记 · 用药」:记一次服用流水 —— 选药(药品库或手输)+ 剂量 + 时间
|
||||
/// → 带 `DiaryEntry.medicationTag` 的日记,进「记录」时间线的「用药」分类。
|
||||
///
|
||||
/// 与药品库(`Medication`,master 清单)分层:这里是「某次吃了多少、什么时候吃的」。
|
||||
/// 嵌套 sheet,自带保存 / 取消(同 `SymptomStartSheet`),关闭后回到写日记页。
|
||||
struct MedicationLogSheet: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@Query(sort: \Medication.updatedAt, order: .reverse)
|
||||
private var library: [Medication]
|
||||
|
||||
/// 选中的药品库药;手输模式为 nil。
|
||||
@State private var selectedMed: Medication?
|
||||
/// 手输药名(药品库为空,或想记不在库里的药)。与 selectedMed 互斥。
|
||||
@State private var manualName = ""
|
||||
@State private var dosage = ""
|
||||
@State private var takenAt: Date = .now
|
||||
|
||||
private var resolvedName: String {
|
||||
(selectedMed?.name ?? manualName).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
private var canSave: Bool { !resolvedName.isEmpty }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
if library.isEmpty {
|
||||
TextField(String(appLoc: "药名,如:缬沙坦胶囊"), text: $manualName)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
} else {
|
||||
ForEach(library) { m in
|
||||
Button { select(m) } label: { medRow(m) }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "pencil")
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
TextField(String(appLoc: "或手动输入药名"), text: $manualName)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.onChange(of: manualName) { _, v in
|
||||
if !v.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
selectedMed = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("吃了哪个药")
|
||||
} footer: {
|
||||
if library.isEmpty {
|
||||
Text("药品库还没有药,可在「记录 · 药品库」拍药盒或手动添加。这里直接手输也行。")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(String(appLoc: "剂量,如:1 片 / 80mg"), text: $dosage)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
} header: {
|
||||
Text("剂量")
|
||||
}
|
||||
|
||||
Section {
|
||||
DatePicker(String(appLoc: "时间"), selection: $takenAt, in: ...Date.now)
|
||||
} header: {
|
||||
Text("时间")
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationTitle("记录用药")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button(String(appLoc: "取消")) { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(String(appLoc: "保存")) { save() }
|
||||
.fontWeight(.semibold)
|
||||
.disabled(!canSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func medRow(_ m: Medication) -> some View {
|
||||
let on = selectedMed === m
|
||||
return HStack(spacing: 10) {
|
||||
Image(systemName: on ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(on ? Tj.Palette.ink : Tj.Palette.text3)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(m.name)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
if !m.detailLine.isEmpty {
|
||||
Text(m.detailLine)
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private func select(_ m: Medication) {
|
||||
selectedMed = m
|
||||
manualName = ""
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard canSave else { return }
|
||||
// content 单行:「药名 [规格] · 剂量」。剂量进正文,时间用 createdAt 承载。
|
||||
// 与 TimelineEntry.firstLine / TimelineEntryDetailView.medicationLines 单行解析兼容。
|
||||
var line = resolvedName
|
||||
if let s = selectedMed?.strength, !s.isEmpty { line += " \(s)" }
|
||||
let dose = dosage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !dose.isEmpty { line += " · \(dose)" }
|
||||
|
||||
let entry = DiaryEntry(content: line, createdAt: takenAt, tags: [DiaryEntry.medicationTag])
|
||||
ctx.insert(entry)
|
||||
try? ctx.save()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MedicationLogSheet()
|
||||
.modelContainer(for: [Medication.self, DiaryEntry.self, Asset.self], inMemory: true)
|
||||
}
|
||||
@@ -18,21 +18,19 @@ struct HomeView: View {
|
||||
|
||||
/// 点「最近记录」某行 → 打开只读详情 sheet(与档案库 C1 同款交互)。
|
||||
@State private var selectedEntry: TimelineEntry?
|
||||
/// 点指标行 → 打开同类聚合详情(历次翻页 + 趋势,与档案库 C1 同款)。
|
||||
@State private var selectedGroup: IndicatorGroup?
|
||||
|
||||
@MainActor
|
||||
private var recentEntries: [TimelineEntry] {
|
||||
let all =
|
||||
TimelineEntry.from(indicators: indicators) +
|
||||
TimelineEntry.aggregatedIndicators(indicators) +
|
||||
reports.map(TimelineEntry.from(report:)) +
|
||||
diaries.map(TimelineEntry.from(diary:)) +
|
||||
symptoms.map(TimelineEntry.from(symptom:))
|
||||
return all.sorted { $0.date > $1.date }.prefix(6).map { $0 }
|
||||
}
|
||||
|
||||
private var recentGrouped: [(section: DateSection, items: [TimelineEntry])] {
|
||||
TimelineGrouping.group(recentEntries)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -65,6 +63,9 @@ struct HomeView: View {
|
||||
TimelineEntryDetailView(detail: d)
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedGroup) { group in
|
||||
IndicatorSeriesDetailView(group: group)
|
||||
}
|
||||
}
|
||||
|
||||
private var greeting: some View {
|
||||
@@ -100,7 +101,10 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private var recentSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
// 聚合(含血压配对 O(m²))在一次 body 内只算一次,再派生分组,避免 .isEmpty 与分组各算一遍。
|
||||
let entries = recentEntries
|
||||
let groups = TimelineGrouping.group(entries)
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
@@ -112,11 +116,11 @@ struct HomeView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if recentEntries.isEmpty {
|
||||
if entries.isEmpty {
|
||||
emptyRecent
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(recentGrouped, id: \.section) { group in
|
||||
ForEach(groups, id: \.section) { group in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(group.section.label)
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
@@ -125,12 +129,16 @@ struct HomeView: View {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(group.items) { entry in
|
||||
Button {
|
||||
if TimelineDetail.resolve(
|
||||
// 指标 → 同类聚合详情(历次 + 趋势);其余 → 只读详情。与档案库 C1 一致。
|
||||
guard let d = TimelineDetail.resolve(
|
||||
for: entry,
|
||||
indicators: indicators, reports: reports,
|
||||
diaries: diaries, symptoms: symptoms
|
||||
) != nil {
|
||||
selectedEntry = entry
|
||||
) else { return }
|
||||
switch d {
|
||||
case .indicator(let i): selectedGroup = IndicatorGroup.of(i)
|
||||
case .bloodPressure(let sys, _): selectedGroup = IndicatorGroup.of(sys)
|
||||
default: selectedEntry = entry
|
||||
}
|
||||
} label: {
|
||||
TimelineRow(entry: entry)
|
||||
|
||||
@@ -31,6 +31,18 @@ struct IndicatorQuickSheet: View {
|
||||
/// nil 时(如 Preview)不显示拍照按钮。
|
||||
var onRequestCamera: (() -> Void)? = nil
|
||||
|
||||
/// 从已有指标「再记一条」时的预选目标。nil = 正常空白新建。
|
||||
/// seriesKey 命中 MonitorMetric / CustomMonitorMetric → 预选对应预设(保留进趋势 + 自动判异常);
|
||||
/// 否则按 name/unit/range 走自由输入预填。数值一律留空,由用户填新读数。
|
||||
var prefill: Prefill? = nil
|
||||
|
||||
struct Prefill: Equatable {
|
||||
var seriesKey: String?
|
||||
var name: String = ""
|
||||
var unit: String = ""
|
||||
var range: String = ""
|
||||
}
|
||||
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query private var profiles: [UserProfile]
|
||||
@@ -69,6 +81,32 @@ struct IndicatorQuickSheet: View {
|
||||
// 隐藏管理 sheet 触发态
|
||||
@State private var showHiddenSheet: Bool = false
|
||||
|
||||
// 「再记一条」预选只应用一次
|
||||
@State private var didApplyPrefill = false
|
||||
|
||||
// 顶部搜索:点放大镜展开搜索框,按名实时过滤长期监测预设 / 自定义指标 / 化验项快捷。
|
||||
@State private var searchingMetrics = false
|
||||
@State private var metricQuery = ""
|
||||
|
||||
private var isSearchingMetrics: Bool {
|
||||
!metricQuery.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
private var filteredMonitorMetrics: [MonitorMetric] {
|
||||
let q = metricQuery.trimmingCharacters(in: .whitespaces)
|
||||
guard !q.isEmpty else { return visibleMonitorMetrics }
|
||||
return visibleMonitorMetrics.filter { $0.displayName.localizedCaseInsensitiveContains(q) }
|
||||
}
|
||||
private var filteredCustomMetrics: [CustomMonitorMetric] {
|
||||
let q = metricQuery.trimmingCharacters(in: .whitespaces)
|
||||
guard !q.isEmpty else { return customMetrics }
|
||||
return customMetrics.filter { $0.name.localizedCaseInsensitiveContains(q) }
|
||||
}
|
||||
private var filteredLabPresets: [IndicatorPreset] {
|
||||
let q = metricQuery.trimmingCharacters(in: .whitespaces)
|
||||
guard !q.isEmpty else { return labPresets }
|
||||
return labPresets.filter { $0.name.localizedCaseInsensitiveContains(q) }
|
||||
}
|
||||
|
||||
private static var defaultReminderTime: Date {
|
||||
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
|
||||
}
|
||||
@@ -137,6 +175,7 @@ struct IndicatorQuickSheet: View {
|
||||
|
||||
footer
|
||||
}
|
||||
.onAppear { applyPrefillIfNeeded() }
|
||||
.task(id: longTermKey) { hydrateReminder() }
|
||||
.background(
|
||||
Tj.Palette.sand
|
||||
@@ -161,19 +200,64 @@ struct IndicatorQuickSheet: View {
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Text("记录指标")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
Text("本地处理 · 永不上传")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Text("记录指标")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
Text("本地处理 · 永不上传")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
searchToggle
|
||||
}
|
||||
if searchingMetrics { searchField }
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
|
||||
private var searchToggle: some View {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
searchingMetrics.toggle()
|
||||
if !searchingMetrics { metricQuery = "" }
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: searchingMetrics ? "xmark" : "magnifyingglass")
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(Tj.Palette.sand2))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(searchingMetrics ? 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: $metricQuery)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.tint(Tj.Palette.ink)
|
||||
if !metricQuery.isEmpty {
|
||||
Button { metricQuery = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(fieldBg)
|
||||
.overlay(fieldBorder)
|
||||
}
|
||||
|
||||
/// 顶部「拍照识别」入口:并入原「指标速记」。点后由 RootView 切到相机 VL 流程。
|
||||
@ViewBuilder
|
||||
private var cameraEntrySection: some View {
|
||||
@@ -241,13 +325,19 @@ struct IndicatorQuickSheet: View {
|
||||
}
|
||||
let columns = [GridItem(.flexible()), GridItem(.flexible())]
|
||||
LazyVGrid(columns: columns, spacing: 8) {
|
||||
ForEach(visibleMonitorMetrics) { m in
|
||||
ForEach(filteredMonitorMetrics) { m in
|
||||
monitorTile(m)
|
||||
}
|
||||
ForEach(customMetrics) { cm in
|
||||
ForEach(filteredCustomMetrics) { cm in
|
||||
customTile(cm)
|
||||
}
|
||||
addCustomTile
|
||||
// 搜索态下不显示「自定义(新建)」格,聚焦过滤结果。
|
||||
if !isSearchingMetrics { addCustomTile }
|
||||
}
|
||||
if isSearchingMetrics, filteredMonitorMetrics.isEmpty, filteredCustomMetrics.isEmpty {
|
||||
Text("没有匹配的长期监测指标")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showHiddenSheet) {
|
||||
@@ -386,14 +476,18 @@ struct IndicatorQuickSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var labPresetSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(labPresets) { p in
|
||||
chip(p.name, selected: selectedLabPreset == p) {
|
||||
applyLab(p)
|
||||
// 搜索且化验项无匹配:整段隐藏(避免只剩一个空标题)。
|
||||
if !(isSearchingMetrics && filteredLabPresets.isEmpty) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(filteredLabPresets) { p in
|
||||
chip(p.name, selected: selectedLabPreset == p) {
|
||||
applyLab(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -941,6 +1035,29 @@ struct IndicatorQuickSheet: View {
|
||||
|
||||
// MARK: - apply preset
|
||||
|
||||
/// 「再记一条」预选:seriesKey 命中长期监测预设 / 自定义指标则选中对应卡片(进趋势 + 自动判异常),
|
||||
/// 否则按 name/unit/range 走自由输入。数值不预填——让用户填新读数。只应用一次。
|
||||
private func applyPrefillIfNeeded() {
|
||||
guard !didApplyPrefill, let p = prefill else { return }
|
||||
didApplyPrefill = true
|
||||
if let key = p.seriesKey {
|
||||
if let m = MonitorMetric.allCases.first(where: { metric in
|
||||
metric.fields.contains { $0.seriesKey == key }
|
||||
}) {
|
||||
applyMonitor(m)
|
||||
return
|
||||
}
|
||||
if let cm = customMetrics.first(where: { $0.seriesKey == key }) {
|
||||
applyCustom(cm)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 无 seriesKey 或未匹配预设(化验项 / 报告 / 自由指标):自由输入预填,不带 seriesKey,不进趋势。
|
||||
name = p.name
|
||||
unit = p.unit
|
||||
range = p.range
|
||||
}
|
||||
|
||||
private func applyMonitor(_ m: MonitorMetric) {
|
||||
if selectedMonitor == m {
|
||||
// 取消选择
|
||||
|
||||
39
康康/Features/Indicator/RecordAnotherButton.swift
Normal file
39
康康/Features/Indicator/RecordAnotherButton.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
extension IndicatorQuickSheet.Prefill {
|
||||
/// 从一条已有指标推断「再记一条」的预选目标:
|
||||
/// seriesKey 命中长期监测 / 自定义指标则预选对应预设(进趋势 + 自动判异常),否则按 name/unit/range 自由预填。
|
||||
init(indicator i: Indicator) {
|
||||
self.init(seriesKey: i.seriesKey, name: i.name, unit: i.unit, range: i.range)
|
||||
}
|
||||
}
|
||||
|
||||
/// 指标详情 / 同类聚合详情底部「再记一条」按钮:打开预选同款指标的录入表单(数值留空,由用户填新读数)。
|
||||
/// 自带弹窗状态,`TimelineEntryDetailView` 与 `IndicatorSeriesDetailView` 共用同一组件。
|
||||
struct RecordAnotherButton: View {
|
||||
/// 按钮文案里显示的指标名(如「空腹血糖」「血压」)。
|
||||
let name: String
|
||||
/// 打开录入表单时的预选目标。
|
||||
let prefill: IndicatorQuickSheet.Prefill
|
||||
|
||||
@State private var showSheet = false
|
||||
|
||||
var body: some View {
|
||||
Button { showSheet = true } label: {
|
||||
Label(String(appLoc: "再记一条「\(name)」"), systemImage: "plus.circle.fill")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.leaf.opacity(0.16))
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.sheet(isPresented: $showSheet) {
|
||||
IndicatorQuickSheet(prefill: prefill)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,20 @@ struct CustomReminderEditSheet: View {
|
||||
|
||||
/// nil = 新建模式。
|
||||
let reminder: CustomReminder?
|
||||
/// 新建模式下的预填(如从「用药记录」点药进来,预填「吃药:药名」+ 用法备注)。
|
||||
/// 编辑模式(reminder != nil)忽略。
|
||||
private let prefillTitle: String
|
||||
private let prefillNote: String
|
||||
|
||||
@State private var title = ""
|
||||
@State private var note = ""
|
||||
@State private var pickedTime: Date = .now
|
||||
@State private var frequency: CustomReminder.Frequency = .daily
|
||||
/// 多选频率(每日/每周/每月/每年 可同时勾选)。
|
||||
@State private var frequencies: Set<CustomReminder.Frequency> = [.daily]
|
||||
@State private var weekdays: Set<Int> = Set(1...7)
|
||||
@State private var dayOfMonth = 1
|
||||
/// 每月多选日期(1...31)。
|
||||
@State private var monthDays: Set<Int> = [1]
|
||||
@State private var dayOfMonth = 1 // 仅「每年」用
|
||||
@State private var month = 1
|
||||
@State private var hydrated = false
|
||||
@State private var showAuthDeniedAlert = false
|
||||
@@ -24,8 +31,10 @@ struct CustomReminderEditSheet: View {
|
||||
/// 常用时间快捷预设(时, 分):早 / 午 / 傍晚 / 睡前。
|
||||
private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)]
|
||||
|
||||
init(reminder: CustomReminder? = nil) {
|
||||
init(reminder: CustomReminder? = nil, prefillTitle: String = "", prefillNote: String = "") {
|
||||
self.reminder = reminder
|
||||
self.prefillTitle = prefillTitle
|
||||
self.prefillNote = prefillNote
|
||||
}
|
||||
|
||||
private var isEditing: Bool { reminder != nil }
|
||||
@@ -33,8 +42,9 @@ struct CustomReminderEditSheet: View {
|
||||
title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
private var canSave: Bool {
|
||||
guard !trimmedTitle.isEmpty else { return false }
|
||||
if frequency == .weekly { return !weekdays.isEmpty }
|
||||
guard !trimmedTitle.isEmpty, !frequencies.isEmpty else { return false }
|
||||
if frequencies.contains(.weekly) && weekdays.isEmpty { return false }
|
||||
if frequencies.contains(.monthly) && monthDays.isEmpty { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -51,18 +61,12 @@ struct CustomReminderEditSheet: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker(String(appLoc: "重复"), selection: $frequency) {
|
||||
Text(String(appLoc: "每日")).tag(CustomReminder.Frequency.daily)
|
||||
Text(String(appLoc: "每周")).tag(CustomReminder.Frequency.weekly)
|
||||
Text(String(appLoc: "每月")).tag(CustomReminder.Frequency.monthly)
|
||||
Text(String(appLoc: "每年")).tag(CustomReminder.Frequency.yearly)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
frequencyChips
|
||||
frequencyDetail
|
||||
} header: {
|
||||
Text("重复")
|
||||
} footer: {
|
||||
Text("可多选:如同时勾选「每周一三五」+「每月1日」,两种节奏都会提醒。")
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -109,23 +113,60 @@ struct CustomReminderEditSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 频率子控件
|
||||
// MARK: - 频率多选 chip
|
||||
|
||||
private static let freqOrder: [CustomReminder.Frequency] = [.daily, .weekly, .monthly, .yearly]
|
||||
|
||||
private func freqLabel(_ f: CustomReminder.Frequency) -> String {
|
||||
switch f {
|
||||
case .daily: return String(appLoc: "每日")
|
||||
case .weekly: return String(appLoc: "每周")
|
||||
case .monthly: return String(appLoc: "每月")
|
||||
case .yearly: return String(appLoc: "每年")
|
||||
}
|
||||
}
|
||||
|
||||
private var frequencyChips: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Self.freqOrder, id: \.self) { f in
|
||||
let on = frequencies.contains(f)
|
||||
Button {
|
||||
if on { frequencies.remove(f) } else { frequencies.insert(f) }
|
||||
} label: {
|
||||
Text(freqLabel(f))
|
||||
.font(.tjScaled( 13, weight: on ? .semibold : .regular))
|
||||
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity, minHeight: 32)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
// MARK: - 频率子控件(随勾选项展开,可同时出现多组)
|
||||
|
||||
@ViewBuilder
|
||||
private var frequencyDetail: some View {
|
||||
switch frequency {
|
||||
case .daily:
|
||||
EmptyView()
|
||||
case .weekly:
|
||||
if frequencies.contains(.weekly) {
|
||||
subCaption(String(appLoc: "每周 · 选星期几"))
|
||||
weekdayRow
|
||||
case .monthly:
|
||||
Picker(String(appLoc: "日期"), selection: $dayOfMonth) {
|
||||
ForEach(1...31, id: \.self) { d in
|
||||
Text(String(appLoc: "\(d)日")).tag(d)
|
||||
}
|
||||
}
|
||||
if dayOfMonth >= 29 { skipHint }
|
||||
case .yearly:
|
||||
}
|
||||
if frequencies.contains(.monthly) {
|
||||
subCaption(String(appLoc: "每月 · 选日期(可多选)"))
|
||||
monthDayGrid
|
||||
if monthDays.contains(where: { $0 >= 29 }) { skipHint }
|
||||
}
|
||||
if frequencies.contains(.yearly) {
|
||||
subCaption(String(appLoc: "每年 · 选月/日"))
|
||||
Picker(String(appLoc: "月份"), selection: $month) {
|
||||
ForEach(1...12, id: \.self) { mo in
|
||||
Text(String(appLoc: "\(mo)月")).tag(mo)
|
||||
@@ -140,6 +181,41 @@ struct CustomReminderEditSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func subCaption(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
/// 每月日期多选网格(1...31,7 列)。
|
||||
private var monthDayGrid: some View {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 7), spacing: 6) {
|
||||
ForEach(1...31, id: \.self) { d in
|
||||
let on = monthDays.contains(d)
|
||||
Button {
|
||||
if on { monthDays.remove(d) } else { monthDays.insert(d) }
|
||||
} label: {
|
||||
Text("\(d)")
|
||||
.font(.tjScaled( 12, weight: on ? .semibold : .regular))
|
||||
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity, minHeight: 30)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
private var skipHint: some View {
|
||||
Text(String(appLoc: "部分月份无此日,该月将跳过"))
|
||||
.font(.tjScaled( 11))
|
||||
@@ -229,13 +305,18 @@ struct CustomReminderEditSheet: View {
|
||||
if let r = reminder {
|
||||
title = r.title
|
||||
note = r.note
|
||||
frequency = r.frequency
|
||||
frequencies = r.frequencies
|
||||
weekdays = Set(r.weekdays)
|
||||
monthDays = Set(r.monthlyDays)
|
||||
dayOfMonth = r.dayOfMonth
|
||||
month = r.month
|
||||
pickedTime = Calendar.current.date(
|
||||
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
|
||||
) ?? .now
|
||||
} else {
|
||||
// 新建模式:从调用方带进来的预填(药名 / 用法)。
|
||||
title = prefillTitle
|
||||
note = prefillNote
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +326,7 @@ struct CustomReminderEditSheet: View {
|
||||
let hour = cal.component(.hour, from: pickedTime)
|
||||
let minute = cal.component(.minute, from: pickedTime)
|
||||
let sortedDays = weekdays.sorted()
|
||||
let sortedMonthDays = monthDays.sorted()
|
||||
|
||||
let target: CustomReminder
|
||||
if let r = reminder {
|
||||
@@ -253,8 +335,9 @@ struct CustomReminderEditSheet: View {
|
||||
r.hour = hour
|
||||
r.minute = minute
|
||||
r.weekdays = sortedDays
|
||||
r.frequency = frequency
|
||||
r.dayOfMonth = dayOfMonth
|
||||
r.frequencies = frequencies // 写 frequenciesRaw(+ 代表 frequencyRaw)
|
||||
r.monthlyDays = sortedMonthDays // 写 monthDays
|
||||
r.dayOfMonth = dayOfMonth // 仅每年用
|
||||
r.month = month
|
||||
r.updatedAt = .now
|
||||
target = r
|
||||
@@ -265,10 +348,11 @@ struct CustomReminderEditSheet: View {
|
||||
hour: hour,
|
||||
minute: minute,
|
||||
weekdays: sortedDays,
|
||||
frequency: frequency,
|
||||
dayOfMonth: dayOfMonth,
|
||||
month: month
|
||||
)
|
||||
new.frequencies = frequencies
|
||||
new.monthlyDays = sortedMonthDays
|
||||
ctx.insert(new)
|
||||
target = new
|
||||
}
|
||||
|
||||
@@ -282,6 +282,6 @@ struct MeView: View {
|
||||
.modelContainer(for: [
|
||||
UserProfile.self, Indicator.self, Report.self, DiaryEntry.self,
|
||||
Asset.self, ChatTurn.self, Symptom.self, MetricReminder.self,
|
||||
CustomMonitorMetric.self,
|
||||
CustomMonitorMetric.self, Medication.self,
|
||||
], inMemory: true)
|
||||
}
|
||||
|
||||
383
康康/Features/Profile/MedicationLibraryView.swift
Normal file
383
康康/Features/Profile/MedicationLibraryView.swift
Normal file
@@ -0,0 +1,383 @@
|
||||
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)
|
||||
}
|
||||
@@ -2,28 +2,41 @@ import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
|
||||
/// 「拍药盒入档」流程:拍药盒/说明书 → Vision OCR → LLM 结构化 → 核对 → 落库。
|
||||
/// 入口:「+ 新建 · 健康日记 · 拍药盒」与「我的 · 个人资料 · 当前用药」。
|
||||
/// 两个入口确认后都走 `MedicationArchiver`:记一条「用药」日记(进记录时间线)+ 同步当前用药。
|
||||
/// 只识别入档,不做用药提醒/剂量建议(§1)。
|
||||
/// 「拍药盒入库」流程:拍药盒/说明书(最多 5 张,选一张识别)→ Vision OCR → LLM 结构化 → 核对 → 存入药品库(连同原图)。
|
||||
/// 入口:「记录 · 药品库」与「记录 · 健康日记 · 拍药盒」。
|
||||
/// 两个入口确认后都走 `MedicationArchiver`:每条药建一个 `Medication`(挂原图),不写日记、不写当前用药。
|
||||
/// 服用流水改由「写日记 · 用药」生成带 `medicationTag` 的 DiaryEntry。只识别入库,不做用药提醒/剂量建议(§1)。
|
||||
///
|
||||
/// 状态机(与 QuickRegionCaptureFlow 同构):
|
||||
/// 状态机:
|
||||
/// ```
|
||||
/// idle(相机/相册) → recognizing(OCR + LLM) → confirm(核对可编辑) → onSave → 关闭
|
||||
/// │ 失败/没读出 ──────► confirm(空行 + 警示文案,手动补)
|
||||
/// idle(相机/相册) ─拍到第1张→ collecting(复看:删/继续拍≤5/选一张/开始识别)
|
||||
/// │ 开始识别
|
||||
/// ▼
|
||||
/// recognizing(选中单张 OCR + LLM) ─→ confirm(核对一种药) ─onSave→ 关闭
|
||||
/// │ 失败/没读出 ───────────────► confirm(空行 + 警示)
|
||||
/// ```
|
||||
struct MedicationScanFlow: View {
|
||||
/// 用户确认后回传条目文本(非空,如 "缬沙坦胶囊 80mg · 一日一次")。落库由调用方做。
|
||||
let onSave: ([String]) -> Void
|
||||
/// 用户确认后回传(结构化药品, 原图)。入库由调用方做(走 MedicationArchiver.archive(medications:))。
|
||||
let onSave: ([ParsedMedication], [UIImage]) -> Void
|
||||
let onClose: () -> Void
|
||||
|
||||
/// 一种药最多关联 5 张原图(正面/背面/说明书…)。
|
||||
static let maxImages = 5
|
||||
|
||||
@State private var phase: Phase = .idle
|
||||
/// 已拍/已选的原图,跨 collecting → recognizing → confirm 一直留着,确认时全部作为该药原图落库。
|
||||
@State private var images: [UIImage] = []
|
||||
/// 识别用的照片索引(在多张里单选一张)。一次只记一种药 → 只 OCR 这一张;删图时校正。
|
||||
@State private var recognizeIndex = 0
|
||||
/// 在 collecting 复看页「继续拍/继续选」时弹相机或相册。
|
||||
@State private var showMoreCapture = false
|
||||
/// 识别任务句柄:识别中点「取消」要能立刻中断,不留后台推理。
|
||||
@State private var recognitionTask: Task<Void, Never>?
|
||||
|
||||
enum Phase {
|
||||
case idle
|
||||
case recognizing(image: UIImage)
|
||||
case collecting
|
||||
case recognizing
|
||||
case confirm(items: [EditableMedication], warning: String?)
|
||||
}
|
||||
|
||||
@@ -35,6 +48,8 @@ struct MedicationScanFlow: View {
|
||||
var include: Bool = true
|
||||
}
|
||||
|
||||
private var remainingSlots: Int { max(0, Self.maxImages - images.count) }
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
@@ -45,10 +60,14 @@ struct MedicationScanFlow: View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
// 不整体 ignoresSafeArea:相机内部已全屏黑底,忽略安全区会让「取消」顶进灵动岛。
|
||||
captureEntry
|
||||
initialCaptureEntry
|
||||
|
||||
case .recognizing(let image):
|
||||
recognizingView(image: image)
|
||||
case .collecting:
|
||||
collectingView
|
||||
.fullScreenCover(isPresented: $showMoreCapture) { moreCaptureSheet }
|
||||
|
||||
case .recognizing:
|
||||
recognizingView
|
||||
|
||||
case .confirm(let items, let warning):
|
||||
NavigationStack {
|
||||
@@ -56,7 +75,7 @@ struct MedicationScanFlow: View {
|
||||
items: items,
|
||||
warning: warning,
|
||||
onSave: { saveItems($0) },
|
||||
onRetake: { phase = .idle }
|
||||
onRetake: { images = []; phase = .idle }
|
||||
)
|
||||
.navigationTitle("核对药品")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -72,31 +91,160 @@ struct MedicationScanFlow: View {
|
||||
|
||||
// MARK: - 入口:拍照(真机)/ 相册(模拟器)
|
||||
|
||||
/// 首张:进入即拍/选。拿到第一张就转 collecting 复看。
|
||||
@ViewBuilder
|
||||
private var captureEntry: some View {
|
||||
private var initialCaptureEntry: some View {
|
||||
#if targetEnvironment(simulator)
|
||||
PhotoPickerSheet(
|
||||
onFinish: { images in
|
||||
if let first = images.first { startRecognition(first) } else { onClose() }
|
||||
onFinish: { picked in
|
||||
appendImages(picked)
|
||||
if images.isEmpty { onClose() } else { phase = .collecting }
|
||||
},
|
||||
onCancel: onClose
|
||||
)
|
||||
#else
|
||||
SingleShotCameraView(
|
||||
onCapture: { startRecognition($0) },
|
||||
onCapture: { appendImages([$0]); phase = .collecting },
|
||||
onCancel: onClose
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func recognizingView(image: UIImage) -> some View {
|
||||
/// collecting 复看页里「继续拍/继续选」弹出的二次采集。
|
||||
@ViewBuilder
|
||||
private var moreCaptureSheet: some View {
|
||||
#if targetEnvironment(simulator)
|
||||
PhotoPickerSheet(
|
||||
onFinish: { picked in appendImages(picked); showMoreCapture = false },
|
||||
onCancel: { showMoreCapture = false }
|
||||
)
|
||||
#else
|
||||
SingleShotCameraView(
|
||||
onCapture: { appendImages([$0]); showMoreCapture = false },
|
||||
onCancel: { showMoreCapture = false }
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func appendImages(_ new: [UIImage]) {
|
||||
guard remainingSlots > 0 else { return }
|
||||
images.append(contentsOf: new.prefix(remainingSlots))
|
||||
}
|
||||
|
||||
// MARK: - 复看(已拍 N 张:删 / 继续拍 / 开始识别)
|
||||
|
||||
private var collectingView: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 12)], spacing: 12) {
|
||||
ForEach(Array(images.enumerated()), id: \.offset) { idx, img in
|
||||
let isPick = idx == recognizeIndex
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 96, height: 96)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(isPick ? Tj.Palette.ink : Color.clear, lineWidth: 3)
|
||||
)
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
if isPick {
|
||||
Text("识别此张")
|
||||
.font(.tjScaled(10, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(Tj.Palette.ink))
|
||||
.padding(5)
|
||||
}
|
||||
}
|
||||
// 点图把它选为「识别用」那张(单选)。
|
||||
.onTapGesture { recognizeIndex = idx }
|
||||
Button {
|
||||
images.remove(at: idx)
|
||||
// 校正识别索引:删选中前面的图要左移;删到越界则收回末尾。
|
||||
if images.isEmpty {
|
||||
recognizeIndex = 0
|
||||
phase = .idle
|
||||
} else if idx < recognizeIndex {
|
||||
recognizeIndex -= 1
|
||||
} else if recognizeIndex >= images.count {
|
||||
recognizeIndex = images.count - 1
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.tjScaled(20))
|
||||
.foregroundStyle(.white, .black.opacity(0.5))
|
||||
.padding(4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
if remainingSlots > 0 {
|
||||
Button { showMoreCapture = true } label: {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "plus")
|
||||
.font(.tjScaled(22, weight: .medium))
|
||||
Text("继续拍")
|
||||
.font(.tjScaled(12))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.frame(width: 96, height: 96)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, style: StrokeStyle(lineWidth: 1, dash: [4]))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("已拍 \(images.count)/\(Self.maxImages) 张 · 可拍正面、背面、说明书")
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
if images.count > 1 {
|
||||
Text("点照片选「识别此张」· 一次记一种药")
|
||||
.font(.tjScaled(11))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
Text("照片与文字均不离开设备")
|
||||
.font(.tjScaled(11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
|
||||
Button {
|
||||
startRecognition()
|
||||
} label: {
|
||||
Text("开始识别")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton())
|
||||
.disabled(images.isEmpty)
|
||||
.opacity(images.isEmpty ? 0.4 : 1)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
flowCancelButton { onClose() }
|
||||
}
|
||||
}
|
||||
|
||||
private var recognizingView: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 320)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||
.padding(.horizontal, 24)
|
||||
if images.indices.contains(recognizeIndex) {
|
||||
Image(uiImage: images[recognizeIndex])
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 320)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
ProgressView().tint(Tj.Palette.ink)
|
||||
Text("正在本地识别药品…")
|
||||
.font(.tjScaled(14))
|
||||
@@ -108,31 +256,37 @@ struct MedicationScanFlow: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// 识别中也要能退出,不能让用户干等(§3.2 不卡死)
|
||||
.overlay(alignment: .topLeading) {
|
||||
Button {
|
||||
flowCancelButton {
|
||||
recognitionTask?.cancel()
|
||||
onClose()
|
||||
} label: {
|
||||
Text("取消")
|
||||
.font(.tjScaled( 16, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.padding(.horizontal, 18)
|
||||
.frame(minHeight: 44)
|
||||
.background(Capsule().fill(Tj.Palette.paper))
|
||||
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 识别(整图 OCR → LLM 结构化)
|
||||
private func flowCancelButton(_ action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text("取消")
|
||||
.font(.tjScaled(16, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.padding(.horizontal, 18)
|
||||
.frame(minHeight: 44)
|
||||
.background(Capsule().fill(Tj.Palette.paper))
|
||||
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private func startRecognition(_ image: UIImage) {
|
||||
phase = .recognizing(image: image)
|
||||
// MARK: - 识别(选中单张 OCR → LLM 结构化)
|
||||
|
||||
private func startRecognition() {
|
||||
guard images.indices.contains(recognizeIndex) else { return }
|
||||
phase = .recognizing
|
||||
let target = images[recognizeIndex]
|
||||
recognitionTask = Task {
|
||||
let (items, warning) = await recognize(image)
|
||||
let (items, warning) = await recognize(target)
|
||||
guard !Task.isCancelled else { return } // 识别中点了取消:不再回写 phase
|
||||
await MainActor.run {
|
||||
// 全失败也不卡死:给一条空行让用户手填(§3.2 失败回退红线)。
|
||||
@@ -148,13 +302,15 @@ struct MedicationScanFlow: View {
|
||||
|
||||
private func recognize(_ image: UIImage) async -> (items: [EditableMedication], warning: String?) {
|
||||
do {
|
||||
let text = try await OCRService.recognizeText(in: image)
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
// 一次只识别选中的这一张 → 一种药。
|
||||
let text = (try? await OCRService.recognizeText(in: image))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if text.isEmpty {
|
||||
return ([], String(appLoc: "没识别到文字,拍清楚一点再试"))
|
||||
}
|
||||
let parsed = try await MedicationScanService.shared.recognizeMedications(fromOCRText: trimmed)
|
||||
let items = parsed.map {
|
||||
let parsed = try await MedicationScanService.shared.recognizeMedications(fromOCRText: text)
|
||||
// 一次一种药:即使识别出多条,也只取第一条。
|
||||
let items = parsed.prefix(1).map {
|
||||
EditableMedication(name: $0.name, strength: $0.strength, usage: $0.usage)
|
||||
}
|
||||
return (items, items.isEmpty ? String(appLoc: "没读出药品,可以手动填写") : nil)
|
||||
@@ -172,34 +328,56 @@ struct MedicationScanFlow: View {
|
||||
// MARK: - 保存
|
||||
|
||||
private func saveItems(_ items: [EditableMedication]) {
|
||||
let entries = items
|
||||
let meds = items
|
||||
.filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
.map {
|
||||
ParsedMedication(name: $0.name, strength: $0.strength, usage: $0.usage).entryText
|
||||
ParsedMedication(name: $0.name.trimmingCharacters(in: .whitespaces),
|
||||
strength: $0.strength.trimmingCharacters(in: .whitespaces),
|
||||
usage: $0.usage.trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
onSave(entries)
|
||||
// 确认后入药品库(连同原图)。空条目则不入库,由调用方据数组是否为空决定。
|
||||
onSave(meds, images)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 统一落库(MainActor,SwiftData 写主上下文必须由 View 侧持有的 ctx 来做,§3.1)
|
||||
// MARK: - 入药品库(MainActor,SwiftData 写主上下文必须由 View 侧持有的 ctx 来做,§3.1)
|
||||
|
||||
/// 拍药盒确认后的统一落库,两个入口共用:
|
||||
/// 1. 记一条带「用药」tag 的 DiaryEntry → 出现在「记录」时间线的「用药」分类
|
||||
/// 2. 同步到 UserProfile.currentMedications(去重)→ AI 解读 / 身体档案 prompt 背景
|
||||
/// 拍药盒确认后入药品库,两个入口(药品库页、写日记 · 拍药盒)共用:
|
||||
/// 每条药建一个 `Medication`(挂原图),按 name+strength 软去重;**不写日记、不写 currentMedications**。
|
||||
/// 服用流水改由「写日记 · 用药」生成带 `DiaryEntry.medicationTag` 的日记。
|
||||
@MainActor
|
||||
enum MedicationArchiver {
|
||||
static func archive(entries: [String], in ctx: ModelContext) {
|
||||
guard !entries.isEmpty else { return }
|
||||
let diary = DiaryEntry(content: entries.joined(separator: "\n"),
|
||||
tags: [DiaryEntry.medicationTag])
|
||||
ctx.insert(diary)
|
||||
static func archive(medications: [ParsedMedication], images: [UIImage] = [], in ctx: ModelContext) {
|
||||
guard !medications.isEmpty else { return }
|
||||
|
||||
let profile = UserProfileStore.loadOrCreate(in: ctx)
|
||||
for entry in entries where !profile.currentMedications.contains(entry) {
|
||||
profile.currentMedications.append(entry)
|
||||
// 原图写加密 Vault(§5/§6:落 Application Support/Vault,目录级硬件加密)。
|
||||
// 多药共享同批原图时只挂「第一条新建的药」,避免同一 JPEG 被多个 Asset 引用、
|
||||
// 删一条 cascade 误删另一条还在用的文件。
|
||||
let savedAssets = images
|
||||
.prefix(MedicationScanFlow.maxImages)
|
||||
.compactMap { try? FileVault.shared.writeJPEG($0) }
|
||||
|
||||
let existing = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
|
||||
var attachedImages = false
|
||||
for m in medications {
|
||||
// 软去重:同 name+strength 已在库则只补用法 / 刷新时间,不重复建。
|
||||
if let dup = existing.first(where: { $0.name == m.name && $0.strength == m.strength }) {
|
||||
if dup.usage.isEmpty, !m.usage.isEmpty { dup.usage = m.usage }
|
||||
dup.updatedAt = .now
|
||||
continue
|
||||
}
|
||||
let med = Medication(name: m.name, strength: m.strength, usage: m.usage)
|
||||
if !attachedImages {
|
||||
for s in savedAssets {
|
||||
let asset = Asset(relativePath: s.relativePath, bytes: s.bytes)
|
||||
ctx.insert(asset)
|
||||
med.assets.append(asset)
|
||||
}
|
||||
attachedImages = true
|
||||
}
|
||||
ctx.insert(med)
|
||||
}
|
||||
profile.updatedAt = .now
|
||||
try? ctx.save()
|
||||
}
|
||||
}
|
||||
@@ -231,13 +409,8 @@ private struct MedicationConfirmView: View {
|
||||
|
||||
ForEach($items) { $item in
|
||||
Section {
|
||||
HStack {
|
||||
TextField(String(appLoc: "药品名,如:缬沙坦胶囊"), text: $item.name)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Toggle("", isOn: $item.include)
|
||||
.labelsHidden()
|
||||
.tint(Tj.Palette.ink)
|
||||
}
|
||||
TextField(String(appLoc: "药品名,如:缬沙坦胶囊"), text: $item.name)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
TextField(String(appLoc: "规格,如:80mg×7粒"), text: $item.strength)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $item.usage)
|
||||
@@ -246,12 +419,6 @@ private struct MedicationConfirmView: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
items.append(.init(name: "", strength: "", usage: ""))
|
||||
} label: {
|
||||
Label("再加一种", systemImage: "plus.circle")
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
Button {
|
||||
onRetake()
|
||||
} label: {
|
||||
@@ -259,7 +426,7 @@ private struct MedicationConfirmView: View {
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
} footer: {
|
||||
Text("将记入健康日记(记录页可查),并同步到「当前用药」供 AI 解读参考。不提供任何用药建议。")
|
||||
Text("一次记一种药,多张照片都会作为这种药的原图存入药品库,供查看与 AI 解读参考。不提供任何用药建议。")
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
@@ -267,7 +434,7 @@ private struct MedicationConfirmView: View {
|
||||
Button {
|
||||
onSave(items)
|
||||
} label: {
|
||||
Text("保存用药记录")
|
||||
Text("存入药品库")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton())
|
||||
@@ -281,5 +448,5 @@ private struct MedicationConfirmView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MedicationScanFlow(onSave: { print($0) }, onClose: {})
|
||||
MedicationScanFlow(onSave: { _, _ in }, onClose: {})
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ private struct ProfileEditForm: View {
|
||||
@State private var healthImportDraft: HealthProfileImportDraft?
|
||||
@State private var healthImportError: String?
|
||||
@State private var isImportingHealthProfile = false
|
||||
@State private var showMedicationScan = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -88,9 +87,6 @@ private struct ProfileEditForm: View {
|
||||
items: $profile.allergies)
|
||||
StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"),
|
||||
items: $profile.familyHistory)
|
||||
StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"),
|
||||
items: $profile.currentMedications,
|
||||
onScan: { showMedicationScan = true })
|
||||
}
|
||||
.navigationTitle("个人资料")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -100,16 +96,6 @@ private struct ProfileEditForm: View {
|
||||
profile.updatedAt = .now
|
||||
try? ctx.save()
|
||||
}
|
||||
.fullScreenCover(isPresented: $showMedicationScan) {
|
||||
// 拍药盒 → 本地 OCR + LLM 识别 → 核对 → 统一落库:
|
||||
// 记一条「用药」日记(进记录时间线)+ 同步当前用药(去重)。
|
||||
MedicationScanFlow(
|
||||
onSave: { entries in
|
||||
MedicationArchiver.archive(entries: entries, in: ctx)
|
||||
},
|
||||
onClose: { showMedicationScan = false }
|
||||
)
|
||||
}
|
||||
.sheet(item: $healthImportDraft) { draft in
|
||||
HealthProfileImportPreviewSheet(
|
||||
draft: draft,
|
||||
@@ -468,27 +454,10 @@ private struct StringListSection: View {
|
||||
let title: String
|
||||
let placeholder: String
|
||||
@Binding var items: [String]
|
||||
/// 非 nil 时在节内显示「拍药盒自动识别」入口(目前仅「当前用药」用)。
|
||||
var onScan: (() -> Void)? = nil
|
||||
@State private var newInput = ""
|
||||
|
||||
var body: some View {
|
||||
Section(title) {
|
||||
if let onScan {
|
||||
Button(action: onScan) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "camera.viewfinder")
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("拍药盒自动识别")
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("拍药盒或说明书,本地识别药名与规格")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(items, id: \.self) { item in
|
||||
HStack {
|
||||
Text(item)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case quick, indicator, healthExport, archive, diary, symptom, reminder
|
||||
case quick, indicator, healthExport, archive, diary, symptom, reminder, medicationLibrary
|
||||
var id: String { rawValue }
|
||||
|
||||
/// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。
|
||||
/// 注:`.quick`(指标速记)已并入 `.indicator`(记录指标)内的「拍照识别」;
|
||||
/// `.symptom`(记录症状)与拍药盒一起并入 `.diary`(健康日记)顶部三选一,不再单列。
|
||||
static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive]
|
||||
/// `.symptom`(记录症状)与拍药盒一起并入 `.diary`(健康日记)顶部三选一,不再单列;
|
||||
/// `.medicationLibrary`(药品库)是浏览/管理目的地,主入口在「记录」Tab 顶部板卡,
|
||||
/// 这里垫底保留一个快捷方式(创建动作在前,药品库作为「去管理」入口排最后)。
|
||||
static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive, .medicationLibrary]
|
||||
|
||||
/// 健康日记行的功能提示 pill(代替 subtitle,让"症状/药盒在日记里"一眼可见)。
|
||||
/// 计算属性:每次按当前语言解析,语言切换即时更新(同 ProfileEditView 的 presets 约定)。
|
||||
@@ -24,6 +26,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case .diary: return String(appLoc: "健康日记")
|
||||
case .symptom: return String(appLoc: "记录症状")
|
||||
case .reminder: return String(appLoc: "开启一个提醒")
|
||||
case .medicationLibrary: return String(appLoc: "药品库")
|
||||
}
|
||||
}
|
||||
var subtitle: String {
|
||||
@@ -35,6 +38,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case .diary: return String(appLoc: "写日记或拍药盒记录用药 · 可让 AI 辅助")
|
||||
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
|
||||
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
|
||||
case .medicationLibrary: return String(appLoc: "管理常用药清单 · 拍药盒或手动添加")
|
||||
}
|
||||
}
|
||||
var icon: String {
|
||||
@@ -46,6 +50,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case .diary: return "heart.text.square"
|
||||
case .symptom: return "waveform.path.ecg"
|
||||
case .reminder: return "bell.badge"
|
||||
case .medicationLibrary: return "pills.fill"
|
||||
}
|
||||
}
|
||||
var accent: Color {
|
||||
@@ -57,6 +62,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case .diary: return Tj.Palette.leaf
|
||||
case .symptom: return Tj.Palette.amber
|
||||
case .reminder: return Tj.Palette.leaf
|
||||
case .medicationLibrary: return Tj.Palette.ink
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +89,7 @@ struct RecordSheet: View {
|
||||
}
|
||||
.padding(.bottom, 14)
|
||||
|
||||
// ScrollView 包裹:6 个入口在小屏固定 detent 下可能溢出,滚动确保都能触达。
|
||||
// ScrollView 包裹:入口在小屏固定 detent 下可能溢出,滚动确保都能触达。
|
||||
ScrollView {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(RecordKind.displayOrder) { kind in
|
||||
|
||||
@@ -140,6 +140,7 @@ struct IndicatorSeriesDetailView: View {
|
||||
} else {
|
||||
pages
|
||||
pager
|
||||
recordAnotherRow
|
||||
if bucket != nil { trendButton }
|
||||
}
|
||||
}
|
||||
@@ -311,6 +312,30 @@ struct IndicatorSeriesDetailView: View {
|
||||
.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 {
|
||||
|
||||
@@ -42,10 +42,12 @@ struct TimelineEntry: Identifiable, Hashable {
|
||||
let kind: TimelineKind
|
||||
let date: Date
|
||||
let title: String
|
||||
let subtitle: String
|
||||
var subtitle: String
|
||||
let trailing: String?
|
||||
let trailingIsAlert: Bool
|
||||
let isOngoing: Bool
|
||||
/// 同名指标聚合后的累计次数(>1 时副标题附「共 N 次」)。非聚合条目恒为 1。
|
||||
var aggregateCount: Int = 1
|
||||
|
||||
static func from(indicator i: Indicator) -> TimelineEntry {
|
||||
TimelineEntry(
|
||||
@@ -87,6 +89,34 @@ struct TimelineEntry: Identifiable, Hashable {
|
||||
return entries
|
||||
}
|
||||
|
||||
/// 「记录」列表 / 首页最近记录用:把同名(同类组)指标聚合成一条,代表取最新一次,
|
||||
/// 附带该组累计次数(`aggregateCount`,>1 时副标题缀「共 N 次」)。
|
||||
/// 点代表条目跳 `IndicatorSeriesDetailView` 看历次。分组口径与聚合详情 / 趋势一致
|
||||
/// (`IndicatorGroup`):血压(bp.*)并一组、有 seriesKey 的按 key、无 seriesKey 的按 name+unit 归一。
|
||||
static func aggregatedIndicators(_ indicators: [Indicator]) -> [TimelineEntry] {
|
||||
var order: [String] = []
|
||||
var groups: [String: [Indicator]] = [:]
|
||||
for i in indicators {
|
||||
let key = IndicatorGroup.of(i).id
|
||||
if groups[key] == nil { order.append(key) }
|
||||
groups[key, default: []].append(i)
|
||||
}
|
||||
return order.compactMap { key -> TimelineEntry? in
|
||||
guard let members = groups[key] else { return nil }
|
||||
// 该组逐条条目(血压已合并 sys/dia),取最新一条作代表。
|
||||
guard var rep = from(indicators: members).max(by: { $0.date < $1.date }) else { return nil }
|
||||
// 次数:血压按测量次数(bp.systolic 条数),其余按成员条数。
|
||||
let count = key == IndicatorGroup.bloodPressure.id
|
||||
? members.filter { $0.seriesKey == "bp.systolic" }.count
|
||||
: members.count
|
||||
rep.aggregateCount = count
|
||||
if count > 1 {
|
||||
rep.subtitle += " · " + String(appLoc: "共 \(count) 次")
|
||||
}
|
||||
return rep
|
||||
}
|
||||
}
|
||||
|
||||
private static func mergedBP(systolic sys: Indicator, diastolic dia: Indicator) -> TimelineEntry {
|
||||
let abnormal = sys.status != .normal || dia.status != .normal
|
||||
// 方向箭头按实际 status 给:两值同向才标 ↑/↓;一高一低只标红不给方向
|
||||
|
||||
@@ -54,6 +54,27 @@ struct TimelineEntryDetailView: View {
|
||||
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var evidenceTarget: Indicator?
|
||||
@State private var reminderPrefill: ReminderPrefill?
|
||||
|
||||
/// 「用药记录」点药 → 预填吃药提醒表单用的载体。
|
||||
private struct ReminderPrefill: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
let note: String
|
||||
}
|
||||
|
||||
/// 报告详情「查看原图」起始页载体。
|
||||
@State private var reportPhotoStart: ReportPhotoPage?
|
||||
private struct ReportPhotoPage: Identifiable {
|
||||
let id = UUID()
|
||||
let index: Int
|
||||
}
|
||||
|
||||
/// 当前详情若是报告则取出,供「查看原图」用。
|
||||
private var reportEntry: Report? {
|
||||
if case .report(let r) = detail { return r }
|
||||
return nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -84,6 +105,15 @@ struct TimelineEntryDetailView: View {
|
||||
EvidenceImagePreview(report: report, indicator: indicator)
|
||||
}
|
||||
}
|
||||
.sheet(item: $reminderPrefill) { prefill in
|
||||
// 复用自由提醒表单(每天/每周/每月/每年 + 时间点;一日多次就再建一条)。
|
||||
CustomReminderEditSheet(prefillTitle: prefill.title, prefillNote: prefill.note)
|
||||
}
|
||||
.sheet(item: $reportPhotoStart) { start in
|
||||
if let r = reportEntry {
|
||||
ReportImagesViewer(assets: r.assets, startIndex: start.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 删除(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6)
|
||||
@@ -120,6 +150,10 @@ struct TimelineEntryDetailView: View {
|
||||
for p in paths { try? FileVault.shared.remove(relativePath: p) }
|
||||
ctx.delete(r)
|
||||
case .diary(let d):
|
||||
// 拍药盒日记可能挂原图;cascade 删 Asset 记录,Vault 里的 JPEG 要手动 unlink。
|
||||
for p in Set(d.assets.map(\.relativePath)) {
|
||||
try? FileVault.shared.remove(relativePath: p)
|
||||
}
|
||||
ctx.delete(d)
|
||||
case .symptom(let s):
|
||||
ctx.delete(s)
|
||||
@@ -167,7 +201,7 @@ struct TimelineEntryDetailView: View {
|
||||
case .indicator: return String(appLoc: "指标详情")
|
||||
case .bloodPressure: return String(appLoc: "血压详情")
|
||||
case .report: return String(appLoc: "报告详情")
|
||||
case .diary: return String(appLoc: "日记详情")
|
||||
case .diary(let d): return d.isMedicationLog ? String(appLoc: "用药详情") : String(appLoc: "日记详情")
|
||||
case .symptom: return String(appLoc: "症状详情")
|
||||
}
|
||||
}
|
||||
@@ -186,28 +220,31 @@ struct TimelineEntryDetailView: View {
|
||||
// MARK: - 指标
|
||||
|
||||
private func indicatorBody(_ 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)
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
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 let report = i.report {
|
||||
evidenceButton(for: i, assets: report.assets)
|
||||
}
|
||||
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
}
|
||||
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 let report = i.report {
|
||||
evidenceButton(for: i, assets: report.assets)
|
||||
}
|
||||
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
RecordAnotherButton(name: i.name, prefill: .init(indicator: i))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,21 +254,28 @@ struct TimelineEntryDetailView: 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)
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
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))
|
||||
}
|
||||
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))
|
||||
// 血压走双字段:seriesKey 用 bp.systolic 反查到 MonitorMetric.bloodPressure。
|
||||
RecordAnotherButton(name: String(appLoc: "血压"),
|
||||
prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic",
|
||||
name: String(appLoc: "血压"),
|
||||
unit: "mmHg", range: sys.range))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,16 +292,16 @@ struct TimelineEntryDetailView: View {
|
||||
TjBadge(text: r.type.label, style: .neutral)
|
||||
Text(Self.dateText(r.reportDate))
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
if !r.assets.isEmpty {
|
||||
Text(String(appLoc: "原图\(r.assets.count)张"))
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
if let inst = r.institution, !inst.isEmpty {
|
||||
field(String(appLoc: "机构"), inst)
|
||||
}
|
||||
}
|
||||
|
||||
if !r.assets.isEmpty {
|
||||
reportPhotosCard(r.assets)
|
||||
}
|
||||
|
||||
ReportSummaryCard(report: r)
|
||||
|
||||
if !r.indicators.isEmpty {
|
||||
@@ -286,26 +330,146 @@ struct TimelineEntryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 日记
|
||||
|
||||
private func diaryBody(_ d: DiaryEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
Text(Self.dateTimeText(d.createdAt))
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
Text(d.content)
|
||||
.font(.tjScaled( 15))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if !d.tags.isEmpty {
|
||||
field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " "))
|
||||
/// 报告原图卡:可点缩略图 → 全屏翻页查看。归档只存图时,这是看原图的唯一入口,必须独立于指标存在。
|
||||
private func reportPhotosCard(_ assets: [Asset]) -> some View {
|
||||
card {
|
||||
HStack {
|
||||
Text(String(appLoc: "原图\(assets.count)张"))
|
||||
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
Text(String(appLoc: "点图放大")).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(Array(assets.enumerated()), id: \.offset) { idx, asset in
|
||||
Button {
|
||||
reportPhotoStart = ReportPhotoPage(index: idx)
|
||||
} label: {
|
||||
reportThumb(asset)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reportThumb(_ asset: Asset) -> some View {
|
||||
VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in
|
||||
Image(uiImage: img).resizable().scaledToFill()
|
||||
} placeholder: { isLoading in
|
||||
if isLoading {
|
||||
Tj.Palette.paper
|
||||
} else {
|
||||
TjPlaceholder(label: String(appLoc: "原图无法读取"))
|
||||
}
|
||||
}
|
||||
.frame(width: 96, height: 120)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 日记
|
||||
|
||||
@ViewBuilder
|
||||
private func diaryBody(_ d: DiaryEntry) -> some View {
|
||||
if d.isMedicationLog {
|
||||
medicationBody(d)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
Text(Self.dateTimeText(d.createdAt))
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
Text(d.content)
|
||||
.font(.tjScaled( 15))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if !d.tags.isEmpty {
|
||||
field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 用药使用记录(展示药名/剂量/时间 + 设置提醒)
|
||||
|
||||
/// 用药使用记录(带「用药」tag 的日记):展示「药名 [规格] · 剂量」+ 时间,下方「设置提醒」。
|
||||
/// 只到点提示,不做剂量/频次建议(CLAUDE.md §1、§10)。
|
||||
private func medicationBody(_ d: DiaryEntry) -> some View {
|
||||
let lines = Self.medicationLines(d.content)
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
Text(Self.dateTimeText(d.createdAt))
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
if lines.isEmpty {
|
||||
Text(d.content)
|
||||
.font(.tjScaled( 15)).foregroundStyle(Tj.Palette.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else {
|
||||
ForEach(Array(lines.enumerated()), id: \.offset) { idx, line in
|
||||
if idx > 0 { divider }
|
||||
Text(line)
|
||||
.font(.tjScaled( 15)).foregroundStyle(Tj.Palette.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
medicationActionRow(d)
|
||||
|
||||
Text("「设置提醒」只到点提示,不提供任何用药或剂量建议。")
|
||||
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 单动作:设置提醒(复用自由提醒表单,预填药名 + 用法)。只到点提示,不做剂量建议。
|
||||
private func medicationActionRow(_ d: DiaryEntry) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
medAction(title: String(appLoc: "设置提醒"), icon: "bell.badge") {
|
||||
let lines = Self.medicationLines(d.content)
|
||||
if lines.count <= 1 {
|
||||
let f = Self.medicationReminderFields(forLine: lines.first ?? d.content)
|
||||
reminderPrefill = ReminderPrefill(title: f.title, note: f.note)
|
||||
} else {
|
||||
// 多种药:一个提醒涵盖,药名清单进备注,用户据此自定时间/频率。
|
||||
reminderPrefill = ReminderPrefill(title: String(appLoc: "服药提醒"),
|
||||
note: lines.joined(separator: "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func medAction(title: String, icon: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: icon).font(.tjScaled( 18, weight: .medium))
|
||||
Text(title).font(.tjScaled( 12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.amber.opacity(0.14))
|
||||
)
|
||||
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - 症状
|
||||
|
||||
private func symptomBody(_ s: Symptom) -> some View {
|
||||
@@ -412,6 +576,76 @@ struct TimelineEntryDetailView: View {
|
||||
private nonisolated static func dateText(_ d: Date) -> String {
|
||||
d.formatted(.dateTime.year().month().day())
|
||||
}
|
||||
|
||||
// MARK: - 用药行解析(纯函数,便于单测)
|
||||
|
||||
/// 把用药日记 content 按换行拆成单行药品,去掉空白行与首尾空格。
|
||||
nonisolated static func medicationLines(_ content: String) -> [String] {
|
||||
content.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
/// 从一行药品文本(如「缬沙坦胶囊 80mg · 一日一次」)派生吃药提醒预填:
|
||||
/// 标题 =「吃药:<药名+规格>」,备注 = 用法(" · " 之后部分,供用户据此选时间/频率)。
|
||||
nonisolated static func medicationReminderFields(forLine line: String) -> (title: String, note: String) {
|
||||
let parts = line.components(separatedBy: " · ")
|
||||
let head = (parts.first ?? line).trimmingCharacters(in: .whitespaces)
|
||||
let usage = parts.count > 1
|
||||
? parts.dropFirst().joined(separator: " · ").trimmingCharacters(in: .whitespaces)
|
||||
: ""
|
||||
let name = head.isEmpty ? line.trimmingCharacters(in: .whitespaces) : head
|
||||
return (title: String(appLoc: "吃药:") + name, note: usage)
|
||||
}
|
||||
}
|
||||
|
||||
/// 报告原图浏览(纯翻页看图,无指标高亮)。归档只存图的报告也能随时调取查看。
|
||||
private struct ReportImagesViewer: 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 {
|
||||
VStack(spacing: 0) {
|
||||
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("原图 · 第 \(selection + 1)/\(assets.count) 页")
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
.background(Tj.Palette.sand)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
|
||||
TabView(selection: $selection) {
|
||||
ForEach(Array(assets.enumerated()), id: \.offset) { index, asset in
|
||||
EvidenceImagePage(asset: asset, highlight: nil)
|
||||
.tag(index)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: assets.count > 1 ? .automatic : .never))
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
}
|
||||
}
|
||||
|
||||
/// 原图证据预览(翻页 + 高亮框)。指标详情与同类聚合详情共用,故为模块内可见。
|
||||
@@ -479,19 +713,16 @@ private struct EvidenceImagePage: View {
|
||||
let asset: Asset
|
||||
let highlight: CGRect?
|
||||
|
||||
private var image: UIImage? {
|
||||
try? FileVault.shared.loadImage(relativePath: asset.relativePath)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
if let image {
|
||||
VaultImage(relativePath: asset.relativePath, maxPixel: 2000) { image in
|
||||
ZStack {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
if let highlight {
|
||||
// 降采样保持原始宽高比,imageSize 仅用于算 letterbox 比例,定位不受影响。
|
||||
EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight)
|
||||
}
|
||||
}
|
||||
@@ -502,9 +733,14 @@ private struct EvidenceImagePage: View {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
} else {
|
||||
TjPlaceholder(label: String(appLoc: "原图无法读取"))
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
} placeholder: { isLoading in
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
} else {
|
||||
TjPlaceholder(label: String(appLoc: "原图无法读取"))
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ struct TrendsView: View {
|
||||
|
||||
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,
|
||||
@@ -25,6 +29,14 @@ struct TrendsView: View {
|
||||
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) {
|
||||
@@ -32,12 +44,14 @@ struct TrendsView: View {
|
||||
header.padding(.top, 4)
|
||||
if seriesBuckets.isEmpty {
|
||||
emptyState
|
||||
} else if filteredMonitor.isEmpty && filteredLab.isEmpty {
|
||||
noMatchState
|
||||
} else {
|
||||
if !monitorBuckets.isEmpty {
|
||||
section(title: String(appLoc: "长期监测"), buckets: monitorBuckets)
|
||||
if !filteredMonitor.isEmpty {
|
||||
section(title: String(appLoc: "长期监测"), buckets: filteredMonitor)
|
||||
}
|
||||
if !labBuckets.isEmpty {
|
||||
section(title: String(appLoc: "化验指标趋势"), buckets: labBuckets)
|
||||
if !filteredLab.isEmpty {
|
||||
section(title: String(appLoc: "化验指标趋势"), buckets: filteredLab)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,9 +65,73 @@ struct TrendsView: View {
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
Text("趋势")
|
||||
.font(.tjTitle(26))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user