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:
@@ -9,6 +9,7 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0003 /* MLXLLM */; };
|
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0003 /* MLXLLM */; };
|
||||||
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0004 /* MLXLMCommon */; };
|
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0004 /* MLXLMCommon */; };
|
||||||
|
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0006 /* MLXVLM */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
files = (
|
files = (
|
||||||
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */,
|
FEED000000000000DEAD0001 /* MLXLLM in Frameworks */,
|
||||||
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */,
|
FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */,
|
||||||
|
FEED000000000000DEAD0005 /* MLXVLM in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -121,6 +123,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
FEED000000000000DEAD0003 /* MLXLLM */,
|
FEED000000000000DEAD0003 /* MLXLLM */,
|
||||||
FEED000000000000DEAD0004 /* MLXLMCommon */,
|
FEED000000000000DEAD0004 /* MLXLMCommon */,
|
||||||
|
FEED000000000000DEAD0006 /* MLXVLM */,
|
||||||
);
|
);
|
||||||
productName = "康康";
|
productName = "康康";
|
||||||
productReference = 5E463CF92FC403BB0089145B /* 康康.app */;
|
productReference = 5E463CF92FC403BB0089145B /* 康康.app */;
|
||||||
@@ -656,6 +659,11 @@
|
|||||||
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
|
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
|
||||||
productName = MLXLMCommon;
|
productName = MLXLMCommon;
|
||||||
};
|
};
|
||||||
|
FEED000000000000DEAD0006 /* MLXVLM */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */;
|
||||||
|
productName = MLXVLM;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 5E463CF12FC403BB0089145B /* Project object */;
|
rootObject = 5E463CF12FC403BB0089145B /* Project object */;
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ actor AIRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private(set) var status: Status = .notReady
|
private(set) var status: Status = .notReady
|
||||||
|
private(set) var vlStatus: Status = .notReady
|
||||||
private(set) var lastDecodeRate: Double = 0
|
private(set) var lastDecodeRate: Double = 0
|
||||||
|
|
||||||
private var llmSession: LLMSession?
|
private var llmSession: LLMSession?
|
||||||
|
private var vlSession: VLSession?
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
@@ -96,4 +98,53 @@ actor AIRuntime {
|
|||||||
private func recordRate(_ rate: Double) {
|
private func recordRate(_ rate: Double) {
|
||||||
if rate > 0 { lastDecodeRate = rate }
|
if rate > 0 { lastDecodeRate = rate }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - VL
|
||||||
|
|
||||||
|
/// 加载 VL 模型。幂等,首调真正 load。
|
||||||
|
func prepareVL() async throws {
|
||||||
|
switch vlStatus {
|
||||||
|
case .ready, .loading:
|
||||||
|
return
|
||||||
|
case .error, .notReady:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
guard ModelStore.shared.isReady(.vl) else {
|
||||||
|
vlStatus = .error("VL 模型未就绪")
|
||||||
|
throw AIRuntimeError.notReady
|
||||||
|
}
|
||||||
|
|
||||||
|
vlStatus = .loading
|
||||||
|
do {
|
||||||
|
let session = try await VLSession.load(
|
||||||
|
folderURL: ModelStore.shared.localURL(for: .vl)
|
||||||
|
)
|
||||||
|
self.vlSession = session
|
||||||
|
vlStatus = .ready
|
||||||
|
} catch {
|
||||||
|
vlStatus = .error("\(error)")
|
||||||
|
throw AIRuntimeError.modelLoadFailed("\(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图像 → JSON 字符串(由 VLPrompts.reportExtraction 引导)。
|
||||||
|
/// 调用方负责解析 + 失败回退(§3.2)。
|
||||||
|
/// AIRuntime 是 actor,本调用与 LLM.generate() 自然串行,不会 OOM。
|
||||||
|
func analyzeReport(imageURLs: [URL],
|
||||||
|
prompt: String,
|
||||||
|
maxTokens: Int = 512) async throws -> String {
|
||||||
|
guard vlStatus == .ready, let session = vlSession else {
|
||||||
|
throw AIRuntimeError.notReady
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try await session.analyze(
|
||||||
|
imageURLs: imageURLs,
|
||||||
|
prompt: prompt,
|
||||||
|
maxTokens: maxTokens
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw AIRuntimeError.inferenceFailed("\(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
康康/AI/Prompts/VLPrompts.swift
Normal file
71
康康/AI/Prompts/VLPrompts.swift
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// VL 模型(Qwen2.5-VL)用于体检 / 化验单识别的 prompt 模板。
|
||||||
|
/// 输出契约:严格 JSON,无任何解释文字、markdown 围栏或前后缀。
|
||||||
|
/// 解析失败 → CaptureService 回退到手动录入(§3.2 失败回退红线)。
|
||||||
|
enum VLPrompts {
|
||||||
|
|
||||||
|
/// 输出 JSON 的字段定义(写进 prompt 里教模型):
|
||||||
|
/// ```
|
||||||
|
/// {
|
||||||
|
/// "title": "春季年度体检", // 报告抬头,无则 "拍摄识别"
|
||||||
|
/// "type": "checkup|lab|imaging|prescription|other",
|
||||||
|
/// "report_date": "YYYY-MM-DD", // 报告日期(无则今天)
|
||||||
|
/// "institution": "XX 医院", // 可空字符串
|
||||||
|
/// "page_count": 1,
|
||||||
|
/// "summary": "整体趋势短句", // 可空字符串
|
||||||
|
/// "indicators": [
|
||||||
|
/// {
|
||||||
|
/// "name": "低密度脂蛋白",
|
||||||
|
/// "value": "3.84",
|
||||||
|
/// "unit": "mmol/L",
|
||||||
|
/// "range": "< 3.40",
|
||||||
|
/// "status": "high|low|normal"
|
||||||
|
/// }
|
||||||
|
/// ]
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
/// `kind` 字段省略 —— UI 由 indicators 数量决定走 A2(单项)或 B3(多项)。
|
||||||
|
|
||||||
|
static let reportExtraction: String = #"""
|
||||||
|
你是一个医学体检报告识别助手。请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||||
|
|
||||||
|
JSON schema(严格):
|
||||||
|
{
|
||||||
|
"title": string,
|
||||||
|
"type": "checkup" | "lab" | "imaging" | "prescription" | "other",
|
||||||
|
"report_date": "YYYY-MM-DD",
|
||||||
|
"institution": string,
|
||||||
|
"page_count": number,
|
||||||
|
"summary": string,
|
||||||
|
"indicators": [
|
||||||
|
{
|
||||||
|
"name": string,
|
||||||
|
"value": string,
|
||||||
|
"unit": string,
|
||||||
|
"range": string,
|
||||||
|
"status": "high" | "low" | "normal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"。
|
||||||
|
- range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象。
|
||||||
|
- 无法识别的字段填空字符串(institution / summary)或合理默认值(report_date 用今天)。
|
||||||
|
- 不要发明指标。看不清的整行跳过。
|
||||||
|
- 化验单一般 type = "lab",体检套餐 = "checkup"。
|
||||||
|
|
||||||
|
示例 1(化验单 · 单项):
|
||||||
|
输入: 一张化验单照片,只能看清「低密度脂蛋白 3.84 mmol/L 参考 <3.40」
|
||||||
|
输出:
|
||||||
|
{"title":"低密度脂蛋白单项","type":"lab","report_date":"2026-05-25","institution":"","page_count":1,"summary":"","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]}
|
||||||
|
|
||||||
|
示例 2(体检 · 多项):
|
||||||
|
输入: 一份春季体检,3 项可读
|
||||||
|
输出:
|
||||||
|
{"title":"春季年度体检","type":"checkup","report_date":"2026-04-12","institution":"协和医院","page_count":1,"summary":"血脂偏高、其他正常","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"},{"name":"谷丙转氨酶","value":"32","unit":"U/L","range":"9 - 50","status":"normal"},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]}
|
||||||
|
|
||||||
|
现在请识别图片并输出 JSON:
|
||||||
|
"""#
|
||||||
|
}
|
||||||
72
康康/AI/VLSession.swift
Normal file
72
康康/AI/VLSession.swift
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
import MLX
|
||||||
|
import MLXVLM
|
||||||
|
import MLXLMCommon
|
||||||
|
|
||||||
|
/// 封装 MLX VL 模型(Qwen2.5-VL)的图像 → 文本推理。
|
||||||
|
/// 与 LLMSession 同款 actor 隔离,串行化由上游 AIRuntime 统一保证。
|
||||||
|
actor VLSession {
|
||||||
|
let container: ModelContainer
|
||||||
|
|
||||||
|
init(container: ModelContainer) {
|
||||||
|
self.container = container
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func withDeviceOverride<R>(
|
||||||
|
_ body: () async throws -> R
|
||||||
|
) async rethrows -> R {
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
return try await Device.withDefaultDevice(.cpu, body)
|
||||||
|
#else
|
||||||
|
return try await body()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从本地目录加载 VL 模型(包含 config.json + weights + tokenizer + processor)。
|
||||||
|
static func load(folderURL: URL) async throws -> VLSession {
|
||||||
|
let configuration = ModelConfiguration(directory: folderURL)
|
||||||
|
let container = try await withDeviceOverride {
|
||||||
|
try await VLMModelFactory.shared.loadContainer(
|
||||||
|
configuration: configuration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return VLSession(container: container)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次性生成(等收完所有 token 再返回完整字符串)。
|
||||||
|
/// VL 用于结构化 JSON 抽取,不需要流式 — 也避免半成品 JSON 抖动 UI。
|
||||||
|
/// - Parameters:
|
||||||
|
/// - imageURLs: 本地 file:// URL,从 FileVault 拿
|
||||||
|
/// - prompt: 文本指令(VLPrompts.reportExtraction)
|
||||||
|
/// - maxTokens: 默认 512(JSON 体量 ≈ 200-400)
|
||||||
|
func analyze(imageURLs: [URL],
|
||||||
|
prompt: String,
|
||||||
|
maxTokens: Int = 512) async throws -> String {
|
||||||
|
try await Self.withDeviceOverride {
|
||||||
|
try await container.perform { (context: ModelContext) in
|
||||||
|
let images = imageURLs.map { UserInput.Image.url($0) }
|
||||||
|
let userInput = UserInput(prompt: prompt, images: images)
|
||||||
|
let lmInput = try await context.processor.prepare(input: userInput)
|
||||||
|
|
||||||
|
let parameters = GenerateParameters(
|
||||||
|
maxTokens: maxTokens,
|
||||||
|
temperature: Float(0.2), // JSON 要稳,温度低
|
||||||
|
topP: Float(0.9)
|
||||||
|
)
|
||||||
|
|
||||||
|
var collected = ""
|
||||||
|
for await event in try MLXLMCommon.generate(
|
||||||
|
input: lmInput,
|
||||||
|
parameters: parameters,
|
||||||
|
context: context
|
||||||
|
) {
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
if case .chunk(let text) = event {
|
||||||
|
collected.append(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return collected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ struct KangkangApp: App {
|
|||||||
ChatTurn.self,
|
ChatTurn.self,
|
||||||
Symptom.self,
|
Symptom.self,
|
||||||
UserProfile.self,
|
UserProfile.self,
|
||||||
|
MetricReminder.self,
|
||||||
|
CustomMonitorMetric.self,
|
||||||
])
|
])
|
||||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct ArchiveListView: View {
|
|||||||
private var symptoms: [Symptom]
|
private var symptoms: [Symptom]
|
||||||
|
|
||||||
@State private var filter: TimelineKind? = nil
|
@State private var filter: TimelineKind? = nil
|
||||||
|
@State private var endingSymptom: Symptom?
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private var allEntries: [TimelineEntry] {
|
private var allEntries: [TimelineEntry] {
|
||||||
@@ -52,7 +53,7 @@ struct ArchiveListView: View {
|
|||||||
Section {
|
Section {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
ForEach(group.items) { entry in
|
ForEach(group.items) { entry in
|
||||||
TimelineRow(entry: entry)
|
rowView(for: entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -67,6 +68,24 @@ struct ArchiveListView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.sheet(item: $endingSymptom) { sym in
|
||||||
|
SymptomEndSheet(symptom: sym)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func rowView(for entry: TimelineEntry) -> some View {
|
||||||
|
if entry.kind == .symptom, entry.isOngoing,
|
||||||
|
let sym = symptoms.first(where: { "symptom-\($0.persistentModelID)" == entry.id }) {
|
||||||
|
Button {
|
||||||
|
endingSymptom = sym
|
||||||
|
} label: {
|
||||||
|
TimelineRow(entry: entry)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else {
|
||||||
|
TimelineRow(entry: entry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
|
|||||||
250
康康/Features/Capture/CaptureReviewForm.swift
Normal file
250
康康/Features/Capture/CaptureReviewForm.swift
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// VL 解析后的可编辑表单。
|
||||||
|
/// 用户可改 title / type / reportDate / institution / summary / 各 indicator;
|
||||||
|
/// 也可删除识别错的 indicator,或手加一行。
|
||||||
|
/// 「保存」回调写 SwiftData + 关联已写入 Vault 的 assets。
|
||||||
|
struct CaptureReviewForm: View {
|
||||||
|
@State var parsed: ParsedReport
|
||||||
|
let assets: [FileVault.SavedAsset]
|
||||||
|
let warning: String?
|
||||||
|
let onSave: (ParsedReport) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
if let warning {
|
||||||
|
warningBanner(warning)
|
||||||
|
}
|
||||||
|
if !assets.isEmpty {
|
||||||
|
pageThumbnails
|
||||||
|
}
|
||||||
|
metaSection
|
||||||
|
indicatorSection
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 顶部 warning
|
||||||
|
|
||||||
|
private func warningBanner(_ text: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 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.brickSoft.opacity(0.5))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 缩略图
|
||||||
|
|
||||||
|
private var pageThumbnails: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel("已保存 \(assets.count) 页(端侧加密)")
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - meta(title / type / date / institution / summary)
|
||||||
|
|
||||||
|
private var metaSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
sectionLabel("基本信息")
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
labeledField("标题") {
|
||||||
|
TextField("如:春季年度体检", text: $parsed.title)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
}
|
||||||
|
labeledField("类型") {
|
||||||
|
Picker("", selection: $parsed.typeRaw) {
|
||||||
|
ForEach(ReportType.allCases, id: \.rawValue) { t in
|
||||||
|
Text(t.label).tag(t.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
labeledField("报告日期") {
|
||||||
|
DatePicker("", selection: $parsed.reportDate,
|
||||||
|
in: ...Date.now,
|
||||||
|
displayedComponents: .date)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.labelsHidden()
|
||||||
|
.environment(\.locale, Locale(identifier: "zh_CN"))
|
||||||
|
}
|
||||||
|
labeledField("机构(可选)") {
|
||||||
|
TextField("如:协和医院", text: $parsed.institution)
|
||||||
|
}
|
||||||
|
labeledField("摘要(可选)") {
|
||||||
|
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
|
||||||
|
.lineLimit(1...3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(fieldBg)
|
||||||
|
.overlay(fieldBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func labeledField<C: View>(_ label: String, @ViewBuilder content: () -> C) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - indicators
|
||||||
|
|
||||||
|
private var indicatorSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
sectionLabel("指标(\(parsed.indicators.count) 项)")
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
parsed.indicators.append(
|
||||||
|
.init(name: "", value: "", unit: "", range: "", status: .normal)
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Label("加一项", systemImage: "plus.circle")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
if parsed.indicators.isEmpty {
|
||||||
|
Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(parsed.indicators.indices, id: \.self) { idx in
|
||||||
|
indicatorRow(idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func indicatorRow(_ idx: Int) -> some View {
|
||||||
|
let binding = $parsed.indicators[idx]
|
||||||
|
return VStack(spacing: 8) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("指标名", text: binding.name)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
Button(role: .destructive) {
|
||||||
|
parsed.indicators.remove(at: idx)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("数值", text: binding.value)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||||
|
.frame(maxWidth: 90)
|
||||||
|
TextField("单位", text: binding.unit)
|
||||||
|
.frame(maxWidth: 80)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
TextField("参考", text: binding.range)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
Picker("", selection: binding.status) {
|
||||||
|
Text("正常").tag(IndicatorStatus.normal)
|
||||||
|
Text("偏高 ↑").tag(IndicatorStatus.high)
|
||||||
|
Text("偏低 ↓").tag(IndicatorStatus.low)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(statusColor(binding.status.wrappedValue).opacity(0.4),
|
||||||
|
lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusColor(_ s: IndicatorStatus) -> Color {
|
||||||
|
switch s {
|
||||||
|
case .normal: return Tj.Palette.leaf
|
||||||
|
case .high: return Tj.Palette.brick
|
||||||
|
case .low: return Tj.Palette.amber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - actions
|
||||||
|
|
||||||
|
private var actions: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
onSave(parsed)
|
||||||
|
} label: {
|
||||||
|
Text("保存到记录")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(TjPrimaryButton())
|
||||||
|
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Text("取消(图片不保留)")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - helpers
|
||||||
|
|
||||||
|
private func sectionLabel(_ t: String) -> some View {
|
||||||
|
Text(t)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.tracking(0.3)
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fieldBg: some View {
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fieldBorder: some View {
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
康康/Features/Capture/DocumentScanner.swift
Normal file
68
康康/Features/Capture/DocumentScanner.swift
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VisionKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
#if canImport(VisionKit) && os(iOS)
|
||||||
|
|
||||||
|
/// VisionKit 文档扫描器的 SwiftUI 包装。
|
||||||
|
/// - 真机:全屏多页文档扫描,自动透视校正
|
||||||
|
/// - 模拟器:`VNDocumentCameraViewController.isSupported == false`,
|
||||||
|
/// 父 View 不要 present 这个,改走 PhotosPicker 回退(见 PhotoPickerSheet)
|
||||||
|
struct DocumentScannerView: UIViewControllerRepresentable {
|
||||||
|
let onFinish: ([UIImage]) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
|
||||||
|
let vc = VNDocumentCameraViewController()
|
||||||
|
vc.delegate = context.coordinator
|
||||||
|
return vc
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: VNDocumentCameraViewController,
|
||||||
|
context: Context) {}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(onFinish: onFinish, onCancel: onCancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
|
||||||
|
let onFinish: ([UIImage]) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
init(onFinish: @escaping ([UIImage]) -> Void,
|
||||||
|
onCancel: @escaping () -> Void) {
|
||||||
|
self.onFinish = onFinish
|
||||||
|
self.onCancel = onCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentCameraViewController(
|
||||||
|
_ controller: VNDocumentCameraViewController,
|
||||||
|
didFinishWith scan: VNDocumentCameraScan
|
||||||
|
) {
|
||||||
|
var images: [UIImage] = []
|
||||||
|
for i in 0..<scan.pageCount {
|
||||||
|
images.append(scan.imageOfPage(at: i))
|
||||||
|
}
|
||||||
|
onFinish(images)
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentCameraViewControllerDidCancel(
|
||||||
|
_ controller: VNDocumentCameraViewController
|
||||||
|
) {
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentCameraViewController(
|
||||||
|
_ controller: VNDocumentCameraViewController,
|
||||||
|
didFailWithError error: Error
|
||||||
|
) {
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var isSupported: Bool {
|
||||||
|
VNDocumentCameraViewController.isSupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
68
康康/Features/Capture/PhotoPickerSheet.swift
Normal file
68
康康/Features/Capture/PhotoPickerSheet.swift
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
|
/// VisionKit 在模拟器不可用,demo / 验证场景走 PhotosPicker 回退选已有照片。
|
||||||
|
/// 真机正式录入走 DocumentScannerView。
|
||||||
|
struct PhotoPickerSheet: View {
|
||||||
|
let onFinish: ([UIImage]) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
@State private var selection: [PhotosPickerItem] = []
|
||||||
|
@State private var loading = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "photo.on.rectangle.angled")
|
||||||
|
.font(.system(size: 56))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text("模拟器没有摄像头,从相册选一张化验单/体检报告")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
PhotosPicker(selection: $selection,
|
||||||
|
maxSelectionCount: 5,
|
||||||
|
matching: .images) {
|
||||||
|
Text("从相册选 ≤5 张")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Tj.Palette.ink)
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("取消", action: onCancel)
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
|
||||||
|
if loading {
|
||||||
|
ProgressView().tint(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(28)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.onChange(of: selection) { _, newValue in
|
||||||
|
guard !newValue.isEmpty else { return }
|
||||||
|
loadImages(from: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadImages(from items: [PhotosPickerItem]) {
|
||||||
|
loading = true
|
||||||
|
Task {
|
||||||
|
var images: [UIImage] = []
|
||||||
|
for item in items {
|
||||||
|
if let data = try? await item.loadTransferable(type: Data.self),
|
||||||
|
let img = UIImage(data: data) {
|
||||||
|
images.append(img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
loading = false
|
||||||
|
if images.isEmpty { onCancel() }
|
||||||
|
else { onFinish(images) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
228
康康/Features/Capture/UnifiedCaptureFlow.swift
Normal file
228
康康/Features/Capture/UnifiedCaptureFlow.swift
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 拍报告 → VL 识别 → 编辑 → 保存(图 + 结构化文本)
|
||||||
|
/// 一条统一流程,替代原 A1-A3 / B1-B5 两套 mockup。
|
||||||
|
///
|
||||||
|
/// 状态机:
|
||||||
|
/// ```
|
||||||
|
/// idle → captured(images) → analyzing → editing(parsed, assets)
|
||||||
|
/// ↓ 失败
|
||||||
|
/// editing(empty, assets)
|
||||||
|
/// editing → saved → dismiss
|
||||||
|
/// ```
|
||||||
|
struct UnifiedCaptureFlow: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
@State private var phase: Phase = .idle
|
||||||
|
|
||||||
|
enum Phase {
|
||||||
|
case idle
|
||||||
|
case analyzing(images: [UIImage])
|
||||||
|
case editing(parsed: ParsedReport,
|
||||||
|
assets: [FileVault.SavedAsset],
|
||||||
|
warning: String?)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
content
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("取消") { onClose() }
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(phaseTitle)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var phaseTitle: String {
|
||||||
|
switch phase {
|
||||||
|
case .idle: return "拍摄报告"
|
||||||
|
case .analyzing: return "本地识别中…"
|
||||||
|
case .editing: return "核对识别结果"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
switch phase {
|
||||||
|
case .idle:
|
||||||
|
captureEntry
|
||||||
|
case .analyzing(let images):
|
||||||
|
AnalyzingView(images: images)
|
||||||
|
case .editing(let parsed, let assets, let warning):
|
||||||
|
CaptureReviewForm(
|
||||||
|
parsed: parsed,
|
||||||
|
assets: assets,
|
||||||
|
warning: warning,
|
||||||
|
onSave: { final in saveAll(parsed: final, assets: assets) },
|
||||||
|
onCancel: onClose
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 入口:相机 / 相册
|
||||||
|
|
||||||
|
private var captureEntry: some View {
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
PhotoPickerSheet(
|
||||||
|
onFinish: { startAnalyze(images: $0) },
|
||||||
|
onCancel: onClose
|
||||||
|
)
|
||||||
|
#else
|
||||||
|
if DocumentScannerView.isSupported {
|
||||||
|
DocumentScannerView(
|
||||||
|
onFinish: { startAnalyze(images: $0) },
|
||||||
|
onCancel: onClose
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
} else {
|
||||||
|
PhotoPickerSheet(
|
||||||
|
onFinish: { startAnalyze(images: $0) },
|
||||||
|
onCancel: onClose
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 启动识别
|
||||||
|
|
||||||
|
private func startAnalyze(images: [UIImage]) {
|
||||||
|
guard !images.isEmpty else { onClose(); return }
|
||||||
|
phase = .analyzing(images: images)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await CaptureService.shared.analyze(images: images)
|
||||||
|
await MainActor.run {
|
||||||
|
phase = .editing(
|
||||||
|
parsed: result.parsed,
|
||||||
|
assets: result.assets,
|
||||||
|
warning: result.parsed.isEmpty
|
||||||
|
? "识别没有读出指标,请手动补充"
|
||||||
|
: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch let CaptureError.parseFailed(msg) {
|
||||||
|
// 解析失败:仍然展示编辑表单,只是 indicators 为空,assets 已保存
|
||||||
|
await fallbackToManual(images: images, msg: "VL 输出无法解析:\(msg)")
|
||||||
|
} catch let CaptureError.inferenceFailed(msg) {
|
||||||
|
await fallbackToManual(images: images, msg: "推理失败:\(msg)")
|
||||||
|
} catch let CaptureError.modelNotReady {
|
||||||
|
await fallbackToManual(images: images, msg: "VL 模型未就绪,先手动录入")
|
||||||
|
} catch CaptureError.writeAssetFailed {
|
||||||
|
await MainActor.run {
|
||||||
|
phase = .editing(
|
||||||
|
parsed: .empty(),
|
||||||
|
assets: [],
|
||||||
|
warning: "图片保存失败,手动录入并保留文本"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await fallbackToManual(images: images, msg: "未知错误:\(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fallbackToManual(images: [UIImage], msg: String) async {
|
||||||
|
// 即便 VL 失败,图片应当已经写入了 Vault(在 CaptureService.analyze 第 1 步)。
|
||||||
|
// 但若是 writeAsset 之前的失败(modelNotReady / inferenceFailed),
|
||||||
|
// 这里再补一次写,保证图不丢。
|
||||||
|
var assets: [FileVault.SavedAsset] = []
|
||||||
|
for img in images {
|
||||||
|
if let a = try? FileVault.shared.writeJPEG(img) { assets.append(a) }
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
phase = .editing(
|
||||||
|
parsed: .empty(),
|
||||||
|
assets: assets,
|
||||||
|
warning: msg
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 持久化
|
||||||
|
|
||||||
|
private func saveAll(parsed final: ParsedReport,
|
||||||
|
assets: [FileVault.SavedAsset]) {
|
||||||
|
let report = Report(
|
||||||
|
title: final.title.isEmpty ? "拍摄识别" : final.title,
|
||||||
|
type: ReportType(rawValue: final.typeRaw) ?? .other,
|
||||||
|
reportDate: final.reportDate,
|
||||||
|
institution: final.institution.isEmpty ? nil : final.institution,
|
||||||
|
summary: final.summary.isEmpty ? nil : final.summary,
|
||||||
|
pageCount: final.pageCount
|
||||||
|
)
|
||||||
|
ctx.insert(report)
|
||||||
|
|
||||||
|
// 关联 Asset
|
||||||
|
for a in assets {
|
||||||
|
let asset = Asset(relativePath: a.relativePath, bytes: a.bytes)
|
||||||
|
ctx.insert(asset)
|
||||||
|
report.assets.append(asset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关联 Indicator
|
||||||
|
for ind in final.indicators {
|
||||||
|
let i = Indicator(
|
||||||
|
name: ind.name,
|
||||||
|
value: ind.value,
|
||||||
|
unit: ind.unit,
|
||||||
|
range: ind.range,
|
||||||
|
status: ind.status,
|
||||||
|
capturedAt: final.reportDate,
|
||||||
|
report: report
|
||||||
|
)
|
||||||
|
ctx.insert(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
try? ctx.save()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 分析中视图
|
||||||
|
|
||||||
|
private struct AnalyzingView: View {
|
||||||
|
let images: [UIImage]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer()
|
||||||
|
if let first = images.first {
|
||||||
|
Image(uiImage: first)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxHeight: 240)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.overlay(
|
||||||
|
ProgressView().tint(Tj.Palette.ink).scaleEffect(1.4)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text("本地识别中")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("\(images.count) 页 · 100% 本地推理")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
329
康康/Features/Indicator/CustomMetricEditor.swift
Normal file
329
康康/Features/Indicator/CustomMetricEditor.swift
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
let customMetricIconChoices: [String] = [
|
||||||
|
"circle.fill",
|
||||||
|
"drop.fill",
|
||||||
|
"flame.fill",
|
||||||
|
"bolt.fill",
|
||||||
|
"leaf.fill",
|
||||||
|
"pills.fill",
|
||||||
|
"gauge.high",
|
||||||
|
"moon.fill",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 名称冲突判定结果。`detectNameConflict` 返回此值用于 UI 警告。
|
||||||
|
enum CustomMetricNameConflict: Equatable {
|
||||||
|
case none
|
||||||
|
case builtin(String) // 撞到 MonitorMetric.displayName
|
||||||
|
case existingCustom(String) // 撞到其他 CustomMonitorMetric.name
|
||||||
|
|
||||||
|
var warningText: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return ""
|
||||||
|
case .builtin(let n): return "「\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块"
|
||||||
|
case .existingCustom(let n):return "已经有一个叫「\(n)」的自定义指标"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 纯函数:给定 candidate name + 现有 customs + 编辑时排除的 seriesKey,返回冲突类型。
|
||||||
|
/// 抽离方便单测,不依赖 SwiftData 上下文。
|
||||||
|
func detectNameConflict(
|
||||||
|
candidate: String,
|
||||||
|
customs: [CustomMonitorMetric],
|
||||||
|
excludingSeriesKey: String? = nil
|
||||||
|
) -> CustomMetricNameConflict {
|
||||||
|
let trimmed = candidate.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty else { return .none }
|
||||||
|
|
||||||
|
if MonitorMetric.allCases.contains(where: { $0.displayName == trimmed }) {
|
||||||
|
return .builtin(trimmed)
|
||||||
|
}
|
||||||
|
for c in customs where c.seriesKey != excludingSeriesKey && c.name == trimmed {
|
||||||
|
return .existingCustom(trimmed)
|
||||||
|
}
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自定义长期监测指标的 create / edit / delete sheet。
|
||||||
|
struct CustomMetricEditor: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
/// nil = 新建;非 nil = 编辑现有
|
||||||
|
let existing: CustomMonitorMetric?
|
||||||
|
/// 保存或删除后回调,parent 可借此 setSelectedCustom(metric? ) 触发后续 UI
|
||||||
|
var onSaved: (CustomMonitorMetric?) -> Void
|
||||||
|
|
||||||
|
@Query private var allCustoms: [CustomMonitorMetric]
|
||||||
|
|
||||||
|
@State private var name: String = ""
|
||||||
|
@State private var unit: String = ""
|
||||||
|
@State private var lower: String = ""
|
||||||
|
@State private var upper: String = ""
|
||||||
|
@State private var icon: String = "circle.fill"
|
||||||
|
@State private var hydrated = false
|
||||||
|
|
||||||
|
private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
|
||||||
|
private var trimmedUnit: String { unit.trimmingCharacters(in: .whitespaces) }
|
||||||
|
private var canSubmit: Bool { !trimmedName.isEmpty }
|
||||||
|
|
||||||
|
private var nameConflict: CustomMetricNameConflict {
|
||||||
|
detectNameConflict(
|
||||||
|
candidate: name,
|
||||||
|
customs: allCustoms,
|
||||||
|
excludingSeriesKey: existing?.seriesKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Capsule()
|
||||||
|
.fill(Tj.Palette.line)
|
||||||
|
.frame(width: 40, height: 4)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
|
header
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
nameSection
|
||||||
|
unitSection
|
||||||
|
rangeRow
|
||||||
|
iconSection
|
||||||
|
if existing != nil {
|
||||||
|
deleteButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
Tj.Palette.sand
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
)
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.hidden)
|
||||||
|
.presentationBackground(Tj.Palette.sand)
|
||||||
|
.presentationCornerRadius(Tj.Radius.xl)
|
||||||
|
.onAppear { hydrate() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack {
|
||||||
|
Text(existing == nil ? "新建自定义指标" : "编辑「\(existing!.name)」")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
if existing == nil {
|
||||||
|
Text("保存后会出现在录入选项里")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nameSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel("名称")
|
||||||
|
TextField("例如:腰围 / 步数 / 睡眠时长", text: $name)
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||||
|
.background(fieldBg)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(
|
||||||
|
nameConflict == .none ? Tj.Palette.line : Tj.Palette.amber,
|
||||||
|
lineWidth: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if nameConflict != .none {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
|
Text(nameConflict.warningText)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var unitSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel("单位(可选)")
|
||||||
|
TextField("例如:cm / 步 / 小时", text: $unit)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||||
|
.background(fieldBg).overlay(fieldBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rangeRow: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
sectionLabel("参考范围(可选)")
|
||||||
|
Spacer()
|
||||||
|
Text("用于自动判定 正常/偏高/偏低")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
rangeField(label: "下限", value: $lower, placeholder: "70")
|
||||||
|
Text("—").foregroundStyle(Tj.Palette.text3)
|
||||||
|
rangeField(label: "上限", value: $upper, placeholder: "90")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rangeField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
||||||
|
TextField(placeholder, text: value)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||||
|
.background(fieldBg).overlay(fieldBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionLabel("图标")
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4),
|
||||||
|
spacing: 8) {
|
||||||
|
ForEach(customMetricIconChoices, id: \.self) { sf in
|
||||||
|
Button {
|
||||||
|
icon = sf
|
||||||
|
} label: {
|
||||||
|
Image(systemName: sf)
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundStyle(icon == sf ? Tj.Palette.paper : Tj.Palette.ink)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 44)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.fill(icon == sf ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: icon == sf ? 0 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deleteButton: some View {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
if let m = existing {
|
||||||
|
ReminderService.cancel(metricId: m.seriesKey)
|
||||||
|
ctx.delete(m)
|
||||||
|
try? ctx.save()
|
||||||
|
onSaved(nil)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
Text("删除这项自定义指标")
|
||||||
|
}
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.brickSoft.opacity(0.5))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("取消") { dismiss() }
|
||||||
|
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||||
|
Button(existing == nil ? "新建" : "保存") { submit() }
|
||||||
|
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||||
|
.disabled(!canSubmit)
|
||||||
|
.opacity(canSubmit ? 1 : 0.4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(
|
||||||
|
Tj.Palette.sand
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - helpers
|
||||||
|
|
||||||
|
private var fieldBg: some View {
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
}
|
||||||
|
private var fieldBorder: some View {
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
|
}
|
||||||
|
private func sectionLabel(_ t: String) -> some View {
|
||||||
|
Text(t).font(.system(size: 12, weight: .semibold)).tracking(0.3)
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hydrate() {
|
||||||
|
guard !hydrated, let m = existing else { hydrated = true; return }
|
||||||
|
name = m.name; unit = m.unit; icon = m.icon
|
||||||
|
lower = m.lowerBound.map { fmt($0) } ?? ""
|
||||||
|
upper = m.upperBound.map { fmt($0) } ?? ""
|
||||||
|
hydrated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() {
|
||||||
|
guard canSubmit else { return }
|
||||||
|
let lo = Double(lower.trimmingCharacters(in: .whitespaces))
|
||||||
|
let hi = Double(upper.trimmingCharacters(in: .whitespaces))
|
||||||
|
if let m = existing {
|
||||||
|
m.name = trimmedName
|
||||||
|
m.unit = trimmedUnit
|
||||||
|
m.lowerBound = lo
|
||||||
|
m.upperBound = hi
|
||||||
|
m.icon = icon
|
||||||
|
try? ctx.save()
|
||||||
|
onSaved(m)
|
||||||
|
} else {
|
||||||
|
let m = CustomMonitorMetric(
|
||||||
|
name: trimmedName,
|
||||||
|
unit: trimmedUnit,
|
||||||
|
lowerBound: lo,
|
||||||
|
upperBound: hi,
|
||||||
|
icon: icon
|
||||||
|
)
|
||||||
|
ctx.insert(m)
|
||||||
|
try? ctx.save()
|
||||||
|
onSaved(m)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fmt(_ v: Double) -> String {
|
||||||
|
v.truncatingRemainder(dividingBy: 1) == 0
|
||||||
|
? String(format: "%.0f", v)
|
||||||
|
: String(format: "%.1f", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,9 +48,41 @@ struct IndicatorQuickSheet: View {
|
|||||||
@State private var systolic: String = ""
|
@State private var systolic: String = ""
|
||||||
@State private var diastolic: 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 profile: UserProfile? { profiles.first }
|
||||||
|
|
||||||
private var isBP: Bool { selectedMonitor == .bloodPressure }
|
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 {
|
private var canSubmit: Bool {
|
||||||
if isBP {
|
if isBP {
|
||||||
@@ -78,16 +110,18 @@ struct IndicatorQuickSheet: View {
|
|||||||
nameSection
|
nameSection
|
||||||
valueRow
|
valueRow
|
||||||
rangeSection
|
rangeSection
|
||||||
if selectedMonitor == nil {
|
if isLongTermMetric {
|
||||||
// 自由输入或 lab preset 时 status 手动;monitor 单字段自动
|
|
||||||
statusSection
|
|
||||||
} else {
|
|
||||||
autoStatusHint
|
autoStatusHint
|
||||||
|
} else {
|
||||||
|
statusSection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
timeSection
|
timeSection
|
||||||
noteSection
|
noteSection
|
||||||
|
if isLongTermMetric {
|
||||||
|
reminderSection
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 20)
|
.padding(.bottom, 20)
|
||||||
@@ -95,6 +129,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
footer
|
footer
|
||||||
}
|
}
|
||||||
|
.task(id: longTermKey) { hydrateReminder() }
|
||||||
.background(
|
.background(
|
||||||
Tj.Palette.sand
|
Tj.Palette.sand
|
||||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||||
@@ -138,8 +173,105 @@ struct IndicatorQuickSheet: View {
|
|||||||
ForEach(MonitorMetric.allCases) { m in
|
ForEach(MonitorMetric.allCases) { m in
|
||||||
monitorTile(m)
|
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 {
|
private func monitorTile(_ m: MonitorMetric) -> some View {
|
||||||
@@ -265,8 +397,8 @@ struct IndicatorQuickSheet: View {
|
|||||||
selectedLabPreset = nil
|
selectedLabPreset = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(selectedMonitor != nil)
|
.disabled(isLongTermMetric)
|
||||||
.opacity(selectedMonitor != nil ? 0.6 : 1)
|
.opacity(isLongTermMetric ? 0.6 : 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,8 +423,8 @@ struct IndicatorQuickSheet: View {
|
|||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(fieldBg)
|
.background(fieldBg)
|
||||||
.overlay(fieldBorder)
|
.overlay(fieldBorder)
|
||||||
.disabled(selectedMonitor != nil)
|
.disabled(isLongTermMetric)
|
||||||
.opacity(selectedMonitor != nil ? 0.6 : 1)
|
.opacity(isLongTermMetric ? 0.6 : 1)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 130)
|
.frame(maxWidth: 130)
|
||||||
}
|
}
|
||||||
@@ -314,8 +446,8 @@ struct IndicatorQuickSheet: View {
|
|||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(fieldBg)
|
.background(fieldBg)
|
||||||
.overlay(fieldBorder)
|
.overlay(fieldBorder)
|
||||||
.disabled(selectedMonitor != nil)
|
.disabled(isLongTermMetric)
|
||||||
.opacity(selectedMonitor != nil ? 0.6 : 1)
|
.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 {
|
private var footer: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Button("取消") { dismiss() }
|
Button("取消") { dismiss() }
|
||||||
@@ -476,6 +795,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
selectedMonitor = m
|
selectedMonitor = m
|
||||||
selectedLabPreset = nil
|
selectedLabPreset = nil
|
||||||
|
selectedCustom = nil
|
||||||
|
|
||||||
if m == .bloodPressure {
|
if m == .bloodPressure {
|
||||||
// 血压走 bp 字段,不动 name/value/unit
|
// 血压走 bp 字段,不动 name/value/unit
|
||||||
@@ -500,21 +820,53 @@ struct IndicatorQuickSheet: View {
|
|||||||
private func applyLab(_ p: IndicatorPreset) {
|
private func applyLab(_ p: IndicatorPreset) {
|
||||||
selectedLabPreset = p
|
selectedLabPreset = p
|
||||||
selectedMonitor = nil
|
selectedMonitor = nil
|
||||||
|
selectedCustom = nil
|
||||||
systolic = ""; diastolic = ""
|
systolic = ""; diastolic = ""
|
||||||
name = p.name
|
name = p.name
|
||||||
if unit.trimmingCharacters(in: .whitespaces).isEmpty { unit = p.unit }
|
if unit.trimmingCharacters(in: .whitespaces).isEmpty { unit = p.unit }
|
||||||
if range.trimmingCharacters(in: .whitespaces).isEmpty { range = p.range }
|
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
|
// MARK: - auto status
|
||||||
|
|
||||||
private var computedSingleStatus: (label: String, color: Color)? {
|
private var computedSingleStatus: (label: String, color: Color)? {
|
||||||
guard let m = selectedMonitor, m != .bloodPressure,
|
guard let v = Double(value.trimmingCharacters(in: .whitespaces)) else { return nil }
|
||||||
let v = Double(value.trimmingCharacters(in: .whitespaces)) else { return nil }
|
if let m = selectedMonitor, m != .bloodPressure {
|
||||||
let f = m.fields[0]
|
let f = m.fields[0]
|
||||||
let r = m.effectiveRange(for: f, profile: profile)
|
let r = m.effectiveRange(for: f, profile: profile)
|
||||||
let s = MonitorMetric.status(value: v, in: r)
|
let s = MonitorMetric.status(value: v, in: r)
|
||||||
return (s.label, s.color)
|
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 }
|
private enum BPSide { case systolic, diastolic }
|
||||||
@@ -538,10 +890,16 @@ struct IndicatorQuickSheet: View {
|
|||||||
saveBP()
|
saveBP()
|
||||||
} else if let m = selectedMonitor {
|
} else if let m = selectedMonitor {
|
||||||
saveSingleMonitor(m)
|
saveSingleMonitor(m)
|
||||||
|
} else if let cm = selectedCustom {
|
||||||
|
saveCustom(cm)
|
||||||
} else {
|
} else {
|
||||||
saveFreeform()
|
saveFreeform()
|
||||||
}
|
}
|
||||||
dismiss()
|
|
||||||
|
Task {
|
||||||
|
await persistReminderIfNeeded()
|
||||||
|
await MainActor.run { dismiss() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveBP() {
|
private func saveBP() {
|
||||||
@@ -603,6 +961,24 @@ struct IndicatorQuickSheet: View {
|
|||||||
try? ctx.save()
|
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() {
|
private func saveFreeform() {
|
||||||
let indicator = Indicator(
|
let indicator = Indicator(
|
||||||
name: name.trimmingCharacters(in: .whitespaces),
|
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 {
|
#Preview {
|
||||||
IndicatorQuickSheet()
|
IndicatorQuickSheet()
|
||||||
.modelContainer(for: [Indicator.self, UserProfile.self], inMemory: true)
|
.modelContainer(for: [
|
||||||
|
Indicator.self, UserProfile.self,
|
||||||
|
MetricReminder.self, CustomMonitorMetric.self
|
||||||
|
], inMemory: true)
|
||||||
}
|
}
|
||||||
|
|||||||
153
康康/Features/Me/CustomMetricsListView.swift
Normal file
153
康康/Features/Me/CustomMetricsListView.swift
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 「我的 · 自定义指标」管理页。
|
||||||
|
/// 从 MeView 进入;集中查看 / 新建 / 编辑 / 删除自定义长期监测指标。
|
||||||
|
struct CustomMetricsListView: View {
|
||||||
|
@Query(sort: \CustomMonitorMetric.createdAt, order: .reverse)
|
||||||
|
private var metrics: [CustomMonitorMetric]
|
||||||
|
|
||||||
|
@Query private var indicators: [Indicator]
|
||||||
|
|
||||||
|
@State private var editingTarget: CustomMetricEditTarget?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
hintBanner
|
||||||
|
if metrics.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
ForEach(metrics) { m in
|
||||||
|
Button {
|
||||||
|
editingTarget = CustomMetricEditTarget(metric: m)
|
||||||
|
} label: {
|
||||||
|
row(m)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationTitle("自定义指标")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
editingTarget = CustomMetricEditTarget(metric: nil)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $editingTarget) { target in
|
||||||
|
CustomMetricEditor(existing: target.metric) { _ in }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - subviews
|
||||||
|
|
||||||
|
private var hintBanner: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "info.circle.fill")
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text("自定义指标会出现在「+ 指标记录 → 长期监测」的 grid 里,可设提醒、进趋势")
|
||||||
|
.font(.system(size: 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: "还没有自定义指标")
|
||||||
|
.frame(width: 220, height: 130)
|
||||||
|
Text("右上角 + 新建一个")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ m: CustomMonitorMetric) -> some View {
|
||||||
|
let count = usageCount(for: m)
|
||||||
|
return HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Tj.Palette.leafSoft)
|
||||||
|
Image(systemName: m.icon)
|
||||||
|
.font(.system(size: 17, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(m.name)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if !m.unit.isEmpty {
|
||||||
|
Text(m.unit)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
if !m.rangeText.isEmpty {
|
||||||
|
Text("·")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text(m.rangeText)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(count == 0 ? "未使用" : "用 \(count) 次")
|
||||||
|
.font(.system(size: 11, weight: count > 0 ? .semibold : .regular))
|
||||||
|
.foregroundStyle(count > 0 ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func usageCount(for m: CustomMonitorMetric) -> Int {
|
||||||
|
indicators.filter { $0.seriesKey == m.seriesKey }.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
CustomMetricsListView()
|
||||||
|
}
|
||||||
|
.modelContainer(for: [
|
||||||
|
CustomMonitorMetric.self, Indicator.self,
|
||||||
|
UserProfile.self, MetricReminder.self,
|
||||||
|
], inMemory: true)
|
||||||
|
}
|
||||||
@@ -4,14 +4,19 @@ import SwiftData
|
|||||||
struct MeView: View {
|
struct MeView: View {
|
||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
@Query private var profiles: [UserProfile]
|
@Query private var profiles: [UserProfile]
|
||||||
|
@Query private var reminders: [MetricReminder]
|
||||||
|
@Query private var customMetrics: [CustomMonitorMetric]
|
||||||
|
|
||||||
private var profile: UserProfile? { profiles.first }
|
private var profile: UserProfile? { profiles.first }
|
||||||
|
private var enabledReminderCount: Int { reminders.filter(\.enabled).count }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
profileCard
|
profileCard
|
||||||
|
remindersCard
|
||||||
|
customMetricsCard
|
||||||
settingsCard(title: "模型管理",
|
settingsCard(title: "模型管理",
|
||||||
detail: "未配置",
|
detail: "未配置",
|
||||||
icon: "cpu")
|
icon: "cpu")
|
||||||
@@ -77,6 +82,85 @@ struct MeView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var remindersCard: some View {
|
||||||
|
NavigationLink {
|
||||||
|
RemindersListView()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(enabledReminderCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||||
|
Image(systemName: "bell.fill")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(enabledReminderCount > 0 ? Tj.Palette.ink : Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("记录提醒")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(reminderLine)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var reminderLine: String {
|
||||||
|
if reminders.isEmpty { return "尚未设置" }
|
||||||
|
if enabledReminderCount == 0 { return "全部已关闭(\(reminders.count) 条)" }
|
||||||
|
return "\(enabledReminderCount) 项启用"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customMetricsCard: some View {
|
||||||
|
NavigationLink {
|
||||||
|
CustomMetricsListView()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(customMetrics.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft)
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(customMetrics.isEmpty ? Tj.Palette.text2 : Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("自定义指标")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(customMetricsLine)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customMetricsLine: String {
|
||||||
|
if customMetrics.isEmpty { return "添加你自己的长期监测项" }
|
||||||
|
return "\(customMetrics.count) 项"
|
||||||
|
}
|
||||||
|
|
||||||
private func settingsCard(title: String, detail: String, icon: String) -> some View {
|
private func settingsCard(title: String, detail: String, icon: String) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -114,6 +198,7 @@ struct MeView: View {
|
|||||||
MeView()
|
MeView()
|
||||||
.modelContainer(for: [
|
.modelContainer(for: [
|
||||||
UserProfile.self, Indicator.self, Report.self, DiaryEntry.self,
|
UserProfile.self, Indicator.self, Report.self, DiaryEntry.self,
|
||||||
Asset.self, ChatTurn.self, Symptom.self,
|
Asset.self, ChatTurn.self, Symptom.self, MetricReminder.self,
|
||||||
|
CustomMonitorMetric.self,
|
||||||
], inMemory: true)
|
], inMemory: true)
|
||||||
}
|
}
|
||||||
|
|||||||
221
康康/Features/Me/RemindersListView.swift
Normal file
221
康康/Features/Me/RemindersListView.swift
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct RemindersListView: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
|
||||||
|
private var reminders: [MetricReminder]
|
||||||
|
|
||||||
|
@State private var editingId: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
header
|
||||||
|
if reminders.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
ForEach(reminders) { r in
|
||||||
|
ReminderRow(
|
||||||
|
reminder: r,
|
||||||
|
isEditing: editingId == r.metricId,
|
||||||
|
onTapEdit: { toggleEdit(r.metricId) },
|
||||||
|
onChange: { Task { await sync(r) } },
|
||||||
|
onDelete: { delete(r) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationTitle("记录提醒")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("\(enabledCount) / \(reminders.count) 项启用")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Text("提醒在录入「指标记录 · 长期监测」时开启")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Spacer(minLength: 40)
|
||||||
|
TjPlaceholder(label: "还没有记录提醒\n去「+ 指标记录」录入时打开")
|
||||||
|
.frame(width: 240, height: 140)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var enabledCount: Int { reminders.filter(\.enabled).count }
|
||||||
|
|
||||||
|
private func toggleEdit(_ id: String) {
|
||||||
|
editingId = (editingId == id) ? nil : id
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sync(_ r: MetricReminder) async {
|
||||||
|
r.updatedAt = .now
|
||||||
|
try? ctx.save()
|
||||||
|
await ReminderService.sync(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func delete(_ r: MetricReminder) {
|
||||||
|
ReminderService.cancel(metricId: r.metricId)
|
||||||
|
ctx.delete(r)
|
||||||
|
try? ctx.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReminderRow: View {
|
||||||
|
@Bindable var reminder: MetricReminder
|
||||||
|
let isEditing: Bool
|
||||||
|
let onTapEdit: () -> Void
|
||||||
|
let onChange: () -> Void
|
||||||
|
let onDelete: () -> Void
|
||||||
|
|
||||||
|
@State private var pickedTime: Date = .now
|
||||||
|
@State private var hydrated = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
headerRow
|
||||||
|
if isEditing {
|
||||||
|
editingPanel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headerRow: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||||
|
Image(systemName: "bell.fill")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(reminder.displayName)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Toggle("", isOn: $reminder.enabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
|
.onChange(of: reminder.enabled) { _, _ in onChange() }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onTapEdit()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: isEditing ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var editingPanel: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("时间").font(.system(size: 13)).foregroundStyle(Tj.Palette.text2)
|
||||||
|
Spacer()
|
||||||
|
DatePicker("", selection: $pickedTime, displayedComponents: .hourAndMinute)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.labelsHidden()
|
||||||
|
.onChange(of: pickedTime) { _, new in
|
||||||
|
let cal = Calendar.current
|
||||||
|
reminder.hour = cal.component(.hour, from: new)
|
||||||
|
reminder.minute = cal.component(.minute, from: new)
|
||||||
|
onChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
weekdayRow
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
onDelete()
|
||||||
|
} label: {
|
||||||
|
Label("删除提醒", systemImage: "trash")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if !hydrated {
|
||||||
|
pickedTime = Calendar.current.date(
|
||||||
|
bySettingHour: reminder.hour, minute: reminder.minute, second: 0, of: .now
|
||||||
|
) ?? .now
|
||||||
|
hydrated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekdayRow: some View {
|
||||||
|
let names = ["一", "二", "三", "四", "五", "六", "日"]
|
||||||
|
let weekdayValues = [2, 3, 4, 5, 6, 7, 1]
|
||||||
|
return HStack(spacing: 6) {
|
||||||
|
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
|
||||||
|
Button {
|
||||||
|
var s = Set(reminder.weekdays)
|
||||||
|
if s.contains(w) { s.remove(w) } else { s.insert(w) }
|
||||||
|
reminder.weekdays = s.sorted()
|
||||||
|
onChange()
|
||||||
|
} label: {
|
||||||
|
Text(names[idx])
|
||||||
|
.font(.system(size: 13,
|
||||||
|
weight: reminder.weekdays.contains(w) ? .semibold : .regular))
|
||||||
|
.foregroundStyle(reminder.weekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 30)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.fill(reminder.weekdays.contains(w) ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line,
|
||||||
|
lineWidth: reminder.weekdays.contains(w) ? 0 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
RemindersListView()
|
||||||
|
}
|
||||||
|
.modelContainer(for: [MetricReminder.self], inMemory: true)
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ extension SeriesBucket {
|
|||||||
/// `minPoints` 以下的系列不返回,默认 2(单点不画线)。
|
/// `minPoints` 以下的系列不返回,默认 2(单点不画线)。
|
||||||
static func build(from indicators: [Indicator],
|
static func build(from indicators: [Indicator],
|
||||||
profile: UserProfile? = nil,
|
profile: UserProfile? = nil,
|
||||||
|
customMetrics: [CustomMonitorMetric] = [],
|
||||||
minPoints: Int = 2) -> [SeriesBucket] {
|
minPoints: Int = 2) -> [SeriesBucket] {
|
||||||
var buckets: [String: [Indicator]] = [:]
|
var buckets: [String: [Indicator]] = [:]
|
||||||
for i in indicators {
|
for i in indicators {
|
||||||
@@ -55,9 +56,15 @@ extension SeriesBucket {
|
|||||||
}
|
}
|
||||||
for k in bpKeys { buckets.removeValue(forKey: k) }
|
for k in bpKeys { buckets.removeValue(forKey: k) }
|
||||||
|
|
||||||
|
let customByKey: [String: CustomMonitorMetric] = Dictionary(
|
||||||
|
uniqueKeysWithValues: customMetrics.map { ($0.seriesKey, $0) }
|
||||||
|
)
|
||||||
|
|
||||||
for (key, items) in buckets {
|
for (key, items) in buckets {
|
||||||
guard items.count >= minPoints else { continue }
|
guard items.count >= minPoints else { continue }
|
||||||
if let bucket = buildSingle(key: key, items: items, profile: profile) {
|
if let bucket = buildSingle(key: key, items: items,
|
||||||
|
profile: profile,
|
||||||
|
custom: customByKey[key]) {
|
||||||
results.append(bucket)
|
results.append(bucket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,15 +74,24 @@ extension SeriesBucket {
|
|||||||
|
|
||||||
private static func buildSingle(key: String,
|
private static func buildSingle(key: String,
|
||||||
items: [Indicator],
|
items: [Indicator],
|
||||||
profile: UserProfile?) -> SeriesBucket? {
|
profile: UserProfile?,
|
||||||
|
custom: CustomMonitorMetric? = nil) -> SeriesBucket? {
|
||||||
let sorted = items.sorted { $0.capturedAt < $1.capturedAt }
|
let sorted = items.sorted { $0.capturedAt < $1.capturedAt }
|
||||||
guard let latest = sorted.last else { return nil }
|
guard let latest = sorted.last else { return nil }
|
||||||
|
|
||||||
|
// 优先 custom,其次 builtin metric,最后 fallback 到 Indicator 自身
|
||||||
let metric = monitorMetric(for: key)
|
let metric = monitorMetric(for: key)
|
||||||
let field = metric?.fields.first { $0.seriesKey == key }
|
let field = metric?.fields.first { $0.seriesKey == key }
|
||||||
let title = metric?.displayName ?? sorted.first?.name ?? key
|
let title = custom?.name
|
||||||
let unit = field?.unit ?? sorted.first?.unit ?? ""
|
?? metric?.displayName
|
||||||
let range = field.flatMap { metric?.effectiveRange(for: $0, profile: profile) }
|
?? sorted.first?.name
|
||||||
|
?? key
|
||||||
|
let unit = custom?.unit.nonEmptyOr(nil)
|
||||||
|
?? field?.unit
|
||||||
|
?? sorted.first?.unit
|
||||||
|
?? ""
|
||||||
|
let range = custom?.referenceRange
|
||||||
|
?? field.flatMap { metric?.effectiveRange(for: $0, profile: profile) }
|
||||||
|
|
||||||
let line = SeriesLine(
|
let line = SeriesLine(
|
||||||
id: key,
|
id: key,
|
||||||
@@ -151,3 +167,10 @@ extension SeriesBucket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
/// 空串 → fallback;非空 → 自身。
|
||||||
|
func nonEmptyOr(_ fallback: String?) -> String? {
|
||||||
|
trimmingCharacters(in: .whitespaces).isEmpty ? fallback : self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
179
康康/Features/Trends/SeriesChartCard.swift
Normal file
179
康康/Features/Trends/SeriesChartCard.swift
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct SeriesChartCard: View {
|
||||||
|
let bucket: SeriesBucket
|
||||||
|
|
||||||
|
private var allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)] {
|
||||||
|
bucket.lines.flatMap { line in line.points.map { (line, $0) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dateDomain: ClosedRange<Date>? {
|
||||||
|
let dates = allPoints.map(\.point.date)
|
||||||
|
guard let lo = dates.min(), let hi = dates.max() else { return nil }
|
||||||
|
if lo == hi {
|
||||||
|
// 只有一个点的极端情况:扩 1 天显示
|
||||||
|
let cal = Calendar.current
|
||||||
|
let earlier = cal.date(byAdding: .hour, value: -12, to: lo) ?? lo
|
||||||
|
let later = cal.date(byAdding: .hour, value: 12, to: hi) ?? hi
|
||||||
|
return earlier...later
|
||||||
|
}
|
||||||
|
return lo...hi
|
||||||
|
}
|
||||||
|
|
||||||
|
private var valueDomain: ClosedRange<Double>? {
|
||||||
|
var lo = Double.greatestFiniteMagnitude
|
||||||
|
var hi = -Double.greatestFiniteMagnitude
|
||||||
|
for (_, p) in allPoints {
|
||||||
|
lo = min(lo, p.value)
|
||||||
|
hi = max(hi, p.value)
|
||||||
|
}
|
||||||
|
for line in bucket.lines {
|
||||||
|
if let r = line.referenceRange {
|
||||||
|
lo = min(lo, r.lowerBound)
|
||||||
|
hi = max(hi, r.upperBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard lo < hi else { return nil }
|
||||||
|
let pad = max(1, (hi - lo) * 0.12)
|
||||||
|
return (lo - pad)...(hi + pad)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
header
|
||||||
|
chart
|
||||||
|
.frame(height: 120)
|
||||||
|
if bucket.lines.count > 1 {
|
||||||
|
legendLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .lastTextBaseline, spacing: 10) {
|
||||||
|
Text(bucket.title)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("\(allPoints.count) 条 · 近 \(daysSpanLabel)")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
latestValueBadge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var latestValueBadge: some View {
|
||||||
|
let parts = bucket.lines.compactMap { line -> String? in
|
||||||
|
guard let p = line.latestPoint else { return nil }
|
||||||
|
return formatValue(p.value)
|
||||||
|
}
|
||||||
|
let joined = parts.joined(separator: " / ")
|
||||||
|
let anyAbnormal = bucket.lines.contains { line in
|
||||||
|
(line.latestPoint?.status ?? .normal) != .normal
|
||||||
|
}
|
||||||
|
return HStack(spacing: 4) {
|
||||||
|
Text(joined)
|
||||||
|
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(anyAbnormal ? Tj.Palette.brick : Tj.Palette.text)
|
||||||
|
Text(bucket.unit)
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chart: some View {
|
||||||
|
Chart {
|
||||||
|
// 参考范围带
|
||||||
|
ForEach(bucket.lines) { line in
|
||||||
|
if let r = line.referenceRange,
|
||||||
|
let dom = dateDomain {
|
||||||
|
RectangleMark(
|
||||||
|
xStart: .value("start", dom.lowerBound),
|
||||||
|
xEnd: .value("end", dom.upperBound),
|
||||||
|
yStart: .value("lo", r.lowerBound),
|
||||||
|
yEnd: .value("hi", r.upperBound)
|
||||||
|
)
|
||||||
|
.foregroundStyle(line.color.opacity(0.08))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 折线 + 点
|
||||||
|
ForEach(bucket.lines) { line in
|
||||||
|
ForEach(line.points) { p in
|
||||||
|
LineMark(
|
||||||
|
x: .value("时间", p.date),
|
||||||
|
y: .value(line.label ?? bucket.title, p.value)
|
||||||
|
)
|
||||||
|
.foregroundStyle(line.color)
|
||||||
|
.interpolationMethod(.catmullRom)
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||||
|
}
|
||||||
|
.symbol {
|
||||||
|
Circle()
|
||||||
|
.fill(line.color)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks(values: .automatic(desiredCount: 4)) { _ in
|
||||||
|
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
|
||||||
|
AxisValueLabel(format: .dateTime.month(.abbreviated).day(),
|
||||||
|
centered: false)
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) { _ in
|
||||||
|
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
|
||||||
|
AxisValueLabel()
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYScale(domain: valueDomain ?? 0...1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var legendLine: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(bucket.lines) { line in
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(line.color)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(line.label ?? line.seriesKey)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var daysSpanLabel: String {
|
||||||
|
guard let dom = dateDomain else { return "—" }
|
||||||
|
let days = Calendar.current.dateComponents([.day],
|
||||||
|
from: dom.lowerBound,
|
||||||
|
to: dom.upperBound).day ?? 0
|
||||||
|
if days <= 0 { return "今天" }
|
||||||
|
if days < 30 { return "\(days) 天" }
|
||||||
|
if days < 365 { return "\(days / 30) 个月" }
|
||||||
|
return "\(days / 365) 年"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatValue(_ v: Double) -> String {
|
||||||
|
v.truncatingRemainder(dividingBy: 1) == 0
|
||||||
|
? String(format: "%.0f", v)
|
||||||
|
: String(format: "%.1f", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,10 +25,22 @@ struct TrendsView: View {
|
|||||||
@Query(sort: \Symptom.startedAt, order: .reverse)
|
@Query(sort: \Symptom.startedAt, order: .reverse)
|
||||||
private var symptoms: [Symptom]
|
private var symptoms: [Symptom]
|
||||||
|
|
||||||
|
@Query private var profiles: [UserProfile]
|
||||||
|
|
||||||
|
@Query private var customMetrics: [CustomMonitorMetric]
|
||||||
|
|
||||||
@State private var mode: CalendarMode = .month
|
@State private var mode: CalendarMode = .month
|
||||||
@State private var anchor: Date = .now
|
@State private var anchor: Date = .now
|
||||||
@State private var selectedDay: SelectedDay?
|
@State private var selectedDay: SelectedDay?
|
||||||
|
|
||||||
|
private var profile: UserProfile? { profiles.first }
|
||||||
|
|
||||||
|
private var seriesBuckets: [SeriesBucket] {
|
||||||
|
SeriesBucket.build(from: indicators,
|
||||||
|
profile: profile,
|
||||||
|
customMetrics: customMetrics)
|
||||||
|
}
|
||||||
|
|
||||||
private let calendar: Calendar = {
|
private let calendar: Calendar = {
|
||||||
var c = Calendar(identifier: .gregorian)
|
var c = Calendar(identifier: .gregorian)
|
||||||
c.firstWeekday = 2
|
c.firstWeekday = 2
|
||||||
@@ -54,6 +66,7 @@ struct TrendsView: View {
|
|||||||
anchorBar
|
anchorBar
|
||||||
calendarBody
|
calendarBody
|
||||||
legend
|
legend
|
||||||
|
seriesSection
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 24)
|
.padding(.bottom, 24)
|
||||||
@@ -186,6 +199,31 @@ struct TrendsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var seriesSection: some View {
|
||||||
|
let buckets = seriesBuckets
|
||||||
|
if !buckets.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(alignment: .lastTextBaseline) {
|
||||||
|
Text("长期监测")
|
||||||
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("\(buckets.count) 项")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(buckets) { bucket in
|
||||||
|
SeriesChartCard(bucket: bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var legend: some View {
|
private var legend: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("图例")
|
Text("图例")
|
||||||
|
|||||||
@@ -171,6 +171,98 @@ final class Symptom {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 用户自定义的长期监测指标。
|
||||||
|
/// 与 hardcoded `MonitorMetric` 并列出现在 IndicatorQuickSheet 的 grid 里;
|
||||||
|
/// `seriesKey` 自动生成成 `"custom.<uuid>"`,以此和 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<Double>? {
|
||||||
|
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 "已关闭" }
|
||||||
|
if isEveryDay { return "每天" }
|
||||||
|
if weekdays.isEmpty { return "未选日" }
|
||||||
|
let names = ["日", "一", "二", "三", "四", "五", "六"]
|
||||||
|
let sorted = weekdays.sorted()
|
||||||
|
return "每周 " + sorted.map { names[$0 - 1] }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeLabel: String {
|
||||||
|
String(format: "%02d:%02d", hour, minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Model
|
@Model
|
||||||
final class ChatTurn {
|
final class ChatTurn {
|
||||||
var question: String
|
var question: String
|
||||||
|
|||||||
@@ -77,18 +77,18 @@ struct RootView: View {
|
|||||||
.fullScreenCover(item: $activeFlow) { flow in
|
.fullScreenCover(item: $activeFlow) { flow in
|
||||||
switch flow {
|
switch flow {
|
||||||
case .quick:
|
case .quick:
|
||||||
QuickCaptureFlow(onClose: { activeFlow = nil })
|
UnifiedCaptureFlow(onClose: { activeFlow = nil })
|
||||||
case .archive:
|
case .archive:
|
||||||
ArchiveFlow(onClose: { activeFlow = nil })
|
UnifiedCaptureFlow(onClose: { activeFlow = nil })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
.sheet(item: $activeFlow) { flow in
|
.sheet(item: $activeFlow) { flow in
|
||||||
switch flow {
|
switch flow {
|
||||||
case .quick:
|
case .quick:
|
||||||
QuickCaptureFlow(onClose: { activeFlow = nil })
|
UnifiedCaptureFlow(onClose: { activeFlow = nil })
|
||||||
case .archive:
|
case .archive:
|
||||||
ArchiveFlow(onClose: { activeFlow = nil })
|
UnifiedCaptureFlow(onClose: { activeFlow = nil })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
218
康康/Services/CaptureService.swift
Normal file
218
康康/Services/CaptureService.swift
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// VL 解析结果(已结构化,可直接喂 SwiftData 模型构造)。
|
||||||
|
/// 与 Indicator/Report 字段近似但解耦 —— 这样 prompt schema 调整不污染数据层。
|
||||||
|
struct ParsedReport: Sendable {
|
||||||
|
var title: String
|
||||||
|
var typeRaw: String
|
||||||
|
var reportDate: Date
|
||||||
|
var institution: String
|
||||||
|
var summary: String
|
||||||
|
var pageCount: Int
|
||||||
|
var indicators: [ParsedIndicator]
|
||||||
|
|
||||||
|
struct ParsedIndicator: Sendable {
|
||||||
|
var name: String
|
||||||
|
var value: String
|
||||||
|
var unit: String
|
||||||
|
var range: String
|
||||||
|
var status: IndicatorStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一项都没识别出来 = 视作失败,UI 走手动录入回退。
|
||||||
|
var isEmpty: Bool { indicators.isEmpty }
|
||||||
|
|
||||||
|
/// 占位空结果,失败回退时给 UI。
|
||||||
|
static func empty(date: Date = .now) -> ParsedReport {
|
||||||
|
ParsedReport(
|
||||||
|
title: "",
|
||||||
|
typeRaw: ReportType.other.rawValue,
|
||||||
|
reportDate: date,
|
||||||
|
institution: "",
|
||||||
|
summary: "",
|
||||||
|
pageCount: 1,
|
||||||
|
indicators: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CaptureService 错误 — UI 决定怎么呈现(回退表单 vs 重试)。
|
||||||
|
enum CaptureError: Error, LocalizedError {
|
||||||
|
case modelNotReady
|
||||||
|
case writeAssetFailed
|
||||||
|
case inferenceFailed(String)
|
||||||
|
case parseFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .modelNotReady: return "VL 模型尚未就绪"
|
||||||
|
case .writeAssetFailed: return "图片保存失败"
|
||||||
|
case .inferenceFailed(let m): return "识别失败:\(m)"
|
||||||
|
case .parseFailed(let m): return "结构化失败:\(m)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CaptureService` 是 actor 是因为它的方法会等 AIRuntime(也是 actor),
|
||||||
|
/// 但本身不持任何可变状态 —— 单例 stateless,纯粹是 §3.1 模块边界的"门面"。
|
||||||
|
actor CaptureService {
|
||||||
|
static let shared = CaptureService()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
/// 写图 + VL 推理 + 解析 → ParsedReport。
|
||||||
|
/// 任何阶段失败,都抛 CaptureError;UI 接住后切到「手动录入」表单。
|
||||||
|
/// - Returns: (ParsedReport, [FileVault.SavedAsset]) 元组,
|
||||||
|
/// SavedAsset 列表用于后续构造 Asset @Model。
|
||||||
|
func analyze(images: [UIImage]) async throws
|
||||||
|
-> (parsed: ParsedReport, assets: [FileVault.SavedAsset]) {
|
||||||
|
|
||||||
|
// 1. 写图到 Vault(全程加密目录)
|
||||||
|
let assets: [FileVault.SavedAsset]
|
||||||
|
do {
|
||||||
|
assets = try images.map { try FileVault.shared.writeJPEG($0) }
|
||||||
|
} catch {
|
||||||
|
throw CaptureError.writeAssetFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. VL 推理
|
||||||
|
try await AIRuntime.shared.prepareVL()
|
||||||
|
let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
|
||||||
|
let raw: String
|
||||||
|
do {
|
||||||
|
raw = try await AIRuntime.shared.analyzeReport(
|
||||||
|
imageURLs: urls,
|
||||||
|
prompt: VLPrompts.reportExtraction
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw CaptureError.inferenceFailed("\(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. JSON 解析(带容错:可能包含围栏 / 前后文字)
|
||||||
|
do {
|
||||||
|
let parsed = try CaptureService.parseReportJSON(raw, pageCount: assets.count)
|
||||||
|
return (parsed, assets)
|
||||||
|
} catch let CaptureError.parseFailed(msg) {
|
||||||
|
throw CaptureError.parseFailed(msg)
|
||||||
|
} catch {
|
||||||
|
throw CaptureError.parseFailed("\(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON parse(static + 纯函数 → 方便单测)
|
||||||
|
|
||||||
|
/// 从 VL 输出里抠出第一段合法 JSON 对象并解析。
|
||||||
|
/// 容错:
|
||||||
|
/// - 去掉 ```json``` markdown 围栏
|
||||||
|
/// - 去掉首尾非 JSON 文字
|
||||||
|
/// - 缺字段填默认值
|
||||||
|
/// 解析不到任何 indicator 也算成功,但 ParsedReport.isEmpty = true,
|
||||||
|
/// UI 走「手动录入」分支。
|
||||||
|
static func parseReportJSON(_ raw: String, pageCount: Int = 1) throws -> ParsedReport {
|
||||||
|
let jsonString = extractJSONObject(from: raw)
|
||||||
|
guard let data = jsonString.data(using: .utf8) else {
|
||||||
|
throw CaptureError.parseFailed("非 UTF-8 输出")
|
||||||
|
}
|
||||||
|
let obj: Any
|
||||||
|
do {
|
||||||
|
obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
|
||||||
|
} catch {
|
||||||
|
throw CaptureError.parseFailed("JSON 不合法:\(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
guard let dict = obj as? [String: Any] else {
|
||||||
|
throw CaptureError.parseFailed("根节点不是对象")
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = (dict["title"] as? String)?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||||
|
let typeRaw = parseReportType(dict["type"] as? String)
|
||||||
|
let reportDate = parseDate(dict["report_date"] as? String) ?? .now
|
||||||
|
let institution = (dict["institution"] as? String) ?? ""
|
||||||
|
let summary = (dict["summary"] as? String) ?? ""
|
||||||
|
let pages = (dict["page_count"] as? Int) ?? pageCount
|
||||||
|
|
||||||
|
let indicatorsRaw = (dict["indicators"] as? [[String: Any]]) ?? []
|
||||||
|
let indicators: [ParsedReport.ParsedIndicator] = indicatorsRaw.compactMap {
|
||||||
|
parseIndicator($0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParsedReport(
|
||||||
|
title: title.isEmpty ? "拍摄识别" : title,
|
||||||
|
typeRaw: typeRaw,
|
||||||
|
reportDate: reportDate,
|
||||||
|
institution: institution,
|
||||||
|
summary: summary,
|
||||||
|
pageCount: max(pages, pageCount),
|
||||||
|
indicators: indicators
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从字符串里抠出第一段平衡的 {...}。处理 markdown 围栏、前后乱码。
|
||||||
|
/// 失败返回原字符串(后续 JSONSerialization 报错)。
|
||||||
|
static func extractJSONObject(from raw: String) -> String {
|
||||||
|
var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
// 去 markdown 围栏
|
||||||
|
if s.hasPrefix("```") {
|
||||||
|
// 砍掉首行 ```json 或 ```
|
||||||
|
if let firstNewline = s.firstIndex(of: "\n") {
|
||||||
|
s = String(s[s.index(after: firstNewline)...])
|
||||||
|
}
|
||||||
|
// 砍掉末尾 ```
|
||||||
|
if let endRange = s.range(of: "```", options: .backwards) {
|
||||||
|
s = String(s[..<endRange.lowerBound])
|
||||||
|
}
|
||||||
|
s = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找第一个 {,然后括号配对到匹配的 }
|
||||||
|
guard let start = s.firstIndex(of: "{") else { return s }
|
||||||
|
var depth = 0
|
||||||
|
var inString = false
|
||||||
|
var escape = false
|
||||||
|
var idx = start
|
||||||
|
while idx < s.endIndex {
|
||||||
|
let ch = s[idx]
|
||||||
|
if escape { escape = false }
|
||||||
|
else if ch == "\\" { escape = true }
|
||||||
|
else if ch == "\"" { inString.toggle() }
|
||||||
|
else if !inString {
|
||||||
|
if ch == "{" { depth += 1 }
|
||||||
|
else if ch == "}" {
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0 {
|
||||||
|
return String(s[start...idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx = s.index(after: idx)
|
||||||
|
}
|
||||||
|
return String(s[start...])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseReportType(_ raw: String?) -> String {
|
||||||
|
guard let raw = raw?.lowercased() else { return ReportType.other.rawValue }
|
||||||
|
return ReportType(rawValue: raw)?.rawValue ?? ReportType.other.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseDate(_ raw: String?) -> Date? {
|
||||||
|
guard let s = raw?.trimmingCharacters(in: .whitespaces), !s.isEmpty else { return nil }
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f.date(from: s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseIndicator(_ d: [String: Any]) -> ParsedReport.ParsedIndicator? {
|
||||||
|
guard let name = (d["name"] as? String)?.trimmingCharacters(in: .whitespaces),
|
||||||
|
!name.isEmpty else { return nil }
|
||||||
|
let value: String
|
||||||
|
if let v = d["value"] as? String { value = v }
|
||||||
|
else if let v = d["value"] as? NSNumber { value = v.stringValue }
|
||||||
|
else { value = "" }
|
||||||
|
let unit = (d["unit"] as? String) ?? ""
|
||||||
|
let range = (d["range"] as? String) ?? ""
|
||||||
|
let statusRaw = (d["status"] as? String)?.lowercased() ?? "normal"
|
||||||
|
let status = IndicatorStatus(rawValue: statusRaw) ?? .normal
|
||||||
|
return .init(name: name, value: value, unit: unit, range: range, status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
康康/Services/ReminderService.swift
Normal file
94
康康/Services/ReminderService.swift
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// 周期性指标提醒的本地通知调度。
|
||||||
|
/// 同一 `metricId` 在 iOS 通知中心展开成 N 条 weekly-repeats 通知,id 形如
|
||||||
|
/// `kangkang.reminder.<metricId>.w<weekday>`,方便按 weekday 单独 cancel。
|
||||||
|
///
|
||||||
|
/// 数据存 SwiftData `MetricReminder`;本服务只负责系统通知中心的同步,
|
||||||
|
/// 不写 SwiftData。两边写入的协调由调用方负责。
|
||||||
|
enum ReminderService {
|
||||||
|
|
||||||
|
static let idPrefix = "kangkang.reminder."
|
||||||
|
|
||||||
|
enum AuthState: String {
|
||||||
|
case granted, denied, notDetermined, provisional
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - authorization
|
||||||
|
|
||||||
|
static func currentAuthState() async -> AuthState {
|
||||||
|
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||||
|
switch settings.authorizationStatus {
|
||||||
|
case .authorized: return .granted
|
||||||
|
case .denied: return .denied
|
||||||
|
case .provisional: return .provisional
|
||||||
|
case .ephemeral: return .granted
|
||||||
|
case .notDetermined: return .notDetermined
|
||||||
|
@unknown default: return .notDetermined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 申请通知权限。已 granted/denied 时直接返回当前状态。
|
||||||
|
@discardableResult
|
||||||
|
static func requestAuthorization() async -> AuthState {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
if settings.authorizationStatus != .notDetermined {
|
||||||
|
return await currentAuthState()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
|
return granted ? .granted : .denied
|
||||||
|
} catch {
|
||||||
|
return .denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - upsert / cancel
|
||||||
|
|
||||||
|
/// 取消该 metric 在通知中心所有 pending 通知,再按当前 enabled/时间/weekdays 重排。
|
||||||
|
/// 调用方在 `MetricReminder` save 之后调用。
|
||||||
|
static func sync(_ reminder: MetricReminder) async {
|
||||||
|
cancel(metricId: reminder.metricId)
|
||||||
|
guard reminder.enabled, !reminder.weekdays.isEmpty else { return }
|
||||||
|
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "该测\(reminder.displayName)了"
|
||||||
|
content.body = "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"
|
||||||
|
content.sound = .default
|
||||||
|
content.threadIdentifier = "kangkang.reminder.\(reminder.metricId)"
|
||||||
|
|
||||||
|
for weekday in reminder.weekdays {
|
||||||
|
var comps = DateComponents()
|
||||||
|
comps.hour = reminder.hour
|
||||||
|
comps.minute = reminder.minute
|
||||||
|
comps.weekday = weekday
|
||||||
|
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
|
||||||
|
let id = identifier(metricId: reminder.metricId, weekday: weekday)
|
||||||
|
let request = UNNotificationRequest(identifier: id,
|
||||||
|
content: content,
|
||||||
|
trigger: trigger)
|
||||||
|
try? await center.add(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消某个 metric 的所有 pending 通知(7 个 weekday 一并取消,不漏)。
|
||||||
|
static func cancel(metricId: String) {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let ids = (1...7).map { identifier(metricId: metricId, weekday: $0) }
|
||||||
|
center.removePendingNotificationRequests(withIdentifiers: ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全清。Me Tab 一键关闭所有提醒时用。
|
||||||
|
static func cancelAll() {
|
||||||
|
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - helpers
|
||||||
|
|
||||||
|
private static func identifier(metricId: String, weekday: Int) -> String {
|
||||||
|
"\(idPrefix)\(metricId).w\(weekday)"
|
||||||
|
}
|
||||||
|
}
|
||||||
112
康康Tests/CaptureServiceJSONTests.swift
Normal file
112
康康Tests/CaptureServiceJSONTests.swift
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import 康康
|
||||||
|
|
||||||
|
struct CaptureServiceJSONTests {
|
||||||
|
|
||||||
|
@Test func parsesCleanJSON() throws {
|
||||||
|
let raw = """
|
||||||
|
{"title":"春检","type":"checkup","report_date":"2026-04-12","institution":"协和","page_count":2,"summary":"血脂偏高","indicators":[{"name":"LDL-C","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]}
|
||||||
|
"""
|
||||||
|
let parsed = try CaptureService.parseReportJSON(raw)
|
||||||
|
#expect(parsed.title == "春检")
|
||||||
|
#expect(parsed.typeRaw == ReportType.checkup.rawValue)
|
||||||
|
#expect(parsed.institution == "协和")
|
||||||
|
#expect(parsed.pageCount == 2)
|
||||||
|
#expect(parsed.indicators.count == 1)
|
||||||
|
#expect(parsed.indicators.first?.status == .high)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stripsMarkdownCodeFence() throws {
|
||||||
|
let raw = """
|
||||||
|
```json
|
||||||
|
{"title":"x","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[]}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
let parsed = try CaptureService.parseReportJSON(raw)
|
||||||
|
#expect(parsed.title == "x")
|
||||||
|
#expect(parsed.typeRaw == ReportType.lab.rawValue)
|
||||||
|
#expect(parsed.indicators.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func extractsObjectAfterLeadingText() throws {
|
||||||
|
let raw = """
|
||||||
|
好的,识别结果如下:
|
||||||
|
{"title":"y","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[]}
|
||||||
|
以上。
|
||||||
|
"""
|
||||||
|
let parsed = try CaptureService.parseReportJSON(raw)
|
||||||
|
#expect(parsed.title == "y")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func handlesNestedBraces() throws {
|
||||||
|
let raw = """
|
||||||
|
{"title":"y","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"含嵌套{x}对象","indicators":[]}
|
||||||
|
"""
|
||||||
|
let parsed = try CaptureService.parseReportJSON(raw)
|
||||||
|
#expect(parsed.summary == "含嵌套{x}对象")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func handlesEscapedQuotesInStrings() throws {
|
||||||
|
let raw = #"{"title":"y \"内嵌\" 引号","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[]}"#
|
||||||
|
let parsed = try CaptureService.parseReportJSON(raw)
|
||||||
|
#expect(parsed.title == #"y "内嵌" 引号"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fillsDefaultsForMissingFields() throws {
|
||||||
|
// 缺 title / type / report_date / institution / summary / page_count
|
||||||
|
let raw = """
|
||||||
|
{"indicators":[{"name":"X","value":"1","unit":"","range":"","status":"normal"}]}
|
||||||
|
"""
|
||||||
|
let parsed = try CaptureService.parseReportJSON(raw)
|
||||||
|
#expect(parsed.title == "拍摄识别") // 默认值
|
||||||
|
#expect(parsed.typeRaw == ReportType.other.rawValue)
|
||||||
|
#expect(parsed.indicators.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func skipsIndicatorsWithEmptyName() throws {
|
||||||
|
let raw = """
|
||||||
|
{"title":"t","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[
|
||||||
|
{"name":"","value":"1","unit":"","range":"","status":"normal"},
|
||||||
|
{"name":" ","value":"1","unit":"","range":"","status":"normal"},
|
||||||
|
{"name":"OK","value":"1","unit":"","range":"","status":"normal"}
|
||||||
|
]}
|
||||||
|
"""
|
||||||
|
let parsed = try CaptureService.parseReportJSON(raw)
|
||||||
|
#expect(parsed.indicators.count == 1)
|
||||||
|
#expect(parsed.indicators.first?.name == "OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func malformedJSONThrows() {
|
||||||
|
let raw = "完全不是 JSON"
|
||||||
|
#expect(throws: CaptureError.self) {
|
||||||
|
_ = try CaptureService.parseReportJSON(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func valueAsNumberStillParses() throws {
|
||||||
|
let raw = """
|
||||||
|
{"title":"t","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[{"name":"X","value":3.84,"unit":"","range":"","status":"high"}]}
|
||||||
|
"""
|
||||||
|
let parsed = try CaptureService.parseReportJSON(raw)
|
||||||
|
#expect(parsed.indicators.first?.value == "3.84")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unknownStatusFallsBackToNormal() throws {
|
||||||
|
let raw = """
|
||||||
|
{"title":"t","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[{"name":"X","value":"1","unit":"","range":"","status":"abnormal"}]}
|
||||||
|
"""
|
||||||
|
let parsed = try CaptureService.parseReportJSON(raw)
|
||||||
|
#expect(parsed.indicators.first?.status == .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func badReportDateFallsBackToNow() throws {
|
||||||
|
let raw = """
|
||||||
|
{"title":"t","type":"lab","report_date":"昨天","institution":"","page_count":1,"summary":"","indicators":[]}
|
||||||
|
"""
|
||||||
|
let parsed = try CaptureService.parseReportJSON(raw)
|
||||||
|
let now = Date()
|
||||||
|
let diff = abs(parsed.reportDate.timeIntervalSince(now))
|
||||||
|
#expect(diff < 5) // 5 秒内算 .now
|
||||||
|
}
|
||||||
|
}
|
||||||
145
康康Tests/CustomMonitorMetricTests.swift
Normal file
145
康康Tests/CustomMonitorMetricTests.swift
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import Testing
|
||||||
|
import SwiftData
|
||||||
|
import Foundation
|
||||||
|
@testable import 康康
|
||||||
|
|
||||||
|
struct CustomMonitorMetricTests {
|
||||||
|
|
||||||
|
private func makeContainer() throws -> ModelContainer {
|
||||||
|
let schema = Schema([
|
||||||
|
CustomMonitorMetric.self,
|
||||||
|
Indicator.self,
|
||||||
|
])
|
||||||
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||||
|
return try ModelContainer(for: schema, configurations: [config])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func seriesKeyAutoPrefixedWithCustom() {
|
||||||
|
let m = CustomMonitorMetric(name: "腰围", unit: "cm")
|
||||||
|
#expect(m.seriesKey.hasPrefix("custom."))
|
||||||
|
#expect(m.seriesKey.count > "custom.".count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func seriesKeyUniquePerInstance() {
|
||||||
|
let a = CustomMonitorMetric(name: "腰围", unit: "cm")
|
||||||
|
let b = CustomMonitorMetric(name: "腰围", unit: "cm")
|
||||||
|
#expect(a.seriesKey != b.seriesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func referenceRangeNilWhenBoundsAbsentOrInverted() {
|
||||||
|
let none = CustomMonitorMetric(name: "x", unit: "")
|
||||||
|
#expect(none.referenceRange == nil)
|
||||||
|
|
||||||
|
let inverted = CustomMonitorMetric(name: "x", unit: "", lowerBound: 100, upperBound: 50)
|
||||||
|
#expect(inverted.referenceRange == nil)
|
||||||
|
|
||||||
|
let valid = CustomMonitorMetric(name: "x", unit: "", lowerBound: 60, upperBound: 100)
|
||||||
|
#expect(valid.referenceRange == 60...100)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rangeTextFormattingDropsTrailingZero() {
|
||||||
|
let intRange = CustomMonitorMetric(name: "x", unit: "cm",
|
||||||
|
lowerBound: 70, upperBound: 90)
|
||||||
|
#expect(intRange.rangeText == "70 - 90")
|
||||||
|
|
||||||
|
let decimalRange = CustomMonitorMetric(name: "y", unit: "kg",
|
||||||
|
lowerBound: 60.5, upperBound: 65.5)
|
||||||
|
#expect(decimalRange.rangeText == "60.5 - 65.5")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func roundtripsThroughSwiftData() throws {
|
||||||
|
let container = try makeContainer()
|
||||||
|
let ctx = ModelContext(container)
|
||||||
|
let m = CustomMonitorMetric(name: "腰围", unit: "cm",
|
||||||
|
lowerBound: 70, upperBound: 90,
|
||||||
|
icon: "flame.fill")
|
||||||
|
ctx.insert(m)
|
||||||
|
try ctx.save()
|
||||||
|
|
||||||
|
let fetched = try #require(try ctx.fetch(FetchDescriptor<CustomMonitorMetric>()).first)
|
||||||
|
#expect(fetched.name == "腰围")
|
||||||
|
#expect(fetched.unit == "cm")
|
||||||
|
#expect(fetched.lowerBound == 70)
|
||||||
|
#expect(fetched.upperBound == 90)
|
||||||
|
#expect(fetched.icon == "flame.fill")
|
||||||
|
#expect(fetched.seriesKey.hasPrefix("custom."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func seriesBucketResolvesCustomTitleAndRange() {
|
||||||
|
let custom = CustomMonitorMetric(name: "腰围", unit: "cm",
|
||||||
|
lowerBound: 70, upperBound: 90)
|
||||||
|
let key = custom.seriesKey
|
||||||
|
let now = Date()
|
||||||
|
let day = { (offset: Int) -> Date in
|
||||||
|
Calendar.current.date(byAdding: .day, value: offset, to: now)!
|
||||||
|
}
|
||||||
|
let items = [
|
||||||
|
Indicator(name: "腰围", value: "80", unit: "cm", range: "70-90",
|
||||||
|
status: .normal, capturedAt: day(-2), seriesKey: key),
|
||||||
|
Indicator(name: "腰围", value: "82", unit: "cm", range: "70-90",
|
||||||
|
status: .normal, capturedAt: day(-1), seriesKey: key),
|
||||||
|
]
|
||||||
|
let buckets = SeriesBucket.build(from: items, customMetrics: [custom])
|
||||||
|
|
||||||
|
#expect(buckets.count == 1)
|
||||||
|
let b = try! #require(buckets.first)
|
||||||
|
#expect(b.title == "腰围")
|
||||||
|
#expect(b.unit == "cm")
|
||||||
|
#expect(b.lines.first?.referenceRange == 70...90)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nameConflictEmptyNameYieldsNone() {
|
||||||
|
let result = detectNameConflict(candidate: " ", customs: [])
|
||||||
|
#expect(result == .none)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nameConflictDetectsBuiltinMatch() {
|
||||||
|
let result = detectNameConflict(candidate: "血压", customs: [])
|
||||||
|
#expect(result == .builtin("血压"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nameConflictBuiltinIgnoresWhitespace() {
|
||||||
|
let result = detectNameConflict(candidate: " 空腹血糖 ", customs: [])
|
||||||
|
#expect(result == .builtin("空腹血糖"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nameConflictDetectsExistingCustom() {
|
||||||
|
let existing = CustomMonitorMetric(name: "腰围", unit: "cm")
|
||||||
|
let result = detectNameConflict(candidate: "腰围", customs: [existing])
|
||||||
|
#expect(result == .existingCustom("腰围"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nameConflictAllowsRenamingSelf() {
|
||||||
|
// 编辑自己时,即使没改名也不应该报冲突
|
||||||
|
let me = CustomMonitorMetric(name: "腰围", unit: "cm")
|
||||||
|
let result = detectNameConflict(
|
||||||
|
candidate: "腰围",
|
||||||
|
customs: [me],
|
||||||
|
excludingSeriesKey: me.seriesKey
|
||||||
|
)
|
||||||
|
#expect(result == .none)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nameConflictUnique() {
|
||||||
|
let result = detectNameConflict(candidate: "步数", customs: [])
|
||||||
|
#expect(result == .none)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func seriesBucketFallsBackToIndicatorNameWhenCustomMissing() {
|
||||||
|
// 用户删了 CustomMonitorMetric 但 Indicator 还在 → title fallback 到 indicator.name
|
||||||
|
let orphanKey = "custom.deleted-xxxxx"
|
||||||
|
let now = Date()
|
||||||
|
let items = [
|
||||||
|
Indicator(name: "睡眠时长", value: "7", unit: "h", range: "",
|
||||||
|
status: .normal, capturedAt: now, seriesKey: orphanKey),
|
||||||
|
Indicator(name: "睡眠时长", value: "8", unit: "h", range: "",
|
||||||
|
status: .normal, capturedAt: now.addingTimeInterval(60),
|
||||||
|
seriesKey: orphanKey),
|
||||||
|
]
|
||||||
|
let buckets = SeriesBucket.build(from: items, customMetrics: [])
|
||||||
|
let b = try! #require(buckets.first)
|
||||||
|
#expect(b.title == "睡眠时长")
|
||||||
|
#expect(b.unit == "h")
|
||||||
|
#expect(b.lines.first?.referenceRange == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
康康Tests/MetricReminderTests.swift
Normal file
79
康康Tests/MetricReminderTests.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import Testing
|
||||||
|
import SwiftData
|
||||||
|
import Foundation
|
||||||
|
@testable import 康康
|
||||||
|
|
||||||
|
struct MetricReminderTests {
|
||||||
|
|
||||||
|
private func makeContainer() throws -> ModelContainer {
|
||||||
|
let schema = Schema([MetricReminder.self])
|
||||||
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||||
|
return try ModelContainer(for: schema, configurations: [config])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func defaultsToEveryDayAt8AM() {
|
||||||
|
let r = MetricReminder(metricId: "bloodPressure", displayName: "血压")
|
||||||
|
#expect(r.hour == 8)
|
||||||
|
#expect(r.minute == 0)
|
||||||
|
#expect(r.weekdays == [1, 2, 3, 4, 5, 6, 7])
|
||||||
|
#expect(r.enabled == true)
|
||||||
|
#expect(r.isEveryDay)
|
||||||
|
#expect(r.frequencyLabel == "每天")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func hourMinuteClampedToValidRange() {
|
||||||
|
let early = MetricReminder(metricId: "x", displayName: "x", hour: -5, minute: 80)
|
||||||
|
#expect(early.hour == 0)
|
||||||
|
#expect(early.minute == 59)
|
||||||
|
let late = MetricReminder(metricId: "y", displayName: "y", hour: 25, minute: -3)
|
||||||
|
#expect(late.hour == 23)
|
||||||
|
#expect(late.minute == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func weekdaysRoundtripThroughSwiftData() throws {
|
||||||
|
let container = try makeContainer()
|
||||||
|
let ctx = ModelContext(container)
|
||||||
|
let r = MetricReminder(
|
||||||
|
metricId: "bloodPressure",
|
||||||
|
displayName: "血压",
|
||||||
|
hour: 7, minute: 30,
|
||||||
|
weekdays: [2, 4, 6]
|
||||||
|
)
|
||||||
|
ctx.insert(r)
|
||||||
|
try ctx.save()
|
||||||
|
|
||||||
|
let fetched = try #require(try ctx.fetch(FetchDescriptor<MetricReminder>()).first)
|
||||||
|
#expect(fetched.weekdays == [2, 4, 6])
|
||||||
|
#expect(fetched.isEveryDay == false)
|
||||||
|
#expect(fetched.frequencyLabel == "每周 一三五")
|
||||||
|
#expect(fetched.timeLabel == "07:30")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func disabledFrequencyLabel() {
|
||||||
|
let r = MetricReminder(metricId: "x", displayName: "x", enabled: false)
|
||||||
|
#expect(r.frequencyLabel == "已关闭")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyWeekdaysNotEveryDay() {
|
||||||
|
let r = MetricReminder(metricId: "x", displayName: "x", weekdays: [])
|
||||||
|
#expect(!r.isEveryDay)
|
||||||
|
#expect(r.frequencyLabel == "未选日")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func metricIdUniquenessEnforced() throws {
|
||||||
|
let container = try makeContainer()
|
||||||
|
let ctx = ModelContext(container)
|
||||||
|
let r1 = MetricReminder(metricId: "bp", displayName: "血压")
|
||||||
|
ctx.insert(r1)
|
||||||
|
try ctx.save()
|
||||||
|
|
||||||
|
let r2 = MetricReminder(metricId: "bp", displayName: "血压重复")
|
||||||
|
ctx.insert(r2)
|
||||||
|
try ctx.save()
|
||||||
|
|
||||||
|
// SwiftData @Attribute(.unique) 在冲突时合并/拒绝(具体行为版本依赖);
|
||||||
|
// 至少 fetch 总数 ≤ 1。
|
||||||
|
let all = try ctx.fetch(FetchDescriptor<MetricReminder>())
|
||||||
|
#expect(all.count == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ struct ModelsSchemaTests {
|
|||||||
ChatTurn.self,
|
ChatTurn.self,
|
||||||
Symptom.self,
|
Symptom.self,
|
||||||
UserProfile.self,
|
UserProfile.self,
|
||||||
|
MetricReminder.self,
|
||||||
|
CustomMonitorMetric.self,
|
||||||
])
|
])
|
||||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||||
return try ModelContainer(for: schema, configurations: [config])
|
return try ModelContainer(for: schema, configurations: [config])
|
||||||
|
|||||||
107
康康Tests/SeriesBucketTests.swift
Normal file
107
康康Tests/SeriesBucketTests.swift
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import 康康
|
||||||
|
|
||||||
|
struct SeriesBucketTests {
|
||||||
|
|
||||||
|
private func makeIndicator(
|
||||||
|
name: String = "测试",
|
||||||
|
value: String,
|
||||||
|
unit: String = "mmol/L",
|
||||||
|
range: String = "",
|
||||||
|
status: IndicatorStatus = .normal,
|
||||||
|
capturedAt: Date,
|
||||||
|
seriesKey: String?
|
||||||
|
) -> Indicator {
|
||||||
|
Indicator(name: name, value: value, unit: unit, range: range,
|
||||||
|
status: status, capturedAt: capturedAt,
|
||||||
|
seriesKey: seriesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func skipsIndicatorsWithoutSeriesKey() {
|
||||||
|
let now = Date()
|
||||||
|
let items = [
|
||||||
|
makeIndicator(value: "5.0", capturedAt: now, seriesKey: nil),
|
||||||
|
makeIndicator(value: "5.2", capturedAt: now, seriesKey: nil),
|
||||||
|
]
|
||||||
|
let buckets = SeriesBucket.build(from: items)
|
||||||
|
#expect(buckets.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func filtersOutSeriesWithFewerThanMinPoints() {
|
||||||
|
let now = Date()
|
||||||
|
let items = [
|
||||||
|
makeIndicator(value: "5.0", capturedAt: now, seriesKey: "glucose.fasting"),
|
||||||
|
]
|
||||||
|
let buckets = SeriesBucket.build(from: items, minPoints: 2)
|
||||||
|
#expect(buckets.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func singleSeriesBucketSortedAscending() {
|
||||||
|
let day = { (offset: Int) -> Date in
|
||||||
|
Calendar.current.date(byAdding: .day, value: offset, to: .now)!
|
||||||
|
}
|
||||||
|
let items = [
|
||||||
|
makeIndicator(value: "5.5", capturedAt: day(-3), seriesKey: "glucose.fasting"),
|
||||||
|
makeIndicator(value: "5.2", capturedAt: day(-1), seriesKey: "glucose.fasting"),
|
||||||
|
makeIndicator(value: "5.8", capturedAt: day(-2), seriesKey: "glucose.fasting"),
|
||||||
|
]
|
||||||
|
let buckets = SeriesBucket.build(from: items)
|
||||||
|
#expect(buckets.count == 1)
|
||||||
|
let line = try! #require(buckets.first?.lines.first)
|
||||||
|
// sorted ascending → -3, -2, -1
|
||||||
|
let values = line.points.map(\.value)
|
||||||
|
#expect(values == [5.5, 5.8, 5.2])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func bloodPressureMergesIntoSingleBucket() {
|
||||||
|
let now = Date()
|
||||||
|
let day = { (offset: Int) -> Date in
|
||||||
|
Calendar.current.date(byAdding: .day, value: offset, to: now)!
|
||||||
|
}
|
||||||
|
let items = [
|
||||||
|
makeIndicator(value: "125", capturedAt: day(-2), seriesKey: "bp.systolic"),
|
||||||
|
makeIndicator(value: "82", capturedAt: day(-2), seriesKey: "bp.diastolic"),
|
||||||
|
makeIndicator(value: "130", capturedAt: day(-1), seriesKey: "bp.systolic"),
|
||||||
|
makeIndicator(value: "85", capturedAt: day(-1), seriesKey: "bp.diastolic"),
|
||||||
|
]
|
||||||
|
let buckets = SeriesBucket.build(from: items)
|
||||||
|
let bp = try! #require(buckets.first { $0.id == "bp" })
|
||||||
|
#expect(bp.lines.count == 2)
|
||||||
|
#expect(bp.title == "血压")
|
||||||
|
#expect(bp.lines.contains { $0.seriesKey == "bp.systolic" })
|
||||||
|
#expect(bp.lines.contains { $0.seriesKey == "bp.diastolic" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func mixedSeriesProducesMultipleBucketsSortedByRecency() {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let day = { (offset: Int) -> Date in
|
||||||
|
cal.date(byAdding: .day, value: offset, to: .now)!
|
||||||
|
}
|
||||||
|
let items = [
|
||||||
|
// weight 较旧
|
||||||
|
makeIndicator(value: "68", capturedAt: day(-10), seriesKey: "weight"),
|
||||||
|
makeIndicator(value: "67", capturedAt: day(-7), seriesKey: "weight"),
|
||||||
|
// glucose 较新
|
||||||
|
makeIndicator(value: "5.1", capturedAt: day(-2), seriesKey: "glucose.fasting"),
|
||||||
|
makeIndicator(value: "5.3", capturedAt: day(-1), seriesKey: "glucose.fasting"),
|
||||||
|
]
|
||||||
|
let buckets = SeriesBucket.build(from: items)
|
||||||
|
#expect(buckets.count == 2)
|
||||||
|
// 最新的 glucose 排前面
|
||||||
|
#expect(buckets.first?.id == "glucose.fasting")
|
||||||
|
#expect(buckets.last?.id == "weight")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nonNumericValueDropped() {
|
||||||
|
let now = Date()
|
||||||
|
let items = [
|
||||||
|
makeIndicator(value: "高", capturedAt: now, seriesKey: "weight"),
|
||||||
|
makeIndicator(value: "68", capturedAt: now, seriesKey: "weight"),
|
||||||
|
makeIndicator(value: "67", capturedAt: now.addingTimeInterval(60), seriesKey: "weight"),
|
||||||
|
]
|
||||||
|
let buckets = SeriesBucket.build(from: items)
|
||||||
|
let line = try! #require(buckets.first?.lines.first)
|
||||||
|
#expect(line.points.count == 2) // "高" 被丢
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user