import Foundation import CoreGraphics import SwiftData enum IndicatorStatus: String, Codable, CaseIterable { case high, low, normal } /// 指标录入来源。manual = 「记录指标」手动录入;quickCapture = 指标速记(VL);report = 报告归档携带。 /// 旧数据无此字段 → 默认 manual(轻量迁移)。 enum IndicatorSource: String, Codable, CaseIterable { case manual, quickCapture, report var label: String { switch self { case .manual: return String(appLoc: "手动记录") case .quickCapture: return String(appLoc: "指标速记") case .report: return String(appLoc: "报告归档") } } } enum ReportType: String, Codable, CaseIterable { case checkup, lab, imaging, prescription, other var label: String { switch self { case .checkup: return String(appLoc: "体检报告") case .lab: return String(appLoc: "化验单") case .imaging: return String(appLoc: "影像报告") case .prescription: return String(appLoc: "处方") case .other: return String(appLoc: "其他") } } } @Model final class Indicator { var name: String var value: String var unit: String var range: String var statusRaw: String var note: String? var capturedAt: Date var report: Report? var asset: Asset? var pinned: Bool = false /// 长期指标系列 key,如 "bp.systolic" / "glucose.fasting" / "weight"。 /// 来源:IndicatorRecordSheet 选预设时填;VL/Report/自由输入留 nil。 /// 用途:Trends 按 seriesKey 分组;Timeline 配对(如 bp.systolic + bp.diastolic 合并)。 var seriesKey: String? /// 录入来源(IndicatorSource.rawValue)。带默认值 → SwiftData 轻量迁移,旧记录视为手动。 var sourceRaw: String = IndicatorSource.manual.rawValue /// VL 从报告原图中定位到的指标证据。页码为 0-based;box 为原图归一化坐标(0...1)。 /// 全部可选以兼容旧数据、手动录入和无定位的模型输出。 var sourcePageIndex: Int? var sourceBoxX: Double? var sourceBoxY: Double? var sourceBoxWidth: Double? var sourceBoxHeight: Double? init(name: String, value: String, unit: String, range: String, status: IndicatorStatus, note: String? = nil, capturedAt: Date = .now, report: Report? = nil, asset: Asset? = nil, pinned: Bool = false, seriesKey: String? = nil, source: IndicatorSource = .manual, sourcePageIndex: Int? = nil, sourceBoxX: Double? = nil, sourceBoxY: Double? = nil, sourceBoxWidth: Double? = nil, sourceBoxHeight: Double? = nil) { self.name = name self.value = value self.unit = unit self.range = range self.statusRaw = status.rawValue self.note = note self.capturedAt = capturedAt self.report = report self.asset = asset self.pinned = pinned self.seriesKey = seriesKey self.sourceRaw = source.rawValue self.sourcePageIndex = sourcePageIndex self.sourceBoxX = sourceBoxX self.sourceBoxY = sourceBoxY self.sourceBoxWidth = sourceBoxWidth self.sourceBoxHeight = sourceBoxHeight } var status: IndicatorStatus { IndicatorStatus(rawValue: statusRaw) ?? .normal } var source: IndicatorSource { IndicatorSource(rawValue: sourceRaw) ?? .manual } var hasEvidenceBox: Bool { evidenceRect != nil && sourcePageIndex != nil } var evidenceRect: CGRect? { guard let x = sourceBoxX, let y = sourceBoxY, let width = sourceBoxWidth, let height = sourceBoxHeight, x >= 0, y >= 0, width > 0, height > 0, x + width <= 1, y + height <= 1 else { return nil } return CGRect(x: x, y: y, width: width, height: height) } } @Model final class Report { var title: String var typeRaw: String var reportDate: Date var institution: String? var note: String? var summary: String? var pageCount: Int var createdAt: Date @Relationship(deleteRule: .cascade, inverse: \Indicator.report) var indicators: [Indicator] = [] @Relationship(deleteRule: .cascade) var assets: [Asset] = [] init(title: String, type: ReportType, reportDate: Date, institution: String? = nil, note: String? = nil, summary: String? = nil, pageCount: Int = 1, createdAt: Date = .now) { self.title = title self.typeRaw = type.rawValue self.reportDate = reportDate self.institution = institution self.note = note self.summary = summary self.pageCount = pageCount self.createdAt = createdAt } var type: ReportType { ReportType(rawValue: typeRaw) ?? .other } } @Model final class DiaryEntry { var content: String var createdAt: Date var tags: [String] /// 拍药盒入档时关联的原图(最多 5 张:正面/背面/说明书…)。 /// 默认空数组 → 旧数据轻量迁移安全(见 swiftdata-rebuild-data-loss)。 /// cascade:删日记同删 Asset 记录;Vault 里的 JPEG 仍需在删除入口手动 unlink。 @Relationship(deleteRule: .cascade) var assets: [Asset] = [] init(content: String, createdAt: Date = .now, tags: [String] = []) { self.content = content self.createdAt = createdAt self.tags = tags } } extension DiaryEntry { /// 「拍药盒入档」落库时打的 tag。是数据标识不是 UI 文案,**不要**走 appLoc 本地化 /// (语言切换后旧数据要还能被识别)。时间线据此把该日记归到「用药」分类。 static let medicationTag = "用药" var isMedicationLog: Bool { tags.contains(Self.medicationTag) } } @Model final class Asset { var relativePath: String var mimeType: String var bytes: Int var createdAt: Date init(relativePath: String, mimeType: String = "image/jpeg", bytes: Int = 0, createdAt: Date = .now) { self.relativePath = relativePath self.mimeType = mimeType self.bytes = bytes self.createdAt = createdAt } } /// 药品库:用户「我有哪些药」的 master 档案(拍药盒识别或手输入库)。 /// 与「用药使用记录」(带 `DiaryEntry.medicationTag` 的日记,记某次服用的剂量 + 时间)分层: /// 这里只放清单 / 规格 / 用法 / 原图,不带服用时间。 /// 新增 @Model 表 → SwiftData 轻量迁移安全(见 KangkangApp 兜底注释)。 @Model final class Medication { var name: String // 药名(通用名,可含商品名),与 ParsedMedication.name 同义 var strength: String // 规格,如 "80mg×7粒";无则 "" var usage: String // 用法,如 "一日一次,一次一粒";无则 "" var note: String? // 备注(可选) var createdAt: Date var updatedAt: Date /// 入库时关联的原图(药盒正面 / 背面 / 说明书,最多 5 张)。默认空数组 → 旧数据轻量迁移安全。 /// cascade:删药品同删 Asset 记录;Vault 里的 JPEG 仍需在删除入口手动 unlink(同 DiaryEntry.assets 约定)。 @Relationship(deleteRule: .cascade) var assets: [Asset] = [] init(name: String, strength: String = "", usage: String = "", note: String? = nil, createdAt: Date = .now) { self.name = name self.strength = strength self.usage = usage self.note = note self.createdAt = createdAt self.updatedAt = createdAt } /// 列表副标题 / 写日记选药时的展示行:"80mg×7粒 · 一日一次"(缺项自动省略)。 var detailLine: String { [strength, usage] .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } .joined(separator: " · ") } } @Model final class Symptom { var name: String var startedAt: Date var endedAt: Date? var note: String? var severity: Int var tags: [String] var createdAt: Date init(name: String, startedAt: Date = .now, endedAt: Date? = nil, note: String? = nil, severity: Int = 3, tags: [String] = [], createdAt: Date = .now) { self.name = name self.startedAt = startedAt self.endedAt = endedAt self.note = note self.severity = max(1, min(5, severity)) self.tags = tags self.createdAt = createdAt } var isOngoing: Bool { endedAt == nil } var duration: TimeInterval { (endedAt ?? .now).timeIntervalSince(startedAt) } } /// 用户自定义的长期监测指标。 /// 与 hardcoded `MonitorMetric` 并列出现在 IndicatorQuickSheet 的 grid 里; /// `seriesKey` 自动生成成 `"custom."`,以此和 Indicator 双向关联。 @Model final class CustomMonitorMetric { @Attribute(.unique) var seriesKey: String var name: String var unit: String var lowerBound: Double? var upperBound: Double? var icon: String var createdAt: Date init(name: String, unit: String, lowerBound: Double? = nil, upperBound: Double? = nil, icon: String = "circle.fill", createdAt: Date = .now) { self.seriesKey = "custom.\(UUID().uuidString)" self.name = name self.unit = unit self.lowerBound = lowerBound self.upperBound = upperBound self.icon = icon self.createdAt = createdAt } var referenceRange: ClosedRange? { guard let lo = lowerBound, let hi = upperBound, lo <= hi else { return nil } return lo...hi } var rangeText: String { guard let r = referenceRange else { return "" } return "\(Self.format(r.lowerBound)) - \(Self.format(r.upperBound))" } private static func format(_ v: Double) -> String { v.truncatingRemainder(dividingBy: 1) == 0 ? String(format: "%.0f", v) : String(format: "%.1f", v) } } /// 长期监测指标的周期性记录提醒。 /// 一个 metric 一条(`metricId` = `MonitorMetric.rawValue`)。 /// 关闭通过 `enabled=false`(保留时间设置),删除走 `ctx.delete`。 @Model final class MetricReminder { @Attribute(.unique) var metricId: String var displayName: String var enabled: Bool var hour: Int // 0...23 var minute: Int // 0...59 var weekdays: [Int] // iOS Calendar 约定:1=日, 2=一, ..., 7=六。全 7 个 = 每天 var createdAt: Date var updatedAt: Date init(metricId: String, displayName: String, hour: Int = 8, minute: Int = 0, weekdays: [Int] = [1, 2, 3, 4, 5, 6, 7], enabled: Bool = true, createdAt: Date = .now) { self.metricId = metricId self.displayName = displayName self.enabled = enabled self.hour = max(0, min(23, hour)) self.minute = max(0, min(59, minute)) self.weekdays = weekdays self.createdAt = createdAt self.updatedAt = createdAt } var isEveryDay: Bool { Set(weekdays) == Set(1...7) } var frequencyLabel: String { if !enabled { return String(appLoc: "已关闭") } if isEveryDay { return String(appLoc: "每天") } if weekdays.isEmpty { return String(appLoc: "未选日") } let names = [String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六")] let sorted = weekdays.sorted() return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined() } var timeLabel: String { String(format: "%02d:%02d", hour, minute) } /// 这条指标提醒在给定日期「这天」是否会触发(weekday 制,全 7 = 每天);关闭则恒为 false。 /// 供主页「今日提醒」筛选。 func occurs(on date: Date, calendar: Calendar = .current) -> Bool { guard enabled else { return false } return weekdays.contains(calendar.component(.weekday, from: date)) } } /// 自由文案的周期性提醒(如「每天 20:00 跑步 5 公里」「每天 12:30 吃 2 片护肝片」)。 /// 与 `MetricReminder`(去记录某指标)语义独立:这里是用户自定义的动作提醒, /// 量词(5 公里 / 2 片)直接写在 `title` 自由文本里。 /// 周期粒度沿用 weekday 约定(全 7 = 每天);本地通知调度见 `ReminderService`。 @Model final class CustomReminder { /// 周期粒度。每日只看时间;每周看 weekdays;每月看 dayOfMonth;每年看 month + dayOfMonth。 enum Frequency: String, CaseIterable, Sendable { case daily, weekly, monthly, yearly } @Attribute(.unique) var id: UUID var title: String // 用户文案,如 "跑步5公里" var note: String // 可选备注 → 通知正文 var hour: Int // 0...23 var minute: Int // 0...59 var weekdays: [Int] // iOS Calendar 约定:1=日, 2=一, ..., 7=六。全 7 个 = 每天 var frequencyRaw: String = "daily" // 旧:单选频率代表值;多选见 frequenciesRaw var dayOfMonth: Int = 1 // yearly 用 + 旧 monthly 单选兜底,1...31 var month: Int = 1 // yearly 用,1...12 /// 多选频率原始值(["daily","weekly",...])。空 = 旧数据,回退到单选 frequency。 var frequenciesRaw: [String] = [] /// 每月多选日期(1...31)。空 = 旧数据,回退到单选 dayOfMonth。 var monthDays: [Int] = [] var enabled: Bool var createdAt: Date var updatedAt: Date init(id: UUID = UUID(), title: String, note: String = "", hour: Int = 8, minute: Int = 0, weekdays: [Int] = [1, 2, 3, 4, 5, 6, 7], frequency: Frequency = .daily, dayOfMonth: Int = 1, month: Int = 1, enabled: Bool = true, createdAt: Date = .now) { self.id = id self.title = title self.note = note self.hour = max(0, min(23, hour)) self.minute = max(0, min(59, minute)) self.weekdays = weekdays self.frequencyRaw = frequency.rawValue self.dayOfMonth = max(1, min(31, dayOfMonth)) self.month = max(1, min(12, month)) self.enabled = enabled self.createdAt = createdAt self.updatedAt = createdAt } var isEveryDay: Bool { Set(weekdays) == Set(1...7) } var frequency: Frequency { get { Frequency(rawValue: frequencyRaw) ?? .daily } set { frequencyRaw = newValue.rawValue } } /// 生效的频率集合(多选)。frequenciesRaw 为空时回退到单选 frequency(兼容旧数据 / 旧 init)。 var frequencies: Set { get { let parsed = Set(frequenciesRaw.compactMap { Frequency(rawValue: $0) }) return parsed.isEmpty ? [frequency] : parsed } set { frequenciesRaw = newValue.map(\.rawValue).sorted() // 同步单选代表值,旧读者读 frequency 仍合理。 if let rep = newValue.map(\.rawValue).sorted().first { frequencyRaw = rep } } } /// 每月生效日期(多选,1...31)。monthDays 为空时回退到单选 dayOfMonth(兼容旧数据)。 /// 注意:不回写 dayOfMonth —— 后者仍归 yearly 独占,避免「同时选每月+每年」时互相覆盖。 var monthlyDays: [Int] { get { monthDays.isEmpty ? [dayOfMonth] : monthDays.sorted() } set { monthDays = Set(newValue.map { max(1, min(31, $0)) }).sorted() } } /// 列表行副标题:多选频率用「 · 」拼接,如「每周一三五 · 每月1·15日」。 /// 含「每日」时直接显示「每天」(已覆盖其余)。 var frequencyLabel: String { if !enabled { return String(appLoc: "已关闭") } let active = frequencies if active.contains(.daily) { return String(appLoc: "每天") } // weekly 选满 7 天等价每天。 if active == [.weekly] && isEveryDay { return String(appLoc: "每天") } let order: [Frequency] = [.weekly, .monthly, .yearly] let parts = order.filter { active.contains($0) }.map { freqPartLabel($0) } return parts.isEmpty ? String(appLoc: "未选日") : parts.joined(separator: " · ") } private func freqPartLabel(_ f: Frequency) -> String { switch f { case .daily: return String(appLoc: "每天") case .weekly: if isEveryDay { return String(appLoc: "每天") } if weekdays.isEmpty { return String(appLoc: "未选日") } let names = [String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六")] return String(appLoc: "每周 ") + weekdays.sorted().map { names[$0 - 1] }.joined() case .monthly: let days = monthlyDays if days.isEmpty { return String(appLoc: "未选日") } return String(appLoc: "每月") + days.map { String($0) }.joined(separator: "·") + String(appLoc: "日") case .yearly: return String(appLoc: "每年\(month)月\(dayOfMonth)日") } } var timeLabel: String { String(format: "%02d:%02d", hour, minute) } /// 这条提醒在给定日期「这天」是否会触发(只看哪天,不看时分);关闭则恒为 false。 /// 供主页「今日提醒」筛选。monthly/yearly 选了无此日的月份(如 31 日)自然返回 false, /// 与 iOS「该月跳过、不顺延」的行为一致。 func occurs(on date: Date, calendar: Calendar = .current) -> Bool { guard enabled else { return false } let c = calendar.dateComponents([.weekday, .day, .month], from: date) let wd = c.weekday ?? -1, day = c.day ?? -1, mo = c.month ?? -1 // 多选频率:任一命中即触发。 for f in frequencies { switch f { case .daily: return true case .weekly: if weekdays.contains(wd) { return true } case .monthly: if monthlyDays.contains(day) { return true } case .yearly: if month == mo && dayOfMonth == day { return true } } } return false } } @Model final class ChatTurn { var question: String var answer: String var referencedIndicatorIDs: [String] var referencedReportIDs: [String] var createdAt: Date var decodeRate: Double init(question: String, answer: String, referencedIndicatorIDs: [String] = [], referencedReportIDs: [String] = [], createdAt: Date = .now, decodeRate: Double = 0) { self.question = question self.answer = answer self.referencedIndicatorIDs = referencedIndicatorIDs self.referencedReportIDs = referencedReportIDs self.createdAt = createdAt self.decodeRate = decodeRate } }