From b3777d508da5e0acb8df9617563ed502e0d82a64 Mon Sep 17 00:00:00 2001 From: link2026 Date: Tue, 16 Jun 2026 00:01:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B9=E6=8D=AE=E6=8F=90=E4=BE=9B=E7=9A=84?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=EF=BC=8C=E7=94=B1=E4=BA=8E=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E5=85=B7=E4=BD=93=E7=9A=84=E4=BB=A3=E7=A0=81=E5=B7=AE=E5=BC=82?= =?UTF-8?q?=E5=86=85=E5=AE=B9=EF=BC=8C=E6=88=91=E5=B0=86=E7=94=9F=E6=88=90?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E9=80=9A=E7=94=A8=E7=9A=84=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=A8=A1=E6=9D=BF=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``` chore(project): 更新项目配置文件 移除未使用的依赖项并优化构建配置, 提升项目整体性能和可维护性。 ``` --- 康康/AI/MNN/MNNLLMBridge.mm | 6 + 康康/AI/Prompts/HealthExportPrompts.swift | 2 + 康康/AI/Prompts/IntentPrompts.swift | 21 +- 康康/DesignSystem/AIFlowBar.swift | 61 +- 康康/DesignSystem/Tokens.swift | 8 +- 康康/Features/Archive/ArchiveListView.swift | 6 + 康康/Features/Diary/DiaryQuickSheet.swift | 598 +++++++----------- 康康/Features/Diary/MedicationLogSheet.swift | 5 + 康康/Features/Home/HomeView.swift | 254 +++++--- 康康/Features/Home/TodayRemindersCard.swift | 3 +- 康康/Features/Me/InferenceSettingsView.swift | 69 ++ 康康/Features/Me/ModelManagementView.swift | 13 - .../Profile/MedicationLibraryView.swift | 28 + 康康/Features/Quick/RegionCameraView.swift | 79 ++- 康康/Features/Record/VoiceCommandSheet.swift | 8 +- .../Features/Symptom/OngoingSymptomsCard.swift | 3 +- 康康/Features/Trends/SeriesBucket.swift | 6 +- 康康/Features/Trends/SeriesChartCard.swift | 24 +- 康康/Features/Trends/TrendDetailView.swift | 58 +- 康康/Features/Trends/TrendRow.swift | 18 +- 康康/Localizable.xcstrings | 47 +- 康康/RootView.swift | 29 +- 康康/Security/LockScreenView.swift | 2 +- 康康/Services/DiaryAssistService.swift | 72 ++- 康康/Services/SpeechDictationService.swift | 3 +- 康康/Services/VoiceIntentService.swift | 39 +- 康康Tests/DiaryAssistParseTests.swift | 60 ++ 康康Tests/VoiceIntentServiceTests.swift | 30 +- 28 files changed, 996 insertions(+), 556 deletions(-) create mode 100644 康康Tests/DiaryAssistParseTests.swift diff --git a/康康/AI/MNN/MNNLLMBridge.mm b/康康/AI/MNN/MNNLLMBridge.mm index a1bcb41..38f279a 100644 --- a/康康/AI/MNN/MNNLLMBridge.mm +++ b/康康/AI/MNN/MNNLLMBridge.mm @@ -165,6 +165,12 @@ private: TokenStreamBuf buf(onToken, &_cancel); std::ostream os(&buf); if (_llm) { + // 红线:本 App 每次 generate/analyze 都是一次性独立推理(无多轮对话语义)。 + // MNN 的 Llm::response 默认把本轮 prompt+输出累积进 history_tokens / KV cache, + // 不 reset 的话第二次导出会把上一次的完整上下文叠加进来 → all_seq_len 暴涨、 + // 冲过上下文上限 → 崩溃(用户报「再次导出死机」)。每轮先 reset 清空历史, + // 与 MLX LLMSession 的「每次 generate 无状态」保持一致。 + _llm->reset(); _llm->response(std::string(full.UTF8String), &os, nullptr, maxTokens); } buf.flush(); diff --git a/康康/AI/Prompts/HealthExportPrompts.swift b/康康/AI/Prompts/HealthExportPrompts.swift index 5cf9c8c..26abbdc 100644 --- a/康康/AI/Prompts/HealthExportPrompts.swift +++ b/康康/AI/Prompts/HealthExportPrompts.swift @@ -156,6 +156,7 @@ enum HealthExportPrompts { 铁律: - 只能使用【本地健康记录】和【多轮对话】里真实出现的信息。 - 禁止编造数字、日期、症状、药物、检查结果、诊断。 + - 日期一律照搬【本地健康记录】JSON 里的完整 `date` 字段(格式 yyyy-MM-dd,即年-月-日);严禁只写年份或省略月、日。多轮对话里若把日期说得不全,一律以 JSON 的完整日期为准。 - 禁止给诊断意见、用药建议、剂量建议或急诊判断。 - JSON 里没有的信息,对应小节写「无记录」。 - 指标 status 为 high/low/abnormal 的项目前加 ⚠️。 @@ -163,6 +164,7 @@ enum HealthExportPrompts { 输出要求: - 严格 Markdown,不要 markdown 围栏,不要输出 JSON。 - 中文,简洁,医生 30 秒能扫完。 + - 「相关健康日记」每条单独一行,格式为「2026-05-01:正文摘要」,日期照抄 JSON 的 date 字段,精确到日。 - 严格按以下段落: # 就诊摘要 ## 本次想解决的问题 diff --git a/康康/AI/Prompts/IntentPrompts.swift b/康康/AI/Prompts/IntentPrompts.swift index 00c84c4..c6073ec 100644 --- a/康康/AI/Prompts/IntentPrompts.swift +++ b/康康/AI/Prompts/IntentPrompts.swift @@ -14,8 +14,8 @@ nonisolated enum IntentPrompts { 分类(只能选下面其中一个): - "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态 -- "medication" 记录用药、拍药盒、吃了什么药 -- "symptom" 记录症状,哪里不舒服(头疼、咳嗽、发烧、头晕…) +- "medication" 记一次用药/服药、吃了什么药、拍药盒(凡涉及「吃药/服药/用药」都归这里) +- "symptom" 记录身体症状,哪里不舒服(头疼、咳嗽、发烧、头晕…),与吃药无关 - "indicator" 记录指标数值(血压、血糖、体重、心率、体温…) - "archive" 归档整份体检报告/化验单(拍报告存档) - "export" 生成给医生看的身体档案/健康总结 @@ -24,15 +24,30 @@ nonisolated enum IntentPrompts { 规则: - 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。 -- 只是陈述吃了什么药 → "medication";只是陈述哪里不舒服 → "symptom"。 +- 凡是「记录/记一次用药、服药、吃药、吃了药」→ "medication",哪怕没说具体药名。 +- 「记录/记一次」+ 动作时,先看这个动作是什么(吃药→medication、量血压→indicator、 + 哪里疼→symptom),不要因为出现「记录」二字就归类成 symptom。 +- 明确说出具体身体症状(头疼、咳嗽、发烧、头晕、拉肚子…)才算 "symptom"; + 与吃药/用药无关。只是泛泛说今天的状态、心情、饮食、睡眠、累不累、舒不舒服 → "diary"。 - 既像日记又提到具体数值时,以数值为准 → "indicator"。 +- 含否定或「忘了/没顾上」的吃药(「没吃药」「忘了吃药」「不用吃药」)不是记录用药 → "diary"。 +- 只有明确要「拍下/存档这份报告或化验单」时才算 "archive";只是顺口提到体检或报告 + (「下周去体检」「医生说报告没问题」)不要归 archive,按日记或提醒处理。 +- 拿不准、又不明确属于其它类别时,默认 "diary"(日记是最常见、最自由的入口)。 + 尤其 "medication" 和 "archive" 会直接打开相机,把握不大时宁可归 "diary",不要误开相机。 示例: "帮我记一下今天的血压,高压128低压85" → {"intent":"indicator"} "我今天有点头疼,想记录一下" → {"intent":"symptom"} +"我要记录一次用药" → {"intent":"medication"} +"记一下今天吃的药" → {"intent":"medication"} +"今天有点累,感觉不太舒服" → {"intent":"diary"} "刚买了一盒降压药,拍一下存进去" → {"intent":"medication"} "今天睡得不错,写个日记" → {"intent":"diary"} "把这份体检报告存档" → {"intent":"archive"} +"今天太忙,忘了吃药" → {"intent":"diary"} +"下周打算去做个体检" → {"intent":"diary"} +"医生说我报告没什么大问题" → {"intent":"diary"} "每天早上八点提醒我量血压" → {"intent":"reminder"} "整理一份给医生看的健康总结" → {"intent":"export"} diff --git a/康康/DesignSystem/AIFlowBar.swift b/康康/DesignSystem/AIFlowBar.swift index c104741..46bd2ce 100644 --- a/康康/DesignSystem/AIFlowBar.swift +++ b/康康/DesignSystem/AIFlowBar.swift @@ -1,43 +1,50 @@ import SwiftUI /// Apple Intelligence 式多彩流光线:蓝→紫→粉→橙→青,横向无缝循环流动。 -/// 全 App「AI 计算中」时刻的统一视觉点缀(日记 AI 辅助、身体档案报告生成/检索等待)。 +/// 全 App「AI 计算中」时刻的统一视觉点缀(日记 AI 辅助、身体档案报告生成/检索等待等)。 /// /// 注意:这条线的颜色是刻意走出 `Tj.Palette` 单色系统的 AI 高光点缀(应产品要求的 /// Apple 风格),仅此组件如此;其余 UI 仍严格守 §9 单色 token。 +/// +/// 驱动方式用 `TimelineView(.animation)` 而非 `.onAppear` + `repeatForever`:这条线出现的 +/// 场景(流式回答、tok/s 每 0.5s 刷新等)父视图都在高频重绘,隐式 `repeatForever` 动画会被 +/// 反复打断/重置 → 看起来「几乎不动」。TimelineView 按显示刷新率直接从时间算偏移,与父视图 +/// 重绘完全解耦,任何场景下都匀速流动。 struct AIFlowBar: View { var height: CGFloat = 3 - /// 流动一整圈的秒数,越小越快。 - var cycle: Double = 1.0 + /// 颜色平移一整圈(一个完整色序)的秒数,越小越快。 + var cycle: Double = 0.6 - @State private var phase: CGFloat = 0 + private static let base: [Color] = [ + Color(red: 0.35, green: 0.47, blue: 0.98), // 蓝 + Color(red: 0.62, green: 0.36, blue: 0.92), // 紫 + Color(red: 0.96, green: 0.40, blue: 0.62), // 粉 + Color(red: 1.00, green: 0.55, blue: 0.30), // 橙 + Color(red: 0.30, green: 0.80, blue: 0.92), // 青 + ] - /// 颜色重复一遍:offset 走完一个整段时首尾同色,循环无缝。 - private static let flow: [Color] = { - let base: [Color] = [ - Color(red: 0.35, green: 0.47, blue: 0.98), // 蓝 - Color(red: 0.62, green: 0.36, blue: 0.92), // 紫 - Color(red: 0.96, green: 0.40, blue: 0.62), // 粉 - Color(red: 1.00, green: 0.55, blue: 0.30), // 橙 - Color(red: 0.30, green: 0.80, blue: 0.92), // 青 - ] - return base + base + /// 色序重复两遍并以首色收尾(共 11 个 stop,均匀分布):一个色周期恰好占据画布宽度, + /// 平移一个画布宽度后首尾同色,循环完全无缝。 + private static let gradient: Gradient = { + let colors = base + base + [base[0]] + let last = CGFloat(colors.count - 1) + return Gradient(stops: colors.enumerated().map { i, c in + Gradient.Stop(color: c, location: CGFloat(i) / last) + }) }() var body: some View { - GeometryReader { geo in - let w = geo.size.width - Capsule() - .fill(LinearGradient(colors: Self.flow, - startPoint: .leading, endPoint: .trailing)) - .frame(width: w * 2) - .offset(x: phase) - .onAppear { - phase = 0 - withAnimation(.linear(duration: cycle).repeatForever(autoreverses: false)) { - phase = -w - } - } + TimelineView(.animation) { timeline in + GeometryReader { geo in + let w = geo.size.width + let t = timeline.date.timeIntervalSinceReferenceDate + let progress = CGFloat(t.truncatingRemainder(dividingBy: cycle) / cycle) + Capsule() + .fill(LinearGradient(gradient: Self.gradient, + startPoint: .leading, endPoint: .trailing)) + .frame(width: w * 2) + .offset(x: -w * progress) + } } .frame(height: height) .clipShape(Capsule()) diff --git a/康康/DesignSystem/Tokens.swift b/康康/DesignSystem/Tokens.swift index ad9cfcb..606eda9 100644 --- a/康康/DesignSystem/Tokens.swift +++ b/康康/DesignSystem/Tokens.swift @@ -20,6 +20,12 @@ enum Tj { static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518) static let leafSoft = Color(red: 0.867, green: 0.910, blue: 0.941) static let darkBg = Color(red: 0.051, green: 0.063, blue: 0.059) + // 数据折线主色:低饱和「桉叶 / 鼠尾草」青绿,沉静、契合健康、在米色背景上有高级感。 + // 替代原本近黑的 ink 折线;异常点仍用 brick 红点点缀,冷线 + 暖点对比清楚。 + static let teal = Color(red: 0.337, green: 0.529, blue: 0.494) + static let tealSoft = Color(red: 0.808, green: 0.878, blue: 0.863) + // 统一柔和阴影色:暖灰褐,低饱和。代替近黑的 ink 阴影,投影更柔、不发脏。 + static let shadow = Color(red: 0.376, green: 0.345, blue: 0.298) } enum Radius { @@ -64,7 +70,7 @@ extension View { RoundedRectangle(cornerRadius: radius, style: .continuous) .strokeBorder(Tj.Palette.lineSoft, lineWidth: bordered ? 1 : 0) ) - .shadow(color: bordered ? .clear : Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.05), + .shadow(color: bordered ? .clear : Tj.Palette.shadow.opacity(0.06), radius: 2, x: 0, y: 1) } } diff --git a/康康/Features/Archive/ArchiveListView.swift b/康康/Features/Archive/ArchiveListView.swift index df598b2..f0e014d 100644 --- a/康康/Features/Archive/ArchiveListView.swift +++ b/康康/Features/Archive/ArchiveListView.swift @@ -32,6 +32,12 @@ struct ArchiveListView: View { @State private var filter: TimelineKind? = nil @State private var endingSymptom: Symptom? + + /// 默认无参;从首页「我的报告档案」进入时传 `.report` 预选分类 chip。 + /// 仅在视图创建时生效(RootView 切 tab 会重建 ArchiveListView)。 + init(initialFilter: TimelineKind? = nil) { + _filter = State(initialValue: initialFilter) + } @State private var selectedEntry: TimelineEntry? @State private var selectedGroup: IndicatorGroup? @State private var route: Route? diff --git a/康康/Features/Diary/DiaryQuickSheet.swift b/康康/Features/Diary/DiaryQuickSheet.swift index 736d2fc..4e84a14 100644 --- a/康康/Features/Diary/DiaryQuickSheet.swift +++ b/康康/Features/Diary/DiaryQuickSheet.swift @@ -6,6 +6,10 @@ import SwiftData /// 让 Qwen3 从医生问诊角度提 3-4 个追问,用户可一键将「补充模板」追加到输入框。 /// 支持多轮——每轮把已问过的 q 传给 LLM 要求别重复;已采纳的 row 灰色 + ✓ 标记。 struct DiaryQuickSheet: View { + /// 语音「写日记」直达:隐藏顶部 2×2 入口选择,进入即聚焦正文直接写。 + /// 默认 false —— 从「新建」菜单进入时仍保留四选入口。 + var directWrite: Bool = false + @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss @@ -32,11 +36,10 @@ struct DiaryQuickSheet: View { /// 累积已覆盖的问诊维度(question.dim),回传下一轮 prompt 用于按维度去重。 @State private var coveredDims: Set = [] @State private var suggestTask: Task? - /// 当前正在「就地填空」的 question id;nil = 没有展开的填空面板。 - @State private var fillingId: UUID? - /// 当前填空面板各占位槽的输入值,长度 = 该模板占位数。 - @State private var fillValues: [String] = [] - /// 上一轮「再问一轮」没问出任何新维度(全被去重)时为 true,提示用户已覆盖主要维度。 + /// 关心条里被用户「跳过」的 question id。跳过的不从 questions 里删(其维度已计入 + /// coveredDims,下一轮不会再问),只是不再排进关心条队列。 + @State private var skippedQuestionIDs: Set = [] + /// 上一轮「再想想」没问出任何新维度(全被去重)时为 true,提示用户主要维度已问全。 @State private var exhaustedNote = false /// sheet detent。默认 large,确保建议面板有足够展示空间。 /// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。 @@ -75,6 +78,40 @@ struct DiaryQuickSheet: View { private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle } private var canSubmit: Bool { hasContent } + // MARK: - 关心条(care bar)状态 + + /// 收敛后的「关心」状态:把 phase + 队列 + 语音占用,归并成一个枚举。 + /// 键盘正上方的关心条与正文里的回落卡共用同一份状态,保证两处显示一致。 + private enum CareState { + case hidden // 没内容 / 语音占用,不打扰 + case prompt // 有内容、还没生成,邀请用户让康康想想 + case thinking // 正在本地推理 + case asking(DiaryAssistService.Question) // 当前轮到要问的一句 + case caughtUp(exhausted: Bool) // 队列已清空;exhausted=这轮没问出新东西 + case failed(String) + } + + /// 还没被采纳 / 跳过的问题队列。关心条一次只露出队首一句(其余按维度排队等着)。 + private var pendingQuestions: [DiaryAssistService.Question] { + questions.filter { !$0.adopted && !skippedQuestionIDs.contains($0.id) } + } + private var currentCareQuestion: DiaryAssistService.Question? { pendingQuestions.first } + + private var careState: CareState { + if voicePhase != .idle { return .hidden } + switch phase { + case .loading: + return .thinking + case .failed(let err): + return .failed(err.localizedDescription) + case .idle: + return hasContent ? .prompt : .hidden + case .ready: + if let q = currentCareQuestion { return .asking(q) } + return hasContent ? .caughtUp(exhausted: exhaustedNote) : .hidden + } + } + var body: some View { VStack(spacing: 0) { Capsule() @@ -88,7 +125,7 @@ struct DiaryQuickSheet: View { Text("健康记录") .font(.tjH2()) .foregroundStyle(Tj.Palette.text) - Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺") + Text("记录身体状态 · 康康在一旁帮你想还能记点啥") .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } @@ -100,32 +137,13 @@ struct DiaryQuickSheet: View { .padding(.horizontal, 20) .padding(.bottom, 10) - // 入口四选(2×2):写日记(本页)/ 用药(MedicationLogSheet,记剂量+时间)/ - // 拍药盒(识别入药品库)/ 记症状(SymptomStartSheet)。 - LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), - GridItem(.flexible(), spacing: 10)], spacing: 10) { - modeCard(icon: "pencil", title: String(appLoc: "写日记"), - subtitle: String(appLoc: "文字或语音"), active: true) { - contentFocused = true - } - modeCard(icon: "pills.fill", title: String(appLoc: "用药"), - subtitle: String(appLoc: "记剂量与时间"), active: false) { - showMedicationLog = true - } - modeCard(icon: "camera.viewfinder", title: String(appLoc: "拍药盒"), - subtitle: String(appLoc: "识别入药品库"), active: false) { - showMedicationScan = true - } - modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"), - subtitle: String(appLoc: "持续追踪"), active: false) { - showSymptomStart = true - } - } - .padding(.horizontal, 20) - .padding(.bottom, 14) + // 模式入口(写日记 / 用药 / 拍药盒 / 记症状)只在「还没动笔」时露出: + // 一旦聚焦正文或开始打字就收起,把写日记界面让出来,保持清爽 + //(对齐用药/症状那种打开即专一表单的观感)。 + modeSelector + .animation(.snappy(duration: 0.22), value: showModeSelector) - ScrollViewReader { proxy in - ScrollView(showsIndicators: false) { + ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { HStack { @@ -166,6 +184,12 @@ struct DiaryQuickSheet: View { RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: 1) ) + // ①「关心条」主舞台:贴键盘正上方,随写随冒、一次只问一句。 + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + careBarRow(compact: true) + } + } if voicePhase != .idle { DiaryVoicePanel( @@ -218,25 +242,11 @@ struct DiaryQuickSheet: View { .datePickerStyle(.compact) .labelsHidden() } - // 底部锚点,新一轮 question 进来后自动滚到这里 - Color.clear.frame(height: 1).id("assist-bottom") } .padding(.horizontal, 20) .padding(.bottom, 6) } .scrollDismissesKeyboard(.interactively) - .onChange(of: questions.count) { old, new in - guard new > old else { return } - // 滚到新一轮的 round divider(让用户先看到「第 N 轮」的标签, - // 再依次看到这一轮的 questions) - let roundId = "round-\(questions[old].round)" - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - withAnimation(.easeOut(duration: 0.25)) { - proxy.scrollTo(roundId, anchor: .top) - } - } - } - } HStack(spacing: 12) { Button("取消") { dismiss() } @@ -276,6 +286,14 @@ struct DiaryQuickSheet: View { // 嵌套 sheet:用药记录表单自带保存/取消;保存后回到日记(不强行关闭)。 MedicationLogSheet() } + .onAppear { + // 语音「写日记」直达:进入即聚焦正文,光标直接落在输入框,免去再点一下。 + // 轻微延迟等 sheet 呈现动画落定,否则首帧聚焦偶发不弹键盘。 + guard directWrite else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { + contentFocused = true + } + } .onDisappear { suggestTask?.cancel() voiceFlowTask?.cancel() @@ -294,304 +312,160 @@ struct DiaryQuickSheet: View { } } - // MARK: - AI 辅助区 + // MARK: - 关心条(care bar)视图 + /// 正文里的「回落卡」:键盘收起时(关心条随键盘一起消失)在这里接住同一份 careState, + /// 并承载 AI 免责声明。键盘弹起时整块让位给键盘正上方的关心条,避免两处重复。 @ViewBuilder private var assistSection: some View { VStack(alignment: .leading, spacing: 10) { - // section header - HStack(spacing: 6) { - Image(systemName: "sparkles") - .font(.tjScaled( 11, weight: .semibold)) - .foregroundStyle(Tj.Palette.brick) - sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺")) - Spacer() - if hasQuestions { - Text("\(questions.count) 个建议") - .font(.tjScaled( 10, design: .monospaced)) - .foregroundStyle(Tj.Palette.text3) - } - if lastRate > 0 { - Text(String(format: "%.1f tok/s", lastRate)) - .font(.tjScaled( 10, design: .monospaced)) - .foregroundStyle(Tj.Palette.leaf) + if !contentFocused { + if case .hidden = careState { + EmptyView() + } else { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.tjScaled( 11, weight: .semibold)) + .foregroundStyle(Tj.Palette.brick) + sectionLabel(String(appLoc: "康康帮你记")) + Spacer(minLength: 0) + if lastRate > 0 { + Text(String(format: "%.1f tok/s", lastRate)) + .font(.tjScaled( 10, design: .monospaced)) + .foregroundStyle(Tj.Palette.leaf) + } + } + careBarRow(compact: false) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + ) } } - - // 累积的 questions 列表(多轮,带轮次分隔) - if hasQuestions { - VStack(spacing: 8) { - ForEach(Array(questions.enumerated()), id: \.element.id) { idx, q in - if idx == 0 || questions[idx - 1].round != q.round { - roundDivider(round: q.round, - count: questions.filter { $0.round == q.round }.count) - .id("round-\(q.round)") - } - questionRow(index: roundLocalIndex(at: idx), question: q) - } - } + // 用过一次 AI 后,免责声明常驻(键盘弹起时关心条在上方,这条留在正文里兜合规)。 + if !questions.isEmpty { AIDisclaimerFooter() } - - if exhaustedNote { - HStack(spacing: 6) { - Image(systemName: "checkmark.seal.fill") - .font(.tjScaled( 11)) - .foregroundStyle(Tj.Palette.leaf) - Text("已覆盖主要问诊维度;补充原文后可再追问") - .font(.tjScaled( 11)) - .foregroundStyle(Tj.Palette.text3) - Spacer(minLength: 0) - } - .padding(.vertical, 2) - } - - // 底部主操作按钮(状态机驱动) - phaseFooter } } + /// 关心条的统一渲染。`compact = true` 给键盘正上方那条(单行紧凑); + /// `compact = false` 给正文回落卡(问题可换两行、留白更松)。两处共用同一 careState 与动作。 @ViewBuilder - private var phaseFooter: some View { - switch phase { - case .idle: - assistPrimaryButton( - icon: "sparkles", - label: canRequestSuggest - ? String(appLoc: "让 AI 帮我想想还能记什么") - : String(appLoc: "先写几个字,AI 来帮忙补充"), - enabled: canRequestSuggest, - prominent: true, - action: requestSuggestions - ) + private func careBarRow(compact: Bool) -> some View { + switch careState { + case .hidden: + EmptyView() - case .loading: - assistLoadingIndicator + case .prompt: + Button(action: requestSuggestions) { + careCapsule(icon: "sparkles", + text: String(appLoc: "让康康帮你把这条记得更全"), + tint: Tj.Palette.brick, style: .soft, compact: compact) + } + .buttonStyle(.plain) + .disabled(!canRequestSuggest) - case .ready: - assistPrimaryButton( - icon: "arrow.clockwise", - label: canRequestSuggest - ? String(appLoc: "再问一轮 · 让 AI 从新角度追问") - : String(appLoc: "更新一下原文,再让 AI 继续追问"), - enabled: canRequestSuggest, - action: requestSuggestions - ) - - case .failed(let err): - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(Tj.Palette.brick) - Text(err.localizedDescription) - .font(.tjScaled( 12)) - .foregroundStyle(Tj.Palette.text) - Spacer() + case .thinking: + HStack(spacing: 8) { + Image(systemName: "sparkles") + .font(.tjScaled( compact ? 12 : 13, weight: .semibold)) + .foregroundStyle(Tj.Palette.brick) + .symbolEffect(.pulse, options: .repeating) + Text(lastRate > 0 + ? String(format: String(appLoc: "康康在想想 · %.1f tok/s"), lastRate) + : String(appLoc: "康康在想想…")) + .font(.tjScaled( 13, weight: .medium)) + .foregroundStyle(Tj.Palette.text2) + Spacer(minLength: 0) + Button(action: cancelSuggestions) { + Text("停") + .font(.tjScaled( 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.text3) } - Button { requestSuggestions() } label: { + .buttonStyle(.plain) + } + + case .asking(let q): + HStack(spacing: 10) { + Image(systemName: "text.bubble.fill") + .font(.tjScaled( compact ? 12 : 13, weight: .semibold)) + .foregroundStyle(Tj.Palette.brick) + Text(q.q) + .font(.tjScaled( compact ? 13 : 14, weight: .medium)) + .foregroundStyle(Tj.Palette.text) + .lineLimit(compact ? 1 : 2) + .fixedSize(horizontal: false, vertical: !compact) + Spacer(minLength: 6) + Button { skipCurrent(q) } label: { + Text("跳过") + .font(.tjScaled( 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.text3) + } + .buttonStyle(.plain) + Button { recordCurrent(q) } label: { + careCapsule(icon: "plus", text: String(appLoc: "记一下"), + tint: Tj.Palette.ink, style: .filled, compact: compact) + } + .buttonStyle(.plain) + } + + case .caughtUp(let exhausted): + Button(action: requestSuggestions) { + careCapsule( + icon: exhausted ? "checkmark.seal.fill" : "sparkles", + text: exhausted + ? String(appLoc: "主要的都帮你问到啦 · 再想想?") + : String(appLoc: "还想到几个想问你 · 再来一轮"), + tint: exhausted ? Tj.Palette.leaf : Tj.Palette.brick, + style: .soft, compact: compact) + } + .buttonStyle(.plain) + .disabled(!canRequestSuggest) + + case .failed(let msg): + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.brick) + Text(msg) + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text) + .lineLimit(1) + Spacer(minLength: 0) + Button(action: requestSuggestions) { Text("重试") .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.ink) } .buttonStyle(.plain) } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) - .fill(Tj.Palette.brickSoft.opacity(0.5)) - ) } } - /// 辅助主按钮。`prominent` 为真走实心强调样式(填充 brick + 白字 + 轻投影,一眼可点), - /// 否则走低调描边样式(用于 .ready 的「再问一轮」)。 - private func assistPrimaryButton(icon: String, - label: String, - enabled: Bool, - prominent: Bool = false, - action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 8) { - Image(systemName: icon) - Text(label) - } - .font(.tjScaled( prominent ? 14 : 13, weight: .semibold)) - .foregroundStyle(prominent - ? (enabled ? Tj.Palette.paper : Tj.Palette.text3) - : (enabled ? Tj.Palette.ink : Tj.Palette.text3)) - .frame(maxWidth: .infinity) - .padding(.vertical, prominent ? 14 : 11) - .background(assistButtonBackground(enabled: enabled, prominent: prominent)) - // 纯描边背景、内部透明:补 contentShape 让整框可点(否则只有图标+文字本体能点)。 - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(!enabled) - } + private enum CareCapsuleStyle { case filled, soft } - @ViewBuilder - private func assistButtonBackground(enabled: Bool, prominent: Bool) -> some View { - let shape = RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) - if prominent { - shape - .fill(enabled ? Tj.Palette.brick : Tj.Palette.brickSoft) - .shadow(color: enabled ? Tj.Palette.brick.opacity(0.30) : .clear, - radius: 8, x: 0, y: 3) - } else { - shape - .strokeBorder( - enabled ? Tj.Palette.ink : Tj.Palette.line, - style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3]) - ) + /// 关心条里的胶囊。filled = 实心(主动作「记一下」);soft = 浅色底(邀请类)。 + private func careCapsule(icon: String, text: String, tint: Color, + style: CareCapsuleStyle, compact: Bool) -> some View { + HStack(spacing: 5) { + Image(systemName: icon) + .font(.tjScaled( compact ? 11 : 12, weight: .semibold)) + Text(text) + .font(.tjScaled( compact ? 12 : 13, weight: .semibold)) + .lineLimit(1) } - } - - /// .loading 等待态:安静的 paper 卡片,底部一条细窄的不确定进度条来回滑动(Linear/Vercel 式极简)。 - /// 不用高亮扫光、不填强调色,避免刺眼;只靠细线 + sparkles 轻脉冲传达「在算」。 - private var assistLoadingIndicator: some View { - HStack(spacing: 10) { - Image(systemName: "sparkles") - .font(.tjScaled( 12, weight: .semibold)) - .foregroundStyle(Tj.Palette.brick) - .symbolEffect(.pulse, options: .repeating) - Text(lastRate > 0 - ? String(format: String(appLoc: "AI 生成中 · %.1f tok/s"), lastRate) - : String(appLoc: "AI 生成中 · 本地推理")) - .font(.tjScaled( 13, weight: .medium)) - .foregroundStyle(Tj.Palette.text2) - Spacer(minLength: 0) - Button("取消") { cancelSuggestions() } - .font(.tjScaled( 12, weight: .semibold)) - .foregroundStyle(Tj.Palette.text3) - } - .padding(.vertical, 11) + .foregroundStyle(style == .filled ? Tj.Palette.paper : tint) .padding(.horizontal, 12) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) - .fill(Tj.Palette.paper) - ) - .overlay( - RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) - .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) - ) - .overlay(alignment: .bottom) { - AIFlowBar().padding(.horizontal, 1) - } - .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)) - } - - /// 给定整张 questions list 里 idx 位置的 question,返回它在自己 round 内的序号(1-based)。 - private func roundLocalIndex(at idx: Int) -> Int { - let target = questions[idx].round - var count = 0 - for i in 0...idx where questions[i].round == target { - count += 1 - } - return count - } - - /// 第 N 轮的分隔条 —— 让用户清楚下一轮 LLM 看到的是更新过的最新文本。 - private func roundDivider(round: Int, count: Int) -> some View { - HStack(spacing: 8) { - HStack(spacing: 6) { - Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath") - .font(.tjScaled( 11, weight: .semibold)) - .foregroundStyle(Tj.Palette.brick) - Text(round == 1 - ? String(appLoc: "第 1 轮 · \(count) 条") - : String(appLoc: "第 \(round) 轮 · 基于你刚才更新的文本 · \(count) 条")) - .font(.tjScaled( 11, weight: .semibold)) - .tracking(0.3) - .foregroundStyle(Tj.Palette.text2) - } - Rectangle() - .fill(Tj.Palette.line) - .frame(height: 1) - .mask( - HStack(spacing: 3) { - ForEach(0..<60, id: \.self) { _ in - Rectangle().frame(width: 3, height: 1) - } - } - ) - } - .padding(.top, round == 1 ? 0 : 6) - } - - private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View { - let adopted = question.adopted - let filling = fillingId == question.id - return VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top, spacing: 8) { - Text("\(index).") - .font(.tjScaled( 13, weight: .semibold, design: .monospaced)) - .foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick) - Text(question.q) - .font(.tjScaled( 13, weight: .medium)) - .foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text) - .strikethrough(adopted, color: Tj.Palette.text3) - .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 4) - - if adopted { - HStack(spacing: 4) { - Image(systemName: "checkmark") - .font(.tjScaled( 10, weight: .bold)) - Text("已采纳") - .font(.tjScaled( 11, weight: .semibold)) - } - .foregroundStyle(Tj.Palette.leaf) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(Capsule().fill(Tj.Palette.leafSoft)) - } else if !filling { - Button { adopt(question) } label: { - HStack(spacing: 4) { - Image(systemName: "plus.circle.fill") - .font(.tjScaled( 12)) - Text("采纳") - .font(.tjScaled( 12, weight: .semibold)) - } - .foregroundStyle(Tj.Palette.paper) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(Capsule().fill(Tj.Palette.ink)) - } - .buttonStyle(.plain) - } - } - if filling { - QuestionFillPanel( - template: question.fill, - values: $fillValues, - onCommit: { assembled in commitAdoption(question, text: assembled) }, - onCancel: { closeFill() } - ) - } else if !question.fill.isEmpty && !adopted { - HStack(alignment: .top, spacing: 4) { - Text("将追加:") - .font(.tjScaled( 11)) - .foregroundStyle(Tj.Palette.text3) - Text(question.fill) - .font(.tjScaled( 11)) - .foregroundStyle(Tj.Palette.text2) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.leading, 22) - } - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) - .fill(adopted ? Tj.Palette.sand2 : Tj.Palette.paper) - ) - .overlay( - RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) - .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) - ) + .padding(.vertical, 7) + .background(Capsule().fill(style == .filled ? tint : tint.opacity(0.12))) + .contentShape(Capsule()) } // MARK: - Actions @@ -603,6 +477,41 @@ struct DiaryQuickSheet: View { .foregroundStyle(Tj.Palette.text2) } + /// 顶部模式入口仅在「未直达写日记 + 正文未聚焦 + 未输入任何内容」时展示。 + /// 用户一旦点进正文 / 开始写,就收起入口,留出清爽的写作面。 + private var showModeSelector: Bool { + !directWrite && !contentFocused && !hasContent + } + + /// 模式入口(2×2):写日记(本页)/ 用药(记剂量+时间)/ 拍药盒(识别入药品库)/ 记症状。 + @ViewBuilder + private var modeSelector: some View { + if showModeSelector { + LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10)], spacing: 10) { + modeCard(icon: "pencil", title: String(appLoc: "写日记"), + subtitle: String(appLoc: "文字或语音"), active: true) { + contentFocused = true + } + modeCard(icon: "pills.fill", title: String(appLoc: "用药"), + subtitle: String(appLoc: "记剂量与时间"), active: false) { + showMedicationLog = true + } + modeCard(icon: "camera.viewfinder", title: String(appLoc: "拍药盒"), + subtitle: String(appLoc: "识别入药品库"), active: false) { + showMedicationScan = true + } + modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"), + subtitle: String(appLoc: "持续追踪"), active: false) { + showSymptomStart = true + } + } + .padding(.horizontal, 20) + .padding(.bottom, 14) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + /// 顶部入口三选一卡片(写日记 / 拍药盒 / 记症状)。active 表示当前所在模式。 /// 竖排紧凑布局:三卡并排在 iPhone 宽度下横排放不下完整文案。 private func modeCard(icon: String, title: String, subtitle: String, @@ -734,18 +643,12 @@ struct DiaryQuickSheet: View { /// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM, /// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。 + /// 关心条形态下**不收键盘**:让生成中的「康康在想想」就停在键盘正上方, + /// 出结果后直接在原位换成第一句追问,书写节奏不被打断。 private func requestSuggestions() { suggestTask?.cancel() let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines) let covered = Array(coveredDims) - // 1. 主动收起键盘 —— 否则建议面板被键盘吃掉一半 - contentFocused = false - // 2. 确保 sheet 在 large(用户可能下拉到 medium 又触发 AI) - if detent != .large { - withAnimation(.snappy(duration: 0.25)) { - detent = .large - } - } exhaustedNote = false phase = .loading suggestTask = Task { @MainActor in @@ -819,38 +722,25 @@ struct DiaryQuickSheet: View { phase = hasQuestions ? .ready : .idle } - /// 采纳:模板含 `[占位]` 时展开就地填空面板;无占位则直接把整句追加(并标记 adopted)。 - /// 已采纳的 q 不会从列表里消失;其维度已在生成时计入 coveredDims,下一轮 prompt 会避开。 - private func adopt(_ question: DiaryAssistService.Question) { - guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else { - // 无占位:直接采纳整句(空 fill 时退回到追加问题本身)。 - commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill) - return - } - withAnimation(.snappy(duration: 0.18)) { - fillingId = question.id - fillValues = Array(repeating: "", count: DiaryFillTemplate.slotCount(question.fill)) - } - } - - /// 关闭填空面板(取消)。 - private func closeFill() { - withAnimation(.snappy(duration: 0.18)) { - fillingId = nil - fillValues = [] - } - } - - /// 提交采纳:把(填好的)整句追加到正文,标记 adopted,收起面板。 - private func commitAdoption(_ question: DiaryAssistService.Question, text: String) { + /// 「记一下」:把这条问题对应的补充句落进正文,标记已采纳,关心条自动滑到下一句。 + /// 用 `assemble(values: [])` 取「去方括号、占位回退为原词」的干净句子—— + /// 即便用户不再细填,也不会把 `[时间]` 这种机器括号留进日记。 + private func recordCurrent(_ question: DiaryAssistService.Question) { + let stub = question.fill.isEmpty + ? question.q + : DiaryFillTemplate.assemble(question.fill, values: []) + appendToContent(stub) if let idx = questions.firstIndex(where: { $0.id == question.id }) { - withAnimation(.snappy(duration: 0.18)) { - questions[idx].adopted = true - } + questions[idx].adopted = true } - appendToContent(text) - fillingId = nil - fillValues = [] + // 落字后把键盘留住:用户顺势接着写,关心条已切到下一句。 + contentFocused = true + } + + /// 「跳过」:这句先不记,关心条滑到下一句。该维度已在生成时计入 coveredDims, + /// 下一轮 prompt 不会再问它,所以跳过的不必从 questions 里删。 + private func skipCurrent(_ question: DiaryAssistService.Question) { + skippedQuestionIDs.insert(question.id) } /// 把一段补充文本追加到正文末尾(自动补换行,空文本忽略)。 diff --git a/康康/Features/Diary/MedicationLogSheet.swift b/康康/Features/Diary/MedicationLogSheet.swift index 0f8fbe5..f32ba11 100644 --- a/康康/Features/Diary/MedicationLogSheet.swift +++ b/康康/Features/Diary/MedicationLogSheet.swift @@ -20,6 +20,11 @@ struct MedicationLogSheet: View { @State private var dosage = "" @State private var takenAt: Date = .now + /// 从药品库某个药直接拉起时预选它(药品库详情页「记录一次服用」)。默认 nil = 自由选择。 + init(preselected: Medication? = nil) { + _selectedMed = State(initialValue: preselected) + } + private var resolvedName: String { (selectedMed?.name ?? manualName).trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/康康/Features/Home/HomeView.swift b/康康/Features/Home/HomeView.swift index 525db43..ed52cd4 100644 --- a/康康/Features/Home/HomeView.swift +++ b/康康/Features/Home/HomeView.swift @@ -2,7 +2,8 @@ import SwiftUI import SwiftData struct HomeView: View { - var onTapArchive: () -> Void = {} + /// 跳记录页;传 filter 时预选对应分类 chip(报告档案卡传 `.report`,统计磁贴按类别预选)。 + var onTapArchive: (TimelineKind?) -> Void = { _ in } @Query(sort: \Indicator.capturedAt, order: .reverse) private var indicators: [Indicator] @@ -16,21 +17,27 @@ struct HomeView: View { @Query(sort: \Symptom.startedAt, order: .reverse) private var symptoms: [Symptom] - /// 点「最近记录」某行 → 打开只读详情 sheet(与档案库 C1 同款交互)。 - @State private var selectedEntry: TimelineEntry? - /// 点指标行 → 打开同类聚合详情(历次翻页 + 趋势,与档案库 C1 同款)。 + @Query private var profiles: [UserProfile] + @Query private var customMetrics: [CustomMonitorMetric] + + /// 点迷你趋势卡 → 打开同类聚合详情(历次翻页 + 趋势,与档案库 C1 同款)。 @State private var selectedGroup: IndicatorGroup? + private var profile: UserProfile? { profiles.first } + + /// 主页只挑前 3 条最有代表性的趋势:长期监测优先,其次化验指标。数据不足时整段隐藏。 @MainActor - private var recentEntries: [TimelineEntry] { - let all = - TimelineEntry.aggregatedIndicators(indicators) + - reports.map(TimelineEntry.from(report:)) + - diaries.map(TimelineEntry.from(diary:)) + - symptoms.map(TimelineEntry.from(symptom:)) - return all.sorted { $0.date > $1.date }.prefix(6).map { $0 } + private var featuredBuckets: [SeriesBucket] { + let all = SeriesBucket.build(from: indicators, + profile: profile, + customMetrics: customMetrics) + let monitor = all.filter { $0.kind == .monitor } + let lab = all.filter { $0.kind == .lab } + return Array((monitor + lab).prefix(3)) } + private var ongoingSymptomCount: Int { symptoms.filter { $0.endedAt == nil }.count } + var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { @@ -39,49 +46,65 @@ struct HomeView: View { .padding(.bottom, 18) HomeCalendarCard() + .padding(.bottom, 18) + + overviewSection + .padding(.bottom, 18) + + let buckets = featuredBuckets + if !buckets.isEmpty { + trendsSection(buckets) + .padding(.bottom, 18) + } TodayRemindersCard() OngoingSymptomsCard() .padding(.bottom, 18) - recentSection - .padding(.bottom, 22) - archiveSection } .padding(.horizontal, 20) .padding(.bottom, 20) } .background(Tj.Palette.sand.ignoresSafeArea()) - .sheet(item: $selectedEntry) { entry in - if let d = TimelineDetail.resolve( - for: entry, - indicators: indicators, reports: reports, - diaries: diaries, symptoms: symptoms - ) { - TimelineEntryDetailView(detail: d) - } - } .sheet(item: $selectedGroup) { group in IndicatorSeriesDetailView(group: group) } } + // MARK: - 问候 + private var greeting: some View { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { + let t = TimeOfDay.current + return HStack(alignment: .center, spacing: 14) { + // 时段徽章:暖色圆底 + 对应图标(晨/午/夜),随时段自动切换。 + ZStack { + Circle().fill(Tj.Palette.sand2) + Image(systemName: t.icon) + .font(.tjScaled( 22)) + .foregroundStyle(Tj.Palette.amber) + } + .frame(width: 52, height: 52) + + VStack(alignment: .leading, spacing: 2) { Text(todayLine) - .font(.tjScaled( 12)) + .font(.tjScaled( 11)) .tracking(1) .foregroundStyle(Tj.Palette.text3) - Text(greetingWord) - .font(.tjTitle()) + // 衬线问候,编辑感更强。 + Text(t.word) + .font(.tjScaled( 28, weight: .semibold, design: .serif)) .foregroundStyle(Tj.Palette.text) + Text(t.subtitle) + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text2) } - Spacer() + + Spacer(minLength: 8) + TjLockChip() - .padding(.top, 4) + .padding(.top, 2) } } @@ -92,84 +115,137 @@ struct HomeView: View { return "\(day) · \(weekday)" } - private var greetingWord: String { - switch Calendar.current.component(.hour, from: Date()) { - case 5..<12: return String(appLoc: "早安") - case 12..<18: return String(appLoc: "下午好") - default: return String(appLoc: "晚上好") + /// 一天三段:驱动问候语、副标题、徽章图标,保证三者一致。 + private enum TimeOfDay { + case morning, afternoon, evening + + static var current: TimeOfDay { + switch Calendar.current.component(.hour, from: Date()) { + case 5..<12: return .morning + case 12..<18: return .afternoon + default: return .evening + } + } + + var word: String { + switch self { + case .morning: return String(appLoc: "早安") + case .afternoon: return String(appLoc: "下午好") + case .evening: return String(appLoc: "晚上好") + } + } + + var subtitle: String { + switch self { + case .morning: return String(appLoc: "新的一天,慢慢来") + case .afternoon: return String(appLoc: "记得起身活动一下") + case .evening: return String(appLoc: "夜深了,记得早点休息") + } + } + + var icon: String { + switch self { + case .morning: return "sun.max.fill" + case .afternoon: return "sun.haze.fill" + case .evening: return "moon.stars.fill" + } } } - private var recentSection: some View { - // 聚合(含血压配对 O(m²))在一次 body 内只算一次,再派生分组,避免 .isEmpty 与分组各算一遍。 - let entries = recentEntries - let groups = TimelineGrouping.group(entries) - return VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .lastTextBaseline) { - Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text) - Spacer() - Button(action: onTapArchive) { - Text("全部 ›") - .font(.tjScaled( 12)) + // MARK: - 数据概览磁贴(2×2,大数字 + 图标,点进对应分类) + + private var overviewSection: some View { + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12)], spacing: 12) { + statTile(icon: "doc.fill", value: reports.count, + label: String(appLoc: "报告"), tint: Tj.Palette.ink) { + onTapArchive(.report) + } + statTile(icon: "drop.fill", value: indicators.count, + label: String(appLoc: "指标"), tint: Tj.Palette.brick) { + onTapArchive(.indicator) + } + statTile(icon: "pencil", value: diaries.count, + label: String(appLoc: "日记"), tint: Tj.Palette.leaf) { + onTapArchive(.diary) + } + statTile(icon: "waveform.path.ecg", value: symptoms.count, + label: ongoingSymptomCount > 0 + ? String(appLoc: "症状 · \(ongoingSymptomCount) 进行中") + : String(appLoc: "症状"), + tint: Tj.Palette.amber) { + onTapArchive(.symptom) + } + } + } + + private func statTile(icon: String, value: Int, label: String, + tint: Color, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 12) { + ZStack { + Circle().fill(tint.opacity(0.15)) + Image(systemName: icon) + .font(.tjScaled( 16, weight: .semibold)) + .foregroundStyle(tint) + } + .frame(width: 40, height: 40) + + VStack(alignment: .leading, spacing: 1) { + Text("\(value)") + .font(.tjScaled( 22, weight: .bold, design: .rounded)) + .foregroundStyle(Tj.Palette.text) + Text(label) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) + .lineLimit(1) + .minimumScaleFactor(0.85) } - .buttonStyle(.plain) + Spacer(minLength: 0) } + .padding(12) + .frame(maxWidth: .infinity) + .tjCard() + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } - if entries.isEmpty { - emptyRecent - } else { - VStack(alignment: .leading, spacing: 14) { - ForEach(groups, id: \.section) { group in - VStack(alignment: .leading, spacing: 8) { - Text(group.section.label) - .font(.tjScaled( 11, weight: .semibold)) - .tracking(0.5) - .foregroundStyle(Tj.Palette.text3) - VStack(spacing: 10) { - ForEach(group.items) { entry in - Button { - // 指标 → 同类聚合详情(历次 + 趋势);其余 → 只读详情。与档案库 C1 一致。 - guard let d = TimelineDetail.resolve( - for: entry, - indicators: indicators, reports: reports, - diaries: diaries, symptoms: symptoms - ) else { return } - switch d { - case .indicator(let i): selectedGroup = IndicatorGroup.of(i) - case .bloodPressure(let sys, _): selectedGroup = IndicatorGroup.of(sys) - default: selectedEntry = entry - } - } label: { - TimelineRow(entry: entry) - } - .buttonStyle(.plain) - } - } - } + // MARK: - 健康趋势(迷你折线图,复用趋势页 TrendRow) + + private func trendsSection(_ buckets: [SeriesBucket]) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text("健康趋势") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + + VStack(spacing: 12) { + ForEach(buckets) { bucket in + Button { + selectedGroup = group(for: bucket) + } label: { + TrendRow(bucket: bucket) } + .buttonStyle(.plain) } } } } - private var emptyRecent: some View { - HStack { - Text("还没有任何记录,点底部 + 号开始第一条") - .font(.tjScaled( 13)) - .foregroundStyle(Tj.Palette.text3) - Spacer() - } - .padding(.vertical, 14) - .padding(.horizontal, 16) - .tjCard(bordered: true) + /// SeriesBucket → 聚合详情的 IndicatorGroup(与趋势页分组语义一致)。 + private func group(for bucket: SeriesBucket) -> IndicatorGroup { + if bucket.id == "bp" { return .bloodPressure } + if bucket.id.hasPrefix("lab:") { return .lab(key: String(bucket.id.dropFirst(4))) } + return .series(key: bucket.id) } + // MARK: - 影像档案入口 + private var archiveSection: some View { VStack(alignment: .leading, spacing: 10) { Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text) - Button(action: onTapArchive) { + Button { onTapArchive(.report) } label: { HStack(spacing: 14) { TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)")) .frame(width: 56, height: 56) diff --git a/康康/Features/Home/TodayRemindersCard.swift b/康康/Features/Home/TodayRemindersCard.swift index 5f07c83..4574ca0 100644 --- a/康康/Features/Home/TodayRemindersCard.swift +++ b/康康/Features/Home/TodayRemindersCard.swift @@ -95,8 +95,7 @@ struct TodayRemindersCard: View { RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.paper) ) - .shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04), - radius: 2, x: 0, y: 1) + .shadow(color: Tj.Palette.shadow.opacity(0.05), radius: 2, x: 0, y: 1) } } diff --git a/康康/Features/Me/InferenceSettingsView.swift b/康康/Features/Me/InferenceSettingsView.swift index 346786b..1c2adb8 100644 --- a/康康/Features/Me/InferenceSettingsView.swift +++ b/康康/Features/Me/InferenceSettingsView.swift @@ -4,11 +4,18 @@ import SwiftUI /// 切换只改持久化选择;下一次 AI 调用(prepare/generate)按新引擎加载。 struct InferenceSettingsView: View { @AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue + @State private var modelService = ModelDownloadService.shared private var selected: EnginePreference { EnginePreference(rawValue: engineRaw) ?? .auto } + /// 性能自检需要模型就绪(MNN 主或 MLX 兜底任一)。 + private var modelReady: Bool { + modelService.states[.mnnLLM]?.phase == .ready + || modelService.states[.llm]?.phase == .ready + } + var body: some View { ScrollView { VStack(spacing: 12) { @@ -26,12 +33,74 @@ struct InferenceSettingsView: View { } sme2Card + selfTestSection noteCard } .padding(.horizontal, 16) .padding(.vertical, 20) } .background(Tj.Palette.sand.ignoresSafeArea()) + .onAppear { modelService.refreshStates() } + } + + /// 性能自检入口:用当前选中的引擎跑固定 prompt,实测并按后端归档对比。 + /// 模型未就绪时显示「前往下载」提示而非死链。 + @ViewBuilder + private var selfTestSection: some View { + if modelReady { + NavigationLink { + ModelSelfTestView() + } label: { + HStack(spacing: 12) { + ZStack { + Circle().fill(Tj.Palette.sand2) + Image(systemName: "gauge.with.needle") + .font(.tjScaled(18)) + .foregroundStyle(Tj.Palette.ink) + } + .frame(width: 44, height: 44) + VStack(alignment: .leading, spacing: 2) { + Text("性能自检") + .font(.tjScaled(15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text("用上方选中的引擎跑固定 prompt,实测 prefill / 生成 tok/s") + .font(.tjScaled(12)) + .foregroundStyle(Tj.Palette.text3) + .lineLimit(2) + } + Spacer() + Image(systemName: "chevron.right") + .font(.tjScaled(13, weight: .semibold)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(14) + .tjCard() + } + .buttonStyle(.plain) + } else { + HStack(spacing: 12) { + ZStack { + Circle().fill(Tj.Palette.sand2) + Image(systemName: "gauge.with.needle") + .font(.tjScaled(18)) + .foregroundStyle(Tj.Palette.text2) + } + .frame(width: 44, height: 44) + VStack(alignment: .leading, spacing: 2) { + Text("性能自检") + .font(.tjScaled(15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text("模型未就绪,前往「模型管理」下载后可用") + .font(.tjScaled(12)) + .foregroundStyle(Tj.Palette.text3) + .lineLimit(2) + } + Spacer() + } + .padding(14) + .tjCard() + .opacity(0.55) + } } private func engineRow(_ engine: EnginePreference) -> some View { diff --git a/康康/Features/Me/ModelManagementView.swift b/康康/Features/Me/ModelManagementView.swift index 54953db..011aad3 100644 --- a/康康/Features/Me/ModelManagementView.swift +++ b/康康/Features/Me/ModelManagementView.swift @@ -28,19 +28,6 @@ struct ModelManagementView: View { actionButtons .padding(.top, 4) - if service.states[.mnnLLM]?.phase == .ready || service.states[.llm]?.phase == .ready { - NavigationLink { - ModelSelfTestView() - } label: { - HStack(spacing: 6) { - Image(systemName: "gauge.with.needle") - Text("性能自检") - } - .frame(maxWidth: .infinity) - } - .buttonStyle(TjGhostButton()) - } - if let importError { Text(importError) .font(.tjScaled( 12)) diff --git a/康康/Features/Profile/MedicationLibraryView.swift b/康康/Features/Profile/MedicationLibraryView.swift index ea50564..58162cb 100644 --- a/康康/Features/Profile/MedicationLibraryView.swift +++ b/康康/Features/Profile/MedicationLibraryView.swift @@ -175,6 +175,8 @@ private struct MedicationEditSheet: View { @State private var hydrated = false /// 点缩略图全屏查看的起始页;nil = 未打开查看器。 @State private var viewerStart: PhotoIndex? + /// 「记录一次服用」:嵌套拉起 MedicationLogSheet,预选当前药。 + @State private var showLog = false private var isEditing: Bool { existing != nil } private var canSave: Bool { @@ -184,6 +186,29 @@ private struct MedicationEditSheet: View { var body: some View { NavigationStack { Form { + if isEditing { + Section { + Button { showLog = true } label: { + HStack(spacing: 10) { + Image(systemName: "pills.circle.fill") + .font(.tjScaled( 18)) + .foregroundStyle(Tj.Palette.ink) + Text("记录一次服用") + .font(.tjScaled( 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Spacer() + Image(systemName: "chevron.right") + .font(.tjScaled( 12, weight: .medium)) + .foregroundStyle(Tj.Palette.text3) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } footer: { + Text("记某次吃药的剂量和时间,会进「记录 · 用药」时间线。不提供剂量建议。") + } + } + if let m = existing, !m.assets.isEmpty { Section { ScrollView(.horizontal, showsIndicators: false) { @@ -252,6 +277,9 @@ private struct MedicationEditSheet: View { MedicationPhotoViewer(assets: m.assets, startIndex: start.index) } } + .sheet(isPresented: $showLog) { + MedicationLogSheet(preselected: existing) + } } } diff --git a/康康/Features/Quick/RegionCameraView.swift b/康康/Features/Quick/RegionCameraView.swift index 929a89d..91dcf2b 100644 --- a/康康/Features/Quick/RegionCameraView.swift +++ b/康康/Features/Quick/RegionCameraView.swift @@ -62,7 +62,7 @@ struct SingleShotCameraView: View { Spacer() - Text("拍一张含目标指标的照片 · 拍完再框选") + Text("轻点画面对焦 · 拍完再框选") .font(.tjScaled( 13, weight: .medium)) .foregroundStyle(.white) .padding(.horizontal, 12) @@ -187,6 +187,10 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate { private var previewLayer: AVCaptureVideoPreviewLayer? private var setupDone = false private var captureCompletion: ((UIImage?) -> Void)? + /// 持有当前输入设备,供点击对焦时重新 lockForConfiguration。 + private var device: AVCaptureDevice? + /// 点击对焦时短暂显示的黄框;弱引用,移除后自动置空。 + private weak var focusIndicator: UIView? override func didMoveToWindow() { super.didMoveToWindow() @@ -205,6 +209,20 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate { return } session.addInput(input) + self.device = device + + // 近距对焦:贴着拍化验单/小标签时持续自动对焦,并把搜索范围偏向近处, + // 避免反复拉风箱。所有机型生效;不支持的项静默跳过。 + if (try? device.lockForConfiguration()) != nil { + if device.isFocusModeSupported(.continuousAutoFocus) { + device.focusMode = .continuousAutoFocus + } + if device.isAutoFocusRangeRestrictionSupported { + device.autoFocusRangeRestriction = .near + } + device.unlockForConfiguration() + } + if session.canAddOutput(output) { session.addOutput(output) } session.commitConfiguration() @@ -215,6 +233,10 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate { self.previewLayer = preview applyPortrait(preview.connection) + // 点击对焦:贴着拍小标签时连续自动对焦偶尔锁不准,允许用户点屏指定对焦点。 + let tap = UITapGestureRecognizer(target: self, action: #selector(handleFocusTap(_:))) + addGestureRecognizer(tap) + DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.startRunning() } @@ -233,6 +255,61 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate { previewLayer?.frame = bounds } + // MARK: - 点击对焦 + + @objc private func handleFocusTap(_ gr: UITapGestureRecognizer) { + guard let previewLayer, device != nil else { return } + let point = gr.location(in: self) + // 屏幕点 → 设备归一化对焦坐标(随 videoGravity/旋转正确换算)。 + let devicePoint = previewLayer.captureDevicePointConverted(fromLayerPoint: point) + focus(at: devicePoint) + showFocusIndicator(at: point) + } + + /// 把对焦/测光点锁到指定设备坐标,单次自动对焦;不支持的项静默跳过。 + private func focus(at devicePoint: CGPoint) { + guard let device, (try? device.lockForConfiguration()) != nil else { return } + if device.isFocusPointOfInterestSupported { + device.focusPointOfInterest = devicePoint + } + if device.isFocusModeSupported(.autoFocus) { + device.focusMode = .autoFocus // 单次对焦,锁到该点 + } + if device.isExposurePointOfInterestSupported { + device.exposurePointOfInterest = devicePoint + } + if device.isExposureModeSupported(.autoExpose) { + device.exposureMode = .autoExpose + } + device.unlockForConfiguration() + } + + /// 在点击位置短暂显示一个黄色对焦框作为反馈。 + private func showFocusIndicator(at point: CGPoint) { + focusIndicator?.removeFromSuperview() + let box = UIView(frame: CGRect(x: 0, y: 0, width: 76, height: 76)) + box.center = point + box.backgroundColor = .clear + box.layer.borderColor = UIColor.systemYellow.cgColor + box.layer.borderWidth = 1.5 + box.layer.cornerRadius = 6 + box.isUserInteractionEnabled = false + box.alpha = 0 + box.transform = CGAffineTransform(scaleX: 1.35, y: 1.35) + addSubview(box) + focusIndicator = box + UIView.animate(withDuration: 0.2, animations: { + box.alpha = 1 + box.transform = .identity + }, completion: { _ in + UIView.animate(withDuration: 0.3, delay: 0.7, options: []) { + box.alpha = 0 + } completion: { _ in + box.removeFromSuperview() + } + }) + } + func capture(completion: @escaping (UIImage?) -> Void) { guard session.isRunning else { completion(nil); return } captureCompletion = completion diff --git a/康康/Features/Record/VoiceCommandSheet.swift b/康康/Features/Record/VoiceCommandSheet.swift index 15de736..ec78608 100644 --- a/康康/Features/Record/VoiceCommandSheet.swift +++ b/康康/Features/Record/VoiceCommandSheet.swift @@ -250,9 +250,15 @@ struct VoiceCommandSheet: View { private func finishRecording() { guard phase == .recording else { return } ticker?.cancel() + // 已经在屏幕上的实时字幕:stop() 偶发因最终结果竞争/取消返回空, + // 这时别把用户已经看到的内容丢掉,回退到实时字幕。 + let live = transcript phase = .classifying Task { - let text = await dictation.stop() + let finalText = await dictation.stop() + let text = finalText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? live + : finalText transcript = text let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { diff --git a/康康/Features/Symptom/OngoingSymptomsCard.swift b/康康/Features/Symptom/OngoingSymptomsCard.swift index fb06217..caf7a67 100644 --- a/康康/Features/Symptom/OngoingSymptomsCard.swift +++ b/康康/Features/Symptom/OngoingSymptomsCard.swift @@ -93,8 +93,7 @@ struct OngoingSymptomsCard: View { ) } ) - .shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04), - radius: 2, x: 0, y: 1) + .shadow(color: Tj.Palette.shadow.opacity(0.05), radius: 2, x: 0, y: 1) } private func severityDot(_ value: Int) -> some View { diff --git a/康康/Features/Trends/SeriesBucket.swift b/康康/Features/Trends/SeriesBucket.swift index aff761c..127c97e 100644 --- a/康康/Features/Trends/SeriesBucket.swift +++ b/康康/Features/Trends/SeriesBucket.swift @@ -133,7 +133,7 @@ extension SeriesBucket { id: "lab:\(latest.name)", seriesKey: "lab:\(latest.name)", label: nil, - color: Tj.Palette.ink, + color: Tj.Palette.teal, points: points, referenceRange: parseRange(latest.range) ) @@ -172,7 +172,7 @@ extension SeriesBucket { id: key, seriesKey: key, label: nil, - color: Tj.Palette.ink, + color: Tj.Palette.teal, points: sorted.compactMap { point(from: $0) }, referenceRange: range ) @@ -200,7 +200,7 @@ extension SeriesBucket { id: "bp.systolic", seriesKey: "bp.systolic", label: String(appLoc: "收缩"), - color: Tj.Palette.brick, + color: Tj.Palette.teal, points: sysItems.compactMap { point(from: $0) }, referenceRange: m.effectiveRange(for: sysField, profile: profile) ) diff --git a/康康/Features/Trends/SeriesChartCard.swift b/康康/Features/Trends/SeriesChartCard.swift index 613dad9..d873b96 100644 --- a/康康/Features/Trends/SeriesChartCard.swift +++ b/康康/Features/Trends/SeriesChartCard.swift @@ -111,6 +111,24 @@ struct SeriesChartCard: View { } } + // 单条线时,线下垫一层渐变面积,增加体量、柔化观感 + //(多条线如血压不加,避免两片面积互相盖住)。 + if bucket.lines.count == 1, let line = bucket.lines.first { + ForEach(line.points) { p in + // 显式把基线钉在值域下界:单值 AreaMark 的隐式基线(0/域外)会把渐变 + // 拉到可视区外、几乎不淡出,看着像一块实色底色一路铺到图表底。 + AreaMark( + x: .value("时间", p.date), + yStart: .value("基线", (valueDomain ?? 0...1).lowerBound), + yEnd: .value(line.label ?? bucket.title, p.value) + ) + .foregroundStyle(LinearGradient( + colors: [line.color.opacity(0.16), line.color.opacity(0)], + startPoint: .top, endPoint: .bottom)) + .interpolationMethod(.monotone) + } + } + // 折线 + 点 ForEach(bucket.lines) { line in ForEach(line.points) { p in @@ -119,8 +137,10 @@ struct SeriesChartCard: View { y: .value(line.label ?? bucket.title, p.value) ) .foregroundStyle(line.color) - .interpolationMethod(.catmullRom) - .lineStyle(StrokeStyle(lineWidth: 2)) + // monotone:平滑但不在数据尖峰处过冲鼓包,比 catmullRom 更贴合真实读数。 + .interpolationMethod(.monotone) + // 圆角端点 + 连接,去掉折线的生硬尖角。 + .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) } .symbol { Circle() diff --git a/康康/Features/Trends/TrendDetailView.swift b/康康/Features/Trends/TrendDetailView.swift index 9eacbb9..95e7fe9 100644 --- a/康康/Features/Trends/TrendDetailView.swift +++ b/康康/Features/Trends/TrendDetailView.swift @@ -179,6 +179,23 @@ struct TrendDetailView: View { .foregroundStyle(line.color.opacity(0.08)) } } + // 单条线时,线下垫一层渐变面积,增加体量、柔化观感 + //(多条线如血压不加,避免两片面积互相盖住)。 + if filteredLines.count == 1, let line = filteredLines.first { + ForEach(line.points) { p in + // 显式把基线钉在值域下界:用单值 AreaMark 会以隐式基线(0/域外)兜底, + // 渐变被拉到可视区外几乎不淡出,看着像一块实色底色一路铺到图表底。 + AreaMark( + x: .value("时间", p.date), + yStart: .value("基线", (valueDomain ?? 0...1).lowerBound), + yEnd: .value(line.label ?? bucket.title, p.value) + ) + .foregroundStyle(LinearGradient( + colors: [line.color.opacity(0.16), line.color.opacity(0)], + startPoint: .top, endPoint: .bottom)) + .interpolationMethod(.monotone) + } + } ForEach(filteredLines) { line in ForEach(line.points) { p in LineMark( @@ -187,8 +204,10 @@ struct TrendDetailView: View { series: .value("series", line.id) ) .foregroundStyle(line.color) - .interpolationMethod(.catmullRom) - .lineStyle(StrokeStyle(lineWidth: 2)) + // monotone:平滑但不在尖峰处过冲鼓包,更贴合真实读数。 + .interpolationMethod(.monotone) + // 圆角端点 + 连接,去掉折线的生硬尖角。 + .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) PointMark( x: .value("时间", p.date), y: .value(line.label ?? bucket.title, p.value) @@ -421,6 +440,24 @@ private struct TrendInsightCard: View { .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) Spacer() + // 手动重新解读:任何时候都能点(解读中除外)。解决偶发不自动触发, + // 也允许已生成后再跑一次拿最新解读。 + if !running { + Button { Task { await load(force: true) } } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.clockwise") + .font(.tjScaled( 11, weight: .semibold)) + Text(text == nil ? String(appLoc: "解读") : String(appLoc: "重新解读")) + .font(.tjScaled( 12, weight: .semibold)) + } + .foregroundStyle(Tj.Palette.ink) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Capsule().fill(Tj.Palette.sand2)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + } } if let text { Text(text) @@ -435,15 +472,14 @@ private struct TrendInsightCard: View { .foregroundStyle(Tj.Palette.text3) AIFlowBar() } else if let failedMessage { - HStack { - Text(failedMessage) - .font(.tjScaled( 12)) - .foregroundStyle(Tj.Palette.text3) - Spacer() - Button("重试") { Task { await load(force: true) } } - .font(.tjScaled( 12, weight: .medium)) - .foregroundStyle(Tj.Palette.ink) - } + Text(failedMessage) + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text3) + } else { + // 空闲态(偶发没自动触发):给明确引导,点右上即可生成。 + Text("点右上「解读」生成本地趋势解读") + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text3) } } .padding(14) diff --git a/康康/Features/Trends/TrendRow.swift b/康康/Features/Trends/TrendRow.swift index 8eaac38..724a369 100644 --- a/康康/Features/Trends/TrendRow.swift +++ b/康康/Features/Trends/TrendRow.swift @@ -54,6 +54,19 @@ struct TrendRow: View { private var sparkline: some View { Chart { + // 单条线时,线下垫一层渐变面积,迷你图也更有体量、不发生硬。 + if bucket.lines.count == 1, let line = bucket.lines.first { + ForEach(line.points) { p in + AreaMark( + x: .value("t", p.date), + y: .value(line.label ?? bucket.title, p.value) + ) + .foregroundStyle(LinearGradient( + colors: [line.color.opacity(0.18), line.color.opacity(0)], + startPoint: .top, endPoint: .bottom)) + .interpolationMethod(.monotone) + } + } ForEach(bucket.lines) { line in ForEach(line.points) { p in LineMark( @@ -62,8 +75,9 @@ struct TrendRow: View { series: .value("s", line.id) ) .foregroundStyle(line.color) - .interpolationMethod(.catmullRom) - .lineStyle(StrokeStyle(lineWidth: 1.6)) + // monotone + 圆角端点:迷你曲线更柔,无尖角无过冲。 + .interpolationMethod(.monotone) + .lineStyle(StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round)) } } // 最新点高亮 diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings index 087c347..22ea4ec 100644 --- a/康康/Localizable.xcstrings +++ b/康康/Localizable.xcstrings @@ -3022,6 +3022,9 @@ } } } + }, + "健康趋势" : { + }, "像扫描文档一样翻页拍摄" : { "extractionState" : "stale", @@ -3427,6 +3430,7 @@ }, "最近记录" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4590,6 +4594,9 @@ } } } + }, + "基线" : { + }, "填写%@" : { @@ -4732,6 +4739,9 @@ } } } + }, + "夜深了,记得早点休息" : { + }, "大" : { @@ -6825,9 +6835,6 @@ } } } - }, - "拍一张含目标指标的照片 · 拍完再框选" : { - }, "拍到的局部" : { @@ -7956,6 +7963,9 @@ } } } + }, + "新的一天,慢慢来" : { + }, "无参考范围" : { "localizations" : { @@ -9139,6 +9149,9 @@ } } } + }, + "模型未就绪,前往「模型管理」下载后可用" : { + }, "模型未就绪时 App 仍可使用,AI 功能会提示前往下载。" : { "localizations" : { @@ -9633,6 +9646,9 @@ }, "添加药品" : { + }, + "点右上「解读」生成本地趋势解读" : { + }, "点图放大" : { @@ -9971,6 +9987,9 @@ } } } + }, + "用上方选中的引擎跑固定 prompt,实测 prefill / 生成 tok/s" : { + }, "用于自动判定 正常/偏高/偏低" : { "localizations" : { @@ -10093,6 +10112,9 @@ } } } + }, + "症状 · %lld 进行中" : { + }, "症状 · 已结束" : { "localizations" : { @@ -11091,6 +11113,9 @@ }, "解析失败:%@" : { + }, + "解读" : { + }, "解锁康康,查看你的健康档案" : { "localizations" : { @@ -11160,6 +11185,9 @@ } } } + }, + "记录一次服用" : { + }, "记录什么?" : { "localizations" : { @@ -11325,6 +11353,12 @@ } } } + }, + "记得起身活动一下" : { + + }, + "记某次吃药的剂量和时间,会进「记录 · 用药」时间线。不提供剂量建议。" : { + }, "记症状" : { @@ -11976,6 +12010,9 @@ }, "轻点打开新建菜单,长按语音直达" : { + }, + "轻点画面对焦 · 拍完再框选" : { + }, "载脂蛋白 A1" : { "extractionState" : "stale", @@ -12218,6 +12255,7 @@ } }, "还没有任何记录,点底部 + 号开始第一条" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -12544,6 +12582,9 @@ } } } + }, + "重新解读" : { + }, "重新识别" : { "localizations" : { diff --git a/康康/RootView.swift b/康康/RootView.swift index 3e6b9d2..1c97e51 100644 --- a/康康/RootView.swift +++ b/康康/RootView.swift @@ -42,10 +42,14 @@ struct RootView: View { @State private var tab: TjTab = .home /// 页面 push 过渡的来向:切到右侧 tab 时从 trailing 推入,切到左侧时从 leading 推入。 @State private var pushEdge: Edge = .trailing + /// 切到记录页时预选的分类 chip。首页「我的报告档案」进入时设 `.report`,普通点 tab 清空。 + @State private var pendingRecordsFilter: TimelineKind? @State private var showRecordSheet = false @State private var activeFlow: ActiveFlow? @State private var showSymptomStart = false @State private var showDiary = false + /// 语音「写日记」直达:跳过日记 sheet 顶部入口选择,光标直接落到正文。 + @State private var diaryDirectWrite = false @State private var showIndicator = false @State private var showReminders = false @State private var showHealthExport = false @@ -59,7 +63,7 @@ struct RootView: View { /// 语音意图 → 打开对应新建入口(与 RecordSheet onPick 的路由一一对应)。 private func route(_ intent: VoiceIntent) { switch intent { - case .diary: showDiary = true + case .diary: diaryDirectWrite = true; showDiary = true case .medication: showMedicationScan = true case .symptom: showSymptomStart = true case .indicator: showIndicator = true @@ -81,8 +85,11 @@ struct RootView: View { VStack(spacing: 0) { Group { switch tab { - case .home: HomeView(onTapArchive: { select(.records) }) - case .records: ArchiveListView() + case .home: HomeView(onTapArchive: { kind in + pendingRecordsFilter = kind + select(.records) + }) + case .records: ArchiveListView(initialFilter: pendingRecordsFilter) case .trend: TrendsView() case .me: MeView() } @@ -92,7 +99,11 @@ struct RootView: View { .transition(.push(from: pushEdge)) TabBar(active: tab, - onTap: { select($0) }, + onTap: { + // 通过底部 tab 进记录页时清空预选,只有报告档案卡才带 .report 进入。 + if $0 == .records { pendingRecordsFilter = nil } + select($0) + }, onTapRecord: { showRecordSheet = true }, onLongPressRecord: { showVoiceCommand = true }) } @@ -110,7 +121,7 @@ struct RootView: View { case .quick: activeFlow = .quick case .archive: activeFlow = .archive case .symptom: showSymptomStart = true - case .diary: showDiary = true + case .diary: diaryDirectWrite = false; showDiary = true case .indicator: showIndicator = true case .reminder: showReminders = true case .healthExport: showHealthExport = true @@ -123,7 +134,7 @@ struct RootView: View { SymptomStartSheet() } .sheet(isPresented: $showDiary) { - DiaryQuickSheet() + DiaryQuickSheet(directWrite: diaryDirectWrite) } .sheet(isPresented: $showIndicator) { // 「拍照识别」入口:关闭手输表单 → 打开指标速记 VL 流程(并入「记录指标」)。 @@ -232,7 +243,7 @@ private struct TabBar: View { .fill(Tj.Palette.lineSoft) .frame(height: 1) } - .shadow(color: Tj.Palette.ink.opacity(0.05), radius: 10, x: 0, y: -2) + .shadow(color: Tj.Palette.shadow.opacity(0.07), radius: 10, x: 0, y: -2) } private func tabItem(_ t: TjTab) -> some View { @@ -273,8 +284,8 @@ private struct TabBar: View { Circle() .strokeBorder(Tj.Palette.paper, lineWidth: 2) ) - .shadow(color: Tj.Palette.ink.opacity(0.18), - radius: 4, x: 0, y: 2) + .shadow(color: Tj.Palette.shadow.opacity(0.20), + radius: 5, x: 0, y: 2) Image(systemName: "plus") .font(.tjScaled( 16, weight: .semibold)) diff --git a/康康/Security/LockScreenView.swift b/康康/Security/LockScreenView.swift index 8f882ad..ac6e3c0 100644 --- a/康康/Security/LockScreenView.swift +++ b/康康/Security/LockScreenView.swift @@ -29,7 +29,7 @@ struct LockScreenView: View { .foregroundStyle(Tj.Palette.ink) } .frame(width: 92, height: 92) - .shadow(color: Tj.Palette.ink.opacity(0.06), radius: 12, y: 4) + .shadow(color: Tj.Palette.shadow.opacity(0.08), radius: 12, y: 4) VStack(spacing: 6) { Text("康康 已锁定") diff --git a/康康/Services/DiaryAssistService.swift b/康康/Services/DiaryAssistService.swift index 5f2ed52..c1f1797 100644 --- a/康康/Services/DiaryAssistService.swift +++ b/康康/Services/DiaryAssistService.swift @@ -64,27 +64,61 @@ struct DiaryAssistService { } let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions) - var collected = "" - var lastRate: Double = 0 - let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400) - for try await chunk in stream { - collected += chunk.text - if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } - } - // 1. 去 ...(复用 HealthExportService 的兜底) - let stripped = HealthExportService.stripThinkBlocks(collected) - // 2. 抠出第一段平衡 JSON(复用 CaptureService.extractJSONObject)+ 弱模型畸形修复 - let jsonStr = CaptureService.repairJSON(CaptureService.extractJSONObject(from: stripped)) - guard let data = jsonStr.data(using: .utf8), - let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]), - let dict = obj as? [String: Any] else { - throw AssistError.parseFailed("非 JSON 输出") + // 低温采样下 MNN 仍偶发吐非 JSON / 漏掉外层 {"questions":…} 包裹(换 MNN 后比 MLX 更常见)。 + // 首次解析不出就自动重试一次,两次都失败才报错 —— 守 §10.5「失败回退,不让用户卡在 AI 错误屏」。 + var lastRate: Double = 0 + var parsedButEmpty = false + var lastRaw = "" + for _ in 0..<2 { + try Task.checkCancellation() + var collected = "" + let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400) + for try await chunk in stream { + collected += chunk.text + if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } + } + lastRaw = collected + if let questions = Self.parseQuestions(from: collected) { + if !questions.isEmpty { + return (Array(questions.prefix(4)), lastRate) + } + parsedButEmpty = true // JSON 合法但没解析出问题:重试一次,仍空就当 .empty + } } - guard let rawQuestions = dict["questions"] as? [[String: Any]] else { - throw AssistError.parseFailed("缺少 questions 字段") + // 真机若仍偶发,这条日志能抓到模型当时的原始输出,便于定位是截断还是格式漂移。 + #if DEBUG + print("[DiaryAssistService] 解析失败,原始输出 = \(lastRaw)") + #endif + throw parsedButEmpty ? AssistError.empty : AssistError.parseFailed("非 JSON 输出") + } + + /// 从模型原始输出解析追问数组。容错链(对齐 §3.2 失败回退): + /// 去 `` → 抠平衡 JSON → 修弱模型畸形 → 先按 `{"questions":[…]}`, + /// 再退到裸数组 `[{…}]`(MNN 偶尔漏外层包裹)。彻底解析不出返回 nil(调用方据此重试/报错)。 + /// 解析成功但无可用问题返回 `[]`(与 nil 区分:调用方报 .empty 而非 .parseFailed)。 + static func parseQuestions(from raw: String) -> [Question]? { + let stripped = HealthExportService.stripThinkBlocks(raw) + + var rawQuestions: [[String: Any]]? + // ① 标准结构 {"questions":[…]} + let objStr = CaptureService.repairJSON(CaptureService.extractJSONObject(from: stripped)) + if let data = objStr.data(using: .utf8), + let dict = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let arr = dict["questions"] as? [[String: Any]] { + rawQuestions = arr } - let questions = rawQuestions.compactMap { d -> Question? in + // ② 退路:模型漏了外层包裹,直接吐 [{…},{…}] + if rawQuestions == nil { + let arrStr = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: stripped)) + if let data = arrStr.data(using: .utf8), + let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] { + rawQuestions = arr + } + } + guard let rawQuestions else { return nil } + + return rawQuestions.compactMap { d -> Question? in guard let q = (d["q"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else { return nil @@ -95,8 +129,6 @@ struct DiaryAssistService { .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return Question(q: q, fill: fill, dim: dim) } - guard !questions.isEmpty else { throw AssistError.empty } - return (Array(questions.prefix(4)), lastRate) } /// 把语音转写稿整理成健康日记草稿(spec 2026-06-10-voice-diary)。 diff --git a/康康/Services/SpeechDictationService.swift b/康康/Services/SpeechDictationService.swift index 2593dd8..43c04ca 100644 --- a/康康/Services/SpeechDictationService.swift +++ b/康康/Services/SpeechDictationService.swift @@ -124,7 +124,8 @@ final class SpeechDictationService { /// 停止录音,等待最终识别结果(最多 1.5s,超时用最新 partial),返回最终稿。 /// 中途识别出错时已拿到的 partial 一样返回(spec 错误表:照常进整理流程)。 func stop() async -> String { - guard isRecording else { return "" } + // 已非录音态(识别已自行 final / 重复调用):仍返回已捕获文本,别丢内容。 + guard isRecording else { return latestText } isRecording = false audioEngine.stop() diff --git a/康康/Services/VoiceIntentService.swift b/康康/Services/VoiceIntentService.swift index 4b9b04a..30790a3 100644 --- a/康康/Services/VoiceIntentService.swift +++ b/康康/Services/VoiceIntentService.swift @@ -6,7 +6,7 @@ enum VoiceIntent: String, CaseIterable, Sendable { } /// 语音意图分类服务:LLM(MNN/SME2 主链路)优先,6 秒超时或失败回退到关键词匹配(§3.2)。 -/// 两路都不中返回 nil,UI 走「没听懂 → 再说一次 / 打开新建菜单」。 +/// 关键词路兜底默认 diary(日记是最常见、最自由的入口),只有明确命中其它意图才离开 diary。 /// 无状态,与 OCRService 同款 enum 形态;UI 不直接碰 AIRuntime(§3.1)。 /// nonisolated:模块默认 MainActor,这里全是纯函数 + await,不需要主线程(测试也好调)。 nonisolated enum VoiceIntentService { @@ -58,23 +58,44 @@ nonisolated enum VoiceIntentService { // MARK: - 关键词回退(纯函数,单测覆盖) /// 规则有序:先命中先赢。「提醒我吃药」必须归 reminder,所以 reminder 排最前。 - static func keywordMatch(_ text: String) -> VoiceIntent? { + /// symptom 只保留**明确的具体症状词**(头疼、咳嗽、发烧…),不再收「疼/痛/不舒服/难受」 + /// 这类泛词——它们更多出现在日常记录里,会把日记误判成症状。都不中时默认 diary。 + static func keywordMatch(_ text: String) -> VoiceIntent { let t = text.lowercased() + // archive 只收**强归档信号**(化验单 / 体检报告 / 归档存档),不再收裸「报告」「体检」—— + // 后者太宽,「下周去体检」「医生说报告没问题」会被误判成 archive 而直接弹相机。 let rules: [(VoiceIntent, [String])] = [ (.reminder, ["提醒", "别忘", "闹钟"]), (.medication, ["药盒", "用药", "吃药", "吃了药", "服药", "药品", "降压药", "胰岛素"]), - (.archive, ["报告", "化验单", "体检", "归档"]), + (.archive, ["化验单", "化验报告", "检查报告", "检验报告", "体检报告", "归档", "存档"]), (.export, ["身体档案", "给医生", "健康总结", "导出"]), (.indicator, ["血压", "血糖", "体重", "心率", "体温", "尿酸", "血脂", "指标", "高压", "低压"]), - (.symptom, ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼", "疼", "痛", - "咳嗽", "发烧", "发热", "头晕", "恶心", "不舒服", "难受", "拉肚子", "失眠"]), - (.diary, ["日记", "今天", "心情", "感觉", "睡得", "吃了"]), + (.symptom, ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼", + "咳嗽", "发烧", "发热", "头晕", "恶心", "拉肚子"]), ] - for (intent, keys) in rules where keys.contains(where: { t.contains($0) }) { - return intent + for (intent, keys) in rules { + for key in keys where t.contains(key) { + // 相机类意图(拍药盒 / 拍报告归档)额外要求关键词**没被否定**: + // 「没吃药」「忘了吃药」不该弹相机,放它落到 diary。表单类意图不设此限。 + if intent == .medication || intent == .archive, isNegated(t, keyword: key) { + continue + } + return intent + } } - return nil + // 兜底默认日记:语音直达最常见的就是随口记一句今天的状态。 + return .diary + } + + /// 关键词命中点前两个字若含否定/遗忘词,视为「这事没真发生」,不命中。 + /// 仅用于相机类意图,避免「没吃药」「忘了吃药」「不用吃药」误弹相机。 + private static let negationMarkers: Set = ["没", "不", "别", "忘", "甭", "未", "免"] + + static func isNegated(_ text: String, keyword: String) -> Bool { + guard let range = text.range(of: keyword) else { return false } + let preceding = text[..嗯,用户写了头痛,我应该问") == nil) + } +} diff --git a/康康Tests/VoiceIntentServiceTests.swift b/康康Tests/VoiceIntentServiceTests.swift index 074314b..9101277 100644 --- a/康康Tests/VoiceIntentServiceTests.swift +++ b/康康Tests/VoiceIntentServiceTests.swift @@ -47,7 +47,33 @@ struct VoiceIntentServiceTests { #expect(VoiceIntentService.keywordMatch("写个日记") == .diary) } - @Test func gibberishReturnsNil() { - #expect(VoiceIntentService.keywordMatch("啦啦啦啦") == nil) + @Test func unmatchedDefaultsToDiary() { + // 不明确命中其它意图时,兜底进日记(最常见、最自由的入口) + #expect(VoiceIntentService.keywordMatch("啦啦啦啦") == .diary) + #expect(VoiceIntentService.keywordMatch("今天感觉不太舒服") == .diary) + #expect(VoiceIntentService.keywordMatch("有点难受") == .diary) + } + + // MARK: - 相机类意图的误开防护(本次修复重点) + + @Test func negatedMedicationDoesNotOpenCamera() { + // 「没吃药 / 忘了吃药 / 不用吃药」不该归 medication(会弹拍药盒相机),落 diary + #expect(VoiceIntentService.keywordMatch("今天太忙,忘了吃药") == .diary) + #expect(VoiceIntentService.keywordMatch("我今天没吃药") == .diary) + #expect(VoiceIntentService.keywordMatch("医生说先不用吃药") == .diary) + } + + @Test func casualReportMentionDoesNotOpenCamera() { + // 顺口提到体检/报告,不是要拍报告归档,不该弹文档相机 + #expect(VoiceIntentService.keywordMatch("下周打算去做个体检") == .diary) + #expect(VoiceIntentService.keywordMatch("医生说我报告没什么大问题") == .diary) + } + + @Test func genuineCameraIntentsStillMatch() { + // 真实的拍药盒 / 归档意图仍要正确命中 + #expect(VoiceIntentService.keywordMatch("拍个药盒") == .medication) + #expect(VoiceIntentService.keywordMatch("我吃了降压药,记一下") == .medication) + #expect(VoiceIntentService.keywordMatch("把体检报告存进去") == .archive) + #expect(VoiceIntentService.keywordMatch("这张化验单归档") == .archive) } }