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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user