import SwiftUI import SwiftData /// 「导出身体档案」全屏 sheet。 /// 状态机:idle → running(extractingIntent → retrieving → generating)→ completed / failed struct HealthExportSheet: View { @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss /// 可选:从历史「重新生成」时传入(暂时未启用,W3 接)。 let initialPrompt: String @State private var prompt: String = "" @State private var phase: HealthExportService.Phase? @State private var content: String = "" @State private var rate: Double = 0 @State private var task: Task? @State private var error: Error? @State private var completed: Bool = false @State private var copiedFlash: Bool = false @FocusState private var promptFocused: Bool init(initialPrompt: String = "") { self.initialPrompt = initialPrompt } private var isRunning: Bool { phase != nil && !completed && error == nil } private var isInputMode: Bool { phase == nil && !completed && error == nil } var body: some View { VStack(spacing: 0) { header ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 18) { if isInputMode { inputSection } else { promptEcho if isRunning { phaseIndicator } if !content.isEmpty { 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) ) } 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) } } } if completed { actionRow } } .background(Tj.Palette.sand.ignoresSafeArea()) .onAppear { if prompt.isEmpty { prompt = initialPrompt } if isInputMode { promptFocused = true } } .onDisappear { task?.cancel() } } // MARK: - Header private var header: some View { HStack(alignment: .center, spacing: 12) { Button { close() } label: { Image(systemName: "xmark") .font(.system(size: 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(.system(size: 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: - Input section (idle) private var inputSection: some View { VStack(alignment: .leading, spacing: 14) { Text("说说你想给医生看什么") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) VStack(alignment: .leading, spacing: 6) { Text("例:我感冒3天了,把最近一个月的健康情况给医生看") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) Text("例:最近血糖好像不稳,把过去三个月的化验单整理一下") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) } ZStack(alignment: .topLeading) { if prompt.isEmpty { Text("在这里输入主诉……") .font(.system(size: 15)) .foregroundStyle(Tj.Palette.text3) .padding(.horizontal, 14) .padding(.vertical, 14) .allowsHitTesting(false) } TextEditor(text: $prompt) .font(.system(size: 15)) .foregroundStyle(Tj.Palette.text) .scrollContentBackground(.hidden) .padding(.horizontal, 10) .padding(.vertical, 8) .frame(minHeight: 130) .focused($promptFocused) } .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) ) HStack { Text("本地 RAG · Qwen3 1.7B · 不上传任何数据") .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text3) Spacer() Button { start() } label: { Text("生成报告") } .buttonStyle(TjPrimaryButton(height: 44, fontSize: 14)) .disabled(prompt.trimmingCharacters(in: .whitespaces).isEmpty) .opacity(prompt.trimmingCharacters(in: .whitespaces).isEmpty ? 0.5 : 1) } } } // MARK: - Prompt echo (after start) private var promptEcho: some View { HStack(alignment: .top, spacing: 8) { Image(systemName: "quote.opening") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) Text(prompt) .font(.system(size: 13)) .foregroundStyle(Tj.Palette.text2) .lineLimit(3) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.sand2) ) } // 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(.system(size: 11, design: .monospaced)) .foregroundStyle(Tj.Palette.leaf) } else { Text(phase?.label ?? "") .font(.system(size: 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(.system(size: 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(.system(size: 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(.system(size: 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(.system(size: 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) } } // MARK: - Actions private func start() { let p = prompt.trimmingCharacters(in: .whitespacesAndNewlines) guard !p.isEmpty else { return } promptFocused = false content = "" rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示 error = nil completed = false phase = .extractingIntent let stream = HealthExportService.shared.export(prompt: p, in: ctx) 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 start() } private func reset() { task?.cancel() task = nil phase = nil content = "" rate = 0 error = nil completed = false promptFocused = 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(.system(size: 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(.system(size: 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(.system(size: 11)) .foregroundStyle(Tj.Palette.brick) Text(inline(abnormalText)) .font(.system(size: 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(.system(size: 14)) .foregroundStyle(Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) } .padding(.leading, 2) } case .body(let s): Text(inline(s)) .font(.system(size: 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) } // 一些常见 LLM 表达,也当异常项高亮 let abnormalSignals = ["偏高", "偏低", "异常", "过高", "过低"] for sig in abnormalSignals where trimmed.contains(sig) { 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) }