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:
@@ -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 {
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
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 {
|
||||
@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)
|
||||
}
|
||||
|
||||
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(单点不画线)。
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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("图例")
|
||||
|
||||
Reference in New Issue
Block a user