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以扩大点击区域 - 修复提醒行展开按钮尺寸,保证不同提醒类型的垂直对齐 ```
551 lines
20 KiB
Swift
551 lines
20 KiB
Swift
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)
|
||
}
|