feat(capture): 统一报告捕获流程并集成视觉语言模型识别
- 替换 QuickCaptureFlow 和 ArchiveFlow 为 UnifiedCaptureFlow 统一流程 - 新增 VLSession 封装 Qwen2.5-VL 模型进行图像文本推理 - 实现 AIRuntime 中 VL 模型的准备和分析功能 - 添加 VLPrompts 定义体检化验单识别的 JSON 输出模板 - 创建 CaptureReviewForm 提供 VL 解析结果的可编辑表单界面 - 集成 VisionKit 文档扫描器支持真机多页文档扫描 - 为模拟器实现 PhotosPicker 回退方案选择已有照片 - 在 RootView 中统一使用 UnifiedCaptureFlow 处理快速和归档流程 - 添加 CustomMetricEditor 支持自定义监测指标的创建编辑删除 - 扩展 KangkangApp 模型配置以支持新数据类型 - 实现档案列表中症状结束功能通过时间线行点击触发
This commit is contained in:
@@ -48,9 +48,41 @@ struct IndicatorQuickSheet: View {
|
||||
@State private var systolic: String = ""
|
||||
@State private var diastolic: String = ""
|
||||
|
||||
// 周期性提醒(仅长期监测可用)
|
||||
@Query private var allReminders: [MetricReminder]
|
||||
@State private var reminderEnabled: Bool = false
|
||||
@State private var reminderTime: Date = Self.defaultReminderTime
|
||||
@State private var reminderWeekdays: Set<Int> = Set(1...7)
|
||||
@State private var reminderHydratedFor: String? = nil
|
||||
@State private var notifAuthBlocked: Bool = false
|
||||
|
||||
// 自定义指标
|
||||
@Query(sort: \CustomMonitorMetric.createdAt, order: .reverse)
|
||||
private var customMetrics: [CustomMonitorMetric]
|
||||
@State private var selectedCustom: CustomMonitorMetric?
|
||||
@State private var editingCustom: CustomMetricEditTarget?
|
||||
|
||||
private static var defaultReminderTime: Date {
|
||||
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
|
||||
}
|
||||
|
||||
private var profile: UserProfile? { profiles.first }
|
||||
|
||||
private var isBP: Bool { selectedMonitor == .bloodPressure }
|
||||
private var isLongTermMetric: Bool { selectedMonitor != nil || selectedCustom != nil }
|
||||
private var isCustomMonitor: Bool { selectedCustom != nil }
|
||||
|
||||
/// 当前长期监测的稳定 key,用于 reminder 关联和 .task(id:) hydrate 触发。
|
||||
/// 血压用 metric.rawValue;custom 用 seriesKey;其他单字段 monitor 用 rawValue;非长期 nil。
|
||||
private var longTermKey: String? {
|
||||
if let m = selectedMonitor { return m.rawValue }
|
||||
if let cm = selectedCustom { return cm.seriesKey }
|
||||
return nil
|
||||
}
|
||||
|
||||
private var longTermDisplayName: String? {
|
||||
selectedMonitor?.displayName ?? selectedCustom?.name
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
if isBP {
|
||||
@@ -78,16 +110,18 @@ struct IndicatorQuickSheet: View {
|
||||
nameSection
|
||||
valueRow
|
||||
rangeSection
|
||||
if selectedMonitor == nil {
|
||||
// 自由输入或 lab preset 时 status 手动;monitor 单字段自动
|
||||
statusSection
|
||||
} else {
|
||||
if isLongTermMetric {
|
||||
autoStatusHint
|
||||
} else {
|
||||
statusSection
|
||||
}
|
||||
}
|
||||
|
||||
timeSection
|
||||
noteSection
|
||||
if isLongTermMetric {
|
||||
reminderSection
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
@@ -95,6 +129,7 @@ struct IndicatorQuickSheet: View {
|
||||
|
||||
footer
|
||||
}
|
||||
.task(id: longTermKey) { hydrateReminder() }
|
||||
.background(
|
||||
Tj.Palette.sand
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||
@@ -138,8 +173,105 @@ struct IndicatorQuickSheet: View {
|
||||
ForEach(MonitorMetric.allCases) { m in
|
||||
monitorTile(m)
|
||||
}
|
||||
ForEach(customMetrics) { cm in
|
||||
customTile(cm)
|
||||
}
|
||||
addCustomTile
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingCustom) { target in
|
||||
CustomMetricEditor(existing: target.metric) { saved in
|
||||
// 新建后自动选中,删除后清空选择
|
||||
if let saved {
|
||||
selectedCustom = saved
|
||||
selectedMonitor = nil
|
||||
selectedLabPreset = nil
|
||||
fillFromCustom(saved)
|
||||
} else if selectedCustom?.seriesKey == target.metric?.seriesKey {
|
||||
selectedCustom = nil
|
||||
clearAllFields()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func customTile(_ cm: CustomMonitorMetric) -> some View {
|
||||
let selected = selectedCustom?.seriesKey == cm.seriesKey
|
||||
return Button {
|
||||
applyCustom(cm)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: cm.icon)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft))
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(cm.name)
|
||||
.font(.system(size: 14, weight: selected ? .semibold : .medium))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Text("自定义")
|
||||
.font(.system(size: 9, design: .monospaced))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(selected ? Tj.Palette.ink : Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button { editingCustom = CustomMetricEditTarget(metric: cm) } label: {
|
||||
Label("编辑", systemImage: "pencil")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
editingCustom = CustomMetricEditTarget(metric: cm)
|
||||
} label: {
|
||||
Label("编辑/删除", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var addCustomTile: some View {
|
||||
Button {
|
||||
editingCustom = CustomMetricEditTarget(metric: nil)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(
|
||||
Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true)
|
||||
)
|
||||
Text("自定义")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.sand2.opacity(0.5))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line.opacity(0.6),
|
||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func monitorTile(_ m: MonitorMetric) -> some View {
|
||||
@@ -265,8 +397,8 @@ struct IndicatorQuickSheet: View {
|
||||
selectedLabPreset = nil
|
||||
}
|
||||
}
|
||||
.disabled(selectedMonitor != nil)
|
||||
.opacity(selectedMonitor != nil ? 0.6 : 1)
|
||||
.disabled(isLongTermMetric)
|
||||
.opacity(isLongTermMetric ? 0.6 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,8 +423,8 @@ struct IndicatorQuickSheet: View {
|
||||
.padding(.vertical, 12)
|
||||
.background(fieldBg)
|
||||
.overlay(fieldBorder)
|
||||
.disabled(selectedMonitor != nil)
|
||||
.opacity(selectedMonitor != nil ? 0.6 : 1)
|
||||
.disabled(isLongTermMetric)
|
||||
.opacity(isLongTermMetric ? 0.6 : 1)
|
||||
}
|
||||
.frame(maxWidth: 130)
|
||||
}
|
||||
@@ -314,8 +446,8 @@ struct IndicatorQuickSheet: View {
|
||||
.padding(.vertical, 12)
|
||||
.background(fieldBg)
|
||||
.overlay(fieldBorder)
|
||||
.disabled(selectedMonitor != nil)
|
||||
.opacity(selectedMonitor != nil ? 0.6 : 1)
|
||||
.disabled(isLongTermMetric)
|
||||
.opacity(isLongTermMetric ? 0.6 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +508,193 @@ struct IndicatorQuickSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 周期提醒
|
||||
|
||||
private var reminderSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
sectionLabel("周期提醒")
|
||||
Spacer()
|
||||
Toggle("", isOn: $reminderEnabled)
|
||||
.labelsHidden()
|
||||
.tint(Tj.Palette.ink)
|
||||
.onChange(of: reminderEnabled) { _, on in
|
||||
if on { Task { await requestNotifAuthIfNeeded() } }
|
||||
}
|
||||
}
|
||||
|
||||
if reminderEnabled {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("时间")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
DatePicker("", selection: $reminderTime,
|
||||
displayedComponents: .hourAndMinute)
|
||||
.datePickerStyle(.compact)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("频率")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
Text(reminderFrequencyLabel)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
weekdayPickerRow
|
||||
HStack(spacing: 8) {
|
||||
quickFreqChip("每天") {
|
||||
reminderWeekdays = Set(1...7)
|
||||
}
|
||||
quickFreqChip("工作日") {
|
||||
reminderWeekdays = Set([2, 3, 4, 5, 6])
|
||||
}
|
||||
quickFreqChip("周末") {
|
||||
reminderWeekdays = Set([1, 7])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if notifAuthBlocked {
|
||||
Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
} else {
|
||||
Text("本机提醒 · 不发任何数据")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(fieldBg)
|
||||
.overlay(fieldBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reminderFrequencyLabel: String {
|
||||
if reminderWeekdays.count == 7 { return "每天" }
|
||||
if reminderWeekdays.isEmpty { return "未选" }
|
||||
let names = ["日", "一", "二", "三", "四", "五", "六"]
|
||||
let sorted = reminderWeekdays.sorted()
|
||||
return "每周 " + sorted.map { names[$0 - 1] }.joined()
|
||||
}
|
||||
|
||||
private var weekdayPickerRow: some View {
|
||||
let names = ["一", "二", "三", "四", "五", "六", "日"]
|
||||
let weekdayValues = [2, 3, 4, 5, 6, 7, 1] // 周一到周日(Apple Calendar 编号)
|
||||
return HStack(spacing: 6) {
|
||||
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
|
||||
Button {
|
||||
if reminderWeekdays.contains(w) {
|
||||
reminderWeekdays.remove(w)
|
||||
} else {
|
||||
reminderWeekdays.insert(w)
|
||||
}
|
||||
} label: {
|
||||
Text(names[idx])
|
||||
.font(.system(size: 13,
|
||||
weight: reminderWeekdays.contains(w) ? .semibold : .regular))
|
||||
.foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity, minHeight: 32)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(reminderWeekdays.contains(w) ? Tj.Palette.ink : Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line,
|
||||
lineWidth: reminderWeekdays.contains(w) ? 0 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(Tj.Palette.sand2))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func hydrateReminder() {
|
||||
guard let key = longTermKey else { return }
|
||||
if reminderHydratedFor == key { return }
|
||||
reminderHydratedFor = key
|
||||
if let existing = allReminders.first(where: { $0.metricId == key }) {
|
||||
reminderEnabled = existing.enabled
|
||||
reminderTime = Calendar.current.date(
|
||||
bySettingHour: existing.hour, minute: existing.minute, second: 0, of: .now
|
||||
) ?? Self.defaultReminderTime
|
||||
reminderWeekdays = Set(existing.weekdays)
|
||||
} else {
|
||||
reminderEnabled = false
|
||||
reminderTime = Self.defaultReminderTime
|
||||
reminderWeekdays = Set(1...7)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestNotifAuthIfNeeded() async {
|
||||
let state = await ReminderService.requestAuthorization()
|
||||
notifAuthBlocked = (state == .denied)
|
||||
if notifAuthBlocked {
|
||||
reminderEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
/// submit() 调用,处理提醒:enabled → upsert SwiftData + 调度通知;disabled → 删旧 reminder + 取消通知。
|
||||
private func persistReminderIfNeeded() async {
|
||||
guard let key = longTermKey, let displayName = longTermDisplayName else { return }
|
||||
let existing = allReminders.first(where: { $0.metricId == key })
|
||||
let cal = Calendar.current
|
||||
let hour = cal.component(.hour, from: reminderTime)
|
||||
let minute = cal.component(.minute, from: reminderTime)
|
||||
|
||||
if reminderEnabled && !reminderWeekdays.isEmpty {
|
||||
let reminder: MetricReminder
|
||||
if let existing {
|
||||
existing.enabled = true
|
||||
existing.hour = hour
|
||||
existing.minute = minute
|
||||
existing.weekdays = reminderWeekdays.sorted()
|
||||
existing.displayName = displayName
|
||||
existing.updatedAt = .now
|
||||
reminder = existing
|
||||
} else {
|
||||
let new = MetricReminder(
|
||||
metricId: key,
|
||||
displayName: displayName,
|
||||
hour: hour,
|
||||
minute: minute,
|
||||
weekdays: reminderWeekdays.sorted(),
|
||||
enabled: true
|
||||
)
|
||||
ctx.insert(new)
|
||||
reminder = new
|
||||
}
|
||||
try? ctx.save()
|
||||
await ReminderService.sync(reminder)
|
||||
} else if let existing {
|
||||
// 关闭:保留 SwiftData 行,只改 enabled = false,取消通知
|
||||
existing.enabled = false
|
||||
existing.updatedAt = .now
|
||||
try? ctx.save()
|
||||
ReminderService.cancel(metricId: key)
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button("取消") { dismiss() }
|
||||
@@ -476,6 +795,7 @@ struct IndicatorQuickSheet: View {
|
||||
}
|
||||
selectedMonitor = m
|
||||
selectedLabPreset = nil
|
||||
selectedCustom = nil
|
||||
|
||||
if m == .bloodPressure {
|
||||
// 血压走 bp 字段,不动 name/value/unit
|
||||
@@ -500,21 +820,53 @@ struct IndicatorQuickSheet: View {
|
||||
private func applyLab(_ p: IndicatorPreset) {
|
||||
selectedLabPreset = p
|
||||
selectedMonitor = nil
|
||||
selectedCustom = nil
|
||||
systolic = ""; diastolic = ""
|
||||
name = p.name
|
||||
if unit.trimmingCharacters(in: .whitespaces).isEmpty { unit = p.unit }
|
||||
if range.trimmingCharacters(in: .whitespaces).isEmpty { range = p.range }
|
||||
}
|
||||
|
||||
private func applyCustom(_ cm: CustomMonitorMetric) {
|
||||
if selectedCustom?.seriesKey == cm.seriesKey {
|
||||
selectedCustom = nil
|
||||
clearAllFields()
|
||||
return
|
||||
}
|
||||
selectedCustom = cm
|
||||
selectedMonitor = nil
|
||||
selectedLabPreset = nil
|
||||
fillFromCustom(cm)
|
||||
}
|
||||
|
||||
private func fillFromCustom(_ cm: CustomMonitorMetric) {
|
||||
name = cm.name
|
||||
value = ""
|
||||
unit = cm.unit
|
||||
range = cm.rangeText
|
||||
systolic = ""; diastolic = ""
|
||||
}
|
||||
|
||||
private func clearAllFields() {
|
||||
name = ""; value = ""; unit = ""; range = ""
|
||||
systolic = ""; diastolic = ""
|
||||
}
|
||||
|
||||
// MARK: - auto status
|
||||
|
||||
private var computedSingleStatus: (label: String, color: Color)? {
|
||||
guard let m = selectedMonitor, m != .bloodPressure,
|
||||
let v = Double(value.trimmingCharacters(in: .whitespaces)) else { return nil }
|
||||
let f = m.fields[0]
|
||||
let r = m.effectiveRange(for: f, profile: profile)
|
||||
let s = MonitorMetric.status(value: v, in: r)
|
||||
return (s.label, s.color)
|
||||
guard let v = Double(value.trimmingCharacters(in: .whitespaces)) else { return nil }
|
||||
if let m = selectedMonitor, m != .bloodPressure {
|
||||
let f = m.fields[0]
|
||||
let r = m.effectiveRange(for: f, profile: profile)
|
||||
let s = MonitorMetric.status(value: v, in: r)
|
||||
return (s.label, s.color)
|
||||
}
|
||||
if let cm = selectedCustom {
|
||||
let s = MonitorMetric.status(value: v, in: cm.referenceRange)
|
||||
return (s.label, s.color)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private enum BPSide { case systolic, diastolic }
|
||||
@@ -538,10 +890,16 @@ struct IndicatorQuickSheet: View {
|
||||
saveBP()
|
||||
} else if let m = selectedMonitor {
|
||||
saveSingleMonitor(m)
|
||||
} else if let cm = selectedCustom {
|
||||
saveCustom(cm)
|
||||
} else {
|
||||
saveFreeform()
|
||||
}
|
||||
dismiss()
|
||||
|
||||
Task {
|
||||
await persistReminderIfNeeded()
|
||||
await MainActor.run { dismiss() }
|
||||
}
|
||||
}
|
||||
|
||||
private func saveBP() {
|
||||
@@ -603,6 +961,24 @@ struct IndicatorQuickSheet: View {
|
||||
try? ctx.save()
|
||||
}
|
||||
|
||||
private func saveCustom(_ cm: CustomMonitorMetric) {
|
||||
let v = Double(value.trimmingCharacters(in: .whitespaces)) ?? 0
|
||||
let status = MonitorMetric.status(value: v, in: cm.referenceRange)
|
||||
let indicator = Indicator(
|
||||
name: cm.name,
|
||||
value: value.trimmingCharacters(in: .whitespaces),
|
||||
unit: cm.unit,
|
||||
range: cm.rangeText,
|
||||
status: status,
|
||||
note: note.isEmpty ? nil : note,
|
||||
capturedAt: capturedAt,
|
||||
pinned: true,
|
||||
seriesKey: cm.seriesKey
|
||||
)
|
||||
ctx.insert(indicator)
|
||||
try? ctx.save()
|
||||
}
|
||||
|
||||
private func saveFreeform() {
|
||||
let indicator = Indicator(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
@@ -638,7 +1014,16 @@ private extension IndicatorStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// `.sheet(item:)` 要求 Identifiable;包一层避免 CustomMonitorMetric? 不能直接当 binding 用。
|
||||
struct CustomMetricEditTarget: Identifiable {
|
||||
let metric: CustomMonitorMetric?
|
||||
var id: String { metric?.seriesKey ?? "_new_" }
|
||||
}
|
||||
|
||||
#Preview {
|
||||
IndicatorQuickSheet()
|
||||
.modelContainer(for: [Indicator.self, UserProfile.self], inMemory: true)
|
||||
.modelContainer(for: [
|
||||
Indicator.self, UserProfile.self,
|
||||
MetricReminder.self, CustomMonitorMetric.self
|
||||
], inMemory: true)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user