替换 Xcode 默认模板: - 删除 ContentView/Item/__App - 新增 App/TijiApp(SwiftData ModelContainer)、RootView(3 Tab + RecordSheet) - DesignSystem:Tokens(色板/字体/圆角)+ Components(卡片/按钮/Chip) - Models:Indicator / Report / DiaryEntry @Model 初版 - Features:Home / Quick(A1-A3)/ Archive(B1-B5)/ Record / Trends / Me 静态 UI W2 AI 基座工作将在此基线上叠加。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
324 lines
12 KiB
Swift
324 lines
12 KiB
Swift
import SwiftUI
|
||
|
||
struct B5IndicatorData {
|
||
let name: String
|
||
let value: String
|
||
let unit: String
|
||
let range: String
|
||
let status: IndicatorStatus
|
||
let note: String?
|
||
}
|
||
|
||
struct B5ResultView: View {
|
||
var onSave: () -> Void
|
||
var onBack: () -> Void
|
||
|
||
@State private var expandedIndex: Int? = 0
|
||
@State private var normalsExpanded = false
|
||
|
||
let abnormal: [B5IndicatorData] = [
|
||
.init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high,
|
||
note: "超过参考上限 0.44。建议关注饮食结构,3 个月内复查。"),
|
||
.init(name: "甘油三酯 TG", value: "1.78", unit: "mmol/L", range: "0.45–1.70", status: .high, note: nil),
|
||
.init(name: "尿酸 UA", value: "428", unit: "μmol/L", range: "150–420", status: .high, note: nil),
|
||
.init(name: "维生素 D", value: "18", unit: "ng/mL", range: "30–100", status: .low, note: nil),
|
||
]
|
||
let normalCount = 24
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
header
|
||
|
||
ScrollView(showsIndicators: false) {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
reportMeta.padding(.bottom, 16)
|
||
summaryCard.padding(.bottom, 18)
|
||
SectionLabel("异常项", count: abnormal.count, accent: .brick)
|
||
.padding(.bottom, 10)
|
||
VStack(spacing: 8) {
|
||
ForEach(Array(abnormal.enumerated()), id: \.offset) { idx, it in
|
||
IndicatorRow(item: it, expanded: expandedIndex == idx) {
|
||
withAnimation { expandedIndex = (expandedIndex == idx) ? nil : idx }
|
||
}
|
||
}
|
||
}
|
||
.padding(.bottom, 18)
|
||
|
||
SectionLabel("正常项", count: normalCount, accent: .leaf)
|
||
.padding(.bottom, 10)
|
||
normalCollapsed
|
||
}
|
||
.padding(.horizontal, 18)
|
||
.padding(.bottom, 16)
|
||
}
|
||
|
||
HStack(spacing: 10) {
|
||
Button(action: onSave) {
|
||
Text("保存归档").frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(TjPrimaryButton())
|
||
|
||
Button { } label: {
|
||
Image(systemName: "square.and.arrow.up")
|
||
.font(.system(size: 16, weight: .semibold))
|
||
}
|
||
.buttonStyle(TjGhostButton(horizontalPadding: 16))
|
||
}
|
||
.padding(.horizontal, 18)
|
||
.padding(.bottom, 14)
|
||
.padding(.top, 10)
|
||
}
|
||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||
}
|
||
|
||
private var header: some View {
|
||
HStack(spacing: 6) {
|
||
Button(action: onBack) {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 18, weight: .semibold))
|
||
.foregroundStyle(Tj.Palette.text)
|
||
.frame(width: 36, height: 36)
|
||
}
|
||
Spacer()
|
||
Button { } label: {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "photo")
|
||
Text("查看原图")
|
||
}
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
.padding(8)
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.top, 4)
|
||
}
|
||
|
||
private var reportMeta: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 8) {
|
||
TjBadge(text: "体检报告", style: .ink)
|
||
Text("3 页")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
Spacer()
|
||
TjLockChip()
|
||
}
|
||
Text("2026 春季年度体检")
|
||
.font(.system(size: 22, weight: .bold))
|
||
.foregroundStyle(Tj.Palette.text)
|
||
Text("2026 / 05 / 25 · 协和医院体检中心")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
}
|
||
|
||
private var summaryCard: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
HStack(spacing: 10) {
|
||
Text("整体摘记")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.tracking(0.3)
|
||
.foregroundStyle(Tj.Palette.brick)
|
||
.fixedSize()
|
||
Rectangle().fill(Tj.Palette.line).frame(height: 1)
|
||
Text("本机摘要")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
.fixedSize()
|
||
}
|
||
.padding(.bottom, 12)
|
||
|
||
HStack(spacing: 14) {
|
||
Stat(n: "28", label: "总项")
|
||
Stat(n: "3", label: "偏高", tone: .brick)
|
||
Stat(n: "1", label: "偏低", tone: .amber)
|
||
Stat(n: "24", label: "正常", tone: .leaf)
|
||
}
|
||
.padding(.bottom, 14)
|
||
|
||
Text("本次共检测 28 项,\(Text("3 项偏高").fontWeight(.semibold).underline(color: Tj.Palette.brick))(血脂相关 2 项 + 尿酸)、\(Text("1 项偏低").fontWeight(.semibold).underline(color: Tj.Palette.amber))(维生素 D)。整体趋势提示代谢风险有所抬升,建议优化饮食并复查血脂。")
|
||
.font(.system(size: 14))
|
||
.foregroundStyle(Tj.Palette.text)
|
||
.lineSpacing(6)
|
||
.padding(.bottom, 12)
|
||
|
||
TjDashedDivider().padding(.bottom, 10)
|
||
|
||
Text("仅供参考,不构成医疗建议")
|
||
.font(.system(size: 11))
|
||
.italic()
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
.padding(.leading, 20)
|
||
.padding(.trailing, 20)
|
||
.padding(.vertical, 20)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
Tj.Palette.paper
|
||
.overlay(alignment: .leading) {
|
||
Tj.Palette.brick.frame(width: 3)
|
||
}
|
||
)
|
||
.clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous))
|
||
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06), radius: 0, x: 0, y: 1)
|
||
}
|
||
|
||
private var normalCollapsed: some View {
|
||
Button { withAnimation { normalsExpanded.toggle() } } label: {
|
||
HStack(spacing: 10) {
|
||
TjBadge(text: "\(normalCount)", style: .leaf)
|
||
Text("谷丙转氨酶、空腹血糖、糖化血红蛋白…")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(Tj.Palette.text2)
|
||
.lineLimit(1)
|
||
Spacer()
|
||
Image(systemName: normalsExpanded ? "chevron.up" : "chevron.down")
|
||
.font(.system(size: 12, weight: .medium))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 14)
|
||
.tjCard(bordered: true)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
private struct Stat: View {
|
||
let n: String
|
||
let label: String
|
||
var tone: Tone = .ink
|
||
|
||
enum Tone { case ink, brick, amber, leaf }
|
||
|
||
var color: Color {
|
||
switch tone {
|
||
case .ink: return Tj.Palette.text
|
||
case .brick: return Tj.Palette.brick
|
||
case .amber: return Color(red: 0.59, green: 0.45, blue: 0.27)
|
||
case .leaf: return Tj.Palette.leaf
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(n)
|
||
.font(.system(size: 24, weight: .semibold))
|
||
.foregroundStyle(color)
|
||
Text(label)
|
||
.font(.system(size: 10))
|
||
.tracking(0.5)
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
private struct SectionLabel: View {
|
||
let title: String
|
||
let count: Int
|
||
let accent: AccentKind
|
||
|
||
enum AccentKind { case brick, leaf }
|
||
|
||
init(_ title: String, count: Int, accent: AccentKind) {
|
||
self.title = title
|
||
self.count = count
|
||
self.accent = accent
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||
.fill(accent == .brick ? Tj.Palette.brick : Tj.Palette.leaf)
|
||
.frame(width: 4, height: 14)
|
||
Text(title).font(.system(size: 13, weight: .semibold)).foregroundStyle(Tj.Palette.text)
|
||
Text("· \(count)").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct IndicatorRow: View {
|
||
let item: B5IndicatorData
|
||
let expanded: Bool
|
||
let onTap: () -> Void
|
||
|
||
var statusBadge: TjBadgeStyle {
|
||
switch item.status {
|
||
case .high: return .brick
|
||
case .low: return .amber
|
||
case .normal: return .leaf
|
||
}
|
||
}
|
||
var statusWord: String {
|
||
switch item.status {
|
||
case .high: return "偏高"
|
||
case .low: return "偏低"
|
||
case .normal: return "正常"
|
||
}
|
||
}
|
||
var valueColor: Color {
|
||
switch item.status {
|
||
case .high: return Tj.Palette.brick
|
||
case .low: return Color(red: 0.55, green: 0.45, blue: 0.32)
|
||
case .normal: return Tj.Palette.text
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
Button(action: onTap) {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack(alignment: .top, spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack(spacing: 8) {
|
||
Text(item.name)
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(Tj.Palette.text)
|
||
.lineLimit(1)
|
||
TjBadge(text: statusWord, style: statusBadge)
|
||
}
|
||
Text("范围 \(item.range) \(item.unit)")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
Spacer(minLength: 8)
|
||
VStack(alignment: .trailing, spacing: 2) {
|
||
Text(item.value)
|
||
.font(.system(size: 22, weight: .semibold))
|
||
.foregroundStyle(valueColor)
|
||
Text(item.unit)
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
}
|
||
|
||
if expanded, let note = item.note {
|
||
TjDashedDivider()
|
||
Text(note)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(Tj.Palette.text2)
|
||
.lineSpacing(5)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 14)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||
.fill(Tj.Palette.paper)
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||
.strokeBorder(
|
||
item.status != .normal
|
||
? Color(red: 0.78, green: 0.68, blue: 0.48).opacity(0.5)
|
||
: Tj.Palette.lineSoft,
|
||
lineWidth: 1
|
||
)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|