缺少代码差异信息,无法生成具体的commit message。请提供code differences内容以便分析并生成符合Angular规范的提交信息。
当您提供代码差异后,我将按照以下格式生成: ``` <type>(<scope>): <subject> <body> ``` 其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
This commit is contained in:
@@ -30,7 +30,6 @@ struct ArchiveListView: View {
|
||||
@State private var filter: TimelineKind? = nil
|
||||
@State private var endingSymptom: Symptom?
|
||||
@State private var selectedEntry: TimelineEntry?
|
||||
@State private var showExportSheet = false
|
||||
@State private var route: Route?
|
||||
|
||||
@MainActor
|
||||
@@ -110,9 +109,6 @@ struct ArchiveListView: View {
|
||||
TimelineEntryDetailView(detail: d)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showExportSheet) {
|
||||
HealthExportSheet()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -150,35 +146,23 @@ struct ArchiveListView: View {
|
||||
.font(.tjTitle(26))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount) 条"))
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
Menu {
|
||||
Button {
|
||||
showExportSheet = true
|
||||
} label: {
|
||||
Label("生成新导出", systemImage: "doc.text.below.ecg")
|
||||
}
|
||||
if !exports.isEmpty {
|
||||
Button {
|
||||
route = .exports
|
||||
} label: {
|
||||
Label("我的导出 · \(exports.count) 份", systemImage: "clock.arrow.circlepath")
|
||||
if !exports.isEmpty {
|
||||
Button { route = .exports } label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
Text("导出历史")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Tj.Palette.ink))
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "doc.text.below.ecg")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Text("导出身体档案")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Tj.Palette.ink))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,19 +201,19 @@ struct ArchiveListView: View {
|
||||
ZStack {
|
||||
Circle().fill(reminderEnabledCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||
Image(systemName: "bell.fill")
|
||||
.font(.system(size: 16))
|
||||
.font(.tjScaled( 16))
|
||||
.foregroundStyle(reminderEnabledCount > 0 ? Tj.Palette.ink : Tj.Palette.text3)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(reminderCountLabel)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
if !reminderTitlePreview.isEmpty {
|
||||
Text(reminderTitleLine)
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(1)
|
||||
}
|
||||
@@ -238,7 +222,7 @@ struct ArchiveListView: View {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
@@ -265,7 +249,7 @@ struct ArchiveListView: View {
|
||||
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
||||
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
@@ -282,14 +266,14 @@ struct ArchiveListView: View {
|
||||
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
|
||||
HStack {
|
||||
Text(section.label)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Rectangle()
|
||||
.fill(Tj.Palette.lineSoft)
|
||||
.frame(height: 1)
|
||||
Text("\(count)")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
@@ -303,7 +287,7 @@ struct ArchiveListView: View {
|
||||
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
|
||||
.frame(width: 240, height: 140)
|
||||
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ struct HealthExportDetailView: View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(Tj.Palette.sand2))
|
||||
@@ -62,7 +62,7 @@ struct HealthExportDetailView: View {
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(Self.absoluteDate(export.createdAt))
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
@@ -81,13 +81,13 @@ struct HealthExportDetailView: View {
|
||||
TjBadge(text: export.modelTag, style: .neutral)
|
||||
if export.decodeRate > 0 {
|
||||
Text(String(format: "%.1f tok/s", export.decodeRate))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
}
|
||||
Spacer()
|
||||
if let from = export.inferredTimeFromDate, let to = export.inferredTimeToDate {
|
||||
Text("\(Self.shortDate(from)) — \(Self.shortDate(to))")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
@@ -96,10 +96,10 @@ struct HealthExportDetailView: View {
|
||||
private var promptBlock: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "quote.opening")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(export.prompt)
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
.padding(12)
|
||||
@@ -119,7 +119,7 @@ struct HealthExportDetailView: View {
|
||||
|
||||
ShareLink(item: export.content) {
|
||||
Label("分享", systemImage: "square.and.arrow.up")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.padding(.horizontal, 14)
|
||||
@@ -134,7 +134,7 @@ struct HealthExportDetailView: View {
|
||||
showDeleteConfirm = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.font(.tjScaled( 15, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Circle().strokeBorder(Tj.Palette.brick.opacity(0.4), lineWidth: 1))
|
||||
|
||||
@@ -57,7 +57,7 @@ struct HealthExportListView: View {
|
||||
.font(.tjTitle(24))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(exports.isEmpty ? "" : String(appLoc: "\(exports.count) 份"))
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
TjLockChip()
|
||||
@@ -88,22 +88,22 @@ struct HealthExportRow: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top) {
|
||||
Text(export.promptPreview)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.font(.tjScaled( 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(Self.relativeDate(export.createdAt))
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
if export.decodeRate > 0 {
|
||||
Text(String(format: "%.1f tok/s", export.decodeRate))
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.font(.tjScaled( 10, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
}
|
||||
Spacer()
|
||||
|
||||
@@ -2,7 +2,7 @@ import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 「导出身体档案」全屏 sheet。
|
||||
/// 状态机:idle → running(extractingIntent → retrieving → generating)→ completed / failed
|
||||
/// 状态机:多轮问答 → running(retrieving → generating)→ completed / failed
|
||||
struct HealthExportSheet: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -10,7 +10,8 @@ struct HealthExportSheet: View {
|
||||
/// 可选:从历史「重新生成」时传入(暂时未启用,W3 接)。
|
||||
let initialPrompt: String
|
||||
|
||||
@State private var prompt: String = ""
|
||||
@State private var turns: [HealthExportDialogueTurn] = []
|
||||
@State private var draftQuestion: String = ""
|
||||
@State private var phase: HealthExportService.Phase?
|
||||
@State private var content: String = ""
|
||||
@State private var rate: Double = 0
|
||||
@@ -18,14 +19,25 @@ struct HealthExportSheet: View {
|
||||
@State private var error: Error?
|
||||
@State private var completed: Bool = false
|
||||
@State private var copiedFlash: Bool = false
|
||||
@FocusState private var promptFocused: Bool
|
||||
@State private var answeringTurnID: UUID?
|
||||
@FocusState private var questionFocused: 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 }
|
||||
private var isGeneratingReport: Bool { phase != nil && !completed && error == nil }
|
||||
private var isAnswering: Bool { answeringTurnID != nil }
|
||||
private var canAsk: Bool {
|
||||
!isAnswering &&
|
||||
!isGeneratingReport &&
|
||||
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
private var canGenerateReport: Bool {
|
||||
!isAnswering &&
|
||||
!isGeneratingReport &&
|
||||
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -33,28 +45,20 @@ struct HealthExportSheet: View {
|
||||
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")
|
||||
introSection
|
||||
|
||||
ForEach(turns) { turn in
|
||||
dialogueBubble(turn)
|
||||
}
|
||||
|
||||
if isGeneratingReport { phaseIndicator }
|
||||
|
||||
if !content.isEmpty {
|
||||
reportCard
|
||||
}
|
||||
|
||||
if let err = error { errorRow(err) }
|
||||
Color.clear.frame(height: 1).id("bottom")
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
@@ -64,13 +68,24 @@ struct HealthExportSheet: View {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.onChange(of: turns) { _, _ in
|
||||
withAnimation(.easeOut(duration: 0.12)) {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
if completed {
|
||||
actionRow
|
||||
} else {
|
||||
composer
|
||||
}
|
||||
if completed { actionRow }
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.onAppear {
|
||||
if prompt.isEmpty { prompt = initialPrompt }
|
||||
if isInputMode { promptFocused = true }
|
||||
if !initialPrompt.isEmpty, draftQuestion.isEmpty, turns.isEmpty {
|
||||
draftQuestion = initialPrompt
|
||||
}
|
||||
questionFocused = true
|
||||
}
|
||||
.onDisappear { task?.cancel() }
|
||||
}
|
||||
@@ -81,17 +96,17 @@ struct HealthExportSheet: View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Button { close() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(Tj.Palette.sand2))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("导出身体档案")
|
||||
Text("身体档案")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("给医生看的就诊摘要")
|
||||
.font(.system(size: 11))
|
||||
Text("先问清楚,再整理给医生")
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
@@ -105,82 +120,92 @@ struct HealthExportSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Input section (idle)
|
||||
// MARK: - Dialogue
|
||||
|
||||
private var inputSection: some View {
|
||||
private var introSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("说说你想给医生看什么")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Text("围绕你的指标和健康日记提问")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("例:我感冒3天了,把最近一个月的健康情况给医生看")
|
||||
.font(.system(size: 12))
|
||||
Text("例:最近血压波动大吗?")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("例:最近血糖好像不稳,把过去三个月的化验单整理一下")
|
||||
.font(.system(size: 12))
|
||||
Text("例:把我最近头晕、睡眠和指标变化整理给医生")
|
||||
.font(.tjScaled( 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)
|
||||
Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.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 dialogueBubble(_ turn: HealthExportDialogueTurn) -> some View {
|
||||
let isUser = turn.role == .user
|
||||
return HStack(alignment: .top, spacing: 8) {
|
||||
if isUser { Spacer(minLength: 44) }
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(turn.role.transcriptLabel)
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
|
||||
if turn.id == answeringTurnID && turn.text.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("正在查看本地记录…")
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
} else {
|
||||
Text(turn.text)
|
||||
.font(.tjScaled( 14))
|
||||
.lineSpacing(3)
|
||||
.foregroundStyle(isUser ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
TextEditor(text: $prompt)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(minHeight: 130)
|
||||
.focused($promptFocused)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: 300, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
.fill(isUser ? Tj.Palette.ink : Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
.strokeBorder(isUser ? Color.clear : 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)
|
||||
}
|
||||
if !isUser { Spacer(minLength: 44) }
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
private var reportCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("整理好的报告")
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.lineLimit(3)
|
||||
MarkdownView(text: content)
|
||||
}
|
||||
.padding(12)
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.sand2)
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -197,11 +222,11 @@ struct HealthExportSheet: View {
|
||||
}
|
||||
if phase == .generating && rate > 0 {
|
||||
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
} else {
|
||||
Text(phase?.label ?? "")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
@@ -213,7 +238,7 @@ struct HealthExportSheet: View {
|
||||
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))
|
||||
.font(.tjScaled( 11, weight: active ? .semibold : .regular))
|
||||
.foregroundStyle(fg)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
@@ -222,7 +247,7 @@ struct HealthExportSheet: View {
|
||||
|
||||
private var arrow: some View {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.font(.tjScaled( 10, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
@@ -243,7 +268,7 @@ struct HealthExportSheet: View {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
Text(err.localizedDescription)
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
Button { reset() } label: { Text("返回修改") }
|
||||
@@ -268,7 +293,7 @@ struct HealthExportSheet: View {
|
||||
|
||||
ShareLink(item: content) {
|
||||
Label("分享", systemImage: "square.and.arrow.up")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.padding(.horizontal, 14)
|
||||
@@ -279,7 +304,7 @@ struct HealthExportSheet: View {
|
||||
|
||||
Spacer()
|
||||
Button { regenerate() } label: {
|
||||
Label("重新生成", systemImage: "arrow.clockwise")
|
||||
Label("重新整理", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 13, horizontalPadding: 16))
|
||||
}
|
||||
@@ -291,19 +316,100 @@ struct HealthExportSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("继续提问或补充情况…", text: $draftQuestion, axis: .vertical)
|
||||
.font(.tjScaled( 14))
|
||||
.lineLimit(1...4)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.sand2)
|
||||
)
|
||||
.focused($questionFocused)
|
||||
.disabled(isAnswering || isGeneratingReport)
|
||||
|
||||
Button { sendQuestion() } label: {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.tjScaled( 15, weight: .bold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Circle().fill(canAsk ? Tj.Palette.ink : Tj.Palette.line))
|
||||
}
|
||||
.disabled(!canAsk)
|
||||
.accessibilityLabel("发送问题")
|
||||
}
|
||||
|
||||
Button { startReportGeneration() } label: {
|
||||
Label("生成整理报告", systemImage: "doc.text.below.ecg")
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
|
||||
.disabled(!canGenerateReport)
|
||||
.opacity(canGenerateReport ? 1 : 0.45)
|
||||
}
|
||||
.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
|
||||
private func sendQuestion() {
|
||||
let question = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !question.isEmpty, !isAnswering, !isGeneratingReport else { return }
|
||||
draftQuestion = ""
|
||||
questionFocused = false
|
||||
|
||||
let userTurn = HealthExportDialogueTurn.user(question)
|
||||
let assistantTurn = HealthExportDialogueTurn.assistant("")
|
||||
turns.append(userTurn)
|
||||
turns.append(assistantTurn)
|
||||
answeringTurnID = assistantTurn.id
|
||||
|
||||
let conversationForPrompt = turns.filter { $0.id != assistantTurn.id }
|
||||
let stream = HealthExportService.shared.answer(
|
||||
question: question,
|
||||
conversation: conversationForPrompt,
|
||||
in: ctx
|
||||
)
|
||||
task?.cancel()
|
||||
task = Task { @MainActor in
|
||||
do {
|
||||
for try await chunk in stream {
|
||||
appendToTurn(id: assistantTurn.id, text: chunk.text)
|
||||
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
|
||||
}
|
||||
answeringTurnID = nil
|
||||
questionFocused = true
|
||||
} catch {
|
||||
answeringTurnID = nil
|
||||
appendToTurn(id: assistantTurn.id, text: error.localizedDescription)
|
||||
questionFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func appendToTurn(id: UUID, text: String) {
|
||||
guard let idx = turns.firstIndex(where: { $0.id == id }) else { return }
|
||||
turns[idx].text += text
|
||||
}
|
||||
|
||||
private func startReportGeneration() {
|
||||
guard canGenerateReport else { return }
|
||||
questionFocused = false
|
||||
content = ""
|
||||
rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示
|
||||
error = nil
|
||||
completed = false
|
||||
phase = .extractingIntent
|
||||
phase = .retrieving
|
||||
|
||||
let stream = HealthExportService.shared.export(prompt: p, in: ctx)
|
||||
let stream = HealthExportService.shared.export(conversation: turns, in: ctx)
|
||||
task?.cancel()
|
||||
task = Task { @MainActor in
|
||||
do {
|
||||
for try await event in stream {
|
||||
@@ -326,7 +432,7 @@ struct HealthExportSheet: View {
|
||||
|
||||
private func regenerate() {
|
||||
completed = false
|
||||
start()
|
||||
startReportGeneration()
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
@@ -337,7 +443,8 @@ struct HealthExportSheet: View {
|
||||
rate = 0
|
||||
error = nil
|
||||
completed = false
|
||||
promptFocused = true
|
||||
answeringTurnID = nil
|
||||
questionFocused = true
|
||||
}
|
||||
|
||||
private func copy() {
|
||||
@@ -377,7 +484,7 @@ struct MarkdownView: View {
|
||||
case .h1(let s):
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(inline(s))
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.font(.tjScaled( 22, weight: .bold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Rectangle()
|
||||
@@ -394,7 +501,7 @@ struct MarkdownView: View {
|
||||
.fill(Tj.Palette.brick)
|
||||
.frame(width: 3, height: 16)
|
||||
Text(inline(s))
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
@@ -404,10 +511,10 @@ struct MarkdownView: View {
|
||||
if let abnormalText = Self.extractAbnormal(s) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
Text(inline(abnormalText))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.font(.tjScaled( 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 0)
|
||||
@@ -431,7 +538,7 @@ struct MarkdownView: View {
|
||||
.frame(width: 4, height: 4)
|
||||
.padding(.top, 6)
|
||||
Text(inline(s))
|
||||
.font(.system(size: 14))
|
||||
.font(.tjScaled( 14))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
@@ -440,7 +547,7 @@ struct MarkdownView: View {
|
||||
|
||||
case .body(let s):
|
||||
Text(inline(s))
|
||||
.font(.system(size: 14))
|
||||
.font(.tjScaled( 14))
|
||||
.lineSpacing(3)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -82,7 +82,7 @@ struct CalendarOverviewView: View {
|
||||
}
|
||||
} label: {
|
||||
Text("回到今天")
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ struct CalendarOverviewView: View {
|
||||
if let onClose {
|
||||
Button(action: onClose) {
|
||||
Text("完成")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ struct CalendarOverviewView: View {
|
||||
}
|
||||
} label: {
|
||||
Text(m.label)
|
||||
.font(.system(size: 13, weight: mode == m ? .semibold : .regular))
|
||||
.font(.tjScaled( 13, weight: mode == m ? .semibold : .regular))
|
||||
.foregroundStyle(mode == m ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 9)
|
||||
@@ -157,7 +157,7 @@ struct CalendarOverviewView: View {
|
||||
HStack {
|
||||
Button { shiftAnchor(-1) } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Circle().fill(Tj.Palette.paper))
|
||||
@@ -177,7 +177,7 @@ struct CalendarOverviewView: View {
|
||||
|
||||
Button { shiftAnchor(1) } label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Circle().fill(Tj.Palette.paper))
|
||||
@@ -230,7 +230,7 @@ struct CalendarOverviewView: View {
|
||||
private var legend: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("图例")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
HStack(spacing: 14) {
|
||||
@@ -249,7 +249,7 @@ struct CalendarOverviewView: View {
|
||||
.fill(color)
|
||||
.frame(width: 14, height: 6)
|
||||
Text(label)
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ struct CaptureReviewForm: View {
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(text)
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let onReanalyze {
|
||||
@@ -48,7 +48,7 @@ struct CaptureReviewForm: View {
|
||||
onReanalyze()
|
||||
} label: {
|
||||
Label("重新识别", systemImage: "arrow.clockwise")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
@@ -131,7 +131,7 @@ struct CaptureReviewForm: View {
|
||||
private func labeledField<C: View>(_ label: String, @ViewBuilder content: () -> C) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.font(.tjScaled( 11, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
content()
|
||||
}
|
||||
@@ -150,14 +150,14 @@ struct CaptureReviewForm: View {
|
||||
)
|
||||
} label: {
|
||||
Label("加一项", systemImage: "plus.circle")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.font(.tjScaled( 12, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
if parsed.indicators.isEmpty {
|
||||
Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
@@ -175,7 +175,7 @@ struct CaptureReviewForm: View {
|
||||
return VStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("指标名", text: binding.name)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.font(.tjScaled( 14, weight: .medium))
|
||||
Button(role: .destructive) {
|
||||
parsed.indicators.removeAll { $0.id == id }
|
||||
} label: {
|
||||
@@ -187,7 +187,7 @@ struct CaptureReviewForm: View {
|
||||
HStack(spacing: 8) {
|
||||
TextField("数值", text: binding.value)
|
||||
.keyboardType(.decimalPad)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 14, weight: .semibold, design: .monospaced))
|
||||
.frame(maxWidth: 90)
|
||||
TextField("单位", text: binding.unit)
|
||||
.frame(maxWidth: 80)
|
||||
@@ -247,7 +247,7 @@ struct CaptureReviewForm: View {
|
||||
|
||||
private func sectionLabel(_ t: String) -> some View {
|
||||
Text(t)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ struct PhotoPickerSheet: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "photo.on.rectangle.angled")
|
||||
.font(.system(size: 56))
|
||||
.font(.tjScaled( 56))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("模拟器没有摄像头,从相册选一张化验单/体检报告")
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -24,7 +24,7 @@ struct PhotoPickerSheet: View {
|
||||
maxSelectionCount: 5,
|
||||
matching: .images) {
|
||||
Text("从相册选 ≤5 张")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Tj.Palette.ink)
|
||||
|
||||
@@ -300,7 +300,12 @@ struct UnifiedCaptureFlow: View {
|
||||
status: ind.status,
|
||||
capturedAt: final.reportDate,
|
||||
report: report,
|
||||
source: .report
|
||||
source: .report,
|
||||
sourcePageIndex: ind.sourcePageIndex,
|
||||
sourceBoxX: ind.sourceBoxX,
|
||||
sourceBoxY: ind.sourceBoxY,
|
||||
sourceBoxWidth: ind.sourceBoxWidth,
|
||||
sourceBoxHeight: ind.sourceBoxHeight
|
||||
)
|
||||
ctx.insert(i)
|
||||
}
|
||||
@@ -346,16 +351,16 @@ private struct AnalyzingView: View {
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("\(images.count) 页 · 100% 本地推理 · 已用 \(elapsed)s")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
if elapsed >= timeoutSeconds - 5 {
|
||||
Text("快超时了,>\(timeoutSeconds)s 会自动转为手动录入")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
}
|
||||
}
|
||||
Button("取消识别 · 改为手动录入", action: onCancel)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.top, 4)
|
||||
Spacer()
|
||||
@@ -375,7 +380,7 @@ private struct CaptureTipSheet: View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "doc.viewfinder")
|
||||
.font(.system(size: 28))
|
||||
.font(.tjScaled( 28))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
Text("拍报告的小贴士")
|
||||
.font(.tjH2())
|
||||
|
||||
@@ -62,12 +62,12 @@ struct DiaryQuickSheet: View {
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
Text("本机保存")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
@@ -154,18 +154,18 @@ struct DiaryQuickSheet: View {
|
||||
// section header
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
|
||||
Spacer()
|
||||
if hasQuestions {
|
||||
Text("\(questions.count) 个建议")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.font(.tjScaled( 10, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
if lastRate > 0 {
|
||||
Text(String(format: "%.1f tok/s", lastRate))
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.font(.tjScaled( 10, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
}
|
||||
}
|
||||
@@ -187,10 +187,10 @@ struct DiaryQuickSheet: View {
|
||||
if exhaustedNote {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
Text("已覆盖主要问诊维度;补充原文后可再追问")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -219,11 +219,11 @@ struct DiaryQuickSheet: View {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("AI 思考中… 本地推理,通常 5-10 秒")
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
Button("取消") { cancelSuggestions() }
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.vertical, 11)
|
||||
@@ -253,13 +253,13 @@ struct DiaryQuickSheet: View {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
Text(err.localizedDescription)
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
}
|
||||
Button { requestSuggestions() } label: {
|
||||
Text("重试")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -282,7 +282,7 @@ struct DiaryQuickSheet: View {
|
||||
Image(systemName: icon)
|
||||
Text(label)
|
||||
}
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 11)
|
||||
@@ -315,12 +315,12 @@ struct DiaryQuickSheet: View {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
Text(round == 1
|
||||
? String(appLoc: "第 1 轮 · \(count) 条")
|
||||
: String(appLoc: "第 \(round) 轮 · 基于你刚才更新的文本 · \(count) 条"))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
@@ -344,10 +344,10 @@ struct DiaryQuickSheet: View {
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(index).")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick)
|
||||
Text(question.q)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text)
|
||||
.strikethrough(adopted, color: Tj.Palette.text3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -356,9 +356,9 @@ struct DiaryQuickSheet: View {
|
||||
if adopted {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.font(.tjScaled( 10, weight: .bold))
|
||||
Text("已采纳")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
.padding(.horizontal, 8)
|
||||
@@ -368,9 +368,9 @@ struct DiaryQuickSheet: View {
|
||||
Button { adopt(question) } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
Text("采纳")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.padding(.horizontal, 10)
|
||||
@@ -390,10 +390,10 @@ struct DiaryQuickSheet: View {
|
||||
} else if !question.fill.isEmpty && !adopted {
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
Text("将追加:")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(question.fill)
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
@@ -416,7 +416,7 @@ struct DiaryQuickSheet: View {
|
||||
|
||||
private func sectionLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ struct QuestionFillPanel: View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
// 实时预览:已填值高亮,未填槽浅色下划线提示。
|
||||
previewText
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
@@ -115,7 +115,7 @@ struct QuestionFillPanel: View {
|
||||
HStack(spacing: 8) {
|
||||
Button(action: onCancel) {
|
||||
Text("取消")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 9)
|
||||
@@ -134,9 +134,9 @@ struct QuestionFillPanel: View {
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "text.append")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
Text("加入记录")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -180,7 +180,7 @@ struct QuestionFillPanel: View {
|
||||
private func slotEditor(index: Int, label: String, options: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
|
||||
if !options.isEmpty {
|
||||
@@ -189,7 +189,7 @@ struct QuestionFillPanel: View {
|
||||
let picked = bindingValue(index) == opt
|
||||
Button { values[index] = opt } label: {
|
||||
Text(opt)
|
||||
.font(.system(size: 12, weight: picked ? .semibold : .regular))
|
||||
.font(.tjScaled( 12, weight: picked ? .semibold : .regular))
|
||||
.foregroundStyle(picked ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
@@ -208,7 +208,7 @@ struct QuestionFillPanel: View {
|
||||
}
|
||||
|
||||
TextField(String(appLoc: "填写\(label)"), text: binding(index))
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
|
||||
@@ -85,10 +85,10 @@ struct HomeCalendarCard: View {
|
||||
Spacer()
|
||||
HStack(spacing: 3) {
|
||||
Text(summaryLine)
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ struct HomeCalendarCard: View {
|
||||
} label: {
|
||||
VStack(spacing: 5) {
|
||||
Text(weekdayLabel(day))
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.font(.tjScaled( 10, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
@@ -128,7 +128,7 @@ struct HomeCalendarCard: View {
|
||||
.strokeBorder(Tj.Palette.ink, lineWidth: 1.2)
|
||||
}
|
||||
Text("\(calendar.component(.day, from: day))")
|
||||
.font(.system(size: 14, weight: isToday ? .bold : .regular))
|
||||
.font(.tjScaled( 14, weight: isToday ? .bold : .regular))
|
||||
.foregroundStyle(isToday ? Tj.Palette.ink : Tj.Palette.text)
|
||||
}
|
||||
.frame(height: 38)
|
||||
|
||||
@@ -71,7 +71,7 @@ struct HomeView: View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(todayLine)
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.tracking(1)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(greetingWord)
|
||||
@@ -106,7 +106,7 @@ struct HomeView: View {
|
||||
Spacer()
|
||||
Button(action: onTapArchive) {
|
||||
Text("全部 ›")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -119,7 +119,7 @@ struct HomeView: View {
|
||||
ForEach(recentGrouped, id: \.section) { group in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(group.section.label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
VStack(spacing: 10) {
|
||||
@@ -148,7 +148,7 @@ struct HomeView: View {
|
||||
private var emptyRecent: some View {
|
||||
HStack {
|
||||
Text("还没有任何记录,点底部 + 号开始第一条")
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
@@ -167,15 +167,15 @@ struct HomeView: View {
|
||||
.frame(width: 56, height: 56)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("我的报告档案")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("\(reports.count) 份 · \(indicators.count) 项指标 · 端侧加密")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.font(.tjScaled( 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
|
||||
@@ -34,12 +34,12 @@ struct RecentItemRow: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(date) · \(type)")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(1)
|
||||
Text(name)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.font(.tjScaled( 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
@@ -47,7 +47,7 @@ struct RecentItemRow: View {
|
||||
Spacer(minLength: 8)
|
||||
if let value {
|
||||
Text(value)
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(status.valueColor)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
|
||||
@@ -61,12 +61,12 @@ struct TodayRemindersCard: View {
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("\(count) 项")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
Button { showingCenter = true } label: {
|
||||
Text("全部 ›")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -77,14 +77,14 @@ struct TodayRemindersCard: View {
|
||||
let isPast = item.isPast(now: tick)
|
||||
return HStack(spacing: 12) {
|
||||
Text(item.timeLabel)
|
||||
.font(.system(size: 14, weight: .semibold).monospacedDigit())
|
||||
.font(.tjScaled( 14, weight: .semibold).monospacedDigit())
|
||||
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.ink)
|
||||
.frame(width: 46, alignment: .leading)
|
||||
Image(systemName: "bell.fill")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.amber)
|
||||
Text(item.title)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.font(.tjScaled( 15, weight: .medium))
|
||||
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
|
||||
@@ -125,7 +125,7 @@ struct CustomMetricEditor: View {
|
||||
Spacer()
|
||||
if existing == nil {
|
||||
Text("保存后会出现在录入选项里")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
@@ -147,10 +147,10 @@ struct CustomMetricEditor: View {
|
||||
if nameConflict != .none {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
Text(nameConflict.warningText)
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 0)
|
||||
@@ -175,7 +175,7 @@ struct CustomMetricEditor: View {
|
||||
sectionLabel(String(appLoc: "参考范围(可选)"))
|
||||
Spacer()
|
||||
Text("用于自动判定 正常/偏高/偏低")
|
||||
.font(.system(size: 10))
|
||||
.font(.tjScaled( 10))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
@@ -188,10 +188,10 @@ struct CustomMetricEditor: View {
|
||||
|
||||
private func rangeField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
||||
Text(label).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||
TextField(placeholder, text: value)
|
||||
.keyboardType(.decimalPad)
|
||||
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
||||
.font(.tjScaled( 16, weight: .medium, design: .monospaced))
|
||||
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||
.background(fieldBg).overlay(fieldBorder)
|
||||
}
|
||||
@@ -207,7 +207,7 @@ struct CustomMetricEditor: View {
|
||||
icon = sf
|
||||
} label: {
|
||||
Image(systemName: sf)
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.font(.tjScaled( 20, weight: .medium))
|
||||
.foregroundStyle(icon == sf ? Tj.Palette.paper : Tj.Palette.ink)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
.background(
|
||||
@@ -239,7 +239,7 @@ struct CustomMetricEditor: View {
|
||||
Image(systemName: "trash")
|
||||
Text("删除这项自定义指标")
|
||||
}
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
@@ -282,7 +282,7 @@ struct CustomMetricEditor: View {
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||
}
|
||||
private func sectionLabel(_ t: String) -> some View {
|
||||
Text(t).font(.system(size: 12, weight: .semibold)).tracking(0.3)
|
||||
Text(t).font(.tjScaled( 12, weight: .semibold)).tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ private let labPresets: [IndicatorPreset] = [
|
||||
/// 无 seriesKey,不进 Trends。
|
||||
/// 3. **自由输入** — name/value/unit/range 全自己填,status 手动选。
|
||||
struct IndicatorQuickSheet: View {
|
||||
/// 「拍照识别」入口回调。由 RootView 注入:关闭本表单 → 打开 QuickRegionCaptureFlow(相机→VL→存)。
|
||||
/// nil 时(如 Preview)不显示拍照按钮。
|
||||
var onRequestCamera: (() -> Void)? = nil
|
||||
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query private var profiles: [UserProfile]
|
||||
@@ -103,6 +107,7 @@ struct IndicatorQuickSheet: View {
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
cameraEntrySection
|
||||
monitorGridSection
|
||||
labPresetSection
|
||||
Divider().padding(.vertical, 4)
|
||||
@@ -161,13 +166,69 @@ struct IndicatorQuickSheet: View {
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
Text("本地处理 · 永不上传")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
|
||||
/// 顶部「拍照识别」入口:并入原「异常项快拍」。点后由 RootView 切到相机 VL 流程。
|
||||
@ViewBuilder
|
||||
private var cameraEntrySection: some View {
|
||||
if let onRequestCamera {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Button {
|
||||
onRequestCamera()
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.brick)
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.tjScaled(18, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("拍照识别")
|
||||
.font(.tjScaled(15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("拍化验单,VL 自动读出数值")
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.tjScaled(14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.tjCard(bordered: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
line
|
||||
Text("或手动填写")
|
||||
.font(.tjScaled(11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.fixedSize()
|
||||
line
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var line: some View {
|
||||
Rectangle()
|
||||
.fill(Tj.Palette.lineSoft)
|
||||
.frame(height: 1)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var monitorGridSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
@@ -217,18 +278,18 @@ struct IndicatorQuickSheet: View {
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: cm.icon)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.font(.tjScaled( 18, weight: .medium))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft))
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(cm.name)
|
||||
.font(.system(size: 14, weight: selected ? .semibold : .medium))
|
||||
.font(.tjScaled( 14, weight: selected ? .semibold : .medium))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Text("自定义")
|
||||
.font(.system(size: 9, design: .monospaced))
|
||||
.font(.tjScaled( 9, design: .monospaced))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
@@ -260,14 +321,14 @@ struct IndicatorQuickSheet: View {
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.font(.tjScaled( 18, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(
|
||||
Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true)
|
||||
)
|
||||
Text("自定义")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.font(.tjScaled( 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
}
|
||||
@@ -293,13 +354,13 @@ struct IndicatorQuickSheet: View {
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: m.icon)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.font(.tjScaled( 18, weight: .medium))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.amber.opacity(0.25)))
|
||||
|
||||
Text(m.displayName)
|
||||
.font(.system(size: 14, weight: selected ? .semibold : .medium))
|
||||
.font(.tjScaled( 14, weight: selected ? .semibold : .medium))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||
Spacer()
|
||||
}
|
||||
@@ -348,7 +409,7 @@ struct IndicatorQuickSheet: View {
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
bpField(label: String(appLoc: "收缩压"), value: $systolic, placeholder: "120")
|
||||
Text("/").font(.system(size: 22, weight: .light)).foregroundStyle(Tj.Palette.text3)
|
||||
Text("/").font(.tjScaled( 22, weight: .light)).foregroundStyle(Tj.Palette.text3)
|
||||
bpField(label: String(appLoc: "舒张压"), value: $diastolic, placeholder: "80")
|
||||
Text("mmHg").foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
@@ -358,10 +419,10 @@ struct IndicatorQuickSheet: View {
|
||||
|
||||
private func bpField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
||||
Text(label).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||
TextField(placeholder, text: value)
|
||||
.keyboardType(.decimalPad)
|
||||
.font(.system(size: 20, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 20, weight: .semibold, design: .monospaced))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.vertical, 10)
|
||||
.frame(width: 90)
|
||||
@@ -380,11 +441,11 @@ struct IndicatorQuickSheet: View {
|
||||
let rangeText = "\(formatRange(sysRange)) / \(formatRange(diasRange))"
|
||||
return HStack(spacing: 4) {
|
||||
Text(rangeText)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
if personalized, let age = profile?.age {
|
||||
Text("· 按\(age)岁调整")
|
||||
.font(.system(size: 10))
|
||||
.font(.tjScaled( 10))
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
}
|
||||
}
|
||||
@@ -427,7 +488,7 @@ struct IndicatorQuickSheet: View {
|
||||
sectionLabel(String(appLoc: "数值"))
|
||||
TextField(monitorFieldPlaceholder, text: $value)
|
||||
.keyboardType(.decimalPad)
|
||||
.font(.system(size: 18, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 18, weight: .semibold, design: .monospaced))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(fieldBg)
|
||||
@@ -475,7 +536,7 @@ struct IndicatorQuickSheet: View {
|
||||
return HStack(spacing: 4) {
|
||||
if personalized, let age = profile?.age {
|
||||
Text("按\(age)岁调整")
|
||||
.font(.system(size: 10))
|
||||
.font(.tjScaled( 10))
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
}
|
||||
}
|
||||
@@ -500,7 +561,7 @@ struct IndicatorQuickSheet: View {
|
||||
statusBadge(s.label, color: s.color)
|
||||
} else {
|
||||
Text("待输入")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
@@ -546,7 +607,7 @@ struct IndicatorQuickSheet: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("时间")
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
DatePicker("", selection: $reminderTime,
|
||||
@@ -558,11 +619,11 @@ struct IndicatorQuickSheet: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("频率")
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
Text(reminderFrequencyLabel)
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
weekdayPickerRow
|
||||
@@ -581,11 +642,11 @@ struct IndicatorQuickSheet: View {
|
||||
|
||||
if notifAuthBlocked {
|
||||
Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
} else {
|
||||
Text("本机提醒 · 不发任何数据")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
@@ -625,7 +686,7 @@ struct IndicatorQuickSheet: View {
|
||||
}
|
||||
} label: {
|
||||
Text(names[idx])
|
||||
.font(.system(size: 13,
|
||||
.font(.tjScaled( 13,
|
||||
weight: reminderWeekdays.contains(w) ? .semibold : .regular))
|
||||
.foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity, minHeight: 32)
|
||||
@@ -647,7 +708,7 @@ struct IndicatorQuickSheet: View {
|
||||
private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
@@ -755,7 +816,7 @@ struct IndicatorQuickSheet: View {
|
||||
|
||||
private func sectionLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
@@ -763,7 +824,7 @@ struct IndicatorQuickSheet: View {
|
||||
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
||||
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
@@ -779,7 +840,7 @@ struct IndicatorQuickSheet: View {
|
||||
manualStatus = value
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
||||
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : color)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
@@ -792,7 +853,7 @@ struct IndicatorQuickSheet: View {
|
||||
|
||||
private func statusBadge(_ label: String, color: Color) -> some View {
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
@@ -832,9 +893,9 @@ struct IndicatorQuickSheet: View {
|
||||
} label: {
|
||||
HStack(spacing: 3) {
|
||||
Text("已隐藏 \(hiddenSet.count)")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.font(.tjScaled( 11, weight: .medium))
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.font(.tjScaled( 9, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.padding(.horizontal, 10)
|
||||
@@ -1121,7 +1182,7 @@ private struct HiddenMonitorRestoreSheet: View {
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
Button("完成") { dismiss() }
|
||||
.font(.system(size: 14))
|
||||
.font(.tjScaled( 14))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
@@ -1146,13 +1207,13 @@ private struct HiddenMonitorRestoreSheet: View {
|
||||
private func row(_ m: MonitorMetric) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: m.icon)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.font(.tjScaled( 16, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(Tj.Palette.amber.opacity(0.25)))
|
||||
|
||||
Text(m.displayName)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.font(.tjScaled( 15, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
|
||||
Spacer()
|
||||
@@ -1160,7 +1221,7 @@ private struct HiddenMonitorRestoreSheet: View {
|
||||
Button("显示") {
|
||||
onRestore(m)
|
||||
}
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
@@ -70,12 +70,12 @@ struct AboutView: View {
|
||||
}
|
||||
|
||||
Text("康康 · 本地优先的健康档案 · \(versionText)")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.top, 4)
|
||||
|
||||
Text("本 App 仅供健康信息记录与参考,不能替代专业医疗意见。")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -98,7 +98,7 @@ struct AboutView: View {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.sand2)
|
||||
Image(systemName: "heart.text.square.fill")
|
||||
.font(.system(size: 34))
|
||||
.font(.tjScaled( 34))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
@@ -108,7 +108,7 @@ struct AboutView: View {
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
|
||||
Text("本地优先的个人健康随记")
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
|
||||
Text(versionText)
|
||||
@@ -133,10 +133,10 @@ struct AboutView: View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
content()
|
||||
@@ -148,7 +148,7 @@ struct AboutView: View {
|
||||
|
||||
@ViewBuilder private func paragraph(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 14))
|
||||
.font(.tjScaled( 14))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.lineSpacing(5)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -161,7 +161,7 @@ struct AboutView: View {
|
||||
.frame(width: 5, height: 5)
|
||||
.padding(.top, 7)
|
||||
Text(text)
|
||||
.font(.system(size: 14))
|
||||
.font(.tjScaled( 14))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.lineSpacing(5)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -41,7 +41,7 @@ struct CustomMetricsListView: View {
|
||||
editingTarget = CustomMetricEditTarget(metric: nil)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ struct CustomMetricsListView: View {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("自定义指标会出现在「+ 指标记录 → 长期监测」的 grid 里,可设提醒、进趋势")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 0)
|
||||
@@ -75,7 +75,7 @@ struct CustomMetricsListView: View {
|
||||
TjPlaceholder(label: String(appLoc: "还没有自定义指标"))
|
||||
.frame(width: 220, height: 130)
|
||||
Text("右上角 + 新建一个")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
@@ -88,28 +88,28 @@ struct CustomMetricsListView: View {
|
||||
ZStack {
|
||||
Circle().fill(Tj.Palette.leafSoft)
|
||||
Image(systemName: m.icon)
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.font(.tjScaled( 17, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(m.name)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
if !m.unit.isEmpty {
|
||||
Text(m.unit)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
if !m.rangeText.isEmpty {
|
||||
Text("·")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(m.rangeText)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
@@ -119,10 +119,10 @@ struct CustomMetricsListView: View {
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(count == 0 ? String(appLoc: "未使用") : String(appLoc: "用 \(count) 次"))
|
||||
.font(.system(size: 11, weight: count > 0 ? .semibold : .regular))
|
||||
.font(.tjScaled( 11, weight: count > 0 ? .semibold : .regular))
|
||||
.foregroundStyle(count > 0 ? Tj.Palette.ink : Tj.Palette.text3)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.font(.tjScaled( 11, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ struct CustomReminderEditSheet: View {
|
||||
|
||||
private var skipHint: some View {
|
||||
Text(String(appLoc: "部分月份无此日,该月将跳过"))
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ struct CustomReminderEditSheet: View {
|
||||
second: 0, of: pickedTime) ?? pickedTime
|
||||
} label: {
|
||||
Text(String(format: "%d:%02d", preset.h, preset.m))
|
||||
.font(.system(size: 13, weight: on ? .semibold : .regular))
|
||||
.font(.tjScaled( 13, weight: on ? .semibold : .regular))
|
||||
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity, minHeight: 30)
|
||||
.background(
|
||||
@@ -203,7 +203,7 @@ struct CustomReminderEditSheet: View {
|
||||
if on { weekdays.remove(w) } else { weekdays.insert(w) }
|
||||
} label: {
|
||||
Text(names[idx])
|
||||
.font(.system(size: 13, weight: on ? .semibold : .regular))
|
||||
.font(.tjScaled( 13, weight: on ? .semibold : .regular))
|
||||
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity, minHeight: 30)
|
||||
.background(
|
||||
|
||||
81
康康/Features/Me/FontSettingsView.swift
Normal file
81
康康/Features/Me/FontSettingsView.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 「我的 · 字体大小」选择页。面向视力不佳 / 老年用户:放大整个 App 字号。
|
||||
/// 选中即时生效(整树重建为新档位,无需重启,同语言切换机制)。
|
||||
/// 每行用各自档位渲染示例字,便于直接比较大小后再选。
|
||||
struct FontSettingsView: View {
|
||||
@State private var manager = FontScaleManager.shared
|
||||
|
||||
/// 示例字基准字号(行内按各档位倍率独立渲染,不随当前全局档位变化,保证可对比)。
|
||||
private let sampleBase: CGFloat = 17
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(FontScale.allCases) { option in
|
||||
row(option)
|
||||
}
|
||||
|
||||
Text("放大后整个 App 的文字立即变大,无需重启。设置会被记住。")
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationTitle("字体大小")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func row(_ option: FontScale) -> some View {
|
||||
let selected = manager.scale == option
|
||||
return Button {
|
||||
manager.set(option)
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Text(option.label)
|
||||
.font(.system(size: 15, weight: selected ? .semibold : .regular))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(option.detail)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
// 示例字:按本档位倍率渲染,直接预览效果。
|
||||
Text("健康档案 Aa 123")
|
||||
.font(.system(size: sampleBase * option.multiplier, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.strokeBorder(selected ? Tj.Palette.ink : Tj.Palette.line, lineWidth: selected ? 0 : 1.5)
|
||||
.background(Circle().fill(selected ? Tj.Palette.ink : Color.clear))
|
||||
.frame(width: 24, height: 24)
|
||||
if selected {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.tjCard()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack { FontSettingsView() }
|
||||
}
|
||||
@@ -12,7 +12,7 @@ struct LanguageSettingsView: View {
|
||||
}
|
||||
|
||||
Text("切换后整个 App 立即生效,无需重启。")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 4)
|
||||
@@ -40,14 +40,14 @@ struct LanguageSettingsView: View {
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
Text(option.displayName)
|
||||
.font(.system(size: 15, weight: selected ? .semibold : .regular))
|
||||
.font(.tjScaled( 15, weight: selected ? .semibold : .regular))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
|
||||
Spacer()
|
||||
|
||||
if selected {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
}
|
||||
@@ -64,11 +64,11 @@ struct LanguageSettingsView: View {
|
||||
switch option.pickerIcon {
|
||||
case .symbol(let name):
|
||||
Image(systemName: name)
|
||||
.font(.system(size: 16))
|
||||
.font(.tjScaled( 16))
|
||||
.foregroundStyle(fg)
|
||||
case .glyph(let g):
|
||||
Text(verbatim: g)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.font(.tjScaled( 17, weight: .semibold))
|
||||
.foregroundStyle(fg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ struct MeView: View {
|
||||
@State private var downloadService = ModelDownloadService.shared
|
||||
@State private var appLock = AppLock.shared
|
||||
@State private var lang = LanguageManager.shared
|
||||
@State private var fontScale = FontScaleManager.shared
|
||||
// key 必须与 AppLock.enabledKey 一致。
|
||||
@AppStorage("faceIDLockEnabled") private var lockEnabled = false
|
||||
|
||||
@@ -37,6 +38,7 @@ struct MeView: View {
|
||||
customMetricsCard
|
||||
modelManagementCard
|
||||
languageCard
|
||||
fontScaleCard
|
||||
faceIDCard
|
||||
NavigationLink {
|
||||
AboutView()
|
||||
@@ -74,23 +76,23 @@ struct MeView: View {
|
||||
Circle()
|
||||
.fill(Tj.Palette.amber.opacity(0.25))
|
||||
Image(systemName: "person.crop.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
.font(.tjScaled( 22))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("个人资料")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(profileLine)
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
@@ -108,23 +110,23 @@ struct MeView: View {
|
||||
Circle()
|
||||
.fill(customMetrics.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft)
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.font(.system(size: 18))
|
||||
.font(.tjScaled( 18))
|
||||
.foregroundStyle(customMetrics.isEmpty ? Tj.Palette.text2 : Tj.Palette.ink)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("自定义指标")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(customMetricsLine)
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
@@ -166,6 +168,17 @@ struct MeView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var fontScaleCard: some View {
|
||||
NavigationLink {
|
||||
FontSettingsView()
|
||||
} label: {
|
||||
settingsCard(title: String(appLoc: "字体大小"),
|
||||
detail: fontScale.scale.label,
|
||||
icon: "textformat.size")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Face ID 启动锁(可交互 Toggle 卡)
|
||||
|
||||
private var faceIDCard: some View {
|
||||
@@ -173,17 +186,17 @@ struct MeView: View {
|
||||
ZStack {
|
||||
Circle().fill(lockEnabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||
Image(systemName: "faceid")
|
||||
.font(.system(size: 18))
|
||||
.font(.tjScaled( 18))
|
||||
.foregroundStyle(lockEnabled ? Tj.Palette.ink : Tj.Palette.text2)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Face ID 启动锁")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.font(.tjScaled( 15, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(faceIDLine)
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
@@ -219,20 +232,20 @@ struct MeView: View {
|
||||
ZStack {
|
||||
Circle().fill(Tj.Palette.sand2)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18))
|
||||
.font(.tjScaled( 18))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.font(.tjScaled( 15, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
Text(detail)
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
|
||||
@@ -43,7 +43,7 @@ struct ModelManagementView: View {
|
||||
|
||||
if let importError {
|
||||
Text(importError)
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
@@ -86,10 +86,10 @@ struct ModelManagementView: View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(kind.displayName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(subtitle(kind))
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
@@ -104,17 +104,17 @@ struct ModelManagementView: View {
|
||||
Spacer()
|
||||
Text(speedText(state))
|
||||
}
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
} else {
|
||||
HStack {
|
||||
Text(formatBytes(ModelManifest.totalBytes(for: kind)))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
if case .failed(let message) = state.phase {
|
||||
Text(message)
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.lineLimit(1)
|
||||
}
|
||||
@@ -156,7 +156,7 @@ struct ModelManagementView: View {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
Text("两个模型都已就绪")
|
||||
}
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
@@ -183,7 +183,7 @@ struct ModelManagementView: View {
|
||||
VStack(spacing: 8) {
|
||||
TjLockChip()
|
||||
Text("100% 本地推理 · 模型仅需下载一次")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
@@ -37,11 +37,11 @@ struct ModelSelfTestView: View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("测试 PROMPT")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(prompt)
|
||||
.font(.system(size: 14))
|
||||
.font(.tjScaled( 14))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
.padding(14)
|
||||
@@ -50,13 +50,13 @@ struct ModelSelfTestView: View {
|
||||
|
||||
HStack {
|
||||
Text(phase.label)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(statusColor)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if rate > 0 {
|
||||
Text(String(format: "%.1f tok/s", rate))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.font(.tjScaled( 12, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ struct RemindersListView: View {
|
||||
|
||||
private var header: some View {
|
||||
Text("新建提醒,或在记录指标时开启")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
@@ -89,7 +89,7 @@ struct RemindersListView: View {
|
||||
|
||||
private func sectionLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 8)
|
||||
@@ -146,18 +146,18 @@ private struct CustomReminderRow: View {
|
||||
Circle()
|
||||
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||
Image(systemName: "bell.fill")
|
||||
.font(.system(size: 16))
|
||||
.font(.tjScaled( 16))
|
||||
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(reminder.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
@@ -173,7 +173,7 @@ private struct CustomReminderRow: View {
|
||||
|
||||
// 与指标提醒行的 28×28 展开按钮等宽,保证两类行的 Toggle 纵向对齐。
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
@@ -223,17 +223,17 @@ private struct ReminderRow: View {
|
||||
Circle()
|
||||
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||
Image(systemName: "bell.fill")
|
||||
.font(.system(size: 16))
|
||||
.font(.tjScaled( 16))
|
||||
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(reminder.displayName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ private struct ReminderRow: View {
|
||||
onTapEdit()
|
||||
} label: {
|
||||
Image(systemName: isEditing ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
@@ -259,7 +259,7 @@ private struct ReminderRow: View {
|
||||
private var editingPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("时间").font(.system(size: 13)).foregroundStyle(Tj.Palette.text2)
|
||||
Text("时间").font(.tjScaled( 13)).foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
DatePicker("", selection: $pickedTime, displayedComponents: .hourAndMinute)
|
||||
.datePickerStyle(.compact)
|
||||
@@ -278,7 +278,7 @@ private struct ReminderRow: View {
|
||||
onDelete()
|
||||
} label: {
|
||||
Label("删除提醒", systemImage: "trash")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -310,7 +310,7 @@ private struct ReminderRow: View {
|
||||
onChange()
|
||||
} label: {
|
||||
Text(names[idx])
|
||||
.font(.system(size: 13,
|
||||
.font(.tjScaled( 13,
|
||||
weight: reminder.weekdays.contains(w) ? .semibold : .regular))
|
||||
.foregroundStyle(reminder.weekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity, minHeight: 30)
|
||||
|
||||
@@ -35,9 +35,40 @@ struct ProfileEditView: View {
|
||||
private struct ProfileEditForm: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Bindable var profile: UserProfile
|
||||
@State private var healthImportDraft: HealthProfileImportDraft?
|
||||
@State private var healthImportError: String?
|
||||
@State private var isImportingHealthProfile = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Button {
|
||||
importHealthProfile()
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
if isImportingHealthProfile {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "heart.text.square")
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("从 Apple 健康导入")
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("只读取生日、性别、身高、血型")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isImportingHealthProfile)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("从 Apple 健康导入")
|
||||
.accessibilityHint("读取生日、性别、身高和血型,确认后填入个人资料")
|
||||
} footer: {
|
||||
Text("导入前会先显示预览,确认后才覆盖个人资料。")
|
||||
}
|
||||
|
||||
Section {
|
||||
BirthYearRow(profile: profile)
|
||||
SexRow(profile: profile)
|
||||
@@ -67,6 +98,90 @@ private struct ProfileEditForm: View {
|
||||
profile.updatedAt = .now
|
||||
try? ctx.save()
|
||||
}
|
||||
.sheet(item: $healthImportDraft) { draft in
|
||||
HealthProfileImportPreviewSheet(
|
||||
draft: draft,
|
||||
profile: profile
|
||||
) {
|
||||
draft.apply(to: profile)
|
||||
try? ctx.save()
|
||||
healthImportDraft = nil
|
||||
}
|
||||
}
|
||||
.alert("无法导入 Apple 健康资料", isPresented: Binding(
|
||||
get: { healthImportError != nil },
|
||||
set: { if !$0 { healthImportError = nil } }
|
||||
)) {
|
||||
Button("好", role: .cancel) { healthImportError = nil }
|
||||
} message: {
|
||||
Text(healthImportError ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private func importHealthProfile() {
|
||||
guard !isImportingHealthProfile else { return }
|
||||
isImportingHealthProfile = true
|
||||
healthImportError = nil
|
||||
Task {
|
||||
do {
|
||||
healthImportDraft = try await HealthProfileImportService.shared.fetchDraft()
|
||||
} catch {
|
||||
healthImportError = error.localizedDescription
|
||||
}
|
||||
isImportingHealthProfile = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HealthProfileImportPreviewSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let draft: HealthProfileImportDraft
|
||||
let profile: UserProfile
|
||||
let onApply: () -> Void
|
||||
|
||||
private var preview: HealthProfileImportPreview {
|
||||
HealthProfileImportPreview(draft: draft, current: profile)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(preview.fields, id: \.title) { field in
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(field.title)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer(minLength: 12)
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(field.imported ?? "未读取到")
|
||||
.foregroundStyle(field.imported == nil ? Tj.Palette.text3 : Tj.Palette.text)
|
||||
Text("当前: \(field.current)")
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("未读取到的字段不会修改。")
|
||||
}
|
||||
}
|
||||
.navigationTitle("确认导入")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("导入") {
|
||||
onApply()
|
||||
dismiss()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +227,7 @@ private struct BirthYearRow: View {
|
||||
Text(selectedLabel)
|
||||
.foregroundStyle(profile.birthYear == nil ? Tj.Palette.text3 : Tj.Palette.text2)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.rotationEffect(.degrees(expanded ? 90 : 0))
|
||||
}
|
||||
@@ -212,7 +327,7 @@ private struct BMIFooter: View {
|
||||
var body: some View {
|
||||
if let bmi = profile.bmi {
|
||||
Text("BMI: \(String(format: "%.1f", bmi)) \(label(bmi))")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +397,7 @@ private struct ChronicSection: View {
|
||||
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
||||
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
@@ -21,7 +21,8 @@ struct QuickRegionCaptureFlow: View {
|
||||
@State private var analyzeTask: Task<Void, Never>? = nil
|
||||
|
||||
/// VL 单次推理超时(防卡死);超时后 cancel 子任务,UI 转手动录入。
|
||||
private let analyzeTimeoutSeconds: Int = 30
|
||||
/// 整页化验单指标多、生成 token 多,30s 偏紧,放宽到 60s。
|
||||
private let analyzeTimeoutSeconds: Int = 60
|
||||
|
||||
enum Phase {
|
||||
case idle
|
||||
@@ -86,23 +87,42 @@ struct QuickRegionCaptureFlow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 入口:相机(真机)/ 相册(模拟器)
|
||||
// MARK: - 入口:整页文档扫描(真机)/ 相册(模拟器或不支持)
|
||||
|
||||
// 旧实现用 RegionCameraView 的「细条小框」(为 1-2 行异常项设计);并入「记录指标 · 拍照识别」后
|
||||
// 用户会拍整张化验单,塞进细条须离远拍 → 小字像素过低,VL 读不出。改用 VisionKit 整页扫描:
|
||||
// 全分辨率 + 自动透视校正,VL 能读清整表。模拟器 / 不支持时回退相册选图。
|
||||
@ViewBuilder
|
||||
private var captureEntry: some View {
|
||||
#if targetEnvironment(simulator)
|
||||
PhotoPickerSheet(
|
||||
onFinish: { imgs in if let first = imgs.first { startAnalyze(image: first) } },
|
||||
onFinish: { imgs in handleScanned(imgs) },
|
||||
onCancel: onClose
|
||||
)
|
||||
#else
|
||||
RegionCameraView(
|
||||
onCapture: { startAnalyze(image: $0) },
|
||||
onCancel: onClose
|
||||
)
|
||||
if DocumentScannerView.isSupported {
|
||||
DocumentScannerView(
|
||||
onFinish: { imgs in handleScanned(imgs) },
|
||||
onCancel: onClose
|
||||
)
|
||||
} else {
|
||||
PhotoPickerSheet(
|
||||
onFinish: { imgs in handleScanned(imgs) },
|
||||
onCancel: onClose
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 扫描/选图回来:取首页跑识别(单张化验单通常一页);无图则关闭。
|
||||
private func handleScanned(_ images: [UIImage]) {
|
||||
if let first = images.first {
|
||||
startAnalyze(image: first)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 识别
|
||||
|
||||
private func startAnalyze(image: UIImage) {
|
||||
@@ -110,12 +130,9 @@ struct QuickRegionCaptureFlow: View {
|
||||
phase = .analyzing(image: image)
|
||||
let timeout = analyzeTimeoutSeconds
|
||||
// 本类型默认 MainActor 隔离,Task{} 继承之,故内部 phase 写入都在主线程,直接赋值即可。
|
||||
// 新链路:Vision 端侧 OCR 取文本 → Qwen3-1.7B LLM 结构化抽指标(替代 3B VL 直读图)。
|
||||
analyzeTask = Task {
|
||||
guard let data = image.jpegData(compressionQuality: 0.9) else {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: String(appLoc: "图片编码失败,手动补充或重拍"))
|
||||
return
|
||||
}
|
||||
let timeoutWarn = String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍")
|
||||
|
||||
let watchdog = Task {
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
@@ -124,12 +141,25 @@ struct QuickRegionCaptureFlow: View {
|
||||
defer { watchdog.cancel() }
|
||||
|
||||
do {
|
||||
let parsed = try await CaptureService.shared.recognizeRegion(imageData: data)
|
||||
// 1. 端侧 OCR
|
||||
let text = try await OCRService.recognizeText(in: image)
|
||||
if Task.isCancelled {
|
||||
phase = .confirm(image: image, items: [], warning: timeoutWarn); return
|
||||
}
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#if DEBUG
|
||||
print("🔤 [OCR] recognized text:\n\(trimmed)\n--- end OCR ---")
|
||||
#endif
|
||||
if trimmed.isEmpty {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍"))
|
||||
warning: String(appLoc: "没识别到文字,手动补充或重拍"))
|
||||
return
|
||||
}
|
||||
// 2. LLM 解析文本 → 指标
|
||||
let parsed = try await CaptureService.shared.recognizeIndicators(fromOCRText: trimmed)
|
||||
if Task.isCancelled {
|
||||
phase = .confirm(image: image, items: [], warning: timeoutWarn); return
|
||||
}
|
||||
let items = Self.buildItems(from: parsed)
|
||||
phase = .confirm(
|
||||
image: image,
|
||||
@@ -138,23 +168,23 @@ struct QuickRegionCaptureFlow: View {
|
||||
)
|
||||
} catch CaptureError.modelNotReady {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: String(appLoc: "VL 模型未就绪,手动补充"))
|
||||
warning: String(appLoc: "AI 模型未就绪,手动补充"))
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: String(appLoc: "VL 输出无法解析:\(msg)"))
|
||||
warning: String(appLoc: "解析失败:\(msg)"))
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: Task.isCancelled
|
||||
? String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍")
|
||||
: String(appLoc: "推理失败:\(msg)"))
|
||||
warning: Task.isCancelled ? timeoutWarn
|
||||
: String(appLoc: "识别失败:\(msg)"))
|
||||
} catch {
|
||||
phase = .confirm(image: image, items: [],
|
||||
warning: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
warning: Task.isCancelled ? timeoutWarn
|
||||
: String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// VL 结果 → 可编辑行,异常项(high/low)置顶、默认勾选。
|
||||
/// LLM 结果 → 可编辑行,异常项(high/low)置顶、默认勾选。
|
||||
private static func buildItems(from parsed: [ParsedReport.ParsedIndicator]) -> [QuickRegionItem] {
|
||||
let mapped = parsed.map {
|
||||
QuickRegionItem(name: $0.name, value: $0.value, unit: $0.unit,
|
||||
@@ -233,16 +263,16 @@ private struct AnalyzingRegionView: View {
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("100% 本地推理 · 已用 \(elapsed)s")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
if elapsed >= timeoutSeconds - 5 {
|
||||
Text("快超时了,>\(timeoutSeconds)s 会自动转手动录入")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
}
|
||||
}
|
||||
Button("取消识别 · 改为手动录入", action: onCancel)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.top, 4)
|
||||
Spacer()
|
||||
|
||||
@@ -55,7 +55,7 @@ struct QuickRegionConfirmView: View {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
Text(text)
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
}
|
||||
@@ -70,11 +70,11 @@ struct QuickRegionConfirmView: View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("拍到的局部")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
Text("仅核对用 · 不保存照片")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Image(uiImage: image)
|
||||
@@ -91,7 +91,7 @@ struct QuickRegionConfirmView: View {
|
||||
onRetake()
|
||||
} label: {
|
||||
Label("重拍", systemImage: "camera.rotate")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ struct QuickRegionConfirmView: View {
|
||||
private var timeCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("测量时间")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
DatePicker("", selection: $capturedAt, in: ...Date.now)
|
||||
.datePickerStyle(.compact)
|
||||
@@ -116,7 +116,7 @@ struct QuickRegionConfirmView: View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Text("识别到的指标 (\(items.count))")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
Button {
|
||||
@@ -124,14 +124,14 @@ struct QuickRegionConfirmView: View {
|
||||
status: .high, include: true))
|
||||
} label: {
|
||||
Label("加一项", systemImage: "plus.circle.fill")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
}
|
||||
|
||||
if items.isEmpty {
|
||||
Text("没有识别到指标,点「加一项」手动补充,或返回重拍")
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 20)
|
||||
@@ -153,17 +153,17 @@ struct QuickRegionConfirmView: View {
|
||||
item.wrappedValue.include.toggle()
|
||||
} label: {
|
||||
Image(systemName: item.wrappedValue.include ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 20))
|
||||
.font(.tjScaled( 20))
|
||||
.foregroundStyle(item.wrappedValue.include ? Tj.Palette.ink : Tj.Palette.text3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
TextField(String(appLoc: "指标名"), text: item.name)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.font(.tjScaled( 15, weight: .medium))
|
||||
|
||||
if abnormal {
|
||||
Text(statusLabel(item.wrappedValue.status))
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.font(.tjScaled( 10, weight: .semibold))
|
||||
.foregroundStyle(statusColor(item.wrappedValue.status))
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(Capsule().fill(statusColor(item.wrappedValue.status).opacity(0.16)))
|
||||
@@ -175,7 +175,7 @@ struct QuickRegionConfirmView: View {
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 14))
|
||||
.font(.tjScaled( 14))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
}
|
||||
}
|
||||
@@ -203,10 +203,10 @@ struct QuickRegionConfirmView: View {
|
||||
mono: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label)
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
TextField("", text: text)
|
||||
.font(.system(size: 14, weight: mono ? .semibold : .regular,
|
||||
.font(.tjScaled( 14, weight: mono ? .semibold : .regular,
|
||||
design: mono ? .monospaced : .default))
|
||||
.keyboardType(mono ? .decimalPad : .default)
|
||||
.textInputAutocapitalization(.never)
|
||||
@@ -234,7 +234,7 @@ struct QuickRegionConfirmView: View {
|
||||
item.wrappedValue.status = st
|
||||
} label: {
|
||||
Text(statusLabel(st))
|
||||
.font(.system(size: 12, weight: selected ? .semibold : .regular))
|
||||
.font(.tjScaled( 12, weight: selected ? .semibold : .regular))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text2)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
@@ -69,7 +69,7 @@ struct RegionCameraView: View {
|
||||
|
||||
// 提示
|
||||
Text("把异常项放进框里 · 对准一两行")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
@@ -89,7 +89,7 @@ struct RegionCameraView: View {
|
||||
onCancel()
|
||||
} label: {
|
||||
Text("取消")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.font(.tjScaled( 16, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
@@ -126,19 +126,19 @@ struct RegionCameraView: View {
|
||||
private var deniedView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 40))
|
||||
.font(.tjScaled( 40))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
Text("相机权限未开启")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(.white)
|
||||
Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。")
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 36)
|
||||
HStack(spacing: 12) {
|
||||
Button("取消") { onCancel() }
|
||||
.font(.system(size: 15))
|
||||
.font(.tjScaled( 15))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 18).padding(.vertical, 10)
|
||||
.background(Capsule().strokeBorder(.white.opacity(0.5), lineWidth: 1))
|
||||
@@ -147,7 +147,7 @@ struct RegionCameraView: View {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
.padding(.horizontal, 18).padding(.vertical, 10)
|
||||
.background(Capsule().fill(.white))
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import SwiftUI
|
||||
|
||||
enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case quick, indicator, archive, diary, symptom, reminder
|
||||
case quick, indicator, healthExport, archive, diary, symptom, reminder
|
||||
var id: String { rawValue }
|
||||
|
||||
/// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。
|
||||
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .quick, .archive]
|
||||
/// 注:`.quick`(异常项快拍)已并入 `.indicator`(记录指标)内的「拍照识别」,不再单列。
|
||||
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .healthExport, .archive]
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .quick: return String(appLoc: "异常项快拍")
|
||||
case .indicator: return String(appLoc: "记录指标")
|
||||
case .healthExport: return String(appLoc: "身体档案")
|
||||
case .archive: return String(appLoc: "体检报告归档")
|
||||
case .diary: return String(appLoc: "健康日记")
|
||||
case .symptom: return String(appLoc: "记录症状")
|
||||
@@ -20,7 +22,8 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .quick: return String(appLoc: "拍一张化验单,VL 自动识别")
|
||||
case .indicator: return String(appLoc: "手动填一项指标(免拍照)")
|
||||
case .indicator: return String(appLoc: "手动填写,或拍照自动识别")
|
||||
case .healthExport: return String(appLoc: "多轮问答后生成给医生看的整理报告")
|
||||
case .archive: return String(appLoc: "完整保存整份报告(可多页)")
|
||||
case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助")
|
||||
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
|
||||
@@ -31,6 +34,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
switch self {
|
||||
case .quick: return "camera.fill"
|
||||
case .indicator: return "number.square.fill"
|
||||
case .healthExport: return "doc.text.below.ecg"
|
||||
case .archive: return "doc.fill"
|
||||
case .diary: return "heart.text.square"
|
||||
case .symptom: return "waveform.path.ecg"
|
||||
@@ -41,6 +45,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
switch self {
|
||||
case .quick: return Tj.Palette.brick
|
||||
case .indicator: return Tj.Palette.brick
|
||||
case .healthExport: return Tj.Palette.ink
|
||||
case .archive: return Tj.Palette.ink
|
||||
case .diary: return Tj.Palette.leaf
|
||||
case .symptom: return Tj.Palette.amber
|
||||
@@ -66,7 +71,7 @@ struct RecordSheet: View {
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
Text("本地处理 · 永不上传")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.bottom, 14)
|
||||
@@ -83,22 +88,22 @@ struct RecordSheet: View {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(kind.accent)
|
||||
Image(systemName: kind.icon)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.font(.tjScaled( 18, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(kind.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(kind.subtitle)
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.font(.tjScaled( 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
@@ -25,7 +25,7 @@ struct OngoingSymptomsCard: View {
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("\(ongoing.count) 个")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
@@ -51,12 +51,12 @@ struct OngoingSymptomsCard: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Text(sym.name)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
severityDot(sym.severity)
|
||||
}
|
||||
Text("已持续 \(formatDuration(interval))")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(isLong ? Tj.Palette.brick : Tj.Palette.text3)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
@@ -64,7 +64,7 @@ struct OngoingSymptomsCard: View {
|
||||
ending = sym
|
||||
} label: {
|
||||
Text("结束")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
@@ -28,7 +28,7 @@ struct SymptomEndSheet: View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("结束症状")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(symptom.name)
|
||||
@@ -40,16 +40,16 @@ struct SymptomEndSheet: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("开始于")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(symptom.startedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.font(.tjScaled( 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("结束时间")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
DatePicker("", selection: $endedAt, in: lowerBound...Date.now)
|
||||
@@ -59,11 +59,11 @@ struct SymptomEndSheet: View {
|
||||
|
||||
HStack {
|
||||
Text("本次持续")
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
Text(durationLabel)
|
||||
.font(.system(size: 15, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 15, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
|
||||
@@ -69,7 +69,7 @@ struct SymptomStartSheet: View {
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
Text("结束时再来点结束")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
@@ -130,15 +130,15 @@ struct SymptomStartSheet: View {
|
||||
sectionLabel(String(appLoc: "强度"))
|
||||
Spacer()
|
||||
Text("\(Int(severity)) / 5")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(severityColor)
|
||||
}
|
||||
Slider(value: $severity, in: 1...5, step: 1)
|
||||
.tint(severityColor)
|
||||
HStack {
|
||||
Text("轻微").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
||||
Text("轻微").font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
Text("剧烈").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
||||
Text("剧烈").font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,7 @@ struct SymptomStartSheet: View {
|
||||
|
||||
private func sectionLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
@@ -198,7 +198,7 @@ struct SymptomStartSheet: View {
|
||||
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
||||
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
@@ -52,6 +52,7 @@ struct TimelineEntryDetailView: View {
|
||||
let detail: TimelineDetail
|
||||
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var evidenceTarget: Indicator?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -77,6 +78,11 @@ struct TimelineEntryDetailView: View {
|
||||
} message: {
|
||||
Text("删除后无法恢复。")
|
||||
}
|
||||
.sheet(item: $evidenceTarget) { indicator in
|
||||
if let report = indicator.report {
|
||||
EvidenceImagePreview(report: report, indicator: indicator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 删除(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6)
|
||||
@@ -84,7 +90,7 @@ struct TimelineEntryDetailView: View {
|
||||
private var deleteButton: some View {
|
||||
Button(role: .destructive) { showDeleteConfirm = true } label: {
|
||||
Label(String(appLoc: "永久删除"), systemImage: "trash")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.font(.tjScaled( 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.brick.opacity(0.8))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
@@ -136,7 +142,7 @@ struct TimelineEntryDetailView: View {
|
||||
HStack(spacing: 12) {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(Tj.Palette.sand2))
|
||||
@@ -187,16 +193,19 @@ struct TimelineEntryDetailView: View {
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(i.value)
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.font(.tjScaled( 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)
|
||||
Text(i.unit).font(.tjScaled( 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 ?? i.source.label)
|
||||
if let report = i.report {
|
||||
evidenceButton(for: i, assets: report.assets)
|
||||
}
|
||||
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
}
|
||||
}
|
||||
@@ -215,9 +224,9 @@ struct TimelineEntryDetailView: View {
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(sys.value)/\(dia?.value ?? "—")")
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.font(.tjScaled( 30, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
Text("mmHg").font(.system(size: 14)).foregroundStyle(Tj.Palette.text3)
|
||||
Text("mmHg").font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
divider
|
||||
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
|
||||
@@ -237,10 +246,10 @@ struct TimelineEntryDetailView: View {
|
||||
HStack(spacing: 8) {
|
||||
TjBadge(text: r.type.label, style: .neutral)
|
||||
Text(Self.dateText(r.reportDate))
|
||||
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
if !r.assets.isEmpty {
|
||||
Text(String(appLoc: "原图\(r.assets.count)张"))
|
||||
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
if let inst = r.institution, !inst.isEmpty {
|
||||
@@ -251,8 +260,8 @@ struct TimelineEntryDetailView: View {
|
||||
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)
|
||||
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||
Text(sum).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
@@ -260,15 +269,18 @@ struct TimelineEntryDetailView: View {
|
||||
if !r.indicators.isEmpty {
|
||||
card {
|
||||
Text(String(appLoc: "指标"))
|
||||
.font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||
.font(.tjScaled( 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)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(ind.name).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text)
|
||||
Spacer(minLength: 8)
|
||||
Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)")
|
||||
.font(.tjScaled( 13, design: .monospaced))
|
||||
.foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
|
||||
statusChip(ind.status)
|
||||
}
|
||||
evidenceButton(for: ind, assets: r.assets)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,9 +298,9 @@ struct TimelineEntryDetailView: View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
card {
|
||||
Text(Self.dateTimeText(d.createdAt))
|
||||
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
Text(d.content)
|
||||
.font(.system(size: 15))
|
||||
.font(.tjScaled( 15))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -309,7 +321,7 @@ struct TimelineEntryDetailView: View {
|
||||
Spacer()
|
||||
if s.isOngoing {
|
||||
Text(String(appLoc: "进行中"))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||
.background(Capsule().fill(Tj.Palette.brick.opacity(0.14)))
|
||||
@@ -346,16 +358,36 @@ struct TimelineEntryDetailView: View {
|
||||
|
||||
private func field(_ label: String, _ value: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text3)
|
||||
Text(label).font(.tjScaled( 13)).foregroundStyle(Tj.Palette.text3)
|
||||
Spacer(minLength: 12)
|
||||
Text(value)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.font(.tjScaled( 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func evidenceButton(for indicator: Indicator, assets: [Asset]) -> some View {
|
||||
if indicator.hasEvidenceBox,
|
||||
let page = indicator.sourcePageIndex,
|
||||
assets.indices.contains(page) {
|
||||
Button {
|
||||
evidenceTarget = indicator
|
||||
} label: {
|
||||
Label(String(appLoc: "查看原图位置"), systemImage: "viewfinder")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Tj.Palette.leaf.opacity(0.14)))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
@@ -370,8 +402,8 @@ struct TimelineEntryDetailView: View {
|
||||
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))
|
||||
if !arrow.isEmpty { Text(arrow).font(.tjScaled( 11, weight: .bold)) }
|
||||
Text(text).font(.tjScaled( 12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 8)
|
||||
@@ -387,3 +419,142 @@ struct TimelineEntryDetailView: View {
|
||||
d.formatted(.dateTime.year().month().day())
|
||||
}
|
||||
}
|
||||
|
||||
private struct EvidenceImagePreview: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let report: Report
|
||||
let indicator: Indicator
|
||||
|
||||
@State private var selection: Int
|
||||
|
||||
init(report: Report, indicator: Indicator) {
|
||||
self.report = report
|
||||
self.indicator = indicator
|
||||
let page = indicator.sourcePageIndex ?? 0
|
||||
_selection = State(initialValue: min(max(page, 0), max(report.assets.count - 1, 0)))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(Tj.Palette.sand2))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(indicator.name)
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("第 \(selection + 1) 页 · 原图证据")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
.background(Tj.Palette.sand)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
|
||||
TabView(selection: $selection) {
|
||||
ForEach(Array(report.assets.enumerated()), id: \.offset) { index, asset in
|
||||
EvidenceImagePage(
|
||||
asset: asset,
|
||||
highlight: index == indicator.sourcePageIndex ? indicator.evidenceRect : nil
|
||||
)
|
||||
.tag(index)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: report.assets.count > 1 ? .automatic : .never))
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EvidenceImagePage: View {
|
||||
let asset: Asset
|
||||
let highlight: CGRect?
|
||||
|
||||
private var image: UIImage? {
|
||||
try? FileVault.shared.loadImage(relativePath: asset.relativePath)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
if let image {
|
||||
ZStack {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
if let highlight {
|
||||
EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight)
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
.background(Tj.Palette.paper)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
} else {
|
||||
TjPlaceholder(label: String(appLoc: "原图无法读取"))
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EvidenceHighlightOverlay: View {
|
||||
let imageSize: CGSize
|
||||
let normalizedRect: CGRect
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let fitted = fittedRect(imageSize: imageSize, containerSize: geo.size)
|
||||
let rect = CGRect(
|
||||
x: fitted.minX + normalizedRect.minX * fitted.width,
|
||||
y: fitted.minY + normalizedRect.minY * fitted.height,
|
||||
width: normalizedRect.width * fitted.width,
|
||||
height: normalizedRect.height * fitted.height
|
||||
)
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.fill(Tj.Palette.brick.opacity(0.16))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.stroke(Tj.Palette.brick, lineWidth: 2)
|
||||
)
|
||||
.frame(width: rect.width, height: rect.height)
|
||||
.position(x: rect.midX, y: rect.midY)
|
||||
.shadow(color: Tj.Palette.brick.opacity(0.24), radius: 8, y: 2)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
private func fittedRect(imageSize: CGSize, containerSize: CGSize) -> CGRect {
|
||||
guard imageSize.width > 0,
|
||||
imageSize.height > 0,
|
||||
containerSize.width > 0,
|
||||
containerSize.height > 0 else {
|
||||
return .zero
|
||||
}
|
||||
let scale = min(containerSize.width / imageSize.width, containerSize.height / imageSize.height)
|
||||
let size = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
|
||||
return CGRect(
|
||||
x: (containerSize.width - size.width) / 2,
|
||||
y: (containerSize.height - size.height) / 2,
|
||||
width: size.width,
|
||||
height: size.height
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ struct TimelineRow: View {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(entry.kind.accent.opacity(0.12))
|
||||
Image(systemName: entry.kind.icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.foregroundStyle(entry.kind.accent)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
@@ -25,12 +25,12 @@ struct TimelineRow: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(entry.date.timelineLabel) · \(entry.subtitle)")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(1)
|
||||
Text(entry.title)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.font(.tjScaled( 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
@@ -38,7 +38,7 @@ struct TimelineRow: View {
|
||||
Spacer(minLength: 8)
|
||||
if let trailing = entry.trailing {
|
||||
Text(trailing)
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(entry.trailingIsAlert ? Tj.Palette.brick : Tj.Palette.text2)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
|
||||
@@ -66,7 +66,7 @@ struct CalendarMonthGrid: View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(weekdayLabels, id: \.self) { w in
|
||||
Text(w)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.font(.tjScaled( 11, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
@@ -123,7 +123,7 @@ private struct DayCellView: View {
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Text("\(dayNumber)")
|
||||
.font(.system(size: 13,
|
||||
.font(.tjScaled( 13,
|
||||
weight: (isToday || isSelected) ? .bold : .regular,
|
||||
design: .default))
|
||||
.foregroundStyle(textColor)
|
||||
@@ -137,7 +137,7 @@ private struct DayCellView: View {
|
||||
}
|
||||
if ranges.count > 2 {
|
||||
Text("+\(ranges.count - 2)")
|
||||
.font(.system(size: 7, design: .monospaced))
|
||||
.font(.tjScaled( 7, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ private struct MiniMonth: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(monthLabel)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
|
||||
LazyVGrid(columns: microColumns, spacing: 2) {
|
||||
|
||||
@@ -104,7 +104,7 @@ struct DayDetailContent: View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(dateLine)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(dayLabel)
|
||||
@@ -114,7 +114,7 @@ struct DayDetailContent: View {
|
||||
Spacer()
|
||||
if totalCount > 0 {
|
||||
Text("\(totalCount) 条")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.font(.tjScaled( 12, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
@@ -140,11 +140,11 @@ struct DayDetailContent: View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Text("\(count)")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
@@ -162,17 +162,17 @@ struct DayDetailContent: View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 6) {
|
||||
Text(s.name)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(state.badge)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.font(.tjScaled( 10, weight: .semibold))
|
||||
.foregroundStyle(state.badgeFg)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(state.badgeBg))
|
||||
}
|
||||
Text("\(state.subtitle) · 持续 \(formatDuration(s.duration))")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
@@ -181,7 +181,7 @@ struct DayDetailContent: View {
|
||||
endingSymptom = s
|
||||
} label: {
|
||||
Text("结束")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
@@ -200,24 +200,24 @@ struct DayDetailContent: View {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(indicatorAccent(i).opacity(0.12))
|
||||
Image(systemName: "drop.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(indicatorAccent(i))
|
||||
}
|
||||
.frame(width: 32, height: 32)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(i.name)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.font(.tjScaled( 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
if !i.range.isEmpty {
|
||||
Text("参考 \(i.range)")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
Text("\(i.value) \(i.unit)\(arrow(i))")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(i.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
@@ -235,23 +235,23 @@ struct DayDetailContent: View {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Tj.Palette.ink2.opacity(0.12))
|
||||
Image(systemName: "doc.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink2)
|
||||
}
|
||||
.frame(width: 32, height: 32)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(r.title)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.font(.tjScaled( 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Text("\(r.type.label) · 共 \(r.pageCount) 页")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
if let summary {
|
||||
Text(summary)
|
||||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 11, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
}
|
||||
}
|
||||
@@ -263,7 +263,7 @@ struct DayDetailContent: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(d.createdAt.formatted(date: .omitted, time: .shortened))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.font(.tjScaled( 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
@@ -284,7 +284,7 @@ struct DayDetailContent: View {
|
||||
.frame(height: 90)
|
||||
.frame(maxWidth: 240)
|
||||
Text("点底部 + 号可以补一条")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
|
||||
@@ -66,10 +66,10 @@ struct SeriesChartCard: View {
|
||||
private var header: some View {
|
||||
HStack(alignment: .lastTextBaseline, spacing: 10) {
|
||||
Text(bucket.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("\(allPoints.count) 条 · 近 \(daysSpanLabel)")
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
latestValueBadge
|
||||
@@ -87,10 +87,10 @@ struct SeriesChartCard: View {
|
||||
}
|
||||
return HStack(spacing: 4) {
|
||||
Text(joined)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(anyAbnormal ? Tj.Palette.brick : Tj.Palette.text)
|
||||
Text(bucket.unit)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.font(.tjScaled( 10, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,7 @@ struct SeriesChartCard: View {
|
||||
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
|
||||
AxisValueLabel()
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.font(.tjScaled( 10, design: .monospaced))
|
||||
}
|
||||
}
|
||||
.chartYScale(domain: valueDomain ?? 0...1)
|
||||
@@ -156,7 +156,7 @@ struct SeriesChartCard: View {
|
||||
.fill(line.color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(line.label ?? line.seriesKey)
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ struct TrendDetailView: View {
|
||||
withAnimation(.snappy(duration: 0.2)) { range = r }
|
||||
} label: {
|
||||
Text(r.label)
|
||||
.font(.system(size: 12, weight: range == r ? .semibold : .regular))
|
||||
.font(.tjScaled( 12, weight: range == r ? .semibold : .regular))
|
||||
.foregroundStyle(range == r ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 7)
|
||||
@@ -210,7 +210,7 @@ struct TrendDetailView: View {
|
||||
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
|
||||
AxisValueLabel()
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.font(.tjScaled( 10, design: .monospaced))
|
||||
}
|
||||
}
|
||||
.chartYScale(domain: valueDomain ?? 0...1)
|
||||
@@ -222,7 +222,7 @@ struct TrendDetailView: View {
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(line.color).frame(width: 8, height: 8)
|
||||
Text(line.label ?? line.seriesKey)
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
}
|
||||
@@ -265,20 +265,20 @@ struct TrendDetailView: View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if filteredLines.count > 1, let label = line.label {
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text(latest.map { fmt($0.value) } ?? "—")
|
||||
.font(.system(size: 28, weight: .bold, design: .monospaced))
|
||||
.font(.tjScaled( 28, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle((latest?.status ?? .normal) == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
Text(bucket.unit)
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
if let delta = deltaText(latest: latest, prev: prev) {
|
||||
Text(delta.text)
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(delta.color)
|
||||
}
|
||||
}
|
||||
@@ -294,10 +294,10 @@ struct TrendDetailView: View {
|
||||
private func statCell(_ label: String, _ value: String) -> some View {
|
||||
VStack(spacing: 3) {
|
||||
Text(value)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(label)
|
||||
.font(.system(size: 10))
|
||||
.font(.tjScaled( 10))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -323,10 +323,10 @@ struct TrendDetailView: View {
|
||||
private var aiPlaceholder: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("AI 趋势解读即将上线")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
@@ -364,7 +364,7 @@ struct TrendDetailView: View {
|
||||
private var pointsList: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("全部记录")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
VStack(spacing: 8) {
|
||||
ForEach(pointRows) { row in
|
||||
@@ -382,7 +382,7 @@ struct TrendDetailView: View {
|
||||
private func pointRowView(_ row: PointRow) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Text(row.day.formatted(.dateTime.year().month(.abbreviated).day()))
|
||||
.font(.system(size: 13))
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 8)
|
||||
HStack(spacing: 10) {
|
||||
@@ -393,14 +393,14 @@ struct TrendDetailView: View {
|
||||
Circle().fill(line.color).frame(width: 6, height: 6)
|
||||
}
|
||||
Text(fmt(p.value) + arrow(p.status))
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(p.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.font(.tjScaled( 11, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
@@ -19,11 +19,11 @@ struct TrendRow: View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(bucket.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 11))
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
@@ -34,17 +34,17 @@ struct TrendRow: View {
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(latestValue)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.font(.tjScaled( 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(anyLatestAbnormal ? Tj.Palette.brick : Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Text(bucket.unit)
|
||||
.font(.system(size: 9, design: .monospaced))
|
||||
.font(.tjScaled( 9, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.fixedSize()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.font(.tjScaled( 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
|
||||
@@ -63,7 +63,7 @@ struct TrendsView: View {
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("\(buckets.count) 项")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
@@ -87,7 +87,7 @@ struct TrendsView: View {
|
||||
.frame(height: 120)
|
||||
.frame(maxWidth: 260)
|
||||
Text("同一指标记录满 2 次后,会在这里出现时间序列")
|
||||
.font(.system(size: 12))
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user