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

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