```
docs(health-profile): 添加防编造加固修订记录到导出健康档案设计文档 补充了关于导出摘要出现虚构病例问题的详细分析和修复方案, 包括检索策略优化、空数据兜底处理和prompt重写等三层防护措施。 ```
This commit is contained in:
295
康康/Features/Timeline/TimelineEntryDetailView.swift
Normal file
295
康康/Features/Timeline/TimelineEntryDetailView.swift
Normal file
@@ -0,0 +1,295 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 时间线条目反查到的源记录,驱动只读详情 sheet。
|
||||
/// 注:报告详情这里是 W2 轻量只读版;W4 的 C2 `ReportDetailView`(三 Tab + 对比上次)另建,
|
||||
/// 届时把时间线报告行改路由到 C2 即可,本类型不与之冲突。
|
||||
enum TimelineDetail {
|
||||
case indicator(Indicator)
|
||||
case bloodPressure(sys: Indicator, dia: Indicator?)
|
||||
case report(Report)
|
||||
case diary(DiaryEntry)
|
||||
case symptom(Symptom)
|
||||
}
|
||||
|
||||
/// 时间线条目的只读详情:展示该记录的完整字段。各类型一屏看完,不可编辑。
|
||||
struct TimelineEntryDetailView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let detail: TimelineDetail
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
bodyContent
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
.presentationCornerRadius(Tj.Radius.xl)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button { dismiss() } 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))
|
||||
}
|
||||
Text(titleText)
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
TjLockChip()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
.background(Tj.Palette.sand)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var titleText: String {
|
||||
switch detail {
|
||||
case .indicator: return String(appLoc: "指标详情")
|
||||
case .bloodPressure: return String(appLoc: "血压详情")
|
||||
case .report: return String(appLoc: "报告详情")
|
||||
case .diary: return String(appLoc: "日记详情")
|
||||
case .symptom: return String(appLoc: "症状详情")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var bodyContent: some View {
|
||||
switch detail {
|
||||
case .indicator(let i): indicatorBody(i)
|
||||
case .bloodPressure(let s, let d): bpBody(sys: s, dia: d)
|
||||
case .report(let r): reportBody(r)
|
||||
case .diary(let d): diaryBody(d)
|
||||
case .symptom(let s): symptomBody(s)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 指标
|
||||
|
||||
private func indicatorBody(_ i: Indicator) -> some View {
|
||||
card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
statusChip(i.status)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(i.value)
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
if !i.unit.isEmpty {
|
||||
Text(i.unit).font(.system(size: 14)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
divider
|
||||
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
|
||||
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
|
||||
field(String(appLoc: "来源"), i.report?.title ?? String(appLoc: "异常项快拍"))
|
||||
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 血压(合并条目)
|
||||
|
||||
private func bpBody(sys: Indicator, dia: Indicator?) -> some View {
|
||||
let combined: IndicatorStatus = sys.status != .normal
|
||||
? sys.status
|
||||
: (dia?.status ?? .normal)
|
||||
return card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
statusChip(combined)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(sys.value)/\(dia?.value ?? "—")")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
Text("mmHg").font(.system(size: 14)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
divider
|
||||
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
|
||||
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 报告
|
||||
|
||||
private func reportBody(_ r: Report) -> some View {
|
||||
let sorted = r.indicators.sorted {
|
||||
($0.status == .normal ? 1 : 0) < ($1.status == .normal ? 1 : 0)
|
||||
}
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
Text(r.title).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
HStack(spacing: 8) {
|
||||
TjBadge(text: r.type.label, style: .neutral)
|
||||
Text(Self.dateText(r.reportDate))
|
||||
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
|
||||
if !r.assets.isEmpty {
|
||||
Text(String(appLoc: "原图\(r.assets.count)张"))
|
||||
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
if let inst = r.institution, !inst.isEmpty {
|
||||
field(String(appLoc: "机构"), inst)
|
||||
}
|
||||
}
|
||||
|
||||
if let sum = r.summary, !sum.isEmpty {
|
||||
card {
|
||||
Text(String(appLoc: "摘要"))
|
||||
.font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||
Text(sum).font(.system(size: 14)).foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
if !r.indicators.isEmpty {
|
||||
card {
|
||||
Text(String(appLoc: "指标"))
|
||||
.font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||
ForEach(sorted) { ind in
|
||||
HStack {
|
||||
Text(ind.name).font(.system(size: 14)).foregroundStyle(Tj.Palette.text)
|
||||
Spacer(minLength: 8)
|
||||
Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)")
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
|
||||
statusChip(ind.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let note = r.note, !note.isEmpty {
|
||||
card { field(String(appLoc: "备注"), note) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 日记
|
||||
|
||||
private func diaryBody(_ d: DiaryEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
Text(Self.dateTimeText(d.createdAt))
|
||||
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
|
||||
Text(d.content)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if !d.tags.isEmpty {
|
||||
field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 症状
|
||||
|
||||
private func symptomBody(_ s: Symptom) -> some View {
|
||||
card {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(s.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
if s.isOngoing {
|
||||
Text(String(appLoc: "进行中"))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||
.background(Capsule().fill(Tj.Palette.brick.opacity(0.14)))
|
||||
}
|
||||
}
|
||||
divider
|
||||
field(String(appLoc: "程度"), "\(s.severity) / 5")
|
||||
field(String(appLoc: "开始"), Self.dateTimeText(s.startedAt))
|
||||
field(String(appLoc: "结束"), s.endedAt.map(Self.dateTimeText) ?? String(appLoc: "进行中"))
|
||||
field(String(appLoc: "持续"), formatDuration(s.duration))
|
||||
if let note = s.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
if !s.tags.isEmpty {
|
||||
field(String(appLoc: "标签"), s.tags.map { "#\($0)" }.joined(separator: " "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 复用件
|
||||
|
||||
@ViewBuilder
|
||||
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) { content() }
|
||||
.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 field(_ label: String, _ value: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text3)
|
||||
Spacer(minLength: 12)
|
||||
Text(value)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
|
||||
private func statusChip(_ s: IndicatorStatus) -> some View {
|
||||
let text: String
|
||||
let color: Color
|
||||
let arrow: String
|
||||
switch s {
|
||||
case .high: text = String(appLoc: "偏高"); color = Tj.Palette.brick; arrow = "↑"
|
||||
case .low: text = String(appLoc: "偏低"); color = Tj.Palette.brick; arrow = "↓"
|
||||
case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = ""
|
||||
}
|
||||
return HStack(spacing: 3) {
|
||||
if !arrow.isEmpty { Text(arrow).font(.system(size: 11, weight: .bold)) }
|
||||
Text(text).font(.system(size: 12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(color.opacity(0.14)))
|
||||
}
|
||||
|
||||
private nonisolated static func dateTimeText(_ d: Date) -> String {
|
||||
d.formatted(.dateTime.year().month().day().hour().minute())
|
||||
}
|
||||
|
||||
private nonisolated static func dateText(_ d: Date) -> String {
|
||||
d.formatted(.dateTime.year().month().day())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user