Files
kangkang/康康/Features/Archive/HealthExportSheet.swift
link2026 40155de709 ```
feat(AI): 优化AIRuntime任务取消机制并增强安全保护

- 在AI推理流中添加Task.checkCancellation()检查,使消费者取消时能快速退出
- 为异步流添加onTermination回调以取消内部Task,与LLMSession一致
- 实现SwiftData store的completeUnlessOpen文件保护,提升数据安全性
- 在store备份过程中同样应用加密保护

feat(home): 优化主页交互体验并统一详情查看功能

- 在主页"最近记录"中点击任意条目可打开只读详情sheet
- 将时间线详情解析逻辑统一收敛到TimelineDetail.resolve方法
- 修复血压条目的精确反查逻辑,避免时间窗匹配错误

feat(archive): 新增提醒任务汇总卡并完善档案库功能

- 在档案库页面新增提醒任务汇总卡,显示总数和启用状态
- 添加按更新时间倒序合并的提醒标题预览功能
- 实现RemindersListView导航路由,统一管理提醒任务
- 优化导出列表显示,优先使用中文标签展示

feat(me): 优化个人中心界面并改进语言设置体验

- 将个人中心标题改为内容文字渲染,解决导航栏背景问题
- 为语言选择器添加个性化图标,使用本族语代表字区分
- 修复语言设置视图的图标显示逻辑

feat(timeline): 新增记录详情页删除功能并优化图表显示

- 在时间线详情页添加永久删除按钮和确认弹窗
- 实现完整的删除逻辑,包括SwiftData硬删和Vault原图unlink
- 修复系列图表的数值范围计算,处理同值数据的对称留白
- 优化血压图表合并逻辑,只保留有数据点的线条

refactor(calendar): 修复DST切换导致的月份天数计算错误

- 使用calendar.range(of:.day,in:.month)替代日期间隔计算
- 避免在夏令时切换月份出现天数偏差问题

fix(ui): 修复多个UI组件的交互响应区域问题

- 为纯描边按钮和胶囊添加contentShape以扩大点击区域
- 修复提醒行展开按钮尺寸,保证不同提醒类型的垂直对齐
```
2026-05-31 09:25:49 +08:00

551 lines
20 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
/// :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<Void, Never>?
@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)
}