fix(core): 代码审查修复 AI 并发/隐私/解析等多处缺陷
- AIRuntime 加 actor 内串行推理闸门,封死 LLM/VL in-flight 并发解码窄口(jetsam OOM 根因) - prepare 的 .loading 改轮询等待消除假就绪竞态;就绪判据 isReady→isComplete 防半下载崩溃 - applyReanalyzed 重新解读时 unlink 旧 Asset,消除 Vault 孤儿图片(§6 隐私承诺) - parseReportJSON 改 extractBalancedJSON + 裸数组兜底,防 VL 多项输出被静默截断丢指标 - 临时文件改 completeUnlessOpen 修锁屏写失败;parseDate 支持多格式防归档年份错位 - TimelineEntry/DayDetailSheet 修「偏高」文案与血压箭头方向(偏低指标不再显示相反结论) - FileVault.wipe 容错;HealthExportSheet 异常关键词排除否定句;modelTag 取实际枚举值 - 删除 B1-B5 + ArchiveFlow 死代码(含违反 §6 的 AES 加密文案) - 补 3 个回归测试,编译 + 测试全部通过
This commit is contained in:
@@ -1,61 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
private enum ArchiveStep: Hashable {
|
||||
case guide
|
||||
case scan
|
||||
case meta
|
||||
case progress
|
||||
case result
|
||||
}
|
||||
|
||||
struct ArchiveFlow: View {
|
||||
var onClose: () -> Void
|
||||
|
||||
@State private var step: ArchiveStep = .guide
|
||||
@State private var capturedPages: Int = 1
|
||||
@State private var totalPages: Int = 3
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
switch step {
|
||||
case .guide:
|
||||
B1GuideView(
|
||||
onSingle: { withAnimation { totalPages = 1; step = .scan } },
|
||||
onMulti: { withAnimation { totalPages = 3; step = .scan } },
|
||||
onSkip: onClose
|
||||
)
|
||||
.transition(.opacity)
|
||||
|
||||
case .scan:
|
||||
B2ScanView(
|
||||
onShoot: { capturedPages = min(capturedPages + 1, totalPages) },
|
||||
onDone: { withAnimation { step = .meta } },
|
||||
onClose: onClose,
|
||||
page: capturedPages,
|
||||
total: totalPages
|
||||
)
|
||||
.transition(.opacity)
|
||||
|
||||
case .meta:
|
||||
B3MetaView(
|
||||
onAnalyze: { withAnimation { step = .progress } },
|
||||
onBack: { withAnimation { step = .scan } }
|
||||
)
|
||||
.transition(.opacity)
|
||||
|
||||
case .progress:
|
||||
B4ProgressView(onComplete: {
|
||||
withAnimation { step = .result }
|
||||
})
|
||||
.transition(.opacity)
|
||||
|
||||
case .result:
|
||||
B5ResultView(
|
||||
onSave: onClose,
|
||||
onBack: { withAnimation { step = .meta } }
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct B1GuideView: View {
|
||||
var onSingle: () -> Void
|
||||
var onMulti: () -> Void
|
||||
var onSkip: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Button(action: onSkip) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: onSkip) {
|
||||
Text("跳过")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.ink)
|
||||
Image(systemName: "doc.text.fill")
|
||||
.font(.system(size: 26, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
}
|
||||
.frame(width: 60, height: 60)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
Text("归档一份\n关键报告")
|
||||
.font(.system(size: 30, weight: .bold))
|
||||
.lineSpacing(6)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Text("推荐拍清晰的\(Text("整张图").underline()),多页报告可一次完成扫描。原图与解读全部本地加密保存,永不上传。")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.lineSpacing(6)
|
||||
.padding(.bottom, 26)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
OptCard(title: String(appLoc: "单张报告"), sub: String(appLoc: "一张图,几秒搞定"), hint: String(appLoc: "化验单 · 处方"), badge: nil, action: onSingle)
|
||||
OptCard(title: String(appLoc: "多页报告"), sub: String(appLoc: "像扫描文档一样翻页拍摄"), hint: String(appLoc: "体检报告 · 影像报告"), badge: String(appLoc: "推荐"), action: onMulti)
|
||||
}
|
||||
|
||||
Spacer(minLength: 18)
|
||||
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.padding(.top, 2)
|
||||
Text("所有照片以 AES 加密存于本机沙盒。康康 服务端无法访问。")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.lineSpacing(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.sand2)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
private struct OptCard: View {
|
||||
let title: String
|
||||
let sub: String
|
||||
let hint: String
|
||||
let badge: String?
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.sand2)
|
||||
Image(systemName: "doc.text")
|
||||
.font(.system(size: 18, weight: .regular))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
if let badge {
|
||||
TjBadge(text: badge, style: .ink)
|
||||
}
|
||||
}
|
||||
Text("\(sub) · \(hint)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(16)
|
||||
.tjCard(bordered: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
B1GuideView(
|
||||
onSingle: { print("单张报告") },
|
||||
onMulti: { print("多页报告") },
|
||||
onSkip: { print("跳过") }
|
||||
)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct B2ScanView: View {
|
||||
var onShoot: () -> Void
|
||||
var onDone: () -> Void
|
||||
var onClose: () -> Void
|
||||
var page: Int = 2
|
||||
var total: Int = 3
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(red: 0.04, green: 0.047, blue: 0.04).ignoresSafeArea()
|
||||
|
||||
mockPaper
|
||||
|
||||
DetectedEdge()
|
||||
.stroke(Color(red: 0.95, green: 0.78, blue: 0.45),
|
||||
style: StrokeStyle(lineWidth: 2, dash: [6, 4]))
|
||||
.opacity(0.95)
|
||||
.padding(.horizontal, 30)
|
||||
.padding(.top, 140)
|
||||
.padding(.bottom, 200)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
Spacer()
|
||||
detectedBadge
|
||||
Spacer()
|
||||
thumbnails
|
||||
bottomControls
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
private var mockPaper: some View {
|
||||
VStack(spacing: 2) {
|
||||
Text("体 检 报 告 (第 \(page) 页)")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.padding(.bottom, 4)
|
||||
ForEach(reportRows, id: \.0) { row in
|
||||
HStack {
|
||||
Text(row.0).frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(row.1)
|
||||
Text(row.2).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.font(.system(size: 9, design: .monospaced))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(red: 0.97, green: 0.95, blue: 0.89).opacity(0.95))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
|
||||
.rotation3DEffect(.degrees(8), axis: (x: 1, y: 0, z: 0))
|
||||
.rotationEffect(.degrees(-1))
|
||||
.shadow(color: .black.opacity(0.6), radius: 20, x: 0, y: 12)
|
||||
.padding(.horizontal, 40)
|
||||
.padding(.top, 160)
|
||||
.padding(.bottom, 220)
|
||||
}
|
||||
|
||||
private var reportRows: [(String, String, String)] {
|
||||
[
|
||||
(String(appLoc: "总胆固醇"), "5.42", "3.10–5.18"),
|
||||
(String(appLoc: "甘油三酯"), "1.78", "0.45–1.70"),
|
||||
(String(appLoc: "低密度脂蛋白"), "3.84↑", "<3.40"),
|
||||
(String(appLoc: "高密度脂蛋白"), "1.21", ">1.04"),
|
||||
(String(appLoc: "载脂蛋白 A1"), "1.42", "1.00–1.60"),
|
||||
(String(appLoc: "载脂蛋白 B"), "1.04", "0.55–1.05"),
|
||||
(String(appLoc: "谷丙转氨酶"), "28", "9–50"),
|
||||
(String(appLoc: "谷草转氨酶"), "24", "15–40"),
|
||||
(String(appLoc: "空腹血糖"), "5.4", "3.9–6.1"),
|
||||
(String(appLoc: "糖化血红蛋白"), "5.7", "4.0–6.0"),
|
||||
]
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
HStack {
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Color.white)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Text("\(page)").font(.system(size: 12, design: .monospaced))
|
||||
Text(" / \(total) · 像扫描文档一样对准")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.foregroundStyle(Color.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Color(red: 0.08, green: 0.11, blue: 0.094).opacity(0.7)))
|
||||
Spacer()
|
||||
Color.clear.frame(width: 36, height: 36)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 50)
|
||||
}
|
||||
|
||||
private var detectedBadge: some View {
|
||||
Text("已识别边框 · 将自动透视校正")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(0.4)
|
||||
.foregroundStyle(Color(red: 0.10, green: 0.115, blue: 0.094))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(Color(red: 0.95, green: 0.78, blue: 0.45)))
|
||||
.padding(.top, 140)
|
||||
}
|
||||
|
||||
private var thumbnails: some View {
|
||||
HStack {
|
||||
PageThumbStack(index: 1)
|
||||
Spacer()
|
||||
Text("已拍 1 页")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
|
||||
private var bottomControls: some View {
|
||||
HStack {
|
||||
Color.clear.frame(width: 60, height: 60)
|
||||
Spacer()
|
||||
Button(action: onShoot) {
|
||||
ZStack {
|
||||
Circle().fill(Tj.Palette.paper)
|
||||
Circle().strokeBorder(Color.white.opacity(0.4), lineWidth: 4)
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
Button(action: onDone) {
|
||||
Text("完成")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(Capsule().fill(Color.white.opacity(0.1)))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetectedEdge: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var p = Path()
|
||||
let w = rect.width
|
||||
let h = rect.height
|
||||
p.move(to: CGPoint(x: w * 0.04, y: h * 0.05))
|
||||
p.addLine(to: CGPoint(x: w * 0.92, y: h * 0.02))
|
||||
p.addLine(to: CGPoint(x: w * 0.96, y: h * 0.96))
|
||||
p.addLine(to: CGPoint(x: 0, y: h * 1.0))
|
||||
p.closeSubpath()
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
struct PageThumbStack: View {
|
||||
let index: Int
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.fill(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.7))
|
||||
.frame(width: 56, height: 76)
|
||||
.rotationEffect(.degrees(2))
|
||||
.offset(x: 4, y: 4)
|
||||
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2)
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.fill(Color(red: 0.97, green: 0.95, blue: 0.89).opacity(0.85))
|
||||
.frame(width: 56, height: 76)
|
||||
.rotationEffect(.degrees(-1))
|
||||
.offset(x: 2, y: 2)
|
||||
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2)
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
.frame(width: 56, height: 76)
|
||||
.overlay(
|
||||
Text("p.\(index)")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.4), radius: 4, x: 0, y: 2)
|
||||
}
|
||||
.frame(width: 64, height: 84, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct B3MetaView: View {
|
||||
var onAnalyze: () -> Void
|
||||
var onBack: () -> Void
|
||||
|
||||
@State private var selectedType = 0
|
||||
private let types = [
|
||||
String(appLoc: "体检报告"),
|
||||
String(appLoc: "化验单"),
|
||||
String(appLoc: "影像报告"),
|
||||
String(appLoc: "处方"),
|
||||
String(appLoc: "其他"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("报告类型")
|
||||
.font(.system(size: 11))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
typeChips.padding(.bottom, 20)
|
||||
|
||||
FormRow(label: "报告日期", value: "2026 / 05 / 25", subtle: false)
|
||||
FormRow(label: "出具机构", value: "协和医院体检中心", subtle: true)
|
||||
FormRow(label: "备注", value: "春季年度体检", subtle: true)
|
||||
|
||||
Text("已拍页面(3 页)")
|
||||
.font(.system(size: 11))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
ForEach(1...3, id: \.self) { n in
|
||||
PageCard(index: n)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Button(action: onAnalyze) {
|
||||
Text("开始 AI 解读").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton())
|
||||
|
||||
Text("预计耗时 5–8 秒 · 端侧 SME2 加速")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
Text("归档信息")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private var typeChips: some View {
|
||||
let columns = [GridItem(.adaptive(minimum: 60, maximum: 200), spacing: 8)]
|
||||
return LazyVGrid(columns: columns, alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(types.enumerated()), id: \.offset) { idx, t in
|
||||
Button { selectedType = idx } label: {
|
||||
Text(t)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(idx == selectedType ? Tj.Palette.paper : Tj.Palette.text2)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule().fill(idx == selectedType ? Tj.Palette.ink : Tj.Palette.sand2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FormRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let subtle: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
HStack(spacing: 6) {
|
||||
Text(value)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(subtle ? Tj.Palette.text3 : Tj.Palette.text)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PageCard: View {
|
||||
let index: Int
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06),
|
||||
radius: 2, x: 0, y: 1)
|
||||
TjPlaceholder(label: "p.\(index)", radius: 4)
|
||||
.padding(6)
|
||||
}
|
||||
.aspectRatio(0.72, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct B4ProgressView: View {
|
||||
var onComplete: () -> Void
|
||||
|
||||
@State private var step: Int = 1
|
||||
@State private var pulse = false
|
||||
@State private var glow = false
|
||||
@State private var rotate: Double = 0
|
||||
@State private var elapsed: Double = 0.2
|
||||
|
||||
private let lineLabels = [
|
||||
String(appLoc: "正在本地识别第 1 / 3 页…"),
|
||||
String(appLoc: "正在本地识别第 2 / 3 页…"),
|
||||
String(appLoc: "正在本地识别第 3 / 3 页…"),
|
||||
String(appLoc: "提取指标 · 共 28 项"),
|
||||
String(appLoc: "生成整体摘要…"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
backgroundGradient.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
chip.padding(.bottom, 36)
|
||||
|
||||
Text("本地 AI · 正在解读")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(Color.white.opacity(0.95))
|
||||
.padding(.bottom, 6)
|
||||
|
||||
Text("QWEN2.5-VL · ON-DEVICE · SME2")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Color.white.opacity(0.55))
|
||||
.padding(.bottom, 30)
|
||||
|
||||
lineList
|
||||
.padding(.horizontal, 28)
|
||||
|
||||
speedBadge.padding(.top, 32)
|
||||
Spacer()
|
||||
|
||||
Text("本地处理中 · 不会上传任何内容")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Color.white.opacity(0.45))
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.onAppear { startAnimations() }
|
||||
}
|
||||
|
||||
private var backgroundGradient: some View {
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color(red: 0.22, green: 0.21, blue: 0.18),
|
||||
Color(red: 0.13, green: 0.12, blue: 0.10),
|
||||
Color(red: 0.08, green: 0.075, blue: 0.06),
|
||||
],
|
||||
center: .init(x: 0.5, y: 0.3),
|
||||
startRadius: 60,
|
||||
endRadius: 700
|
||||
)
|
||||
}
|
||||
|
||||
private var chip: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(red: 0.93, green: 0.75, blue: 0.40).opacity(glow ? 0.18 : 0.0))
|
||||
.frame(width: 176, height: 176)
|
||||
.blur(radius: 30)
|
||||
|
||||
Circle()
|
||||
.strokeBorder(Color.white.opacity(0.18),
|
||||
style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
|
||||
.frame(width: 140, height: 140)
|
||||
.rotationEffect(.degrees(rotate))
|
||||
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.36, green: 0.34, blue: 0.30),
|
||||
Color(red: 0.22, green: 0.21, blue: 0.18)],
|
||||
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1)
|
||||
)
|
||||
.frame(width: 96, height: 96)
|
||||
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 12)
|
||||
.overlay(ChipGlyph())
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Circle()
|
||||
.fill(Color(red: 0.95, green: 0.78, blue: 0.40))
|
||||
.frame(width: 6, height: 6)
|
||||
.opacity(pulse ? 1 : 0.35)
|
||||
.shadow(color: Color(red: 0.95, green: 0.78, blue: 0.40), radius: 6)
|
||||
.padding(10)
|
||||
}
|
||||
.scaleEffect(pulse ? 1.06 : 1.0)
|
||||
.opacity(pulse ? 0.92 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
private var lineList: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(Array(lineLabels.enumerated()), id: \.offset) { idx, label in
|
||||
LineRow(
|
||||
text: label,
|
||||
done: step > idx + 1,
|
||||
active: step == idx + 1,
|
||||
isLast: idx == lineLabels.count - 1
|
||||
)
|
||||
.opacity(step >= idx + 1 ? 1 : 0)
|
||||
.offset(y: step >= idx + 1 ? 0 : 6)
|
||||
.animation(.easeOut(duration: 0.4).delay(Double(idx) * 0.05), value: step)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var speedBadge: some View {
|
||||
Text(String(format: String(appLoc: "已处理 %.1fs · 比云端快 4.2×"), elapsed))
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.tracking(0.6)
|
||||
.foregroundStyle(Color.white.opacity(0.75))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Color.white.opacity(0.08)))
|
||||
}
|
||||
|
||||
private func startAnimations() {
|
||||
withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) {
|
||||
pulse.toggle()
|
||||
}
|
||||
withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) {
|
||||
glow.toggle()
|
||||
}
|
||||
withAnimation(.linear(duration: 14).repeatForever(autoreverses: false)) {
|
||||
rotate = 360
|
||||
}
|
||||
|
||||
Task {
|
||||
for _ in 0..<lineLabels.count {
|
||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||
await MainActor.run {
|
||||
withAnimation { step += 1 }
|
||||
elapsed += 0.9
|
||||
}
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 600_000_000)
|
||||
await MainActor.run { onComplete() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LineRow: View {
|
||||
let text: String
|
||||
let done: Bool
|
||||
let active: Bool
|
||||
let isLast: Bool
|
||||
|
||||
@State private var dotPulse = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(done
|
||||
? Color(red: 0.95, green: 0.78, blue: 0.40)
|
||||
: Color.white.opacity(0.12))
|
||||
if done {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(Color(red: 0.10, green: 0.115, blue: 0.094))
|
||||
}
|
||||
}
|
||||
.frame(width: 14, height: 14)
|
||||
|
||||
Text(text)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(done ? Color.white.opacity(0.95) : Color.white.opacity(0.45))
|
||||
|
||||
if active {
|
||||
Spacer()
|
||||
Text("···")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(Color.white.opacity(dotPulse ? 0.9 : 0.4))
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
|
||||
dotPulse.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChipGlyph: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.8), lineWidth: 1.4)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(Color(red: 0.95, green: 0.78, blue: 0.40).opacity(0.35))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.strokeBorder(Color(red: 0.95, green: 0.78, blue: 0.40), lineWidth: 1)
|
||||
)
|
||||
.frame(width: 16, height: 16)
|
||||
|
||||
innerCross
|
||||
outerPins
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
}
|
||||
|
||||
private var innerCross: some View {
|
||||
Canvas { ctx, size in
|
||||
let amber = Color(red: 0.95, green: 0.78, blue: 0.40)
|
||||
let stroke = GraphicsContext.Shading.color(amber)
|
||||
let cx = size.width / 2
|
||||
let cy = size.height / 2
|
||||
|
||||
let pairs: [(CGPoint, CGPoint)] = [
|
||||
(CGPoint(x: cx, y: cy - 8), CGPoint(x: cx, y: cy - 4)),
|
||||
(CGPoint(x: cx, y: cy + 4), CGPoint(x: cx, y: cy + 8)),
|
||||
(CGPoint(x: cx - 8, y: cy), CGPoint(x: cx - 4, y: cy)),
|
||||
(CGPoint(x: cx + 4, y: cy), CGPoint(x: cx + 8, y: cy)),
|
||||
]
|
||||
for (s, e) in pairs {
|
||||
var p = Path()
|
||||
p.move(to: s)
|
||||
p.addLine(to: e)
|
||||
ctx.stroke(p, with: stroke, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||||
}
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
}
|
||||
|
||||
private var outerPins: some View {
|
||||
Canvas { ctx, size in
|
||||
let pinColor = GraphicsContext.Shading.color(Color.white.opacity(0.45))
|
||||
let cx = size.width / 2
|
||||
let cy = size.height / 2
|
||||
let halfChip: CGFloat = 14
|
||||
let outsideStart: CGFloat = 20
|
||||
let outsideEnd: CGFloat = 26
|
||||
|
||||
let positions: [CGFloat] = [-8, 0, 8]
|
||||
|
||||
for offset in positions {
|
||||
// top
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: cx + offset, y: cy - outsideEnd))
|
||||
p.addLine(to: CGPoint(x: cx + offset, y: cy - halfChip))
|
||||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||||
|
||||
// bottom
|
||||
p = Path()
|
||||
p.move(to: CGPoint(x: cx + offset, y: cy + halfChip))
|
||||
p.addLine(to: CGPoint(x: cx + offset, y: cy + outsideEnd))
|
||||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||||
|
||||
// left
|
||||
p = Path()
|
||||
p.move(to: CGPoint(x: cx - outsideEnd, y: cy + offset))
|
||||
p.addLine(to: CGPoint(x: cx - halfChip, y: cy + offset))
|
||||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||||
|
||||
// right
|
||||
p = Path()
|
||||
p.move(to: CGPoint(x: cx + halfChip, y: cy + offset))
|
||||
p.addLine(to: CGPoint(x: cx + outsideStart + 2, y: cy + offset))
|
||||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||||
}
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
B4ProgressView(onComplete: {})
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
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: String(appLoc: "低密度脂蛋白胆固醇"), value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high,
|
||||
note: String(appLoc: "超过参考上限 0.44。建议关注饮食结构,3 个月内复查。")),
|
||||
.init(name: String(appLoc: "甘油三酯 TG"), value: "1.78", unit: "mmol/L", range: "0.45–1.70", status: .high, note: nil),
|
||||
.init(name: String(appLoc: "尿酸 UA"), value: "428", unit: "μmol/L", range: "150–420", status: .high, note: nil),
|
||||
.init(name: String(appLoc: "维生素 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(String(appLoc: "异常项"), 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(String(appLoc: "正常项"), 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: String(appLoc: "体检报告"), 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: String(appLoc: "总项"))
|
||||
Stat(n: "3", label: String(appLoc: "偏高"), tone: .brick)
|
||||
Stat(n: "1", label: String(appLoc: "偏低"), tone: .amber)
|
||||
Stat(n: "24", label: String(appLoc: "正常"), 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 String(appLoc: "偏高")
|
||||
case .low: return String(appLoc: "偏低")
|
||||
case .normal: return String(appLoc: "正常")
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -458,9 +458,14 @@ struct MarkdownView: View {
|
||||
return trimmed.replacingOccurrences(of: "⚠️", with: "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
// 一些常见 LLM 表达,也当异常项高亮
|
||||
// 关键词兜底高亮,但排除否定语境(「无异常」「未见偏高」「没有偏低」等),
|
||||
// 否则正常结论会被误标红。判断:信号词前最近 4 字内出现否定词即视为否定。
|
||||
let negations = ["无", "未", "没"]
|
||||
let abnormalSignals = ["偏高", "偏低", "异常", "过高", "过低"]
|
||||
for sig in abnormalSignals where trimmed.contains(sig) {
|
||||
for sig in abnormalSignals {
|
||||
guard let r = trimmed.range(of: sig) else { continue }
|
||||
let window = String(trimmed[..<r.lowerBound].suffix(4))
|
||||
if negations.contains(where: { window.contains($0) }) { continue }
|
||||
return trimmed
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user