Files
kangkang/康康/Features/Archive/HealthExportSheet.swift
link2026 de19d7abcd 根据提供的code differences信息,由于没有具体的代码变更内容,我将生成一个通用的commit message模板:
```
docs(readme): 更新文档说明

- 添加了项目使用指南
- 完善了API接口说明
- 修正了一些文字错误
```

注:由于未提供具体的代码差异信息,以上为示例格式。请提供具体的代码变更内容以便生成准确的commit message。
2026-06-17 08:35:59 +08:00

891 lines
34 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.
This file contains Unicode characters that might be confused with other characters. 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 lastScrollAt: Date = .distantPast //
@State private var answeringTurnID: UUID?
@State private var retrieval: HealthExportService.RetrievalSummary?
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
@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
}
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 hasUserContent: Bool {
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
}
/// :,()
private var canGenerateReport: Bool {
!isAnswering &&
!isGeneratingReport &&
(hasUserContent || !draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
var body: some View {
VStack(spacing: 0) {
header
ScrollViewReader { proxy in
ScrollView {
LazyVStack(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)
}
// content / turns token ,;
// ~8Hz,
.onChange(of: content) { _, _ in throttledScrollToBottom(proxy) }
.onChange(of: turns) { _, _ in throttledScrollToBottom(proxy) }
.onChange(of: completed) { _, done in
if done { scrollToBottom(proxy) }
}
.onChange(of: answeringTurnID) { _, id in
if id == nil { scrollToBottom(proxy) }
}
}
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() }
.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
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)
quickPromptRow
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 !isUser, let summary = turnRetrievals[turn.id] {
RetrievalChipsView(summary: summary)
}
if turn.id == answeringTurnID && turn.text.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text(turnRetrievals[turn.id] == nil
? "正在查看本地记录…"
: "正在根据这些记录回答…")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
AIFlowBar()
}
} 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)
if completed {
Divider().background(Tj.Palette.lineSoft)
AIDisclaimerFooter()
}
}
.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 let retrieval {
RetrievalChipsView(summary: retrieval)
}
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)
}
// AI :线( AI )
AIFlowBar().padding(.top, 2)
}
}
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(friendlyMessage(for: err))
.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: AIDisclaimer.appended(to: 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("发送问题")
}
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)
}
}
.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 event in stream {
switch event {
case .retrieved(let summary):
withAnimation(.snappy(duration: 0.25)) {
turnRetrievals[assistantTurn.id] = summary
}
case .token(let chunk):
appendToTurn(id: assistantTurn.id, text: chunk.text)
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
case .phaseChanged, .completed:
break
}
}
answeringTurnID = nil
questionFocused = true
} catch {
answeringTurnID = nil
questionFocused = true
if error is CancellationError { return }
#if DEBUG
print("[HealthExport] answer failed: \(error)")
#endif
// ;, AI
if let idx = turns.firstIndex(where: { $0.id == assistantTurn.id }),
turns[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
turns[idx].text = "这次没能回答上来,请换个说法再试一次。"
}
}
}
}
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
// :(/),
//
let draft = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
if !draft.isEmpty {
turns.append(.user(draft))
draftQuestion = ""
}
content = ""
rate = 0 // , tok/s
error = nil
completed = false
retrieval = nil
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 .retrieved(let summary):
withAnimation(.snappy(duration: 0.25)) { retrieval = summary }
case .token(let chunk):
content += chunk.text
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
case .completed:
completed = true
}
}
} catch {
if error is CancellationError { return }
#if DEBUG
print("[HealthExport] export failed: \(error)")
#endif
self.error = error
self.phase = nil
}
}
}
private func regenerate() {
completed = false
startReportGeneration()
}
/// :,()
private func stopGeneration() {
task?.cancel()
task = nil
phase = nil
rate = 0
completed = false
content = ""
retrieval = nil
}
private func reset() {
task?.cancel()
task = nil
phase = nil
content = ""
rate = 0
error = nil
completed = false
answeringTurnID = nil
retrieval = nil
turnRetrievals = [:]
questionFocused = true
}
private func copy() {
UIPasteboard.general.string = AIDisclaimer.appended(to: content)
copiedFlash = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
copiedFlash = false
}
}
private func close() {
task?.cancel()
dismiss()
}
// MARK: -
private func scrollToBottom(_ proxy: ScrollViewProxy) {
lastScrollAt = Date()
withAnimation(.easeOut(duration: 0.12)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
/// : token , 8Hz, token
private func throttledScrollToBottom(_ proxy: ScrollViewProxy) {
guard Date().timeIntervalSince(lastScrollAt) > 0.12 else { return }
scrollToBottom(proxy)
}
/// ()
private func friendlyMessage(for error: Error) -> String {
if error is CancellationError { return "" }
return "这次没能生成成功,请稍后重试。"
}
}
// MARK: - chips( RAG )
/// RAG :N + chips
/// ( embedding) (§12 3)
private struct RetrievalChipsView: View {
let summary: HealthExportService.RetrievalSummary
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if summary.totalCount == 0 {
Text("本地档案中暂无相关记录,将仅按你的描述整理")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
} else {
Text(String(appLoc: "已在本地档案中找到 \(summary.totalCount) 条相关记录"))
.font(.tjScaled( 11, weight: .medium))
.foregroundStyle(Tj.Palette.leaf)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(Array(summary.chips.enumerated()), id: \.offset) { _, chip in
Text(chip)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
.lineLimit(1)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.sand2))
.overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
}
}
.padding(.vertical, 1)
}
}
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
// 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
}
/// :,
/// markdown ,,
private final class AttrBox { let value: AttributedString; init(_ v: AttributedString) { value = v } }
private static let inlineCache: NSCache<NSString, AttrBox> = {
let c = NSCache<NSString, AttrBox>()
c.countLimit = 256
return c
}()
private func inline(_ s: String) -> AttributedString {
// :(),
// , AttributedString(markdown:)
if !s.contains(where: { $0 == "*" || $0 == "_" || $0 == "[" || $0 == "`" }) {
return AttributedString(s)
}
let key = s as NSString
if let hit = Self.inlineCache.object(forKey: key) { return hit.value }
// **bold** / *italic* / [text](url) AttributedString markdown
let attr = (try? AttributedString(
markdown: s,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
)) ?? AttributedString(s)
Self.inlineCache.setObject(AttrBox(attr), forKey: key)
return attr
}
// 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)
}