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:
link2026
2026-05-26 11:18:00 +08:00
parent 39edc25dc1
commit 1b01923c8e
27 changed files with 3128 additions and 29 deletions

View File

@@ -25,9 +25,11 @@ actor AIRuntime {
}
private(set) var status: Status = .notReady
private(set) var vlStatus: Status = .notReady
private(set) var lastDecodeRate: Double = 0
private var llmSession: LLMSession?
private var vlSession: VLSession?
private init() {}
@@ -96,4 +98,53 @@ actor AIRuntime {
private func recordRate(_ rate: Double) {
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)")
}
}
}

View 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
View 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
}
}
}
}

View File

@@ -12,6 +12,8 @@ struct KangkangApp: App {
ChatTurn.self,
Symptom.self,
UserProfile.self,
MetricReminder.self,
CustomMonitorMetric.self,
])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {

View File

@@ -15,6 +15,7 @@ struct ArchiveListView: View {
private var symptoms: [Symptom]
@State private var filter: TimelineKind? = nil
@State private var endingSymptom: Symptom?
@MainActor
private var allEntries: [TimelineEntry] {
@@ -52,7 +53,7 @@ struct ArchiveListView: View {
Section {
VStack(spacing: 10) {
ForEach(group.items) { entry in
TimelineRow(entry: entry)
rowView(for: entry)
}
}
.padding(.horizontal, 20)
@@ -67,6 +68,24 @@ struct ArchiveListView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.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 {

View 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)
}
}

View 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

View 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) }
}
}
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -48,9 +48,41 @@ struct IndicatorQuickSheet: View {
@State private var systolic: String = ""
@State private var diastolic: String = ""
// ()
@Query private var allReminders: [MetricReminder]
@State private var reminderEnabled: Bool = false
@State private var reminderTime: Date = Self.defaultReminderTime
@State private var reminderWeekdays: Set<Int> = Set(1...7)
@State private var reminderHydratedFor: String? = nil
@State private var notifAuthBlocked: Bool = false
//
@Query(sort: \CustomMonitorMetric.createdAt, order: .reverse)
private var customMetrics: [CustomMonitorMetric]
@State private var selectedCustom: CustomMonitorMetric?
@State private var editingCustom: CustomMetricEditTarget?
private static var defaultReminderTime: Date {
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
}
private var profile: UserProfile? { profiles.first }
private var isBP: Bool { selectedMonitor == .bloodPressure }
private var isLongTermMetric: Bool { selectedMonitor != nil || selectedCustom != nil }
private var isCustomMonitor: Bool { selectedCustom != nil }
/// key, reminder .task(id:) hydrate
/// metric.rawValue;custom seriesKey; monitor rawValue; nil
private var longTermKey: String? {
if let m = selectedMonitor { return m.rawValue }
if let cm = selectedCustom { return cm.seriesKey }
return nil
}
private var longTermDisplayName: String? {
selectedMonitor?.displayName ?? selectedCustom?.name
}
private var canSubmit: Bool {
if isBP {
@@ -78,16 +110,18 @@ struct IndicatorQuickSheet: View {
nameSection
valueRow
rangeSection
if selectedMonitor == nil {
// lab preset status ;monitor
statusSection
} else {
if isLongTermMetric {
autoStatusHint
} else {
statusSection
}
}
timeSection
noteSection
if isLongTermMetric {
reminderSection
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
@@ -95,6 +129,7 @@ struct IndicatorQuickSheet: View {
footer
}
.task(id: longTermKey) { hydrateReminder() }
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
@@ -138,8 +173,105 @@ struct IndicatorQuickSheet: View {
ForEach(MonitorMetric.allCases) { m in
monitorTile(m)
}
ForEach(customMetrics) { cm in
customTile(cm)
}
addCustomTile
}
}
.sheet(item: $editingCustom) { target in
CustomMetricEditor(existing: target.metric) { saved in
// ,
if let saved {
selectedCustom = saved
selectedMonitor = nil
selectedLabPreset = nil
fillFromCustom(saved)
} else if selectedCustom?.seriesKey == target.metric?.seriesKey {
selectedCustom = nil
clearAllFields()
}
}
}
}
private func customTile(_ cm: CustomMonitorMetric) -> some View {
let selected = selectedCustom?.seriesKey == cm.seriesKey
return Button {
applyCustom(cm)
} label: {
HStack(spacing: 10) {
Image(systemName: cm.icon)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
.frame(width: 32, height: 32)
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft))
VStack(alignment: .leading, spacing: 1) {
Text(cm.name)
.font(.system(size: 14, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.lineLimit(1)
Text("自定义")
.font(.system(size: 9, design: .monospaced))
.foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3)
}
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(selected ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)
)
}
.buttonStyle(.plain)
.contextMenu {
Button { editingCustom = CustomMetricEditTarget(metric: cm) } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) {
editingCustom = CustomMetricEditTarget(metric: cm)
} label: {
Label("编辑/删除", systemImage: "trash")
}
}
}
private var addCustomTile: some View {
Button {
editingCustom = CustomMetricEditTarget(metric: nil)
} label: {
HStack(spacing: 10) {
Image(systemName: "plus")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
.frame(width: 32, height: 32)
.background(
Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true)
)
Text("自定义")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2.opacity(0.5))
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line.opacity(0.6),
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
)
}
.buttonStyle(.plain)
}
private func monitorTile(_ m: MonitorMetric) -> some View {
@@ -265,8 +397,8 @@ struct IndicatorQuickSheet: View {
selectedLabPreset = nil
}
}
.disabled(selectedMonitor != nil)
.opacity(selectedMonitor != nil ? 0.6 : 1)
.disabled(isLongTermMetric)
.opacity(isLongTermMetric ? 0.6 : 1)
}
}
@@ -291,8 +423,8 @@ struct IndicatorQuickSheet: View {
.padding(.vertical, 12)
.background(fieldBg)
.overlay(fieldBorder)
.disabled(selectedMonitor != nil)
.opacity(selectedMonitor != nil ? 0.6 : 1)
.disabled(isLongTermMetric)
.opacity(isLongTermMetric ? 0.6 : 1)
}
.frame(maxWidth: 130)
}
@@ -314,8 +446,8 @@ struct IndicatorQuickSheet: View {
.padding(.vertical, 12)
.background(fieldBg)
.overlay(fieldBorder)
.disabled(selectedMonitor != nil)
.opacity(selectedMonitor != nil ? 0.6 : 1)
.disabled(isLongTermMetric)
.opacity(isLongTermMetric ? 0.6 : 1)
}
}
@@ -376,6 +508,193 @@ struct IndicatorQuickSheet: View {
}
}
// MARK: -
private var reminderSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
sectionLabel("周期提醒")
Spacer()
Toggle("", isOn: $reminderEnabled)
.labelsHidden()
.tint(Tj.Palette.ink)
.onChange(of: reminderEnabled) { _, on in
if on { Task { await requestNotifAuthIfNeeded() } }
}
}
if reminderEnabled {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("时间")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
DatePicker("", selection: $reminderTime,
displayedComponents: .hourAndMinute)
.datePickerStyle(.compact)
.labelsHidden()
}
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("频率")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Text(reminderFrequencyLabel)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
weekdayPickerRow
HStack(spacing: 8) {
quickFreqChip("每天") {
reminderWeekdays = Set(1...7)
}
quickFreqChip("工作日") {
reminderWeekdays = Set([2, 3, 4, 5, 6])
}
quickFreqChip("周末") {
reminderWeekdays = Set([1, 7])
}
}
}
if notifAuthBlocked {
Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.brick)
} else {
Text("本机提醒 · 不发任何数据")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
}
.padding(12)
.background(fieldBg)
.overlay(fieldBorder)
}
}
}
private var reminderFrequencyLabel: String {
if reminderWeekdays.count == 7 { return "每天" }
if reminderWeekdays.isEmpty { return "未选" }
let names = ["", "", "", "", "", "", ""]
let sorted = reminderWeekdays.sorted()
return "每周 " + sorted.map { names[$0 - 1] }.joined()
}
private var weekdayPickerRow: some View {
let names = ["", "", "", "", "", "", ""]
let weekdayValues = [2, 3, 4, 5, 6, 7, 1] // (Apple Calendar )
return HStack(spacing: 6) {
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
Button {
if reminderWeekdays.contains(w) {
reminderWeekdays.remove(w)
} else {
reminderWeekdays.insert(w)
}
} label: {
Text(names[idx])
.font(.system(size: 13,
weight: reminderWeekdays.contains(w) ? .semibold : .regular))
.foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 32)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(reminderWeekdays.contains(w) ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line,
lineWidth: reminderWeekdays.contains(w) ? 0 : 1)
)
}
.buttonStyle(.plain)
}
}
}
private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text2)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
}
private func hydrateReminder() {
guard let key = longTermKey else { return }
if reminderHydratedFor == key { return }
reminderHydratedFor = key
if let existing = allReminders.first(where: { $0.metricId == key }) {
reminderEnabled = existing.enabled
reminderTime = Calendar.current.date(
bySettingHour: existing.hour, minute: existing.minute, second: 0, of: .now
) ?? Self.defaultReminderTime
reminderWeekdays = Set(existing.weekdays)
} else {
reminderEnabled = false
reminderTime = Self.defaultReminderTime
reminderWeekdays = Set(1...7)
}
}
private func requestNotifAuthIfNeeded() async {
let state = await ReminderService.requestAuthorization()
notifAuthBlocked = (state == .denied)
if notifAuthBlocked {
reminderEnabled = false
}
}
/// submit() ,:enabled upsert SwiftData + ;disabled reminder +
private func persistReminderIfNeeded() async {
guard let key = longTermKey, let displayName = longTermDisplayName else { return }
let existing = allReminders.first(where: { $0.metricId == key })
let cal = Calendar.current
let hour = cal.component(.hour, from: reminderTime)
let minute = cal.component(.minute, from: reminderTime)
if reminderEnabled && !reminderWeekdays.isEmpty {
let reminder: MetricReminder
if let existing {
existing.enabled = true
existing.hour = hour
existing.minute = minute
existing.weekdays = reminderWeekdays.sorted()
existing.displayName = displayName
existing.updatedAt = .now
reminder = existing
} else {
let new = MetricReminder(
metricId: key,
displayName: displayName,
hour: hour,
minute: minute,
weekdays: reminderWeekdays.sorted(),
enabled: true
)
ctx.insert(new)
reminder = new
}
try? ctx.save()
await ReminderService.sync(reminder)
} else if let existing {
// : SwiftData , enabled = false,
existing.enabled = false
existing.updatedAt = .now
try? ctx.save()
ReminderService.cancel(metricId: key)
}
}
private var footer: some View {
HStack(spacing: 12) {
Button("取消") { dismiss() }
@@ -476,6 +795,7 @@ struct IndicatorQuickSheet: View {
}
selectedMonitor = m
selectedLabPreset = nil
selectedCustom = nil
if m == .bloodPressure {
// bp , name/value/unit
@@ -500,21 +820,53 @@ struct IndicatorQuickSheet: View {
private func applyLab(_ p: IndicatorPreset) {
selectedLabPreset = p
selectedMonitor = nil
selectedCustom = nil
systolic = ""; diastolic = ""
name = p.name
if unit.trimmingCharacters(in: .whitespaces).isEmpty { unit = p.unit }
if range.trimmingCharacters(in: .whitespaces).isEmpty { range = p.range }
}
private func applyCustom(_ cm: CustomMonitorMetric) {
if selectedCustom?.seriesKey == cm.seriesKey {
selectedCustom = nil
clearAllFields()
return
}
selectedCustom = cm
selectedMonitor = nil
selectedLabPreset = nil
fillFromCustom(cm)
}
private func fillFromCustom(_ cm: CustomMonitorMetric) {
name = cm.name
value = ""
unit = cm.unit
range = cm.rangeText
systolic = ""; diastolic = ""
}
private func clearAllFields() {
name = ""; value = ""; unit = ""; range = ""
systolic = ""; diastolic = ""
}
// MARK: - auto status
private var computedSingleStatus: (label: String, color: Color)? {
guard let m = selectedMonitor, m != .bloodPressure,
let v = Double(value.trimmingCharacters(in: .whitespaces)) else { return nil }
let f = m.fields[0]
let r = m.effectiveRange(for: f, profile: profile)
let s = MonitorMetric.status(value: v, in: r)
return (s.label, s.color)
guard let v = Double(value.trimmingCharacters(in: .whitespaces)) else { return nil }
if let m = selectedMonitor, m != .bloodPressure {
let f = m.fields[0]
let r = m.effectiveRange(for: f, profile: profile)
let s = MonitorMetric.status(value: v, in: r)
return (s.label, s.color)
}
if let cm = selectedCustom {
let s = MonitorMetric.status(value: v, in: cm.referenceRange)
return (s.label, s.color)
}
return nil
}
private enum BPSide { case systolic, diastolic }
@@ -538,10 +890,16 @@ struct IndicatorQuickSheet: View {
saveBP()
} else if let m = selectedMonitor {
saveSingleMonitor(m)
} else if let cm = selectedCustom {
saveCustom(cm)
} else {
saveFreeform()
}
dismiss()
Task {
await persistReminderIfNeeded()
await MainActor.run { dismiss() }
}
}
private func saveBP() {
@@ -603,6 +961,24 @@ struct IndicatorQuickSheet: View {
try? ctx.save()
}
private func saveCustom(_ cm: CustomMonitorMetric) {
let v = Double(value.trimmingCharacters(in: .whitespaces)) ?? 0
let status = MonitorMetric.status(value: v, in: cm.referenceRange)
let indicator = Indicator(
name: cm.name,
value: value.trimmingCharacters(in: .whitespaces),
unit: cm.unit,
range: cm.rangeText,
status: status,
note: note.isEmpty ? nil : note,
capturedAt: capturedAt,
pinned: true,
seriesKey: cm.seriesKey
)
ctx.insert(indicator)
try? ctx.save()
}
private func saveFreeform() {
let indicator = Indicator(
name: name.trimmingCharacters(in: .whitespaces),
@@ -638,7 +1014,16 @@ private extension IndicatorStatus {
}
}
/// `.sheet(item:)` Identifiable; CustomMonitorMetric? binding
struct CustomMetricEditTarget: Identifiable {
let metric: CustomMonitorMetric?
var id: String { metric?.seriesKey ?? "_new_" }
}
#Preview {
IndicatorQuickSheet()
.modelContainer(for: [Indicator.self, UserProfile.self], inMemory: true)
.modelContainer(for: [
Indicator.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self
], inMemory: true)
}

View 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)
}

View File

@@ -4,14 +4,19 @@ import SwiftData
struct MeView: View {
@Environment(\.modelContext) private var ctx
@Query private var profiles: [UserProfile]
@Query private var reminders: [MetricReminder]
@Query private var customMetrics: [CustomMonitorMetric]
private var profile: UserProfile? { profiles.first }
private var enabledReminderCount: Int { reminders.filter(\.enabled).count }
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 12) {
profileCard
remindersCard
customMetricsCard
settingsCard(title: "模型管理",
detail: "未配置",
icon: "cpu")
@@ -77,6 +82,85 @@ struct MeView: View {
.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 {
HStack(spacing: 12) {
ZStack {
@@ -114,6 +198,7 @@ struct MeView: View {
MeView()
.modelContainer(for: [
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)
}

View 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)
}

View File

@@ -36,6 +36,7 @@ extension SeriesBucket {
/// `minPoints` , 2(线)
static func build(from indicators: [Indicator],
profile: UserProfile? = nil,
customMetrics: [CustomMonitorMetric] = [],
minPoints: Int = 2) -> [SeriesBucket] {
var buckets: [String: [Indicator]] = [:]
for i in indicators {
@@ -55,9 +56,15 @@ extension SeriesBucket {
}
for k in bpKeys { buckets.removeValue(forKey: k) }
let customByKey: [String: CustomMonitorMetric] = Dictionary(
uniqueKeysWithValues: customMetrics.map { ($0.seriesKey, $0) }
)
for (key, items) in buckets {
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)
}
}
@@ -67,15 +74,24 @@ extension SeriesBucket {
private static func buildSingle(key: String,
items: [Indicator],
profile: UserProfile?) -> SeriesBucket? {
profile: UserProfile?,
custom: CustomMonitorMetric? = nil) -> SeriesBucket? {
let sorted = items.sorted { $0.capturedAt < $1.capturedAt }
guard let latest = sorted.last else { return nil }
// custom, builtin metric, fallback Indicator
let metric = monitorMetric(for: key)
let field = metric?.fields.first { $0.seriesKey == key }
let title = metric?.displayName ?? sorted.first?.name ?? key
let unit = field?.unit ?? sorted.first?.unit ?? ""
let range = field.flatMap { metric?.effectiveRange(for: $0, profile: profile) }
let title = custom?.name
?? metric?.displayName
?? 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(
id: key,
@@ -151,3 +167,10 @@ extension SeriesBucket {
}
}
}
private extension String {
/// fallback;
func nonEmptyOr(_ fallback: String?) -> String? {
trimmingCharacters(in: .whitespaces).isEmpty ? fallback : self
}
}

View 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)
}
}

View File

@@ -25,10 +25,22 @@ struct TrendsView: View {
@Query(sort: \Symptom.startedAt, order: .reverse)
private var symptoms: [Symptom]
@Query private var profiles: [UserProfile]
@Query private var customMetrics: [CustomMonitorMetric]
@State private var mode: CalendarMode = .month
@State private var anchor: Date = .now
@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 = {
var c = Calendar(identifier: .gregorian)
c.firstWeekday = 2
@@ -54,6 +66,7 @@ struct TrendsView: View {
anchorBar
calendarBody
legend
seriesSection
}
.padding(.horizontal, 20)
.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 {
VStack(alignment: .leading, spacing: 8) {
Text("图例")

View File

@@ -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
final class ChatTurn {
var question: String

View File

@@ -77,18 +77,18 @@ struct RootView: View {
.fullScreenCover(item: $activeFlow) { flow in
switch flow {
case .quick:
QuickCaptureFlow(onClose: { activeFlow = nil })
UnifiedCaptureFlow(onClose: { activeFlow = nil })
case .archive:
ArchiveFlow(onClose: { activeFlow = nil })
UnifiedCaptureFlow(onClose: { activeFlow = nil })
}
}
#else
.sheet(item: $activeFlow) { flow in
switch flow {
case .quick:
QuickCaptureFlow(onClose: { activeFlow = nil })
UnifiedCaptureFlow(onClose: { activeFlow = nil })
case .archive:
ArchiveFlow(onClose: { activeFlow = nil })
UnifiedCaptureFlow(onClose: { activeFlow = nil })
}
}
#endif

View 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)
}
}

View 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)"
}
}