```
feat(iOS): 更新MNN后端模型配置优化性能 将MNN主模型从Qwen3.5-4B(~2.64GiB)降级为Qwen3.5-2B(~1.1GiB),因为4B版本 实测运行过慢,影响用户体验。iPhone17+/SME2设备使用2B模型,保留MLX 兜底方案用于模拟器和备用场景,确保AI推理性能和存储效率的平衡。 ```
This commit is contained in:
@@ -30,6 +30,7 @@ struct ArchiveListView: View {
|
||||
@State private var filter: TimelineKind? = nil
|
||||
@State private var endingSymptom: Symptom?
|
||||
@State private var selectedEntry: TimelineEntry?
|
||||
@State private var selectedGroup: IndicatorGroup?
|
||||
@State private var route: Route?
|
||||
|
||||
@MainActor
|
||||
@@ -109,6 +110,9 @@ struct ArchiveListView: View {
|
||||
TimelineEntryDetailView(detail: d)
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedGroup) { group in
|
||||
IndicatorSeriesDetailView(group: group)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -123,9 +127,14 @@ struct ArchiveListView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
// 其余条目(报告/指标/日记/已结束症状):点 → 只读详情
|
||||
// 其余条目:指标 → 同类聚合详情(横向翻页 + 趋势);报告/日记/已结束症状 → 只读详情
|
||||
Button {
|
||||
if detail(for: entry) != nil { selectedEntry = entry }
|
||||
guard let d = detail(for: entry) else { return }
|
||||
switch d {
|
||||
case .indicator(let i): selectedGroup = IndicatorGroup.of(i)
|
||||
case .bloodPressure(let sys, _): selectedGroup = IndicatorGroup.of(sys)
|
||||
default: selectedEntry = entry
|
||||
}
|
||||
} label: {
|
||||
TimelineRow(entry: entry)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ struct HealthExportDetailView: View {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
|
||||
AIDisclaimerFooter()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
@@ -117,7 +119,7 @@ struct HealthExportDetailView: View {
|
||||
}
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
|
||||
|
||||
ShareLink(item: export.content) {
|
||||
ShareLink(item: AIDisclaimer.appended(to: export.content)) {
|
||||
Label("分享", systemImage: "square.and.arrow.up")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.tracking(1)
|
||||
@@ -149,7 +151,7 @@ struct HealthExportDetailView: View {
|
||||
}
|
||||
|
||||
private func copy() {
|
||||
UIPasteboard.general.string = export.content
|
||||
UIPasteboard.general.string = AIDisclaimer.appended(to: export.content)
|
||||
copiedFlash = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
|
||||
copiedFlash = false
|
||||
|
||||
@@ -22,6 +22,11 @@ struct HealthExportSheet: View {
|
||||
@State private var answeringTurnID: UUID?
|
||||
@FocusState private var questionFocused: Bool
|
||||
|
||||
// 快捷问答
|
||||
@State private var promptStore = QuickPromptStore.shared
|
||||
@State private var showAddPrompt = false
|
||||
@State private var newPromptText = ""
|
||||
|
||||
init(initialPrompt: String = "") {
|
||||
self.initialPrompt = initialPrompt
|
||||
}
|
||||
@@ -33,10 +38,16 @@ struct HealthExportSheet: View {
|
||||
!isGeneratingReport &&
|
||||
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
/// 已有有效用户对话内容。
|
||||
private var hasUserContent: Bool {
|
||||
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
||||
}
|
||||
|
||||
/// 可生成报告:有对话内容,或输入框里有文字(允许跳过多轮对话直接生成)。
|
||||
private var canGenerateReport: Bool {
|
||||
!isAnswering &&
|
||||
!isGeneratingReport &&
|
||||
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
||||
(hasUserContent || !draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -88,6 +99,75 @@ struct HealthExportSheet: View {
|
||||
questionFocused = true
|
||||
}
|
||||
.onDisappear { task?.cancel() }
|
||||
.alert("添加快捷问答", isPresented: $showAddPrompt) {
|
||||
TextField("输入一句常用问题…", text: $newPromptText)
|
||||
Button("取消", role: .cancel) { newPromptText = "" }
|
||||
Button("添加") {
|
||||
promptStore.add(prompt: newPromptText)
|
||||
newPromptText = ""
|
||||
}
|
||||
} message: {
|
||||
Text("保存后点一下,就能把这句话填进输入框")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 快捷问答
|
||||
|
||||
/// 内置 + 自定义快捷问答 chip 行;点 chip 填入输入框,末尾「+ 自定义」追加,长按自定义删除。
|
||||
private var quickPromptRow: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(promptStore.all) { p in
|
||||
quickPromptChip(p)
|
||||
}
|
||||
addQuickPromptChip
|
||||
}
|
||||
.padding(.vertical, 1) // 给 chip 描边留出像素,避免被 ScrollView 裁切
|
||||
}
|
||||
}
|
||||
|
||||
private func quickPromptChip(_ p: QuickPrompt) -> some View {
|
||||
Button {
|
||||
draftQuestion = p.prompt
|
||||
questionFocused = true
|
||||
} label: {
|
||||
Text(p.title)
|
||||
.font(.tjScaled( 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Tj.Palette.sand2))
|
||||
.overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
if !p.isBuiltin {
|
||||
Button(role: .destructive) {
|
||||
promptStore.delete(p)
|
||||
} label: {
|
||||
Label("删除", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var addQuickPromptChip: some View {
|
||||
Button { showAddPrompt = true } label: {
|
||||
Label("自定义", systemImage: "plus")
|
||||
.font(.tjScaled( 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Tj.Palette.paper))
|
||||
.overlay(
|
||||
Capsule().strokeBorder(
|
||||
Tj.Palette.line,
|
||||
style: StrokeStyle(lineWidth: 1, dash: [3])
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -128,14 +208,7 @@ struct HealthExportSheet: View {
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("例:最近血压波动大吗?")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("例:把我最近头晕、睡眠和指标变化整理给医生")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
quickPromptRow
|
||||
|
||||
Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
|
||||
.font(.tjScaled( 11))
|
||||
@@ -162,11 +235,11 @@ struct HealthExportSheet: View {
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
|
||||
if turn.id == answeringTurnID && turn.text.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("正在查看本地记录…")
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
AIFlowBar()
|
||||
}
|
||||
} else {
|
||||
Text(turn.text)
|
||||
@@ -196,6 +269,11 @@ struct HealthExportSheet: View {
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
MarkdownView(text: content)
|
||||
|
||||
if completed {
|
||||
Divider().background(Tj.Palette.lineSoft)
|
||||
AIDisclaimerFooter()
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -229,6 +307,9 @@ struct HealthExportSheet: View {
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
// AI 计算中:多彩流光线(与日记 AI 辅助同一组件)
|
||||
AIFlowBar().padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +372,7 @@ struct HealthExportSheet: View {
|
||||
}
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
|
||||
|
||||
ShareLink(item: content) {
|
||||
ShareLink(item: AIDisclaimer.appended(to: content)) {
|
||||
Label("分享", systemImage: "square.and.arrow.up")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.tracking(1)
|
||||
@@ -319,7 +400,7 @@ struct HealthExportSheet: View {
|
||||
private var composer: some View {
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("继续提问或补充情况…", text: $draftQuestion, axis: .vertical)
|
||||
TextField("写下要整理什么,或先提问补充情况…", text: $draftQuestion, axis: .vertical)
|
||||
.font(.tjScaled( 14))
|
||||
.lineLimit(1...4)
|
||||
.padding(.horizontal, 12)
|
||||
@@ -342,12 +423,28 @@ struct HealthExportSheet: View {
|
||||
.accessibilityLabel("发送问题")
|
||||
}
|
||||
|
||||
Button { startReportGeneration() } label: {
|
||||
Label("生成整理报告", systemImage: "doc.text.below.ecg")
|
||||
if isGeneratingReport {
|
||||
Button { stopGeneration() } label: {
|
||||
Label("停止生成", systemImage: "stop.fill")
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.brick, lineWidth: 1)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Button { startReportGeneration() } label: {
|
||||
Label("生成整理报告", systemImage: "doc.text.below.ecg")
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
|
||||
.disabled(!canGenerateReport)
|
||||
.opacity(canGenerateReport ? 1 : 0.45)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
|
||||
.disabled(!canGenerateReport)
|
||||
.opacity(canGenerateReport ? 1 : 0.45)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
@@ -402,6 +499,15 @@ struct HealthExportSheet: View {
|
||||
private func startReportGeneration() {
|
||||
guard canGenerateReport else { return }
|
||||
questionFocused = false
|
||||
|
||||
// 直接生成:输入框里有文字(快捷问答/手输)就把它作为一条诉求追加进对话,
|
||||
// 不必先走多轮问答 —— 用户点一下「生成报告」即可。
|
||||
let draft = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !draft.isEmpty {
|
||||
turns.append(.user(draft))
|
||||
draftQuestion = ""
|
||||
}
|
||||
|
||||
content = ""
|
||||
rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示
|
||||
error = nil
|
||||
@@ -435,6 +541,16 @@ struct HealthExportSheet: View {
|
||||
startReportGeneration()
|
||||
}
|
||||
|
||||
/// 停止正在进行的报告生成:取消推理任务,回到可重新生成的干净态(已写的诉求保留在对话里)。
|
||||
private func stopGeneration() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
phase = nil
|
||||
rate = 0
|
||||
completed = false
|
||||
content = ""
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
@@ -448,7 +564,7 @@ struct HealthExportSheet: View {
|
||||
}
|
||||
|
||||
private func copy() {
|
||||
UIPasteboard.general.string = content
|
||||
UIPasteboard.general.string = AIDisclaimer.appended(to: content)
|
||||
copiedFlash = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
|
||||
copiedFlash = false
|
||||
|
||||
92
康康/Features/Archive/QuickPrompt.swift
Normal file
92
康康/Features/Archive/QuickPrompt.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 「身体档案」里的快捷问答:点一下把一句常用问题填进输入框。
|
||||
/// 内置 3 条(不可删),用户可自定义追加(可删)。
|
||||
struct QuickPrompt: Identifiable, Codable, Equatable {
|
||||
let id: UUID
|
||||
var title: String // chip 上显示的短标签
|
||||
var prompt: String // 点击后填入输入框的完整问题
|
||||
var isBuiltin: Bool
|
||||
|
||||
init(id: UUID = UUID(), title: String, prompt: String, isBuiltin: Bool) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.prompt = prompt
|
||||
self.isBuiltin = isBuiltin
|
||||
}
|
||||
}
|
||||
|
||||
/// 快捷问答存储:内置常量 + 自定义条目(UserDefaults JSON,无 SwiftData schema 迁移风险)。
|
||||
/// 自定义条目只是 UI 便利项、不是健康记录,故不进 SwiftData。
|
||||
@Observable
|
||||
final class QuickPromptStore {
|
||||
static let shared = QuickPromptStore()
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let storageKey = "kk.quickPrompts.custom.v1"
|
||||
|
||||
private(set) var custom: [QuickPrompt]
|
||||
|
||||
private init() {
|
||||
if let data = defaults.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([QuickPrompt].self, from: data) {
|
||||
custom = decoded
|
||||
} else {
|
||||
custom = []
|
||||
}
|
||||
}
|
||||
|
||||
/// 内置在前、自定义在后,供 chip 行展示。
|
||||
var all: [QuickPrompt] { Self.builtins + custom }
|
||||
|
||||
/// 追加一条自定义问答。空白忽略;标签自动取问题前几个字。
|
||||
func add(prompt rawPrompt: String) {
|
||||
let trimmed = rawPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
custom.append(QuickPrompt(title: Self.deriveTitle(trimmed),
|
||||
prompt: trimmed,
|
||||
isBuiltin: false))
|
||||
persist()
|
||||
}
|
||||
|
||||
/// 删除一条自定义问答(内置不可删)。
|
||||
func delete(_ p: QuickPrompt) {
|
||||
guard !p.isBuiltin else { return }
|
||||
custom.removeAll { $0.id == p.id }
|
||||
persist()
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(custom) {
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义条目的短标签:压成单行,取前 8 个字,超出补省略号。
|
||||
static func deriveTitle(_ prompt: String) -> String {
|
||||
let oneLine = prompt.replacingOccurrences(of: "\n", with: " ")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
let head = oneLine.prefix(8)
|
||||
return oneLine.count > 8 ? "\(head)…" : String(head)
|
||||
}
|
||||
|
||||
/// 内置 3 条(首屏):覆盖「就诊 / 解读 / 速查」三类,数据依赖稳、不碰诊断红线。
|
||||
static let builtins: [QuickPrompt] = [
|
||||
QuickPrompt(
|
||||
title: "就诊摘要",
|
||||
prompt: "根据我最近的身体症状,结合历史指标,整理一份让门诊医生快速了解我情况的就诊摘要。",
|
||||
isBuiltin: true
|
||||
),
|
||||
QuickPrompt(
|
||||
title: "趋势解读",
|
||||
prompt: "把我血压最近半年的变化讲清楚:是变好还是变差、要注意什么。",
|
||||
isBuiltin: true
|
||||
),
|
||||
QuickPrompt(
|
||||
title: "速答清单",
|
||||
prompt: "把我的过敏史、正在吃的药、慢性病整理成一句话清单,方便就诊时快速回答医生。",
|
||||
isBuiltin: true
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -182,6 +182,7 @@ struct DiaryQuickSheet: View {
|
||||
questionRow(index: roundLocalIndex(at: idx), question: q)
|
||||
}
|
||||
}
|
||||
AIDisclaimerFooter()
|
||||
}
|
||||
|
||||
if exhaustedNote {
|
||||
@@ -212,30 +213,12 @@ struct DiaryQuickSheet: View {
|
||||
? String(appLoc: "让 AI 帮我想想还能记什么")
|
||||
: String(appLoc: "先写几个字,AI 来帮忙补充"),
|
||||
enabled: canRequestSuggest,
|
||||
prominent: true,
|
||||
action: requestSuggestions
|
||||
)
|
||||
|
||||
case .loading:
|
||||
HStack(spacing: 10) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("AI 思考中… 本地推理,通常 5-10 秒")
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
Button("取消") { cancelSuggestions() }
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.vertical, 11)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
assistLoadingIndicator
|
||||
|
||||
case .ready:
|
||||
assistPrimaryButton(
|
||||
@@ -273,26 +256,25 @@ struct DiaryQuickSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 辅助主按钮。`prominent` 为真走实心强调样式(填充 brick + 白字 + 轻投影,一眼可点),
|
||||
/// 否则走低调描边样式(用于 .ready 的「再问一轮」)。
|
||||
private func assistPrimaryButton(icon: String,
|
||||
label: String,
|
||||
enabled: Bool,
|
||||
prominent: Bool = false,
|
||||
action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
Text(label)
|
||||
}
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
||||
.font(.tjScaled( prominent ? 14 : 13, weight: .semibold))
|
||||
.foregroundStyle(prominent
|
||||
? (enabled ? Tj.Palette.paper : Tj.Palette.text3)
|
||||
: (enabled ? Tj.Palette.ink : Tj.Palette.text3))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 11)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(
|
||||
enabled ? Tj.Palette.ink : Tj.Palette.line,
|
||||
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
|
||||
)
|
||||
)
|
||||
.padding(.vertical, prominent ? 14 : 11)
|
||||
.background(assistButtonBackground(enabled: enabled, prominent: prominent))
|
||||
// 纯描边背景、内部透明:补 contentShape 让整框可点(否则只有图标+文字本体能点)。
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
@@ -300,6 +282,58 @@ struct DiaryQuickSheet: View {
|
||||
.disabled(!enabled)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func assistButtonBackground(enabled: Bool, prominent: Bool) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
if prominent {
|
||||
shape
|
||||
.fill(enabled ? Tj.Palette.brick : Tj.Palette.brickSoft)
|
||||
.shadow(color: enabled ? Tj.Palette.brick.opacity(0.30) : .clear,
|
||||
radius: 8, x: 0, y: 3)
|
||||
} else {
|
||||
shape
|
||||
.strokeBorder(
|
||||
enabled ? Tj.Palette.ink : Tj.Palette.line,
|
||||
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// .loading 等待态:安静的 paper 卡片,底部一条细窄的不确定进度条来回滑动(Linear/Vercel 式极简)。
|
||||
/// 不用高亮扫光、不填强调色,避免刺眼;只靠细线 + sparkles 轻脉冲传达「在算」。
|
||||
private var assistLoadingIndicator: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
Text(lastRate > 0
|
||||
? String(format: String(appLoc: "AI 生成中 · %.1f tok/s"), lastRate)
|
||||
: String(appLoc: "AI 生成中 · 本地推理"))
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 0)
|
||||
Button("取消") { cancelSuggestions() }
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.vertical, 11)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
.overlay(alignment: .bottom) {
|
||||
AIFlowBar().padding(.horizontal, 1)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||
}
|
||||
|
||||
/// 给定整张 questions list 里 idx 位置的 question,返回它在自己 round 内的序号(1-based)。
|
||||
private func roundLocalIndex(at idx: Int) -> Int {
|
||||
let target = questions[idx].round
|
||||
|
||||
@@ -174,7 +174,7 @@ struct IndicatorQuickSheet: View {
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
|
||||
/// 顶部「拍照识别」入口:并入原「异常项快拍」。点后由 RootView 切到相机 VL 流程。
|
||||
/// 顶部「拍照识别」入口:并入原「指标速记」。点后由 RootView 切到相机 VL 流程。
|
||||
@ViewBuilder
|
||||
private var cameraEntrySection: some View {
|
||||
if let onRequestCamera {
|
||||
|
||||
@@ -3,10 +3,10 @@ import SwiftUI
|
||||
/// 推理引擎设置:在 MNN(CPU/SME2,考核路径)与 MLX(GPU,兜底)间切换,并展示 SME2 探测状态。
|
||||
/// 切换只改持久化选择;下一次 AI 调用(prepare/generate)按新引擎加载。
|
||||
struct InferenceSettingsView: View {
|
||||
@AppStorage("kk.inferenceEngine") private var engineRaw = InferenceEngine.mnn.rawValue
|
||||
@AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue
|
||||
|
||||
private var selected: InferenceEngine {
|
||||
InferenceEngine(rawValue: engineRaw) ?? .mnn
|
||||
private var selected: EnginePreference {
|
||||
EnginePreference(rawValue: engineRaw) ?? .auto
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -21,7 +21,7 @@ struct InferenceSettingsView: View {
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
|
||||
ForEach(InferenceEngine.allCases, id: \.self) { engine in
|
||||
ForEach(EnginePreference.allCases, id: \.self) { engine in
|
||||
engineRow(engine)
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ struct InferenceSettingsView: View {
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private func engineRow(_ engine: InferenceEngine) -> some View {
|
||||
let available = engine.isAvailable
|
||||
private func engineRow(_ engine: EnginePreference) -> some View {
|
||||
let available = isAvailable(engine)
|
||||
let isOn = (selected == engine)
|
||||
return Button {
|
||||
guard available else { return }
|
||||
@@ -44,7 +44,7 @@ struct InferenceSettingsView: View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle().fill(isOn ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||
Image(systemName: engine == .mnn ? "cpu.fill" : "bolt.fill")
|
||||
Image(systemName: iconName(engine))
|
||||
.font(.tjScaled(18))
|
||||
.foregroundStyle(isOn ? Tj.Palette.ink : Tj.Palette.text2)
|
||||
}
|
||||
@@ -74,8 +74,35 @@ struct InferenceSettingsView: View {
|
||||
.disabled(!available)
|
||||
}
|
||||
|
||||
private func subtitle(_ engine: InferenceEngine, available: Bool) -> String {
|
||||
/// .auto 永远可用;具体引擎看自身可用性。
|
||||
private func isAvailable(_ engine: EnginePreference) -> Bool {
|
||||
switch engine {
|
||||
case .auto: return true
|
||||
case .mnn: return InferenceEngine.mnn.isAvailable
|
||||
case .mlx: return InferenceEngine.mlx.isAvailable
|
||||
}
|
||||
}
|
||||
|
||||
private func iconName(_ engine: EnginePreference) -> String {
|
||||
switch engine {
|
||||
case .auto: return "wand.and.stars"
|
||||
case .mnn: return "cpu.fill"
|
||||
case .mlx: return "bolt.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func subtitle(_ engine: EnginePreference, available: Bool) -> String {
|
||||
switch engine {
|
||||
case .auto:
|
||||
// 显示自动解析后实际命中的引擎,让用户看清「这台机选了什么」。
|
||||
let resolved = engine.resolved
|
||||
if resolved == .mnn {
|
||||
return InferenceEngine.cpuSupportsSME2
|
||||
? String(appLoc: "按本机配置选择 · 当前 MNN + SME2")
|
||||
: String(appLoc: "按本机配置选择 · 当前 MNN(NEON)")
|
||||
} else {
|
||||
return String(appLoc: "按本机配置选择 · 当前 MLX(MNN 不可用)")
|
||||
}
|
||||
case .mnn:
|
||||
if !available { return String(appLoc: "本设备/模拟器不可用,自动回退 MLX") }
|
||||
return InferenceEngine.cpuSupportsSME2
|
||||
|
||||
@@ -10,8 +10,6 @@ struct ModelManagementView: View {
|
||||
@State private var showCellularConfirm = false
|
||||
@State private var showImporter = false
|
||||
@State private var importError: String?
|
||||
@AppStorage(QuickRegionRecognitionEngine.storageKey)
|
||||
private var quickRegionEngineRaw = QuickRegionRecognitionEngine.defaultValue.rawValue
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let monitorQueue = DispatchQueue(label: "kk.netmonitor")
|
||||
@@ -27,8 +25,6 @@ struct ModelManagementView: View {
|
||||
modelCard(kind)
|
||||
}
|
||||
|
||||
recognitionEngineCard
|
||||
|
||||
actionButtons
|
||||
.padding(.top, 4)
|
||||
|
||||
@@ -80,46 +76,6 @@ struct ModelManagementView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 拍照识别引擎
|
||||
|
||||
private var selectedRecognitionEngine: QuickRegionRecognitionEngine {
|
||||
QuickRegionRecognitionEngine(storedValue: quickRegionEngineRaw)
|
||||
}
|
||||
|
||||
private var recognitionEngineCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
ZStack {
|
||||
Circle().fill(Tj.Palette.sand2)
|
||||
Image(systemName: "camera.metering.center.weighted")
|
||||
.font(.tjScaled( 18))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
.frame(width: 38, height: 38)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("异常项拍照识别")
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(selectedRecognitionEngine.detail)
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Picker("异常项拍照识别", selection: $quickRegionEngineRaw) {
|
||||
ForEach(QuickRegionRecognitionEngine.allCases) { engine in
|
||||
Text(engine.title).tag(engine.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.tjCard()
|
||||
}
|
||||
|
||||
// MARK: - 模型卡片
|
||||
|
||||
private func modelCard(_ kind: ModelKind) -> some View {
|
||||
@@ -198,7 +154,7 @@ struct ModelManagementView: View {
|
||||
} else if allReady {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
Text("Qwen3.5-4B 已就绪")
|
||||
Text("Qwen3.5-2B 已就绪")
|
||||
}
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
|
||||
@@ -421,7 +421,10 @@ private struct EntryInputField: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField(placeholder, text: $text, axis: .vertical)
|
||||
.lineLimit(1...4)
|
||||
.lineLimit(1...5)
|
||||
.foregroundStyle(Tj.Palette.text) // 固定深色:避免深色模式下继承系统 .primary 变白看不清
|
||||
.tint(Tj.Palette.ink)
|
||||
.frame(minHeight: 40, alignment: .top) // 初始就有聊天框体量,内容多了随 axis 增长
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
|
||||
@@ -2,7 +2,7 @@ import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
|
||||
/// 异常项快拍 · 统一流程。
|
||||
/// 指标速记 · 统一流程。
|
||||
/// 整幅单拍(真机)/ 相册(模拟器)→ 静态图手动框选 → 框内 OCR+LLM 抽指标 → 核对 → 存独立 Indicator。
|
||||
///
|
||||
/// 状态机:
|
||||
@@ -15,8 +15,6 @@ struct QuickRegionCaptureFlow: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
let onClose: () -> Void
|
||||
|
||||
@AppStorage(QuickRegionRecognitionEngine.storageKey)
|
||||
private var recognitionEngineRaw = QuickRegionRecognitionEngine.defaultValue.rawValue
|
||||
@State private var phase: Phase = .idle
|
||||
|
||||
enum Phase {
|
||||
@@ -59,7 +57,7 @@ struct QuickRegionCaptureFlow: View {
|
||||
onCancel: { onClose() },
|
||||
onRetake: { phase = .idle }
|
||||
)
|
||||
.navigationTitle(String(appLoc: "核对异常项"))
|
||||
.navigationTitle(String(appLoc: "核对指标"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
@@ -97,29 +95,18 @@ struct QuickRegionCaptureFlow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 识别(框内子图 → OCR → LLM)
|
||||
// MARK: - 识别(框内子图 → Vision OCR → Qwen3 整理)
|
||||
|
||||
/// 对已裁好的框内子图跑识别。失败/超时返回提示文案,绝不抛出(由 RegionAdjustView 展示)。
|
||||
/// 链路由「我的 → 模型管理 → 拍照识别引擎」决定:
|
||||
/// - Apple Vision:Vision 端侧 OCR → Qwen3-1.7B 结构化抽指标
|
||||
/// - Qwen3-VL:局部图片 → Qwen3-VL 直接结构化抽指标
|
||||
/// 固定链路:Vision 端侧 OCR 出文字 → Qwen3 跑一次结构化整理抽指标。
|
||||
/// (旧的「大模型直读」VL 路径已移除:端侧看图慢且易卡,OCR→整理又快又准。)
|
||||
private func recognizeRegion(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) {
|
||||
let engine = QuickRegionRecognitionEngine(storedValue: recognitionEngineRaw)
|
||||
switch engine {
|
||||
case .appleVision:
|
||||
return await recognizeWithAppleVision(image)
|
||||
case .qwenVL:
|
||||
return await recognizeWithQwenVL(image)
|
||||
}
|
||||
}
|
||||
|
||||
private func recognizeWithAppleVision(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) {
|
||||
do {
|
||||
let text = try await OCRService.recognizeText(in: image)
|
||||
if Task.isCancelled { return ([], nil) } // 超时:文案由调用方给
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#if DEBUG
|
||||
print("🔤 [OCR · region] recognized text:\n\(trimmed)\n--- end OCR ---")
|
||||
NSLog("KKDBG-OCR region text:\n%@\n--- end OCR ---", trimmed)
|
||||
#endif
|
||||
if trimmed.isEmpty {
|
||||
return ([], String(appLoc: "没识别到文字,挪一下框再试"))
|
||||
@@ -139,30 +126,6 @@ struct QuickRegionCaptureFlow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func recognizeWithQwenVL(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) {
|
||||
let prepared = RegionImageCropper.prepareForQwenVL(image)
|
||||
guard let data = prepared.jpegData(compressionQuality: 0.95) else {
|
||||
return ([], String(appLoc: "图片编码失败,手动补充"))
|
||||
}
|
||||
#if DEBUG
|
||||
print("🖼️ [Qwen3-VL region] prepared image=\(Int(prepared.size.width))x\(Int(prepared.size.height)), bytes=\(data.count)")
|
||||
#endif
|
||||
do {
|
||||
let parsed = try await CaptureService.shared.recognizeRegion(imageData: data)
|
||||
if Task.isCancelled { return ([], nil) }
|
||||
let items = Self.buildItems(from: parsed)
|
||||
return (items, items.isEmpty ? String(appLoc: "没读出指标,挪一下框再试") : nil)
|
||||
} catch CaptureError.modelNotReady {
|
||||
return ([], String(appLoc: "模型未就绪,请在模型管理下载或切回 Apple Vision"))
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
return ([], String(appLoc: "解析失败:\(msg)"))
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
return ([], Task.isCancelled ? nil : String(appLoc: "识别失败:\(msg)"))
|
||||
} catch {
|
||||
return ([], Task.isCancelled ? nil : String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
|
||||
/// LLM 结果 → 可编辑行,异常项(high/low)置顶、默认勾选。
|
||||
private static func buildItems(from parsed: [ParsedReport.ParsedIndicator]) -> [QuickRegionItem] {
|
||||
let mapped = parsed.map {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// 异常项快拍 · 确认页。VL 识别结果逐项可编辑 + 勾选纳入,确认后只存数值(不留图)。
|
||||
/// 指标速记 · 确认页。VL 识别结果逐项可编辑 + 勾选纳入,确认后只存数值(不留图)。
|
||||
/// 与「记录指标」自由输入落库一致 —— 每个勾选项 = 一条独立 Indicator。
|
||||
struct QuickRegionConfirmView: View {
|
||||
let image: UIImage?
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum QuickRegionRecognitionEngine: String, CaseIterable, Identifiable, Sendable {
|
||||
case appleVision
|
||||
case qwenVL
|
||||
|
||||
static let storageKey = "quickRegionRecognitionEngine"
|
||||
static let defaultValue: QuickRegionRecognitionEngine = .appleVision
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
init(storedValue: String) {
|
||||
self = QuickRegionRecognitionEngine(rawValue: storedValue) ?? Self.defaultValue
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .appleVision: return String(appLoc: "Apple Vision")
|
||||
case .qwenVL: return String(appLoc: "大模型直读")
|
||||
}
|
||||
}
|
||||
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .appleVision:
|
||||
return String(appLoc: "系统 OCR + 文本模型解析")
|
||||
case .qwenVL:
|
||||
return String(appLoc: "Qwen3.5-4B 多模态直接看图(MNN/MLX)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import SwiftUI
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
/// 异常项快拍 · 静态图框选识别。
|
||||
/// 指标速记 · 静态图框选识别。
|
||||
/// 拍/选一张后,在静态照片上手动拖动 + 缩放一个方框,点「识别」只对框内做 OCR+LLM。
|
||||
/// 可反复挪框重识别,满意后进入核对页;0 项也能进核对手动补(失败回退红线)。
|
||||
struct RegionAdjustView: View {
|
||||
|
||||
@@ -3,7 +3,7 @@ import AVFoundation
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
/// 异常项快拍 · 整幅单拍相机。
|
||||
/// 指标速记 · 整幅单拍相机。
|
||||
/// 全屏实时预览 + 一个快门 → 返回**整幅** upright UIImage(不裁剪)。
|
||||
/// 拍完后由 `RegionAdjustView` 在静态图上手动框选识别区域。
|
||||
/// 只在真机可用(模拟器无相机,`QuickRegionCaptureFlow` 退化到 PhotoPicker)。
|
||||
@@ -60,7 +60,7 @@ struct SingleShotCameraView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("拍一张含异常指标的照片 · 拍完再框选")
|
||||
Text("拍一张含目标指标的照片 · 拍完再框选")
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
@@ -97,7 +97,7 @@ struct SingleShotCameraView: View {
|
||||
Text("相机权限未开启")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(.white)
|
||||
Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。")
|
||||
Text("指标速记需要相机。去「设置 → 康康 → 相机」打开后再回来。")
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -352,49 +352,6 @@ enum RegionImageCropper {
|
||||
guard rect.width >= 1, rect.height >= 1, let cropped = cg.cropping(to: rect) else { return up }
|
||||
return UIImage(cgImage: cropped, scale: up.scale, orientation: .up)
|
||||
}
|
||||
|
||||
/// Qwen3-VL 局部图预处理:宽而矮的小框直接喂 VL 时,processor 再缩放容易把小字压没。
|
||||
/// 这里只用于 Qwen3-VL 分支,Apple Vision OCR 保持吃原始裁剪图。
|
||||
static func prepareForQwenVL(_ image: UIImage,
|
||||
minimumShortEdge: CGFloat = 448,
|
||||
maximumLongEdge: CGFloat = 2400,
|
||||
padding: CGFloat = 64) -> UIImage {
|
||||
let up = image.normalizedUp()
|
||||
guard let cg = up.cgImage else { return up }
|
||||
|
||||
let sourceSize = CGSize(width: cg.width, height: cg.height)
|
||||
guard sourceSize.width > 0, sourceSize.height > 0 else { return up }
|
||||
|
||||
let short = min(sourceSize.width, sourceSize.height)
|
||||
let long = max(sourceSize.width, sourceSize.height)
|
||||
var scale = max(1, minimumShortEdge / short)
|
||||
if long * scale > maximumLongEdge {
|
||||
scale = maximumLongEdge / long
|
||||
}
|
||||
|
||||
let contentSize = CGSize(
|
||||
width: max(1, (sourceSize.width * scale).rounded()),
|
||||
height: max(1, (sourceSize.height * scale).rounded())
|
||||
)
|
||||
let canvasSize = CGSize(
|
||||
width: contentSize.width + padding * 2,
|
||||
height: contentSize.height + padding * 2
|
||||
)
|
||||
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = 1
|
||||
format.opaque = true
|
||||
let renderer = UIGraphicsImageRenderer(size: canvasSize, format: format)
|
||||
return renderer.image { ctx in
|
||||
UIColor.white.setFill()
|
||||
ctx.fill(CGRect(origin: .zero, size: canvasSize))
|
||||
|
||||
UIImage(cgImage: cg, scale: 1, orientation: .up).draw(
|
||||
in: CGRect(x: padding, y: padding,
|
||||
width: contentSize.width, height: contentSize.height)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
|
||||
@@ -5,12 +5,12 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
var id: String { rawValue }
|
||||
|
||||
/// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。
|
||||
/// 注:`.quick`(异常项快拍)已并入 `.indicator`(记录指标)内的「拍照识别」,不再单列。
|
||||
/// 注:`.quick`(指标速记)已并入 `.indicator`(记录指标)内的「拍照识别」,不再单列。
|
||||
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .healthExport, .archive]
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .quick: return String(appLoc: "异常项快拍")
|
||||
case .quick: return String(appLoc: "指标速记")
|
||||
case .indicator: return String(appLoc: "记录指标")
|
||||
case .healthExport: return String(appLoc: "身体档案")
|
||||
case .archive: return String(appLoc: "体检报告归档")
|
||||
|
||||
457
康康/Features/Timeline/IndicatorSeriesDetailView.swift
Normal file
457
康康/Features/Timeline/IndicatorSeriesDetailView.swift
Normal file
@@ -0,0 +1,457 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 一个指标的「同类组」标识。决定聚合详情里收哪些历次记录、跳哪个趋势 bucket。
|
||||
/// - `.series`:有 seriesKey 的长期监测指标(血糖/体重/...)。
|
||||
/// - `.bloodPressure`:血压(bp.systolic + bp.diastolic 合并成一对)。
|
||||
/// - `.lab`:无 seriesKey 的化验/手动指标,按 name+unit 归一化 key 聚合。
|
||||
enum IndicatorGroup: Identifiable, Hashable {
|
||||
case series(key: String)
|
||||
case bloodPressure
|
||||
case lab(key: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .series(let k): return "series:\(k)"
|
||||
case .bloodPressure: return "bp"
|
||||
case .lab(let k): return "lab:\(k)"
|
||||
}
|
||||
}
|
||||
|
||||
/// 从单条指标推断其所属同类组(与趋势页 SeriesBucket 的分组语义一致)。
|
||||
static func of(_ i: Indicator) -> IndicatorGroup {
|
||||
if let key = i.seriesKey, !key.isEmpty {
|
||||
return key.hasPrefix("bp.") ? .bloodPressure : .series(key: key)
|
||||
}
|
||||
return .lab(key: SeriesBucket.normalizedKey(name: i.name, unit: i.unit))
|
||||
}
|
||||
}
|
||||
|
||||
/// 同类指标聚合详情:横向翻页看该指标的历次记录,底部可跳趋势图。
|
||||
/// 从「记录」页点指标条目进入。数据全部 @Query 实时,删除后翻页列表自动更新。
|
||||
struct IndicatorSeriesDetailView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var ctx
|
||||
|
||||
let group: IndicatorGroup
|
||||
|
||||
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
||||
private var indicators: [Indicator]
|
||||
@Query private var profiles: [UserProfile]
|
||||
@Query private var customMetrics: [CustomMonitorMetric]
|
||||
|
||||
@State private var selection: String?
|
||||
@State private var showTrend = false
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var evidenceTarget: Indicator?
|
||||
|
||||
// MARK: - 数据
|
||||
|
||||
/// 聚合详情里的一页:单值指标一条;血压一对。
|
||||
private enum Record: Identifiable {
|
||||
case single(Indicator)
|
||||
case bp(sys: Indicator, dia: Indicator?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .single(let i): return "\(i.persistentModelID)"
|
||||
case .bp(let s, _): return "bp-\(s.persistentModelID)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 历次血压对:以 bp.systolic 为锚,按 ±5s 配 bp.diastolic(同 TimelineEntry 合并规则)。
|
||||
private var bloodPressureRecords: [Record] {
|
||||
let sysList = indicators
|
||||
.filter { $0.seriesKey == "bp.systolic" }
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
var usedDia = Set<PersistentIdentifier>()
|
||||
return sysList.map { sys in
|
||||
let dia = indicators.first {
|
||||
$0.seriesKey == "bp.diastolic" &&
|
||||
!usedDia.contains($0.persistentModelID) &&
|
||||
abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5
|
||||
}
|
||||
if let dia { usedDia.insert(dia.persistentModelID) }
|
||||
return .bp(sys: sys, dia: dia)
|
||||
}
|
||||
}
|
||||
|
||||
private var records: [Record] {
|
||||
switch group {
|
||||
case .bloodPressure:
|
||||
return bloodPressureRecords
|
||||
case .series(let key):
|
||||
return indicators
|
||||
.filter { $0.seriesKey == key }
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
.map(Record.single)
|
||||
case .lab(let nk):
|
||||
return indicators
|
||||
.filter {
|
||||
($0.seriesKey ?? "").isEmpty &&
|
||||
SeriesBucket.normalizedKey(name: $0.name, unit: $0.unit) == nk
|
||||
}
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
.map(Record.single)
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch group {
|
||||
case .bloodPressure:
|
||||
return String(appLoc: "血压")
|
||||
case .series, .lab:
|
||||
if case let .single(i)? = records.first { return i.name }
|
||||
return String(appLoc: "指标详情")
|
||||
}
|
||||
}
|
||||
|
||||
/// 对应的趋势 bucket(需 ≥2 个可解析数值点才存在);nil 时隐藏「查看趋势图」。
|
||||
private var bucket: SeriesBucket? {
|
||||
let all = SeriesBucket.build(from: indicators,
|
||||
profile: profiles.first,
|
||||
customMetrics: customMetrics)
|
||||
switch group {
|
||||
case .bloodPressure:
|
||||
return all.first { $0.id == "bp" }
|
||||
case .series(let key):
|
||||
return all.first { b in b.lines.contains { $0.seriesKey == key } }
|
||||
case .lab(let nk):
|
||||
return all.first { $0.kind == .lab && $0.id == "lab:\(nk)" }
|
||||
}
|
||||
}
|
||||
|
||||
private var currentIndex: Int {
|
||||
records.firstIndex { $0.id == selection } ?? 0
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
if records.isEmpty {
|
||||
Spacer()
|
||||
TjPlaceholder(label: String(appLoc: "记录已不存在"))
|
||||
.frame(width: 200, height: 120)
|
||||
Spacer()
|
||||
} else {
|
||||
pages
|
||||
pager
|
||||
if bucket != nil { trendButton }
|
||||
}
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationDestination(isPresented: $showTrend) {
|
||||
if let bucket { TrendDetailView(bucket: bucket) }
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
.presentationCornerRadius(Tj.Radius.xl)
|
||||
.onAppear { if selection == nil { selection = records.first?.id } }
|
||||
.alert(String(appLoc: "永久删除这条记录?"), isPresented: $showDeleteConfirm) {
|
||||
Button(String(appLoc: "删除"), role: .destructive) { deleteCurrent() }
|
||||
Button(String(appLoc: "取消"), role: .cancel) { }
|
||||
} message: {
|
||||
Text("删除后无法恢复。")
|
||||
}
|
||||
.sheet(item: $evidenceTarget) { indicator in
|
||||
if let report = indicator.report {
|
||||
EvidenceImagePreview(report: report, indicator: indicator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.tjScaled(16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(Tj.Palette.sand2))
|
||||
}
|
||||
Text(title)
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
if records.count > 1 {
|
||||
Text("\(records.count) 条")
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
TjLockChip()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
.background(Tj.Palette.sand)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 翻页内容
|
||||
|
||||
private var pages: some View {
|
||||
TabView(selection: $selection) {
|
||||
ForEach(records) { rec in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
recordCard(rec)
|
||||
deleteButton
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.tag(Optional(rec.id))
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func recordCard(_ rec: Record) -> some View {
|
||||
switch rec {
|
||||
case .single(let i): singleCard(i)
|
||||
case .bp(let sys, let dia): bpCard(sys: sys, dia: dia)
|
||||
}
|
||||
}
|
||||
|
||||
private func singleCard(_ i: Indicator) -> some View {
|
||||
card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
statusChip(i.status)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(i.value)
|
||||
.font(.tjScaled(30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
if !i.unit.isEmpty {
|
||||
Text(i.unit).font(.tjScaled(14)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
divider
|
||||
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
|
||||
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
|
||||
field(String(appLoc: "来源"), i.report?.title ?? i.source.label)
|
||||
if i.report != nil { evidenceButton(for: i) }
|
||||
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
}
|
||||
}
|
||||
|
||||
private func bpCard(sys: Indicator, dia: Indicator?) -> some View {
|
||||
let combined: IndicatorStatus = sys.status != .normal ? sys.status : (dia?.status ?? .normal)
|
||||
return card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
statusChip(combined)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(sys.value)/\(dia?.value ?? "—")")
|
||||
.font(.tjScaled(30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
Text("mmHg").font(.tjScaled(14)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
divider
|
||||
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
|
||||
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 翻页器
|
||||
|
||||
private var pager: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 20) {
|
||||
pagerArrow("chevron.left", enabled: currentIndex > 0) {
|
||||
if currentIndex > 0 { selection = records[currentIndex - 1].id }
|
||||
}
|
||||
if records.count <= 7 {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array(records.enumerated()), id: \.offset) { idx, _ in
|
||||
Circle()
|
||||
.fill(idx == currentIndex ? Tj.Palette.ink : Tj.Palette.line)
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
pagerArrow("chevron.right", enabled: currentIndex < records.count - 1) {
|
||||
if currentIndex < records.count - 1 { selection = records[currentIndex + 1].id }
|
||||
}
|
||||
}
|
||||
Text("第 \(currentIndex + 1) / 共 \(records.count) 条")
|
||||
.font(.tjScaled(11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func pagerArrow(_ system: String, enabled: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: system)
|
||||
.font(.tjScaled(13, weight: .semibold))
|
||||
.foregroundStyle(enabled ? Tj.Palette.text : Tj.Palette.text3.opacity(0.4))
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Circle().fill(Tj.Palette.sand2))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
}
|
||||
|
||||
// MARK: - 趋势 / 删除
|
||||
|
||||
private var trendButton: some View {
|
||||
Button { showTrend = true } label: {
|
||||
Label(String(appLoc: "查看趋势图"), systemImage: "chart.xyaxis.line")
|
||||
.font(.tjScaled(15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.ink)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
|
||||
private var deleteButton: some View {
|
||||
Button(role: .destructive) { showDeleteConfirm = true } label: {
|
||||
Label(String(appLoc: "永久删除"), systemImage: "trash")
|
||||
.font(.tjScaled(12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.brick.opacity(0.8))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.brick.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
/// 删当前页记录(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6)。
|
||||
/// 删后把 selection 落到相邻一条;删空则关闭。
|
||||
private func deleteCurrent() {
|
||||
guard records.indices.contains(currentIndex) else { return }
|
||||
let removingIndex = currentIndex
|
||||
switch records[removingIndex] {
|
||||
case .single(let i):
|
||||
deleteIndicator(i)
|
||||
case .bp(let sys, let dia):
|
||||
deleteIndicator(sys)
|
||||
if let dia { deleteIndicator(dia) }
|
||||
}
|
||||
try? ctx.save()
|
||||
|
||||
let remaining = records
|
||||
if remaining.isEmpty {
|
||||
dismiss()
|
||||
} else {
|
||||
let next = min(removingIndex, remaining.count - 1)
|
||||
selection = remaining[next].id
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteIndicator(_ i: Indicator) {
|
||||
if let asset = i.asset {
|
||||
try? FileVault.shared.remove(relativePath: asset.relativePath)
|
||||
ctx.delete(asset)
|
||||
}
|
||||
ctx.delete(i)
|
||||
}
|
||||
|
||||
// MARK: - 复用件
|
||||
|
||||
@ViewBuilder
|
||||
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) { content() }
|
||||
.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 func field(_ label: String, _ value: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Text(label).font(.tjScaled(13)).foregroundStyle(Tj.Palette.text3)
|
||||
Spacer(minLength: 12)
|
||||
Text(value)
|
||||
.font(.tjScaled(14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func evidenceButton(for indicator: Indicator) -> some View {
|
||||
if indicator.hasEvidenceBox,
|
||||
let page = indicator.sourcePageIndex,
|
||||
let assets = indicator.report?.assets,
|
||||
assets.indices.contains(page) {
|
||||
Button {
|
||||
evidenceTarget = indicator
|
||||
} label: {
|
||||
Label(String(appLoc: "查看原图位置"), systemImage: "viewfinder")
|
||||
.font(.tjScaled(12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Tj.Palette.leaf.opacity(0.14)))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
|
||||
private func statusChip(_ s: IndicatorStatus) -> some View {
|
||||
let text: String
|
||||
let color: Color
|
||||
let arrow: String
|
||||
switch s {
|
||||
case .high: text = String(appLoc: "偏高"); color = Tj.Palette.brick; arrow = "↑"
|
||||
case .low: text = String(appLoc: "偏低"); color = Tj.Palette.brick; arrow = "↓"
|
||||
case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = ""
|
||||
}
|
||||
return HStack(spacing: 3) {
|
||||
if !arrow.isEmpty { Text(arrow).font(.tjScaled(11, weight: .bold)) }
|
||||
Text(text).font(.tjScaled(12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(color.opacity(0.14)))
|
||||
}
|
||||
|
||||
private nonisolated static func dateTimeText(_ d: Date) -> String {
|
||||
d.formatted(.dateTime.year().month().day().hour().minute())
|
||||
}
|
||||
}
|
||||
@@ -420,7 +420,8 @@ struct TimelineEntryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct EvidenceImagePreview: View {
|
||||
/// 原图证据预览(翻页 + 高亮框)。指标详情与同类聚合详情共用,故为模块内可见。
|
||||
struct EvidenceImagePreview: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let report: Report
|
||||
let indicator: Indicator
|
||||
|
||||
Reference in New Issue
Block a user