Files
kangkang/康康/Features/Archive/HealthExportSheet.swift
link2026 77a4ee1c37 缺少代码差异信息,无法生成具体的commit message。请提供code differences内容以便分析并生成符合Angular规范的提交信息。
当您提供代码差异后,我将按照以下格式生成:

```
<type>(<scope>): <subject>

<body>
```

其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
2026-06-07 14:17:18 +08:00

663 lines
24 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import SwiftData
/// sheet
/// : running(retrieving generating) completed / failed
struct HealthExportSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
/// :(,W3 )
let initialPrompt: String
@State private var turns: [HealthExportDialogueTurn] = []
@State private var draftQuestion: String = ""
@State private var phase: HealthExportService.Phase?
@State private var content: String = ""
@State private var rate: Double = 0
@State private var task: Task<Void, Never>?
@State private var error: Error?
@State private var completed: Bool = false
@State private var copiedFlash: Bool = false
@State private var answeringTurnID: UUID?
@FocusState private var questionFocused: Bool
init(initialPrompt: String = "") {
self.initialPrompt = initialPrompt
}
private var isGeneratingReport: Bool { phase != nil && !completed && error == nil }
private var isAnswering: Bool { answeringTurnID != nil }
private var canAsk: Bool {
!isAnswering &&
!isGeneratingReport &&
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var canGenerateReport: Bool {
!isAnswering &&
!isGeneratingReport &&
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
}
var body: some View {
VStack(spacing: 0) {
header
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 18) {
introSection
ForEach(turns) { turn in
dialogueBubble(turn)
}
if isGeneratingReport { phaseIndicator }
if !content.isEmpty {
reportCard
}
if let err = error { errorRow(err) }
Color.clear.frame(height: 1).id("bottom")
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
.onChange(of: content) { _, _ in
withAnimation(.easeOut(duration: 0.12)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.onChange(of: turns) { _, _ in
withAnimation(.easeOut(duration: 0.12)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
if completed {
actionRow
} else {
composer
}
}
.background(Tj.Palette.sand.ignoresSafeArea())
.onAppear {
if !initialPrompt.isEmpty, draftQuestion.isEmpty, turns.isEmpty {
draftQuestion = initialPrompt
}
questionFocused = true
}
.onDisappear { task?.cancel() }
}
// MARK: - Header
private var header: some View {
HStack(alignment: .center, spacing: 12) {
Button { close() } label: {
Image(systemName: "xmark")
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
VStack(alignment: .leading, spacing: 2) {
Text("身体档案")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("先问清楚,再整理给医生")
.font(.tjScaled( 11))
.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: - Dialogue
private var introSection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("围绕你的指标和健康日记提问")
.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)
}
Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
.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 dialogueBubble(_ turn: HealthExportDialogueTurn) -> some View {
let isUser = turn.role == .user
return HStack(alignment: .top, spacing: 8) {
if isUser { Spacer(minLength: 44) }
VStack(alignment: .leading, spacing: 6) {
Text(turn.role.transcriptLabel)
.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()
Text("正在查看本地记录…")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
}
} else {
Text(turn.text)
.font(.tjScaled( 14))
.lineSpacing(3)
.foregroundStyle(isUser ? Tj.Palette.paper : Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(12)
.frame(maxWidth: 300, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(isUser ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(isUser ? Color.clear : Tj.Palette.lineSoft, lineWidth: 1)
)
if !isUser { Spacer(minLength: 44) }
}
}
private var reportCard: some View {
VStack(alignment: .leading, spacing: 10) {
Text("整理好的报告")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
MarkdownView(text: content)
}
.padding(16)
.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)
)
}
// MARK: - Phase indicator
private var phaseIndicator: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
phasePill(.extractingIntent)
arrow
phasePill(.retrieving)
arrow
phasePill(.generating)
}
if phase == .generating && rate > 0 {
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
} else {
Text(phase?.label ?? "")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
}
}
private func phasePill(_ p: HealthExportService.Phase) -> some View {
let active = (p == phase)
let done = phaseOrder(p) < phaseOrder(phase ?? .extractingIntent)
let fill = active ? Tj.Palette.ink : (done ? Tj.Palette.leaf : Tj.Palette.sand2)
let fg = (active || done) ? Tj.Palette.paper : Tj.Palette.text3
return Text(p.label)
.font(.tjScaled( 11, weight: active ? .semibold : .regular))
.foregroundStyle(fg)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().fill(fill))
}
private var arrow: some View {
Image(systemName: "chevron.right")
.font(.tjScaled( 10, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
private func phaseOrder(_ p: HealthExportService.Phase) -> Int {
switch p {
case .extractingIntent: return 0
case .retrieving: return 1
case .generating: return 2
case .completed: return 3
}
}
// MARK: - Error
private func errorRow(_ err: Error) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.brick)
Text(err.localizedDescription)
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text)
}
Button { reset() } label: { Text("返回修改") }
.buttonStyle(TjGhostButton(height: 40, fontSize: 13))
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.6))
)
}
// MARK: - Action row (completed)
private var actionRow: some View {
HStack(spacing: 10) {
Button { copy() } label: {
Label(copiedFlash ? "已复制" : "复制", systemImage: copiedFlash ? "checkmark" : "doc.on.doc")
}
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
ShareLink(item: content) {
Label("分享", systemImage: "square.and.arrow.up")
.font(.tjScaled( 13, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 14)
.frame(height: 44)
.background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1))
.contentShape(Capsule()) // :
}
Spacer()
Button { regenerate() } label: {
Label("重新整理", systemImage: "arrow.clockwise")
}
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 13, horizontalPadding: 16))
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Tj.Palette.paper)
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
private var composer: some View {
VStack(spacing: 10) {
HStack(spacing: 8) {
TextField("继续提问或补充情况…", text: $draftQuestion, axis: .vertical)
.font(.tjScaled( 14))
.lineLimit(1...4)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.sand2)
)
.focused($questionFocused)
.disabled(isAnswering || isGeneratingReport)
Button { sendQuestion() } label: {
Image(systemName: "arrow.up")
.font(.tjScaled( 15, weight: .bold))
.foregroundStyle(Tj.Palette.paper)
.frame(width: 40, height: 40)
.background(Circle().fill(canAsk ? Tj.Palette.ink : Tj.Palette.line))
}
.disabled(!canAsk)
.accessibilityLabel("发送问题")
}
Button { startReportGeneration() } label: {
Label("生成整理报告", systemImage: "doc.text.below.ecg")
}
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
.disabled(!canGenerateReport)
.opacity(canGenerateReport ? 1 : 0.45)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Tj.Palette.paper)
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
// MARK: - Actions
private func sendQuestion() {
let question = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
guard !question.isEmpty, !isAnswering, !isGeneratingReport else { return }
draftQuestion = ""
questionFocused = false
let userTurn = HealthExportDialogueTurn.user(question)
let assistantTurn = HealthExportDialogueTurn.assistant("")
turns.append(userTurn)
turns.append(assistantTurn)
answeringTurnID = assistantTurn.id
let conversationForPrompt = turns.filter { $0.id != assistantTurn.id }
let stream = HealthExportService.shared.answer(
question: question,
conversation: conversationForPrompt,
in: ctx
)
task?.cancel()
task = Task { @MainActor in
do {
for try await chunk in stream {
appendToTurn(id: assistantTurn.id, text: chunk.text)
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
}
answeringTurnID = nil
questionFocused = true
} catch {
answeringTurnID = nil
appendToTurn(id: assistantTurn.id, text: error.localizedDescription)
questionFocused = true
}
}
}
private func appendToTurn(id: UUID, text: String) {
guard let idx = turns.firstIndex(where: { $0.id == id }) else { return }
turns[idx].text += text
}
private func startReportGeneration() {
guard canGenerateReport else { return }
questionFocused = false
content = ""
rate = 0 // , tok/s
error = nil
completed = false
phase = .retrieving
let stream = HealthExportService.shared.export(conversation: turns, in: ctx)
task?.cancel()
task = Task { @MainActor in
do {
for try await event in stream {
switch event {
case .phaseChanged(let ph):
phase = ph
case .token(let chunk):
content += chunk.text
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
case .completed:
completed = true
}
}
} catch {
self.error = error
self.phase = nil
}
}
}
private func regenerate() {
completed = false
startReportGeneration()
}
private func reset() {
task?.cancel()
task = nil
phase = nil
content = ""
rate = 0
error = nil
completed = false
answeringTurnID = nil
questionFocused = true
}
private func copy() {
UIPasteboard.general.string = content
copiedFlash = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
copiedFlash = false
}
}
private func close() {
task?.cancel()
dismiss()
}
}
// MARK: - Markdown ()
/// Markdown ,
/// : `# ``## ``-` `****`( AttributedString inline )
/// prompt LLM
struct MarkdownView: View {
let text: String
var body: some View {
let blocks = Self.parse(text)
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
renderBlock(block)
}
}
}
@ViewBuilder
private func renderBlock(_ block: Block) -> some View {
switch block {
case .h1(let s):
VStack(alignment: .leading, spacing: 8) {
Text(inline(s))
.font(.tjScaled( 22, weight: .bold))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
Rectangle()
.fill(Tj.Palette.ink)
.frame(height: 1)
.frame(maxWidth: .infinity)
}
.padding(.top, 2)
.padding(.bottom, 4)
case .h2(let s):
HStack(alignment: .center, spacing: 8) {
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(Tj.Palette.brick)
.frame(width: 3, height: 16)
Text(inline(s))
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
}
.padding(.top, 10)
.padding(.bottom, 2)
case .bullet(let s):
if let abnormalText = Self.extractAbnormal(s) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.brick)
Text(inline(abnormalText))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.55))
)
.overlay(alignment: .leading) {
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(Tj.Palette.brick)
.frame(width: 3)
}
} else {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Circle()
.fill(Tj.Palette.text3)
.frame(width: 4, height: 4)
.padding(.top, 6)
Text(inline(s))
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.leading, 2)
}
case .body(let s):
Text(inline(s))
.font(.tjScaled( 14))
.lineSpacing(3)
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
case .gap:
Spacer().frame(height: 4)
}
}
/// bullet , strip
/// nil()
private static func extractAbnormal(_ s: String) -> String? {
let trimmed = s.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("⚠️") {
return trimmed.replacingOccurrences(of: "⚠️", with: "")
.trimmingCharacters(in: .whitespaces)
}
// ,(),
// : 4
let negations = ["", "", ""]
let abnormalSignals = ["偏高", "偏低", "异常", "过高", "过低"]
for sig in abnormalSignals {
guard let r = trimmed.range(of: sig) else { continue }
let window = String(trimmed[..<r.lowerBound].suffix(4))
if negations.contains(where: { window.contains($0) }) { continue }
return trimmed
}
return nil
}
private func inline(_ s: String) -> AttributedString {
// **bold** / *italic* / [text](url) AttributedString markdown
if let attr = try? AttributedString(
markdown: s,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
) {
return attr
}
return AttributedString(s)
}
// MARK: -
enum Block {
case h1(String)
case h2(String)
case bullet(String)
case body(String)
case gap
}
static func parse(_ raw: String) -> [Block] {
var out: [Block] = []
let lines = raw.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")
for line in lines {
let t = line.trimmingCharacters(in: .whitespaces)
if t.isEmpty {
// gap
if case .gap = out.last { continue }
out.append(.gap)
continue
}
if t.hasPrefix("# ") {
out.append(.h1(String(t.dropFirst(2))))
} else if t.hasPrefix("## ") {
out.append(.h2(String(t.dropFirst(3))))
} else if t.hasPrefix("### ") {
out.append(.h2(String(t.dropFirst(4))))
} else if t.hasPrefix("- ") || t.hasPrefix("* ") {
out.append(.bullet(String(t.dropFirst(2))))
} else {
out.append(.body(t))
}
}
return out
}
}
#Preview("HealthExportSheet · 空状态") {
HealthExportSheet()
.modelContainer(for: [
Indicator.self, Report.self, DiaryEntry.self, Asset.self,
ChatTurn.self, Symptom.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self, HealthExport.self
], inMemory: true)
}
#Preview("MarkdownView · 演示") {
ScrollView {
MarkdownView(text: """
# 就诊摘要 — 感冒就诊
## 主诉
患者男,38 岁,感冒 3 天未愈,主诉鼻塞、咳嗽、低烧。
## 患者背景
- 高血压 2 年
- 在服药:**缬沙坦 80mg qd**
- 过敏:青霉素
## 近期症状
- 2026-05-24 感冒(进行中,severity 2):鼻塞、低烧
- 2026-05-20 头痛(已结束)
## 关键指标
- ⚠️ 收缩压 142 mmHg (参考 <140) — 2026-05-26
- 体温 37.2 ℃ (参考 36-37) — 2026-05-25
""")
.padding()
}
.background(Tj.Palette.sand)
}