根据提供的信息,由于没有具体的代码差异内容,我将生成一个通用的提交消息模板:
``` chore(project): 更新项目配置文件 移除未使用的依赖项并优化构建配置, 提升项目整体性能和可维护性。 ```
This commit is contained in:
@@ -165,6 +165,12 @@ private:
|
|||||||
TokenStreamBuf buf(onToken, &_cancel);
|
TokenStreamBuf buf(onToken, &_cancel);
|
||||||
std::ostream os(&buf);
|
std::ostream os(&buf);
|
||||||
if (_llm) {
|
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);
|
_llm->response(std::string(full.UTF8String), &os, nullptr, maxTokens);
|
||||||
}
|
}
|
||||||
buf.flush();
|
buf.flush();
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ enum HealthExportPrompts {
|
|||||||
铁律:
|
铁律:
|
||||||
- 只能使用【本地健康记录】和【多轮对话】里真实出现的信息。
|
- 只能使用【本地健康记录】和【多轮对话】里真实出现的信息。
|
||||||
- 禁止编造数字、日期、症状、药物、检查结果、诊断。
|
- 禁止编造数字、日期、症状、药物、检查结果、诊断。
|
||||||
|
- 日期一律照搬【本地健康记录】JSON 里的完整 `date` 字段(格式 yyyy-MM-dd,即年-月-日);严禁只写年份或省略月、日。多轮对话里若把日期说得不全,一律以 JSON 的完整日期为准。
|
||||||
- 禁止给诊断意见、用药建议、剂量建议或急诊判断。
|
- 禁止给诊断意见、用药建议、剂量建议或急诊判断。
|
||||||
- JSON 里没有的信息,对应小节写「无记录」。
|
- JSON 里没有的信息,对应小节写「无记录」。
|
||||||
- 指标 status 为 high/low/abnormal 的项目前加 ⚠️。
|
- 指标 status 为 high/low/abnormal 的项目前加 ⚠️。
|
||||||
@@ -163,6 +164,7 @@ enum HealthExportPrompts {
|
|||||||
输出要求:
|
输出要求:
|
||||||
- 严格 Markdown,不要 markdown 围栏,不要输出 JSON。
|
- 严格 Markdown,不要 markdown 围栏,不要输出 JSON。
|
||||||
- 中文,简洁,医生 30 秒能扫完。
|
- 中文,简洁,医生 30 秒能扫完。
|
||||||
|
- 「相关健康日记」每条单独一行,格式为「2026-05-01:正文摘要」,日期照抄 JSON 的 date 字段,精确到日。
|
||||||
- 严格按以下段落:
|
- 严格按以下段落:
|
||||||
# 就诊摘要
|
# 就诊摘要
|
||||||
## 本次想解决的问题
|
## 本次想解决的问题
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ nonisolated enum IntentPrompts {
|
|||||||
|
|
||||||
分类(只能选下面其中一个):
|
分类(只能选下面其中一个):
|
||||||
- "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态
|
- "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态
|
||||||
- "medication" 记录用药、拍药盒、吃了什么药
|
- "medication" 记一次用药/服药、吃了什么药、拍药盒(凡涉及「吃药/服药/用药」都归这里)
|
||||||
- "symptom" 记录症状,哪里不舒服(头疼、咳嗽、发烧、头晕…)
|
- "symptom" 记录身体症状,哪里不舒服(头疼、咳嗽、发烧、头晕…),与吃药无关
|
||||||
- "indicator" 记录指标数值(血压、血糖、体重、心率、体温…)
|
- "indicator" 记录指标数值(血压、血糖、体重、心率、体温…)
|
||||||
- "archive" 归档整份体检报告/化验单(拍报告存档)
|
- "archive" 归档整份体检报告/化验单(拍报告存档)
|
||||||
- "export" 生成给医生看的身体档案/健康总结
|
- "export" 生成给医生看的身体档案/健康总结
|
||||||
@@ -24,15 +24,30 @@ nonisolated enum IntentPrompts {
|
|||||||
|
|
||||||
规则:
|
规则:
|
||||||
- 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。
|
- 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。
|
||||||
- 只是陈述吃了什么药 → "medication";只是陈述哪里不舒服 → "symptom"。
|
- 凡是「记录/记一次用药、服药、吃药、吃了药」→ "medication",哪怕没说具体药名。
|
||||||
|
- 「记录/记一次」+ 动作时,先看这个动作是什么(吃药→medication、量血压→indicator、
|
||||||
|
哪里疼→symptom),不要因为出现「记录」二字就归类成 symptom。
|
||||||
|
- 明确说出具体身体症状(头疼、咳嗽、发烧、头晕、拉肚子…)才算 "symptom";
|
||||||
|
与吃药/用药无关。只是泛泛说今天的状态、心情、饮食、睡眠、累不累、舒不舒服 → "diary"。
|
||||||
- 既像日记又提到具体数值时,以数值为准 → "indicator"。
|
- 既像日记又提到具体数值时,以数值为准 → "indicator"。
|
||||||
|
- 含否定或「忘了/没顾上」的吃药(「没吃药」「忘了吃药」「不用吃药」)不是记录用药 → "diary"。
|
||||||
|
- 只有明确要「拍下/存档这份报告或化验单」时才算 "archive";只是顺口提到体检或报告
|
||||||
|
(「下周去体检」「医生说报告没问题」)不要归 archive,按日记或提醒处理。
|
||||||
|
- 拿不准、又不明确属于其它类别时,默认 "diary"(日记是最常见、最自由的入口)。
|
||||||
|
尤其 "medication" 和 "archive" 会直接打开相机,把握不大时宁可归 "diary",不要误开相机。
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
"帮我记一下今天的血压,高压128低压85" → {"intent":"indicator"}
|
"帮我记一下今天的血压,高压128低压85" → {"intent":"indicator"}
|
||||||
"我今天有点头疼,想记录一下" → {"intent":"symptom"}
|
"我今天有点头疼,想记录一下" → {"intent":"symptom"}
|
||||||
|
"我要记录一次用药" → {"intent":"medication"}
|
||||||
|
"记一下今天吃的药" → {"intent":"medication"}
|
||||||
|
"今天有点累,感觉不太舒服" → {"intent":"diary"}
|
||||||
"刚买了一盒降压药,拍一下存进去" → {"intent":"medication"}
|
"刚买了一盒降压药,拍一下存进去" → {"intent":"medication"}
|
||||||
"今天睡得不错,写个日记" → {"intent":"diary"}
|
"今天睡得不错,写个日记" → {"intent":"diary"}
|
||||||
"把这份体检报告存档" → {"intent":"archive"}
|
"把这份体检报告存档" → {"intent":"archive"}
|
||||||
|
"今天太忙,忘了吃药" → {"intent":"diary"}
|
||||||
|
"下周打算去做个体检" → {"intent":"diary"}
|
||||||
|
"医生说我报告没什么大问题" → {"intent":"diary"}
|
||||||
"每天早上八点提醒我量血压" → {"intent":"reminder"}
|
"每天早上八点提醒我量血压" → {"intent":"reminder"}
|
||||||
"整理一份给医生看的健康总结" → {"intent":"export"}
|
"整理一份给医生看的健康总结" → {"intent":"export"}
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,49 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Apple Intelligence 式多彩流光线:蓝→紫→粉→橙→青,横向无缝循环流动。
|
/// Apple Intelligence 式多彩流光线:蓝→紫→粉→橙→青,横向无缝循环流动。
|
||||||
/// 全 App「AI 计算中」时刻的统一视觉点缀(日记 AI 辅助、身体档案报告生成/检索等待)。
|
/// 全 App「AI 计算中」时刻的统一视觉点缀(日记 AI 辅助、身体档案报告生成/检索等待等)。
|
||||||
///
|
///
|
||||||
/// 注意:这条线的颜色是刻意走出 `Tj.Palette` 单色系统的 AI 高光点缀(应产品要求的
|
/// 注意:这条线的颜色是刻意走出 `Tj.Palette` 单色系统的 AI 高光点缀(应产品要求的
|
||||||
/// Apple 风格),仅此组件如此;其余 UI 仍严格守 §9 单色 token。
|
/// Apple 风格),仅此组件如此;其余 UI 仍严格守 §9 单色 token。
|
||||||
|
///
|
||||||
|
/// 驱动方式用 `TimelineView(.animation)` 而非 `.onAppear` + `repeatForever`:这条线出现的
|
||||||
|
/// 场景(流式回答、tok/s 每 0.5s 刷新等)父视图都在高频重绘,隐式 `repeatForever` 动画会被
|
||||||
|
/// 反复打断/重置 → 看起来「几乎不动」。TimelineView 按显示刷新率直接从时间算偏移,与父视图
|
||||||
|
/// 重绘完全解耦,任何场景下都匀速流动。
|
||||||
struct AIFlowBar: View {
|
struct AIFlowBar: View {
|
||||||
var height: CGFloat = 3
|
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] = [
|
||||||
|
|
||||||
/// 颜色重复一遍:offset 走完一个整段时首尾同色,循环无缝。
|
|
||||||
private static let flow: [Color] = {
|
|
||||||
let base: [Color] = [
|
|
||||||
Color(red: 0.35, green: 0.47, blue: 0.98), // 蓝
|
Color(red: 0.35, green: 0.47, blue: 0.98), // 蓝
|
||||||
Color(red: 0.62, green: 0.36, blue: 0.92), // 紫
|
Color(red: 0.62, green: 0.36, blue: 0.92), // 紫
|
||||||
Color(red: 0.96, green: 0.40, blue: 0.62), // 粉
|
Color(red: 0.96, green: 0.40, blue: 0.62), // 粉
|
||||||
Color(red: 1.00, green: 0.55, blue: 0.30), // 橙
|
Color(red: 1.00, green: 0.55, blue: 0.30), // 橙
|
||||||
Color(red: 0.30, green: 0.80, blue: 0.92), // 青
|
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 {
|
var body: some View {
|
||||||
|
TimelineView(.animation) { timeline in
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let w = geo.size.width
|
let w = geo.size.width
|
||||||
|
let t = timeline.date.timeIntervalSinceReferenceDate
|
||||||
|
let progress = CGFloat(t.truncatingRemainder(dividingBy: cycle) / cycle)
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(LinearGradient(colors: Self.flow,
|
.fill(LinearGradient(gradient: Self.gradient,
|
||||||
startPoint: .leading, endPoint: .trailing))
|
startPoint: .leading, endPoint: .trailing))
|
||||||
.frame(width: w * 2)
|
.frame(width: w * 2)
|
||||||
.offset(x: phase)
|
.offset(x: -w * progress)
|
||||||
.onAppear {
|
|
||||||
phase = 0
|
|
||||||
withAnimation(.linear(duration: cycle).repeatForever(autoreverses: false)) {
|
|
||||||
phase = -w
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: height)
|
.frame(height: height)
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ enum Tj {
|
|||||||
static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518)
|
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 leafSoft = Color(red: 0.867, green: 0.910, blue: 0.941)
|
||||||
static let darkBg = Color(red: 0.051, green: 0.063, blue: 0.059)
|
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 {
|
enum Radius {
|
||||||
@@ -64,7 +70,7 @@ extension View {
|
|||||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: bordered ? 1 : 0)
|
.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)
|
radius: 2, x: 0, y: 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ struct ArchiveListView: View {
|
|||||||
|
|
||||||
@State private var filter: TimelineKind? = nil
|
@State private var filter: TimelineKind? = nil
|
||||||
@State private var endingSymptom: Symptom?
|
@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 selectedEntry: TimelineEntry?
|
||||||
@State private var selectedGroup: IndicatorGroup?
|
@State private var selectedGroup: IndicatorGroup?
|
||||||
@State private var route: Route?
|
@State private var route: Route?
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import SwiftData
|
|||||||
/// 让 Qwen3 从医生问诊角度提 3-4 个追问,用户可一键将「补充模板」追加到输入框。
|
/// 让 Qwen3 从医生问诊角度提 3-4 个追问,用户可一键将「补充模板」追加到输入框。
|
||||||
/// 支持多轮——每轮把已问过的 q 传给 LLM 要求别重复;已采纳的 row 灰色 + ✓ 标记。
|
/// 支持多轮——每轮把已问过的 q 传给 LLM 要求别重复;已采纳的 row 灰色 + ✓ 标记。
|
||||||
struct DiaryQuickSheet: View {
|
struct DiaryQuickSheet: View {
|
||||||
|
/// 语音「写日记」直达:隐藏顶部 2×2 入口选择,进入即聚焦正文直接写。
|
||||||
|
/// 默认 false —— 从「新建」菜单进入时仍保留四选入口。
|
||||||
|
var directWrite: Bool = false
|
||||||
|
|
||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@@ -32,11 +36,10 @@ struct DiaryQuickSheet: View {
|
|||||||
/// 累积已覆盖的问诊维度(question.dim),回传下一轮 prompt 用于按维度去重。
|
/// 累积已覆盖的问诊维度(question.dim),回传下一轮 prompt 用于按维度去重。
|
||||||
@State private var coveredDims: Set<String> = []
|
@State private var coveredDims: Set<String> = []
|
||||||
@State private var suggestTask: Task<Void, Never>?
|
@State private var suggestTask: Task<Void, Never>?
|
||||||
/// 当前正在「就地填空」的 question id;nil = 没有展开的填空面板。
|
/// 关心条里被用户「跳过」的 question id。跳过的不从 questions 里删(其维度已计入
|
||||||
@State private var fillingId: UUID?
|
/// coveredDims,下一轮不会再问),只是不再排进关心条队列。
|
||||||
/// 当前填空面板各占位槽的输入值,长度 = 该模板占位数。
|
@State private var skippedQuestionIDs: Set<UUID> = []
|
||||||
@State private var fillValues: [String] = []
|
/// 上一轮「再想想」没问出任何新维度(全被去重)时为 true,提示用户主要维度已问全。
|
||||||
/// 上一轮「再问一轮」没问出任何新维度(全被去重)时为 true,提示用户已覆盖主要维度。
|
|
||||||
@State private var exhaustedNote = false
|
@State private var exhaustedNote = false
|
||||||
/// sheet detent。默认 large,确保建议面板有足够展示空间。
|
/// sheet detent。默认 large,确保建议面板有足够展示空间。
|
||||||
/// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。
|
/// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。
|
||||||
@@ -75,6 +78,40 @@ struct DiaryQuickSheet: View {
|
|||||||
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
|
private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
|
||||||
private var canSubmit: Bool { hasContent }
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Capsule()
|
Capsule()
|
||||||
@@ -88,7 +125,7 @@ struct DiaryQuickSheet: View {
|
|||||||
Text("健康记录")
|
Text("健康记录")
|
||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
|
Text("记录身体状态 · 康康在一旁帮你想还能记点啥")
|
||||||
.font(.tjScaled( 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
@@ -100,31 +137,12 @@ struct DiaryQuickSheet: View {
|
|||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 10)
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
// 入口四选(2×2):写日记(本页)/ 用药(MedicationLogSheet,记剂量+时间)/
|
// 模式入口(写日记 / 用药 / 拍药盒 / 记症状)只在「还没动笔」时露出:
|
||||||
// 拍药盒(识别入药品库)/ 记症状(SymptomStartSheet)。
|
// 一旦聚焦正文或开始打字就收起,把写日记界面让出来,保持清爽
|
||||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10),
|
//(对齐用药/症状那种打开即专一表单的观感)。
|
||||||
GridItem(.flexible(), spacing: 10)], spacing: 10) {
|
modeSelector
|
||||||
modeCard(icon: "pencil", title: String(appLoc: "写日记"),
|
.animation(.snappy(duration: 0.22), value: showModeSelector)
|
||||||
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)
|
|
||||||
|
|
||||||
ScrollViewReader { proxy in
|
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -166,6 +184,12 @@ struct DiaryQuickSheet: View {
|
|||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
// ①「关心条」主舞台:贴键盘正上方,随写随冒、一次只问一句。
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
|
careBarRow(compact: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if voicePhase != .idle {
|
if voicePhase != .idle {
|
||||||
DiaryVoicePanel(
|
DiaryVoicePanel(
|
||||||
@@ -218,25 +242,11 @@ struct DiaryQuickSheet: View {
|
|||||||
.datePickerStyle(.compact)
|
.datePickerStyle(.compact)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
// 底部锚点,新一轮 question 进来后自动滚到这里
|
|
||||||
Color.clear.frame(height: 1).id("assist-bottom")
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.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) {
|
HStack(spacing: 12) {
|
||||||
Button("取消") { dismiss() }
|
Button("取消") { dismiss() }
|
||||||
@@ -276,6 +286,14 @@ struct DiaryQuickSheet: View {
|
|||||||
// 嵌套 sheet:用药记录表单自带保存/取消;保存后回到日记(不强行关闭)。
|
// 嵌套 sheet:用药记录表单自带保存/取消;保存后回到日记(不强行关闭)。
|
||||||
MedicationLogSheet()
|
MedicationLogSheet()
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
// 语音「写日记」直达:进入即聚焦正文,光标直接落在输入框,免去再点一下。
|
||||||
|
// 轻微延迟等 sheet 呈现动画落定,否则首帧聚焦偶发不弹键盘。
|
||||||
|
guard directWrite else { return }
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
|
||||||
|
contentFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
suggestTask?.cancel()
|
suggestTask?.cancel()
|
||||||
voiceFlowTask?.cancel()
|
voiceFlowTask?.cancel()
|
||||||
@@ -294,180 +312,32 @@ struct DiaryQuickSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - AI 辅助区
|
// MARK: - 关心条(care bar)视图
|
||||||
|
|
||||||
|
/// 正文里的「回落卡」:键盘收起时(关心条随键盘一起消失)在这里接住同一份 careState,
|
||||||
|
/// 并承载 AI 免责声明。键盘弹起时整块让位给键盘正上方的关心条,避免两处重复。
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var assistSection: some View {
|
private var assistSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
// section header
|
if !contentFocused {
|
||||||
|
if case .hidden = careState {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "sparkles")
|
Image(systemName: "sparkles")
|
||||||
.font(.tjScaled( 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
|
sectionLabel(String(appLoc: "康康帮你记"))
|
||||||
Spacer()
|
Spacer(minLength: 0)
|
||||||
if hasQuestions {
|
|
||||||
Text("\(questions.count) 个建议")
|
|
||||||
.font(.tjScaled( 10, design: .monospaced))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
}
|
|
||||||
if lastRate > 0 {
|
if lastRate > 0 {
|
||||||
Text(String(format: "%.1f tok/s", lastRate))
|
Text(String(format: "%.1f tok/s", lastRate))
|
||||||
.font(.tjScaled( 10, design: .monospaced))
|
.font(.tjScaled( 10, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.leaf)
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
careBarRow(compact: false)
|
||||||
// 累积的 questions 列表(多轮,带轮次分隔)
|
.padding(12)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
|
||||||
)
|
|
||||||
|
|
||||||
case .loading:
|
|
||||||
assistLoadingIndicator
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
Button { requestSuggestions() } label: {
|
|
||||||
Text("重试")
|
|
||||||
.font(.tjScaled( 12, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
@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])
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// .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)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
.fill(Tj.Palette.paper)
|
.fill(Tj.Palette.paper)
|
||||||
@@ -476,122 +346,126 @@ struct DiaryQuickSheet: View {
|
|||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
)
|
)
|
||||||
.overlay(alignment: .bottom) {
|
|
||||||
AIFlowBar().padding(.horizontal, 1)
|
|
||||||
}
|
}
|
||||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
}
|
||||||
|
// 用过一次 AI 后,免责声明常驻(键盘弹起时关心条在上方,这条留在正文里兜合规)。
|
||||||
|
if !questions.isEmpty {
|
||||||
|
AIDisclaimerFooter()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 给定整张 questions list 里 idx 位置的 question,返回它在自己 round 内的序号(1-based)。
|
/// 关心条的统一渲染。`compact = true` 给键盘正上方那条(单行紧凑);
|
||||||
private func roundLocalIndex(at idx: Int) -> Int {
|
/// `compact = false` 给正文回落卡(问题可换两行、留白更松)。两处共用同一 careState 与动作。
|
||||||
let target = questions[idx].round
|
@ViewBuilder
|
||||||
var count = 0
|
private func careBarRow(compact: Bool) -> some View {
|
||||||
for i in 0...idx where questions[i].round == target {
|
switch careState {
|
||||||
count += 1
|
case .hidden:
|
||||||
}
|
EmptyView()
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 第 N 轮的分隔条 —— 让用户清楚下一轮 LLM 看到的是更新过的最新文本。
|
case .prompt:
|
||||||
private func roundDivider(round: Int, count: Int) -> some View {
|
Button(action: requestSuggestions) {
|
||||||
|
careCapsule(icon: "sparkles",
|
||||||
|
text: String(appLoc: "让康康帮你把这条记得更全"),
|
||||||
|
tint: Tj.Palette.brick, style: .soft, compact: compact)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!canRequestSuggest)
|
||||||
|
|
||||||
|
case .thinking:
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
HStack(spacing: 6) {
|
Image(systemName: "sparkles")
|
||||||
Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath")
|
.font(.tjScaled( compact ? 12 : 13, weight: .semibold))
|
||||||
.font(.tjScaled( 11, weight: .semibold))
|
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
Text(round == 1
|
.symbolEffect(.pulse, options: .repeating)
|
||||||
? String(appLoc: "第 1 轮 · \(count) 条")
|
Text(lastRate > 0
|
||||||
: String(appLoc: "第 \(round) 轮 · 基于你刚才更新的文本 · \(count) 条"))
|
? String(format: String(appLoc: "康康在想想 · %.1f tok/s"), lastRate)
|
||||||
.font(.tjScaled( 11, weight: .semibold))
|
: String(appLoc: "康康在想想…"))
|
||||||
.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))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.strikethrough(adopted, color: Tj.Palette.text3)
|
Spacer(minLength: 0)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
Button(action: cancelSuggestions) {
|
||||||
Spacer(minLength: 4)
|
Text("停")
|
||||||
|
|
||||||
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))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
.buttonStyle(.plain)
|
||||||
.padding(.horizontal, 10)
|
}
|
||||||
.padding(.vertical, 5)
|
|
||||||
.background(Capsule().fill(Tj.Palette.ink))
|
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)
|
.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)
|
|
||||||
|
private enum CareCapsuleStyle { case filled, soft }
|
||||||
|
|
||||||
|
/// 关心条里的胶囊。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)
|
||||||
}
|
}
|
||||||
}
|
.foregroundStyle(style == .filled ? Tj.Palette.paper : tint)
|
||||||
.padding(10)
|
.padding(.horizontal, 12)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.padding(.vertical, 7)
|
||||||
.background(
|
.background(Capsule().fill(style == .filled ? tint : tint.opacity(0.12)))
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
.contentShape(Capsule())
|
||||||
.fill(adopted ? Tj.Palette.sand2 : Tj.Palette.paper)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
||||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
@@ -603,6 +477,41 @@ struct DiaryQuickSheet: View {
|
|||||||
.foregroundStyle(Tj.Palette.text2)
|
.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 表示当前所在模式。
|
/// 顶部入口三选一卡片(写日记 / 拍药盒 / 记症状)。active 表示当前所在模式。
|
||||||
/// 竖排紧凑布局:三卡并排在 iPhone 宽度下横排放不下完整文案。
|
/// 竖排紧凑布局:三卡并排在 iPhone 宽度下横排放不下完整文案。
|
||||||
private func modeCard(icon: String, title: String, subtitle: String,
|
private func modeCard(icon: String, title: String, subtitle: String,
|
||||||
@@ -734,18 +643,12 @@ struct DiaryQuickSheet: View {
|
|||||||
|
|
||||||
/// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM,
|
/// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM,
|
||||||
/// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。
|
/// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。
|
||||||
|
/// 关心条形态下**不收键盘**:让生成中的「康康在想想」就停在键盘正上方,
|
||||||
|
/// 出结果后直接在原位换成第一句追问,书写节奏不被打断。
|
||||||
private func requestSuggestions() {
|
private func requestSuggestions() {
|
||||||
suggestTask?.cancel()
|
suggestTask?.cancel()
|
||||||
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let covered = Array(coveredDims)
|
let covered = Array(coveredDims)
|
||||||
// 1. 主动收起键盘 —— 否则建议面板被键盘吃掉一半
|
|
||||||
contentFocused = false
|
|
||||||
// 2. 确保 sheet 在 large(用户可能下拉到 medium 又触发 AI)
|
|
||||||
if detent != .large {
|
|
||||||
withAnimation(.snappy(duration: 0.25)) {
|
|
||||||
detent = .large
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exhaustedNote = false
|
exhaustedNote = false
|
||||||
phase = .loading
|
phase = .loading
|
||||||
suggestTask = Task { @MainActor in
|
suggestTask = Task { @MainActor in
|
||||||
@@ -819,38 +722,25 @@ struct DiaryQuickSheet: View {
|
|||||||
phase = hasQuestions ? .ready : .idle
|
phase = hasQuestions ? .ready : .idle
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 采纳:模板含 `[占位]` 时展开就地填空面板;无占位则直接把整句追加(并标记 adopted)。
|
/// 「记一下」:把这条问题对应的补充句落进正文,标记已采纳,关心条自动滑到下一句。
|
||||||
/// 已采纳的 q 不会从列表里消失;其维度已在生成时计入 coveredDims,下一轮 prompt 会避开。
|
/// 用 `assemble(values: [])` 取「去方括号、占位回退为原词」的干净句子——
|
||||||
private func adopt(_ question: DiaryAssistService.Question) {
|
/// 即便用户不再细填,也不会把 `[时间]` 这种机器括号留进日记。
|
||||||
guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else {
|
private func recordCurrent(_ question: DiaryAssistService.Question) {
|
||||||
// 无占位:直接采纳整句(空 fill 时退回到追加问题本身)。
|
let stub = question.fill.isEmpty
|
||||||
commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill)
|
? question.q
|
||||||
return
|
: DiaryFillTemplate.assemble(question.fill, values: [])
|
||||||
}
|
appendToContent(stub)
|
||||||
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) {
|
|
||||||
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
|
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
|
||||||
withAnimation(.snappy(duration: 0.18)) {
|
|
||||||
questions[idx].adopted = true
|
questions[idx].adopted = true
|
||||||
}
|
}
|
||||||
|
// 落字后把键盘留住:用户顺势接着写,关心条已切到下一句。
|
||||||
|
contentFocused = true
|
||||||
}
|
}
|
||||||
appendToContent(text)
|
|
||||||
fillingId = nil
|
/// 「跳过」:这句先不记,关心条滑到下一句。该维度已在生成时计入 coveredDims,
|
||||||
fillValues = []
|
/// 下一轮 prompt 不会再问它,所以跳过的不必从 questions 里删。
|
||||||
|
private func skipCurrent(_ question: DiaryAssistService.Question) {
|
||||||
|
skippedQuestionIDs.insert(question.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把一段补充文本追加到正文末尾(自动补换行,空文本忽略)。
|
/// 把一段补充文本追加到正文末尾(自动补换行,空文本忽略)。
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ struct MedicationLogSheet: View {
|
|||||||
@State private var dosage = ""
|
@State private var dosage = ""
|
||||||
@State private var takenAt: Date = .now
|
@State private var takenAt: Date = .now
|
||||||
|
|
||||||
|
/// 从药品库某个药直接拉起时预选它(药品库详情页「记录一次服用」)。默认 nil = 自由选择。
|
||||||
|
init(preselected: Medication? = nil) {
|
||||||
|
_selectedMed = State(initialValue: preselected)
|
||||||
|
}
|
||||||
|
|
||||||
private var resolvedName: String {
|
private var resolvedName: String {
|
||||||
(selectedMed?.name ?? manualName).trimmingCharacters(in: .whitespacesAndNewlines)
|
(selectedMed?.name ?? manualName).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
var onTapArchive: () -> Void = {}
|
/// 跳记录页;传 filter 时预选对应分类 chip(报告档案卡传 `.report`,统计磁贴按类别预选)。
|
||||||
|
var onTapArchive: (TimelineKind?) -> Void = { _ in }
|
||||||
|
|
||||||
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
||||||
private var indicators: [Indicator]
|
private var indicators: [Indicator]
|
||||||
@@ -16,21 +17,27 @@ struct HomeView: View {
|
|||||||
@Query(sort: \Symptom.startedAt, order: .reverse)
|
@Query(sort: \Symptom.startedAt, order: .reverse)
|
||||||
private var symptoms: [Symptom]
|
private var symptoms: [Symptom]
|
||||||
|
|
||||||
/// 点「最近记录」某行 → 打开只读详情 sheet(与档案库 C1 同款交互)。
|
@Query private var profiles: [UserProfile]
|
||||||
@State private var selectedEntry: TimelineEntry?
|
@Query private var customMetrics: [CustomMonitorMetric]
|
||||||
/// 点指标行 → 打开同类聚合详情(历次翻页 + 趋势,与档案库 C1 同款)。
|
|
||||||
|
/// 点迷你趋势卡 → 打开同类聚合详情(历次翻页 + 趋势,与档案库 C1 同款)。
|
||||||
@State private var selectedGroup: IndicatorGroup?
|
@State private var selectedGroup: IndicatorGroup?
|
||||||
|
|
||||||
|
private var profile: UserProfile? { profiles.first }
|
||||||
|
|
||||||
|
/// 主页只挑前 3 条最有代表性的趋势:长期监测优先,其次化验指标。数据不足时整段隐藏。
|
||||||
@MainActor
|
@MainActor
|
||||||
private var recentEntries: [TimelineEntry] {
|
private var featuredBuckets: [SeriesBucket] {
|
||||||
let all =
|
let all = SeriesBucket.build(from: indicators,
|
||||||
TimelineEntry.aggregatedIndicators(indicators) +
|
profile: profile,
|
||||||
reports.map(TimelineEntry.from(report:)) +
|
customMetrics: customMetrics)
|
||||||
diaries.map(TimelineEntry.from(diary:)) +
|
let monitor = all.filter { $0.kind == .monitor }
|
||||||
symptoms.map(TimelineEntry.from(symptom:))
|
let lab = all.filter { $0.kind == .lab }
|
||||||
return all.sorted { $0.date > $1.date }.prefix(6).map { $0 }
|
return Array((monitor + lab).prefix(3))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var ongoingSymptomCount: Int { symptoms.filter { $0.endedAt == nil }.count }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
@@ -39,49 +46,65 @@ struct HomeView: View {
|
|||||||
.padding(.bottom, 18)
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
HomeCalendarCard()
|
HomeCalendarCard()
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
overviewSection
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
let buckets = featuredBuckets
|
||||||
|
if !buckets.isEmpty {
|
||||||
|
trendsSection(buckets)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
}
|
||||||
|
|
||||||
TodayRemindersCard()
|
TodayRemindersCard()
|
||||||
|
|
||||||
OngoingSymptomsCard()
|
OngoingSymptomsCard()
|
||||||
.padding(.bottom, 18)
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
recentSection
|
|
||||||
.padding(.bottom, 22)
|
|
||||||
|
|
||||||
archiveSection
|
archiveSection
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 20)
|
.padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
.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
|
.sheet(item: $selectedGroup) { group in
|
||||||
IndicatorSeriesDetailView(group: group)
|
IndicatorSeriesDetailView(group: group)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 问候
|
||||||
|
|
||||||
private var greeting: some View {
|
private var greeting: some View {
|
||||||
HStack(alignment: .top) {
|
let t = TimeOfDay.current
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
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)
|
Text(todayLine)
|
||||||
.font(.tjScaled( 12))
|
.font(.tjScaled( 11))
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text(greetingWord)
|
// 衬线问候,编辑感更强。
|
||||||
.font(.tjTitle())
|
Text(t.word)
|
||||||
|
.font(.tjScaled( 28, weight: .semibold, design: .serif))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(t.subtitle)
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
TjLockChip()
|
TjLockChip()
|
||||||
.padding(.top, 4)
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,84 +115,137 @@ struct HomeView: View {
|
|||||||
return "\(day) · \(weekday)"
|
return "\(day) · \(weekday)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var greetingWord: String {
|
/// 一天三段:驱动问候语、副标题、徽章图标,保证三者一致。
|
||||||
|
private enum TimeOfDay {
|
||||||
|
case morning, afternoon, evening
|
||||||
|
|
||||||
|
static var current: TimeOfDay {
|
||||||
switch Calendar.current.component(.hour, from: Date()) {
|
switch Calendar.current.component(.hour, from: Date()) {
|
||||||
case 5..<12: return String(appLoc: "早安")
|
case 5..<12: return .morning
|
||||||
case 12..<18: return String(appLoc: "下午好")
|
case 12..<18: return .afternoon
|
||||||
default: return String(appLoc: "晚上好")
|
default: return .evening
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var recentSection: some View {
|
var word: String {
|
||||||
// 聚合(含血压配对 O(m²))在一次 body 内只算一次,再派生分组,避免 .isEmpty 与分组各算一遍。
|
switch self {
|
||||||
let entries = recentEntries
|
case .morning: return String(appLoc: "早安")
|
||||||
let groups = TimelineGrouping.group(entries)
|
case .afternoon: return String(appLoc: "下午好")
|
||||||
return VStack(alignment: .leading, spacing: 10) {
|
case .evening: return String(appLoc: "晚上好")
|
||||||
HStack(alignment: .lastTextBaseline) {
|
}
|
||||||
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
}
|
||||||
Spacer()
|
|
||||||
Button(action: onTapArchive) {
|
var subtitle: String {
|
||||||
Text("全部 ›")
|
switch self {
|
||||||
.font(.tjScaled( 12))
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.85)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.tjCard()
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
if entries.isEmpty {
|
// MARK: - 健康趋势(迷你折线图,复用趋势页 TrendRow)
|
||||||
emptyRecent
|
|
||||||
} else {
|
private func trendsSection(_ buckets: [SeriesBucket]) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
ForEach(groups, id: \.section) { group in
|
Text("健康趋势")
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
.font(.tjH2())
|
||||||
Text(group.section.label)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.font(.tjScaled( 11, weight: .semibold))
|
|
||||||
.tracking(0.5)
|
VStack(spacing: 12) {
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
ForEach(buckets) { bucket in
|
||||||
VStack(spacing: 10) {
|
|
||||||
ForEach(group.items) { entry in
|
|
||||||
Button {
|
Button {
|
||||||
// 指标 → 同类聚合详情(历次 + 趋势);其余 → 只读详情。与档案库 C1 一致。
|
selectedGroup = group(for: bucket)
|
||||||
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: {
|
} label: {
|
||||||
TimelineRow(entry: entry)
|
TrendRow(bucket: bucket)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
/// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var emptyRecent: some View {
|
// MARK: - 影像档案入口
|
||||||
HStack {
|
|
||||||
Text("还没有任何记录,点底部 + 号开始第一条")
|
|
||||||
.font(.tjScaled( 13))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.tjCard(bordered: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var archiveSection: some View {
|
private var archiveSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||||
|
|
||||||
Button(action: onTapArchive) {
|
Button { onTapArchive(.report) } label: {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
|
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56, height: 56)
|
||||||
|
|||||||
@@ -95,8 +95,7 @@ struct TodayRemindersCard: View {
|
|||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
.fill(Tj.Palette.paper)
|
.fill(Tj.Palette.paper)
|
||||||
)
|
)
|
||||||
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04),
|
.shadow(color: Tj.Palette.shadow.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||||
radius: 2, x: 0, y: 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,18 @@ import SwiftUI
|
|||||||
/// 切换只改持久化选择;下一次 AI 调用(prepare/generate)按新引擎加载。
|
/// 切换只改持久化选择;下一次 AI 调用(prepare/generate)按新引擎加载。
|
||||||
struct InferenceSettingsView: View {
|
struct InferenceSettingsView: View {
|
||||||
@AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue
|
@AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue
|
||||||
|
@State private var modelService = ModelDownloadService.shared
|
||||||
|
|
||||||
private var selected: EnginePreference {
|
private var selected: EnginePreference {
|
||||||
EnginePreference(rawValue: engineRaw) ?? .auto
|
EnginePreference(rawValue: engineRaw) ?? .auto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 性能自检需要模型就绪(MNN 主或 MLX 兜底任一)。
|
||||||
|
private var modelReady: Bool {
|
||||||
|
modelService.states[.mnnLLM]?.phase == .ready
|
||||||
|
|| modelService.states[.llm]?.phase == .ready
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
@@ -26,12 +33,74 @@ struct InferenceSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sme2Card
|
sme2Card
|
||||||
|
selfTestSection
|
||||||
noteCard
|
noteCard
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 20)
|
.padding(.vertical, 20)
|
||||||
}
|
}
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
.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 {
|
private func engineRow(_ engine: EnginePreference) -> some View {
|
||||||
|
|||||||
@@ -28,19 +28,6 @@ struct ModelManagementView: View {
|
|||||||
actionButtons
|
actionButtons
|
||||||
.padding(.top, 4)
|
.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 {
|
if let importError {
|
||||||
Text(importError)
|
Text(importError)
|
||||||
.font(.tjScaled( 12))
|
.font(.tjScaled( 12))
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ private struct MedicationEditSheet: View {
|
|||||||
@State private var hydrated = false
|
@State private var hydrated = false
|
||||||
/// 点缩略图全屏查看的起始页;nil = 未打开查看器。
|
/// 点缩略图全屏查看的起始页;nil = 未打开查看器。
|
||||||
@State private var viewerStart: PhotoIndex?
|
@State private var viewerStart: PhotoIndex?
|
||||||
|
/// 「记录一次服用」:嵌套拉起 MedicationLogSheet,预选当前药。
|
||||||
|
@State private var showLog = false
|
||||||
|
|
||||||
private var isEditing: Bool { existing != nil }
|
private var isEditing: Bool { existing != nil }
|
||||||
private var canSave: Bool {
|
private var canSave: Bool {
|
||||||
@@ -184,6 +186,29 @@ private struct MedicationEditSheet: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
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 {
|
if let m = existing, !m.assets.isEmpty {
|
||||||
Section {
|
Section {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
@@ -252,6 +277,9 @@ private struct MedicationEditSheet: View {
|
|||||||
MedicationPhotoViewer(assets: m.assets, startIndex: start.index)
|
MedicationPhotoViewer(assets: m.assets, startIndex: start.index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showLog) {
|
||||||
|
MedicationLogSheet(preselected: existing)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ struct SingleShotCameraView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Text("拍一张含目标指标的照片 · 拍完再框选")
|
Text("轻点画面对焦 · 拍完再框选")
|
||||||
.font(.tjScaled( 13, weight: .medium))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -187,6 +187,10 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
|
|||||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
private var setupDone = false
|
private var setupDone = false
|
||||||
private var captureCompletion: ((UIImage?) -> Void)?
|
private var captureCompletion: ((UIImage?) -> Void)?
|
||||||
|
/// 持有当前输入设备,供点击对焦时重新 lockForConfiguration。
|
||||||
|
private var device: AVCaptureDevice?
|
||||||
|
/// 点击对焦时短暂显示的黄框;弱引用,移除后自动置空。
|
||||||
|
private weak var focusIndicator: UIView?
|
||||||
|
|
||||||
override func didMoveToWindow() {
|
override func didMoveToWindow() {
|
||||||
super.didMoveToWindow()
|
super.didMoveToWindow()
|
||||||
@@ -205,6 +209,20 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
session.addInput(input)
|
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) }
|
if session.canAddOutput(output) { session.addOutput(output) }
|
||||||
session.commitConfiguration()
|
session.commitConfiguration()
|
||||||
|
|
||||||
@@ -215,6 +233,10 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
|
|||||||
self.previewLayer = preview
|
self.previewLayer = preview
|
||||||
applyPortrait(preview.connection)
|
applyPortrait(preview.connection)
|
||||||
|
|
||||||
|
// 点击对焦:贴着拍小标签时连续自动对焦偶尔锁不准,允许用户点屏指定对焦点。
|
||||||
|
let tap = UITapGestureRecognizer(target: self, action: #selector(handleFocusTap(_:)))
|
||||||
|
addGestureRecognizer(tap)
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
self?.session.startRunning()
|
self?.session.startRunning()
|
||||||
}
|
}
|
||||||
@@ -233,6 +255,61 @@ final class RegionPreviewUIView: UIView, AVCapturePhotoCaptureDelegate {
|
|||||||
previewLayer?.frame = bounds
|
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) {
|
func capture(completion: @escaping (UIImage?) -> Void) {
|
||||||
guard session.isRunning else { completion(nil); return }
|
guard session.isRunning else { completion(nil); return }
|
||||||
captureCompletion = completion
|
captureCompletion = completion
|
||||||
|
|||||||
@@ -250,9 +250,15 @@ struct VoiceCommandSheet: View {
|
|||||||
private func finishRecording() {
|
private func finishRecording() {
|
||||||
guard phase == .recording else { return }
|
guard phase == .recording else { return }
|
||||||
ticker?.cancel()
|
ticker?.cancel()
|
||||||
|
// 已经在屏幕上的实时字幕:stop() 偶发因最终结果竞争/取消返回空,
|
||||||
|
// 这时别把用户已经看到的内容丢掉,回退到实时字幕。
|
||||||
|
let live = transcript
|
||||||
phase = .classifying
|
phase = .classifying
|
||||||
Task {
|
Task {
|
||||||
let text = await dictation.stop()
|
let finalText = await dictation.stop()
|
||||||
|
let text = finalText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
? live
|
||||||
|
: finalText
|
||||||
transcript = text
|
transcript = text
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else {
|
guard !trimmed.isEmpty else {
|
||||||
|
|||||||
@@ -93,8 +93,7 @@ struct OngoingSymptomsCard: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04),
|
.shadow(color: Tj.Palette.shadow.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||||
radius: 2, x: 0, y: 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func severityDot(_ value: Int) -> some View {
|
private func severityDot(_ value: Int) -> some View {
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ extension SeriesBucket {
|
|||||||
id: "lab:\(latest.name)",
|
id: "lab:\(latest.name)",
|
||||||
seriesKey: "lab:\(latest.name)",
|
seriesKey: "lab:\(latest.name)",
|
||||||
label: nil,
|
label: nil,
|
||||||
color: Tj.Palette.ink,
|
color: Tj.Palette.teal,
|
||||||
points: points,
|
points: points,
|
||||||
referenceRange: parseRange(latest.range)
|
referenceRange: parseRange(latest.range)
|
||||||
)
|
)
|
||||||
@@ -172,7 +172,7 @@ extension SeriesBucket {
|
|||||||
id: key,
|
id: key,
|
||||||
seriesKey: key,
|
seriesKey: key,
|
||||||
label: nil,
|
label: nil,
|
||||||
color: Tj.Palette.ink,
|
color: Tj.Palette.teal,
|
||||||
points: sorted.compactMap { point(from: $0) },
|
points: sorted.compactMap { point(from: $0) },
|
||||||
referenceRange: range
|
referenceRange: range
|
||||||
)
|
)
|
||||||
@@ -200,7 +200,7 @@ extension SeriesBucket {
|
|||||||
id: "bp.systolic",
|
id: "bp.systolic",
|
||||||
seriesKey: "bp.systolic",
|
seriesKey: "bp.systolic",
|
||||||
label: String(appLoc: "收缩"),
|
label: String(appLoc: "收缩"),
|
||||||
color: Tj.Palette.brick,
|
color: Tj.Palette.teal,
|
||||||
points: sysItems.compactMap { point(from: $0) },
|
points: sysItems.compactMap { point(from: $0) },
|
||||||
referenceRange: m.effectiveRange(for: sysField, profile: profile)
|
referenceRange: m.effectiveRange(for: sysField, profile: profile)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(bucket.lines) { line in
|
||||||
ForEach(line.points) { p in
|
ForEach(line.points) { p in
|
||||||
@@ -119,8 +137,10 @@ struct SeriesChartCard: View {
|
|||||||
y: .value(line.label ?? bucket.title, p.value)
|
y: .value(line.label ?? bucket.title, p.value)
|
||||||
)
|
)
|
||||||
.foregroundStyle(line.color)
|
.foregroundStyle(line.color)
|
||||||
.interpolationMethod(.catmullRom)
|
// monotone:平滑但不在数据尖峰处过冲鼓包,比 catmullRom 更贴合真实读数。
|
||||||
.lineStyle(StrokeStyle(lineWidth: 2))
|
.interpolationMethod(.monotone)
|
||||||
|
// 圆角端点 + 连接,去掉折线的生硬尖角。
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
|
||||||
}
|
}
|
||||||
.symbol {
|
.symbol {
|
||||||
Circle()
|
Circle()
|
||||||
|
|||||||
@@ -179,6 +179,23 @@ struct TrendDetailView: View {
|
|||||||
.foregroundStyle(line.color.opacity(0.08))
|
.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(filteredLines) { line in
|
||||||
ForEach(line.points) { p in
|
ForEach(line.points) { p in
|
||||||
LineMark(
|
LineMark(
|
||||||
@@ -187,8 +204,10 @@ struct TrendDetailView: View {
|
|||||||
series: .value("series", line.id)
|
series: .value("series", line.id)
|
||||||
)
|
)
|
||||||
.foregroundStyle(line.color)
|
.foregroundStyle(line.color)
|
||||||
.interpolationMethod(.catmullRom)
|
// monotone:平滑但不在尖峰处过冲鼓包,更贴合真实读数。
|
||||||
.lineStyle(StrokeStyle(lineWidth: 2))
|
.interpolationMethod(.monotone)
|
||||||
|
// 圆角端点 + 连接,去掉折线的生硬尖角。
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
|
||||||
PointMark(
|
PointMark(
|
||||||
x: .value("时间", p.date),
|
x: .value("时间", p.date),
|
||||||
y: .value(line.label ?? bucket.title, p.value)
|
y: .value(line.label ?? bucket.title, p.value)
|
||||||
@@ -421,6 +440,24 @@ private struct TrendInsightCard: View {
|
|||||||
.font(.tjScaled( 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer()
|
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 {
|
if let text {
|
||||||
Text(text)
|
Text(text)
|
||||||
@@ -435,15 +472,14 @@ private struct TrendInsightCard: View {
|
|||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
AIFlowBar()
|
AIFlowBar()
|
||||||
} else if let failedMessage {
|
} else if let failedMessage {
|
||||||
HStack {
|
|
||||||
Text(failedMessage)
|
Text(failedMessage)
|
||||||
.font(.tjScaled( 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
} else {
|
||||||
Button("重试") { Task { await load(force: true) } }
|
// 空闲态(偶发没自动触发):给明确引导,点右上即可生成。
|
||||||
.font(.tjScaled( 12, weight: .medium))
|
Text("点右上「解读」生成本地趋势解读")
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.font(.tjScaled( 12))
|
||||||
}
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ struct TrendRow: View {
|
|||||||
|
|
||||||
private var sparkline: some View {
|
private var sparkline: some View {
|
||||||
Chart {
|
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(bucket.lines) { line in
|
||||||
ForEach(line.points) { p in
|
ForEach(line.points) { p in
|
||||||
LineMark(
|
LineMark(
|
||||||
@@ -62,8 +75,9 @@ struct TrendRow: View {
|
|||||||
series: .value("s", line.id)
|
series: .value("s", line.id)
|
||||||
)
|
)
|
||||||
.foregroundStyle(line.color)
|
.foregroundStyle(line.color)
|
||||||
.interpolationMethod(.catmullRom)
|
// monotone + 圆角端点:迷你曲线更柔,无尖角无过冲。
|
||||||
.lineStyle(StrokeStyle(lineWidth: 1.6))
|
.interpolationMethod(.monotone)
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 最新点高亮
|
// 最新点高亮
|
||||||
|
|||||||
@@ -3022,6 +3022,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"健康趋势" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"像扫描文档一样翻页拍摄" : {
|
"像扫描文档一样翻页拍摄" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -3427,6 +3430,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"最近记录" : {
|
"最近记录" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -4590,6 +4594,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"基线" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"填写%@" : {
|
"填写%@" : {
|
||||||
|
|
||||||
@@ -4732,6 +4739,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"夜深了,记得早点休息" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"大" : {
|
"大" : {
|
||||||
|
|
||||||
@@ -6825,9 +6835,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"拍一张含目标指标的照片 · 拍完再框选" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"拍到的局部" : {
|
"拍到的局部" : {
|
||||||
|
|
||||||
@@ -7956,6 +7963,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"新的一天,慢慢来" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"无参考范围" : {
|
"无参考范围" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -9139,6 +9149,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"模型未就绪,前往「模型管理」下载后可用" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"模型未就绪时 App 仍可使用,AI 功能会提示前往下载。" : {
|
"模型未就绪时 App 仍可使用,AI 功能会提示前往下载。" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -9633,6 +9646,9 @@
|
|||||||
},
|
},
|
||||||
"添加药品" : {
|
"添加药品" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"点右上「解读」生成本地趋势解读" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"点图放大" : {
|
"点图放大" : {
|
||||||
|
|
||||||
@@ -9971,6 +9987,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"用上方选中的引擎跑固定 prompt,实测 prefill / 生成 tok/s" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"用于自动判定 正常/偏高/偏低" : {
|
"用于自动判定 正常/偏高/偏低" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -10093,6 +10112,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"症状 · %lld 进行中" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"症状 · 已结束" : {
|
"症状 · 已结束" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -11091,6 +11113,9 @@
|
|||||||
},
|
},
|
||||||
"解析失败:%@" : {
|
"解析失败:%@" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"解读" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"解锁康康,查看你的健康档案" : {
|
"解锁康康,查看你的健康档案" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -11160,6 +11185,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"记录一次服用" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"记录什么?" : {
|
"记录什么?" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -11325,6 +11353,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"记得起身活动一下" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"记某次吃药的剂量和时间,会进「记录 · 用药」时间线。不提供剂量建议。" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"记症状" : {
|
"记症状" : {
|
||||||
|
|
||||||
@@ -11976,6 +12010,9 @@
|
|||||||
},
|
},
|
||||||
"轻点打开新建菜单,长按语音直达" : {
|
"轻点打开新建菜单,长按语音直达" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"轻点画面对焦 · 拍完再框选" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"载脂蛋白 A1" : {
|
"载脂蛋白 A1" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -12218,6 +12255,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"还没有任何记录,点底部 + 号开始第一条" : {
|
"还没有任何记录,点底部 + 号开始第一条" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -12544,6 +12582,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"重新解读" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"重新识别" : {
|
"重新识别" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -42,10 +42,14 @@ struct RootView: View {
|
|||||||
@State private var tab: TjTab = .home
|
@State private var tab: TjTab = .home
|
||||||
/// 页面 push 过渡的来向:切到右侧 tab 时从 trailing 推入,切到左侧时从 leading 推入。
|
/// 页面 push 过渡的来向:切到右侧 tab 时从 trailing 推入,切到左侧时从 leading 推入。
|
||||||
@State private var pushEdge: Edge = .trailing
|
@State private var pushEdge: Edge = .trailing
|
||||||
|
/// 切到记录页时预选的分类 chip。首页「我的报告档案」进入时设 `.report`,普通点 tab 清空。
|
||||||
|
@State private var pendingRecordsFilter: TimelineKind?
|
||||||
@State private var showRecordSheet = false
|
@State private var showRecordSheet = false
|
||||||
@State private var activeFlow: ActiveFlow?
|
@State private var activeFlow: ActiveFlow?
|
||||||
@State private var showSymptomStart = false
|
@State private var showSymptomStart = false
|
||||||
@State private var showDiary = false
|
@State private var showDiary = false
|
||||||
|
/// 语音「写日记」直达:跳过日记 sheet 顶部入口选择,光标直接落到正文。
|
||||||
|
@State private var diaryDirectWrite = false
|
||||||
@State private var showIndicator = false
|
@State private var showIndicator = false
|
||||||
@State private var showReminders = false
|
@State private var showReminders = false
|
||||||
@State private var showHealthExport = false
|
@State private var showHealthExport = false
|
||||||
@@ -59,7 +63,7 @@ struct RootView: View {
|
|||||||
/// 语音意图 → 打开对应新建入口(与 RecordSheet onPick 的路由一一对应)。
|
/// 语音意图 → 打开对应新建入口(与 RecordSheet onPick 的路由一一对应)。
|
||||||
private func route(_ intent: VoiceIntent) {
|
private func route(_ intent: VoiceIntent) {
|
||||||
switch intent {
|
switch intent {
|
||||||
case .diary: showDiary = true
|
case .diary: diaryDirectWrite = true; showDiary = true
|
||||||
case .medication: showMedicationScan = true
|
case .medication: showMedicationScan = true
|
||||||
case .symptom: showSymptomStart = true
|
case .symptom: showSymptomStart = true
|
||||||
case .indicator: showIndicator = true
|
case .indicator: showIndicator = true
|
||||||
@@ -81,8 +85,11 @@ struct RootView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Group {
|
Group {
|
||||||
switch tab {
|
switch tab {
|
||||||
case .home: HomeView(onTapArchive: { select(.records) })
|
case .home: HomeView(onTapArchive: { kind in
|
||||||
case .records: ArchiveListView()
|
pendingRecordsFilter = kind
|
||||||
|
select(.records)
|
||||||
|
})
|
||||||
|
case .records: ArchiveListView(initialFilter: pendingRecordsFilter)
|
||||||
case .trend: TrendsView()
|
case .trend: TrendsView()
|
||||||
case .me: MeView()
|
case .me: MeView()
|
||||||
}
|
}
|
||||||
@@ -92,7 +99,11 @@ struct RootView: View {
|
|||||||
.transition(.push(from: pushEdge))
|
.transition(.push(from: pushEdge))
|
||||||
|
|
||||||
TabBar(active: tab,
|
TabBar(active: tab,
|
||||||
onTap: { select($0) },
|
onTap: {
|
||||||
|
// 通过底部 tab 进记录页时清空预选,只有报告档案卡才带 .report 进入。
|
||||||
|
if $0 == .records { pendingRecordsFilter = nil }
|
||||||
|
select($0)
|
||||||
|
},
|
||||||
onTapRecord: { showRecordSheet = true },
|
onTapRecord: { showRecordSheet = true },
|
||||||
onLongPressRecord: { showVoiceCommand = true })
|
onLongPressRecord: { showVoiceCommand = true })
|
||||||
}
|
}
|
||||||
@@ -110,7 +121,7 @@ struct RootView: View {
|
|||||||
case .quick: activeFlow = .quick
|
case .quick: activeFlow = .quick
|
||||||
case .archive: activeFlow = .archive
|
case .archive: activeFlow = .archive
|
||||||
case .symptom: showSymptomStart = true
|
case .symptom: showSymptomStart = true
|
||||||
case .diary: showDiary = true
|
case .diary: diaryDirectWrite = false; showDiary = true
|
||||||
case .indicator: showIndicator = true
|
case .indicator: showIndicator = true
|
||||||
case .reminder: showReminders = true
|
case .reminder: showReminders = true
|
||||||
case .healthExport: showHealthExport = true
|
case .healthExport: showHealthExport = true
|
||||||
@@ -123,7 +134,7 @@ struct RootView: View {
|
|||||||
SymptomStartSheet()
|
SymptomStartSheet()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showDiary) {
|
.sheet(isPresented: $showDiary) {
|
||||||
DiaryQuickSheet()
|
DiaryQuickSheet(directWrite: diaryDirectWrite)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showIndicator) {
|
.sheet(isPresented: $showIndicator) {
|
||||||
// 「拍照识别」入口:关闭手输表单 → 打开指标速记 VL 流程(并入「记录指标」)。
|
// 「拍照识别」入口:关闭手输表单 → 打开指标速记 VL 流程(并入「记录指标」)。
|
||||||
@@ -232,7 +243,7 @@ private struct TabBar: View {
|
|||||||
.fill(Tj.Palette.lineSoft)
|
.fill(Tj.Palette.lineSoft)
|
||||||
.frame(height: 1)
|
.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 {
|
private func tabItem(_ t: TjTab) -> some View {
|
||||||
@@ -273,8 +284,8 @@ private struct TabBar: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.strokeBorder(Tj.Palette.paper, lineWidth: 2)
|
.strokeBorder(Tj.Palette.paper, lineWidth: 2)
|
||||||
)
|
)
|
||||||
.shadow(color: Tj.Palette.ink.opacity(0.18),
|
.shadow(color: Tj.Palette.shadow.opacity(0.20),
|
||||||
radius: 4, x: 0, y: 2)
|
radius: 5, x: 0, y: 2)
|
||||||
|
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.tjScaled( 16, weight: .semibold))
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ struct LockScreenView: View {
|
|||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
.frame(width: 92, height: 92)
|
.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) {
|
VStack(spacing: 6) {
|
||||||
Text("康康 已锁定")
|
Text("康康 已锁定")
|
||||||
|
|||||||
@@ -64,27 +64,61 @@ struct DiaryAssistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions)
|
let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions)
|
||||||
var collected = ""
|
|
||||||
|
// 低温采样下 MNN 仍偶发吐非 JSON / 漏掉外层 {"questions":…} 包裹(换 MNN 后比 MLX 更常见)。
|
||||||
|
// 首次解析不出就自动重试一次,两次都失败才报错 —— 守 §10.5「失败回退,不让用户卡在 AI 错误屏」。
|
||||||
var lastRate: Double = 0
|
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)
|
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400)
|
||||||
for try await chunk in stream {
|
for try await chunk in stream {
|
||||||
collected += chunk.text
|
collected += chunk.text
|
||||||
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 真机若仍偶发,这条日志能抓到模型当时的原始输出,便于定位是截断还是格式漂移。
|
||||||
|
#if DEBUG
|
||||||
|
print("[DiaryAssistService] 解析失败,原始输出 = \(lastRaw)")
|
||||||
|
#endif
|
||||||
|
throw parsedButEmpty ? AssistError.empty : AssistError.parseFailed("非 JSON 输出")
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 去 <think>...</think>(复用 HealthExportService 的兜底)
|
/// 从模型原始输出解析追问数组。容错链(对齐 §3.2 失败回退):
|
||||||
let stripped = HealthExportService.stripThinkBlocks(collected)
|
/// 去 `<think>` → 抠平衡 JSON → 修弱模型畸形 → 先按 `{"questions":[…]}`,
|
||||||
// 2. 抠出第一段平衡 JSON(复用 CaptureService.extractJSONObject)+ 弱模型畸形修复
|
/// 再退到裸数组 `[{…}]`(MNN 偶尔漏外层包裹)。彻底解析不出返回 nil(调用方据此重试/报错)。
|
||||||
let jsonStr = CaptureService.repairJSON(CaptureService.extractJSONObject(from: stripped))
|
/// 解析成功但无可用问题返回 `[]`(与 nil 区分:调用方报 .empty 而非 .parseFailed)。
|
||||||
guard let data = jsonStr.data(using: .utf8),
|
static func parseQuestions(from raw: String) -> [Question]? {
|
||||||
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
|
let stripped = HealthExportService.stripThinkBlocks(raw)
|
||||||
let dict = obj as? [String: Any] else {
|
|
||||||
throw AssistError.parseFailed("非 JSON 输出")
|
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
|
||||||
}
|
}
|
||||||
guard let rawQuestions = dict["questions"] as? [[String: Any]] else {
|
// ② 退路:模型漏了外层包裹,直接吐 [{…},{…}]
|
||||||
throw AssistError.parseFailed("缺少 questions 字段")
|
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
|
||||||
}
|
}
|
||||||
let questions = rawQuestions.compactMap { d -> Question? in
|
}
|
||||||
|
guard let rawQuestions else { return nil }
|
||||||
|
|
||||||
|
return rawQuestions.compactMap { d -> Question? in
|
||||||
guard let q = (d["q"] as? String)?
|
guard let q = (d["q"] as? String)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else {
|
.trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else {
|
||||||
return nil
|
return nil
|
||||||
@@ -95,8 +129,6 @@ struct DiaryAssistService {
|
|||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return Question(q: q, fill: fill, dim: dim)
|
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)。
|
/// 把语音转写稿整理成健康日记草稿(spec 2026-06-10-voice-diary)。
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ final class SpeechDictationService {
|
|||||||
/// 停止录音,等待最终识别结果(最多 1.5s,超时用最新 partial),返回最终稿。
|
/// 停止录音,等待最终识别结果(最多 1.5s,超时用最新 partial),返回最终稿。
|
||||||
/// 中途识别出错时已拿到的 partial 一样返回(spec 错误表:照常进整理流程)。
|
/// 中途识别出错时已拿到的 partial 一样返回(spec 错误表:照常进整理流程)。
|
||||||
func stop() async -> String {
|
func stop() async -> String {
|
||||||
guard isRecording else { return "" }
|
// 已非录音态(识别已自行 final / 重复调用):仍返回已捕获文本,别丢内容。
|
||||||
|
guard isRecording else { return latestText }
|
||||||
isRecording = false
|
isRecording = false
|
||||||
|
|
||||||
audioEngine.stop()
|
audioEngine.stop()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ enum VoiceIntent: String, CaseIterable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 语音意图分类服务:LLM(MNN/SME2 主链路)优先,6 秒超时或失败回退到关键词匹配(§3.2)。
|
/// 语音意图分类服务:LLM(MNN/SME2 主链路)优先,6 秒超时或失败回退到关键词匹配(§3.2)。
|
||||||
/// 两路都不中返回 nil,UI 走「没听懂 → 再说一次 / 打开新建菜单」。
|
/// 关键词路兜底默认 diary(日记是最常见、最自由的入口),只有明确命中其它意图才离开 diary。
|
||||||
/// 无状态,与 OCRService 同款 enum 形态;UI 不直接碰 AIRuntime(§3.1)。
|
/// 无状态,与 OCRService 同款 enum 形态;UI 不直接碰 AIRuntime(§3.1)。
|
||||||
/// nonisolated:模块默认 MainActor,这里全是纯函数 + await,不需要主线程(测试也好调)。
|
/// nonisolated:模块默认 MainActor,这里全是纯函数 + await,不需要主线程(测试也好调)。
|
||||||
nonisolated enum VoiceIntentService {
|
nonisolated enum VoiceIntentService {
|
||||||
@@ -58,23 +58,44 @@ nonisolated enum VoiceIntentService {
|
|||||||
// MARK: - 关键词回退(纯函数,单测覆盖)
|
// MARK: - 关键词回退(纯函数,单测覆盖)
|
||||||
|
|
||||||
/// 规则有序:先命中先赢。「提醒我吃药」必须归 reminder,所以 reminder 排最前。
|
/// 规则有序:先命中先赢。「提醒我吃药」必须归 reminder,所以 reminder 排最前。
|
||||||
static func keywordMatch(_ text: String) -> VoiceIntent? {
|
/// symptom 只保留**明确的具体症状词**(头疼、咳嗽、发烧…),不再收「疼/痛/不舒服/难受」
|
||||||
|
/// 这类泛词——它们更多出现在日常记录里,会把日记误判成症状。都不中时默认 diary。
|
||||||
|
static func keywordMatch(_ text: String) -> VoiceIntent {
|
||||||
let t = text.lowercased()
|
let t = text.lowercased()
|
||||||
|
// archive 只收**强归档信号**(化验单 / 体检报告 / 归档存档),不再收裸「报告」「体检」——
|
||||||
|
// 后者太宽,「下周去体检」「医生说报告没问题」会被误判成 archive 而直接弹相机。
|
||||||
let rules: [(VoiceIntent, [String])] = [
|
let rules: [(VoiceIntent, [String])] = [
|
||||||
(.reminder, ["提醒", "别忘", "闹钟"]),
|
(.reminder, ["提醒", "别忘", "闹钟"]),
|
||||||
(.medication, ["药盒", "用药", "吃药", "吃了药", "服药", "药品", "降压药", "胰岛素"]),
|
(.medication, ["药盒", "用药", "吃药", "吃了药", "服药", "药品", "降压药", "胰岛素"]),
|
||||||
(.archive, ["报告", "化验单", "体检", "归档"]),
|
(.archive, ["化验单", "化验报告", "检查报告", "检验报告", "体检报告", "归档", "存档"]),
|
||||||
(.export, ["身体档案", "给医生", "健康总结", "导出"]),
|
(.export, ["身体档案", "给医生", "健康总结", "导出"]),
|
||||||
(.indicator, ["血压", "血糖", "体重", "心率", "体温", "尿酸", "血脂", "指标",
|
(.indicator, ["血压", "血糖", "体重", "心率", "体温", "尿酸", "血脂", "指标",
|
||||||
"高压", "低压"]),
|
"高压", "低压"]),
|
||||||
(.symptom, ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼", "疼", "痛",
|
(.symptom, ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼",
|
||||||
"咳嗽", "发烧", "发热", "头晕", "恶心", "不舒服", "难受", "拉肚子", "失眠"]),
|
"咳嗽", "发烧", "发热", "头晕", "恶心", "拉肚子"]),
|
||||||
(.diary, ["日记", "今天", "心情", "感觉", "睡得", "吃了"]),
|
|
||||||
]
|
]
|
||||||
for (intent, keys) in rules where keys.contains(where: { t.contains($0) }) {
|
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 intent
|
||||||
}
|
}
|
||||||
return nil
|
}
|
||||||
|
// 兜底默认日记:语音直达最常见的就是随口记一句今天的状态。
|
||||||
|
return .diary
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关键词命中点前两个字若含否定/遗忘词,视为「这事没真发生」,不命中。
|
||||||
|
/// 仅用于相机类意图,避免「没吃药」「忘了吃药」「不用吃药」误弹相机。
|
||||||
|
private static let negationMarkers: Set<Character> = ["没", "不", "别", "忘", "甭", "未", "免"]
|
||||||
|
|
||||||
|
static func isNegated(_ text: String, keyword: String) -> Bool {
|
||||||
|
guard let range = text.range(of: keyword) else { return false }
|
||||||
|
let preceding = text[..<range.lowerBound].suffix(2)
|
||||||
|
return preceding.contains { negationMarkers.contains($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
60
康康Tests/DiaryAssistParseTests.swift
Normal file
60
康康Tests/DiaryAssistParseTests.swift
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import 康康
|
||||||
|
|
||||||
|
/// 写日记 AI 辅助的纯解析函数 `DiaryAssistService.parseQuestions`。
|
||||||
|
/// 这是「非 JSON 输出」回归的修复重点:解析要对 MNN 的各种畸形输出健壮,
|
||||||
|
/// 彻底解析不出才返回 nil(由 suggest 重试/报错),解析成功但无问题返回 []。
|
||||||
|
/// parseQuestions 随 @MainActor struct 隔离,测试整体标 @MainActor。
|
||||||
|
@MainActor
|
||||||
|
struct DiaryAssistParseTests {
|
||||||
|
|
||||||
|
@Test func parsesStandardWrappedJSON() {
|
||||||
|
let raw = #"{"questions":[{"dim":"起病诱因","q":"什么时候开始的?","fill":"从[时间]开始,"},{"dim":"症状性质","q":"是哪种不适?","fill":"性质是[]"}]}"#
|
||||||
|
let qs = DiaryAssistService.parseQuestions(from: raw)
|
||||||
|
#expect(qs?.count == 2)
|
||||||
|
#expect(qs?.first?.dim == "起病诱因")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parsesMarkdownFenced() {
|
||||||
|
let raw = """
|
||||||
|
```json
|
||||||
|
{"questions":[{"dim":"持续频率","q":"持续多久了?","fill":"已持续[时长]"}]}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parsesThinkWrapped() {
|
||||||
|
let raw = "<think>用户头痛,该问起病诱因</think>{\"questions\":[{\"dim\":\"起病诱因\",\"q\":\"何时开始?\",\"fill\":\"从[时间]\"}]}"
|
||||||
|
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parsesBareArrayWithoutWrapper() {
|
||||||
|
// MNN 偶尔漏掉外层 {"questions":…},直接吐数组 —— 必须能兜住
|
||||||
|
let raw = #"[{"dim":"加重缓解","q":"做什么会加重?","fill":"[活动]时加重"},{"dim":"生活方式","q":"睡眠如何?","fill":"近期睡眠[]"}]"#
|
||||||
|
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func repairsTrailingCommaAndSmartQuotes() {
|
||||||
|
// 尾逗号 + 中文弯引号:repairJSON 应修好后正常解析
|
||||||
|
let raw = "{“questions”:[{“dim”:“用药过敏”,“q”:“在吃什么药?”,“fill”:“在服[药名],”},]}"
|
||||||
|
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyQuestionsArrayReturnsEmptyNotNil() {
|
||||||
|
// 合法 JSON 但没有问题:返回 [](调用方报 .empty,不是 .parseFailed)
|
||||||
|
let qs = DiaryAssistService.parseQuestions(from: #"{"questions":[]}"#)
|
||||||
|
#expect(qs != nil)
|
||||||
|
#expect(qs?.isEmpty == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func proseReturnsNil() {
|
||||||
|
#expect(DiaryAssistService.parseQuestions(from: "我觉得你可以多问问睡眠情况。") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unterminatedThinkOnlyReturnsNil() {
|
||||||
|
// 整段都在思考、没吐 JSON 就被截断:strip 后为空 → nil(交给 suggest 重试)
|
||||||
|
#expect(DiaryAssistService.parseQuestions(from: "<think>嗯,用户写了头痛,我应该问") == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,33 @@ struct VoiceIntentServiceTests {
|
|||||||
#expect(VoiceIntentService.keywordMatch("写个日记") == .diary)
|
#expect(VoiceIntentService.keywordMatch("写个日记") == .diary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func gibberishReturnsNil() {
|
@Test func unmatchedDefaultsToDiary() {
|
||||||
#expect(VoiceIntentService.keywordMatch("啦啦啦啦") == nil)
|
// 不明确命中其它意图时,兜底进日记(最常见、最自由的入口)
|
||||||
|
#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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user