diff --git a/康康.xcodeproj/project.pbxproj b/康康.xcodeproj/project.pbxproj index 455d64f..5eeb8f8 100644 --- a/康康.xcodeproj/project.pbxproj +++ b/康康.xcodeproj/project.pbxproj @@ -410,7 +410,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = F2C8C774FG; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -421,6 +421,8 @@ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。"; INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。"; + INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。"; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。"; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; @@ -462,7 +464,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = F2C8C774FG; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -473,6 +475,8 @@ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。"; INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。"; + INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。"; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。"; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; @@ -512,7 +516,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = F2C8C774FG; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -539,7 +543,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = F2C8C774FG; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -565,7 +569,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = F2C8C774FG; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -591,7 +595,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = F2C8C774FG; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; diff --git a/康康/AI/Prompts/HealthExportPrompts.swift b/康康/AI/Prompts/HealthExportPrompts.swift index 6a19c74..85e27d8 100644 --- a/康康/AI/Prompts/HealthExportPrompts.swift +++ b/康康/AI/Prompts/HealthExportPrompts.swift @@ -114,4 +114,72 @@ enum HealthExportPrompts { /no_think """ } + + // MARK: - 多轮导出对话 + + /// 多轮导出页里,用户每次提问时用这个 prompt 回答。 + /// 输入上下文限定为本地指标 + 健康日记,回答只做解释/归纳,不持久化。 + static func dialogueAnswer(latestQuestion: String, + transcript: String, + dataJSON: String) -> String { + """ + 你是康康的本地健康档案助手。请根据【本地健康记录】回答用户最新问题。 + + 铁律: + - 只能使用【本地健康记录】和【多轮对话】里已有的信息。 + - 禁止诊断、禁止用药/剂量建议、禁止急诊判断。 + - 数据里没有的信息,直接说「记录里没有」,不要编造。 + - 重点围绕指标和健康日记做大白话解释,回答要短,最多 5 条要点。 + - 如果用户的目标是给医生看,可以提示稍后点击「生成整理报告」。 + + 【本地健康记录】: + \(dataJSON) + + 【多轮对话】: + \(transcript.isEmpty ? "无" : transcript) + + 【用户最新问题】: + \(latestQuestion) + + 直接输出中文回答,不要 Markdown 标题,不要 : + /no_think + """ + } + + /// 对话结束后,把整段交流整理成一份给医生看的 Markdown 报告。 + static func dialogueReportGeneration(transcript: String, + dataJSON: String) -> String { + """ + 你是健康数据整理员。请把【多轮对话】和【本地健康记录】整理成一份给医生看的摘要报告。 + 这是抽取 / 搬运任务,不是医疗诊断。 + + 铁律: + - 只能使用【本地健康记录】和【多轮对话】里真实出现的信息。 + - 禁止编造数字、日期、症状、药物、检查结果、诊断。 + - 禁止给诊断意见、用药建议、剂量建议或急诊判断。 + - JSON 里没有的信息,对应小节写「无记录」。 + - 指标 status 为 high/low/abnormal 的项目前加 ⚠️。 + + 输出要求: + - 严格 Markdown,不要 markdown 围栏,不要输出 JSON。 + - 中文,简洁,医生 30 秒能扫完。 + - 严格按以下段落: + # 就诊摘要 + ## 本次想解决的问题 + ## 相关健康日记 + ## 相关指标 + ## 已知背景 + ## 患者关心的问题 + ## 可带给医生确认的要点 + + 【本地健康记录】: + \(dataJSON) + + 【多轮对话】: + \(transcript.isEmpty ? "无" : transcript) + + 直接输出 Markdown,不要思考过程,不要 : + /no_think + """ + } } diff --git a/康康/AI/Prompts/VLPrompts.swift b/康康/AI/Prompts/VLPrompts.swift index 099c938..9902e5b 100644 --- a/康康/AI/Prompts/VLPrompts.swift +++ b/康康/AI/Prompts/VLPrompts.swift @@ -20,7 +20,9 @@ enum VLPrompts { /// "value": "3.84", /// "unit": "mmol/L", /// "range": "< 3.40", - /// "status": "high|low|normal" + /// "status": "high|low|normal", + /// "source_page": 1, + /// "source_box": [0.18, 0.42, 0.68, 0.49] /// } /// ] /// } @@ -56,7 +58,9 @@ JSON schema(严格): "value": string, "unit": string, "range": string, - "status": "high" | "low" | "normal" + "status": "high" | "low" | "normal", + "source_page": number, + "source_box": [number, number, number, number] } ] } @@ -68,16 +72,18 @@ JSON schema(严格): - report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。 - 不要发明指标。数值看不清的整行跳过;但**没有参考范围不是跳过的理由**,结论页叙述式文字(如「总胆红素: 23.0(μmol/L)↑」)同样要提取,range 填 "",status 按箭头/「偏高」等标记判断。 - 化验单一般 type = "lab",体检套餐 = "checkup"。 +- source_page 是该指标所在图片页码,从 1 开始。 +- source_box 是该指标整行在该页图片里的归一化矩形 [x,y,width,height],左上角为 (0,0),右下角为 (1,1)。尽量框住指标名、数值、单位、参考范围和异常标记所在整行;不确定位置时填 [0,0,0,0]。 示例 1(化验单 · 单项): 输入: 一张化验单照片,只能看清「低密度脂蛋白 3.84 mmol/L 参考 <3.40」 输出: -{"title":"低密度脂蛋白单项","type":"lab","report_date":"2026-05-25","institution":"","page_count":1,"summary":"","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]} +{"title":"低密度脂蛋白单项","type":"lab","report_date":"2026-05-25","institution":"","page_count":1,"summary":"","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.18,0.42,0.68,0.08]}]} 示例 2(体检 · 多项): 输入: 一份春季体检,3 项可读 输出: -{"title":"春季年度体检","type":"checkup","report_date":"2026-04-12","institution":"协和医院","page_count":1,"summary":"血脂偏高、其他正常","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"},{"name":"谷丙转氨酶","value":"32","unit":"U/L","range":"9 - 50","status":"normal"},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]} +{"title":"春季年度体检","type":"checkup","report_date":"2026-04-12","institution":"协和医院","page_count":1,"summary":"血脂偏高、其他正常","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.12,0.31,0.76,0.07]},{"name":"谷丙转氨酶","value":"32","unit":"U/L","range":"9 - 50","status":"normal","source_page":1,"source_box":[0.12,0.39,0.76,0.07]},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal","source_page":1,"source_box":[0.12,0.47,0.76,0.07]}]} 现在请识别图片并输出 JSON: """# @@ -138,5 +144,59 @@ JSON schema(严格): {"indicators":[{"name":"总胆红素","value":"23.0","unit":"μmol/L","range":"","status":"high"}]} 现在请识别这张局部照片并输出 JSON: +"""# + + // MARK: - OCR 文本 → 指标(LLM 解析,非 VL) + + /// 「拍照识别」新链路:先用 Vision OCR 把化验单读成纯文本,再用 Qwen3-1.7B 从文本结构化抽指标。 + /// 比让 3B VL 直接读密集小字稳得多。输入是 OCR 文本(可能有错字/错位/噪声)。 + static func indicatorsFromText(_ ocrText: String, today: Date = .now) -> String { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + let todayStr = f.string(from: today) + return indicatorsFromTextTemplate + .replacingOccurrences(of: "{{TODAY}}", with: todayStr) + .replacingOccurrences(of: "{{OCR_TEXT}}", with: ocrText) + } + + private static let indicatorsFromTextTemplate: String = #""" +你是医学化验单/体检报告的结构化助手。下面是对一张报告做 OCR 得到的纯文本,可能有错字、错位、多余符号或换行混乱。 +请从中提取所有「指标名 + 数值」,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。 + +今天的日期是 {{TODAY}}。 + +JSON schema(严格): +{ + "indicators": [ + { + "name": string, + "value": string, + "unit": string, + "range": string, + "status": "high" | "low" | "normal" + } + ] +} + +规则: +- 只提取「有明确数值」的检验/体检指标;页眉、医院名、医生签名、采样时间、栏目标题、OCR 噪声一律忽略。 +- status 判断优先级:① 文本里的箭头/标记(↑/H/偏高 → "high",↓/L/偏低 → "low")最优先;② 没有标记时用 value 与 range 比较;③ 都没有 → "normal"。 +- range 保留原文(如 "3.9 - 6.1"、"< 3.40"、"208 - 428");OCR 把破折号写成 "--" / "~" 都归一成 " - ";没有参考范围就填 ""。 +- 单位识别不出就填 "",不要编造;不要发明指标;同一指标只输出一次。 +- name 用规范中文指标名(行内重复的去掉,英文缩写括注可保留)。 +- 数值明显是 OCR 乱码(字母混入数字)且无法判断的,跳过该行。 + +示例 OCR 文本: +淋巴细胞数 3.0 1.8 -- 6.3 X10^9/L +尿酸 486 208-428 μmol/L +总胆红素(TB): 23.0 (μmol/L) ↑ +对应输出: +{"indicators":[{"name":"淋巴细胞数","value":"3.0","unit":"X10^9/L","range":"1.8 - 6.3","status":"normal"},{"name":"尿酸","value":"486","unit":"μmol/L","range":"208 - 428","status":"high"},{"name":"总胆红素","value":"23.0","unit":"μmol/L","range":"","status":"high"}]} + +现在请解析下面这段 OCR 文本,只输出 JSON。/no_think + +OCR 文本: +{{OCR_TEXT}} """# } diff --git a/康康/App/FontScale.swift b/康康/App/FontScale.swift new file mode 100644 index 0000000..1fc078a --- /dev/null +++ b/康康/App/FontScale.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/// 全局字体放大档位。面向视力不佳 / 老年用户:放大整个 App 的字号。 +/// 倍率作用于所有走 `Font.tjScaled` / `Font.tjTitle` 等的字体(即全 App 固定字号)。 +enum FontScale: String, CaseIterable, Identifiable { + case standard, large, extraLarge, huge + + var id: String { rawValue } + + /// 字号倍率。超大档位有限,避免密集布局严重溢出。 + var multiplier: CGFloat { + switch self { + case .standard: return 1.0 + case .large: return 1.2 + case .extraLarge: return 1.4 + case .huge: return 1.6 + } + } + + var label: String { + switch self { + case .standard: return String(appLoc: "标准") + case .large: return String(appLoc: "大") + case .extraLarge: return String(appLoc: "特大") + case .huge: return String(appLoc: "超大") + } + } + + var detail: String { + switch self { + case .standard: return String(appLoc: "默认字号") + case .large: return String(appLoc: "字号放大 20%") + case .extraLarge: return String(appLoc: "字号放大 40%") + case .huge: return String(appLoc: "字号放大 60%") + } + } +} + +/// 全 App 单例。持久化字体档位;切换后由根视图 `.id` 触发整树重建即时生效(同语言切换机制)。 +@Observable +final class FontScaleManager { + static let shared = FontScaleManager() + + private let storageKey = "appFontScale" + + private(set) var scale: FontScale + + private init() { + let saved = UserDefaults.standard.string(forKey: storageKey) + scale = FontScale(rawValue: saved ?? "") ?? .standard + appFontScale = scale.multiplier + } + + func set(_ newScale: FontScale) { + guard newScale != scale else { return } + scale = newScale + UserDefaults.standard.set(newScale.rawValue, forKey: storageKey) + appFontScale = newScale.multiplier + } +} + +/// nonisolated 快照:`Font.tjScaled` 是 static func,在任意上下文按值读取倍率。 +/// 只由 `FontScaleManager`(MainActor)写入;读为快照,无竞态影响(同 Localization 的 appLocBundle 模式)。 +nonisolated(unsafe) var appFontScale: CGFloat = 1.0 diff --git a/康康/App/KangkangApp.swift b/康康/App/KangkangApp.swift index bdde285..f1c1dd3 100644 --- a/康康/App/KangkangApp.swift +++ b/康康/App/KangkangApp.swift @@ -4,6 +4,7 @@ import SwiftData @main struct KangkangApp: App { @State private var lang = LanguageManager.shared + @State private var fontScale = FontScaleManager.shared init() { // 启动即给 MLX 显存缓存设上限,配合 entitlement + LLM/VL 互斥卸载防 jetsam OOM。 @@ -98,7 +99,8 @@ struct KangkangApp: App { AppLockContainer { RootView() .environment(\.locale, lang.locale) - .id(lang.current) // 语言切换 → 整树重建,即时生效 + // 语言 / 字体档位切换 → 整树重建,即时生效(固定字号经 tjScaled 读新倍率)。 + .id("\(lang.current.rawValue)-\(fontScale.scale.rawValue)") } } .modelContainer(sharedModelContainer) diff --git a/康康/DesignSystem/Components.swift b/康康/DesignSystem/Components.swift index fa1604b..7f87f69 100644 --- a/康康/DesignSystem/Components.swift +++ b/康康/DesignSystem/Components.swift @@ -4,9 +4,9 @@ struct TjLockChip: View { var body: some View { HStack(spacing: 4) { Image(systemName: "lock.fill") - .font(.system(size: 9, weight: .semibold)) + .font(.tjScaled( 9, weight: .semibold)) Text("本地加密") - .font(.system(size: 10)) + .font(.tjScaled( 10)) .tracking(0.5) } .foregroundStyle(Tj.Palette.paper) @@ -44,7 +44,7 @@ struct TjBadge: View { var style: TjBadgeStyle = .neutral var body: some View { Text(text) - .font(.system(size: 10, weight: .semibold)) + .font(.tjScaled( 10, weight: .semibold)) .tracking(0.3) .foregroundStyle(style.fg) .padding(.horizontal, 7) @@ -66,7 +66,7 @@ struct TjPlaceholder: View { DiagonalStripes(spacing: 7, color: dark ? Color.white.opacity(0.04) : Color.black.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) Text(label) - .font(.system(size: 11, design: .monospaced)) + .font(.tjScaled( 11, design: .monospaced)) .tracking(0.5) .foregroundStyle(dark ? Color.white.opacity(0.5) : Tj.Palette.text3) .multilineTextAlignment(.center) @@ -101,7 +101,7 @@ struct TjPrimaryButton: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .font(.system(size: fontSize, weight: .semibold)) + .font(.tjScaled( fontSize, weight: .semibold)) .tracking(1) .foregroundStyle(Tj.Palette.paper) .padding(.horizontal, horizontalPadding) @@ -118,7 +118,7 @@ struct TjGhostButton: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .font(.system(size: fontSize, weight: .semibold)) + .font(.tjScaled( fontSize, weight: .semibold)) .tracking(1) .foregroundStyle(Tj.Palette.ink) .padding(.horizontal, horizontalPadding) diff --git a/康康/DesignSystem/Tokens.swift b/康康/DesignSystem/Tokens.swift index bd46837..ad9cfcb 100644 --- a/康康/DesignSystem/Tokens.swift +++ b/康康/DesignSystem/Tokens.swift @@ -39,10 +39,18 @@ enum Tj { } extension Font { - static func tjTitle(_ size: CGFloat = 30) -> Font { .system(size: size, weight: .bold, design: .default) } - static func tjH2(_ size: CGFloat = 18) -> Font { .system(size: size, weight: .bold, design: .default) } - static func tjMono(_ size: CGFloat = 11) -> Font { .system(size: size, weight: .regular, design: .monospaced) } - static func tjSerifBody(_ size: CGFloat = 17) -> Font { .system(size: size, weight: .regular, design: .default) } + /// 全 App 字体的唯一缩放出口。按全局档位 `appFontScale` 放大字号(老年/视力辅助)。 + /// 所有固定字号都经 `.system(size:)` → 机械迁移为 `.tjScaled(` 走这里;改档位 + 根视图重建即全局生效。 + static func tjScaled(_ size: CGFloat, + weight: Font.Weight = .regular, + design: Font.Design = .default) -> Font { + .system(size: size * appFontScale, weight: weight, design: design) + } + + static func tjTitle(_ size: CGFloat = 30) -> Font { .tjScaled(size, weight: .bold) } + static func tjH2(_ size: CGFloat = 18) -> Font { .tjScaled(size, weight: .bold) } + static func tjMono(_ size: CGFloat = 11) -> Font { .tjScaled(size, design: .monospaced) } + static func tjSerifBody(_ size: CGFloat = 17) -> Font { .tjScaled(size) } } extension View { diff --git a/康康/Features/Archive/ArchiveListView.swift b/康康/Features/Archive/ArchiveListView.swift index 5cef318..affc22f 100644 --- a/康康/Features/Archive/ArchiveListView.swift +++ b/康康/Features/Archive/ArchiveListView.swift @@ -30,7 +30,6 @@ struct ArchiveListView: View { @State private var filter: TimelineKind? = nil @State private var endingSymptom: Symptom? @State private var selectedEntry: TimelineEntry? - @State private var showExportSheet = false @State private var route: Route? @MainActor @@ -110,9 +109,6 @@ struct ArchiveListView: View { TimelineEntryDetailView(detail: d) } } - .fullScreenCover(isPresented: $showExportSheet) { - HealthExportSheet() - } } @ViewBuilder @@ -150,35 +146,23 @@ struct ArchiveListView: View { .font(.tjTitle(26)) .foregroundStyle(Tj.Palette.text) Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount) 条")) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Spacer() - Menu { - Button { - showExportSheet = true - } label: { - Label("生成新导出", systemImage: "doc.text.below.ecg") - } - if !exports.isEmpty { - Button { - route = .exports - } label: { - Label("我的导出 · \(exports.count) 份", systemImage: "clock.arrow.circlepath") + if !exports.isEmpty { + Button { route = .exports } label: { + HStack(spacing: 6) { + Image(systemName: "clock.arrow.circlepath") + .font(.tjScaled( 12, weight: .semibold)) + Text("导出历史") + .font(.tjScaled( 13, weight: .semibold)) } + .foregroundStyle(Tj.Palette.paper) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Capsule().fill(Tj.Palette.ink)) } - } label: { - HStack(spacing: 6) { - Image(systemName: "doc.text.below.ecg") - .font(.system(size: 12, weight: .semibold)) - Text("导出身体档案") - .font(.system(size: 13, weight: .semibold)) - Image(systemName: "chevron.down") - .font(.system(size: 9, weight: .semibold)) - } - .foregroundStyle(Tj.Palette.paper) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(Capsule().fill(Tj.Palette.ink)) + .buttonStyle(.plain) } } } @@ -217,19 +201,19 @@ struct ArchiveListView: View { ZStack { Circle().fill(reminderEnabledCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2) Image(systemName: "bell.fill") - .font(.system(size: 16)) + .font(.tjScaled( 16)) .foregroundStyle(reminderEnabledCount > 0 ? Tj.Palette.ink : Tj.Palette.text3) } .frame(width: 36, height: 36) VStack(alignment: .leading, spacing: 2) { Text(reminderCountLabel) - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .lineLimit(1) if !reminderTitlePreview.isEmpty { Text(reminderTitleLine) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) .lineLimit(1) } @@ -238,7 +222,7 @@ struct ArchiveListView: View { Spacer(minLength: 0) Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) } .padding(14) @@ -265,7 +249,7 @@ struct ArchiveListView: View { private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) - .font(.system(size: 13, weight: selected ? .semibold : .regular)) + .font(.tjScaled( 13, weight: selected ? .semibold : .regular)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text) .padding(.horizontal, 14) .padding(.vertical, 8) @@ -282,14 +266,14 @@ struct ArchiveListView: View { private func sectionHeader(_ section: DateSection, count: Int) -> some View { HStack { Text(section.label) - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .tracking(0.5) .foregroundStyle(Tj.Palette.text2) Rectangle() .fill(Tj.Palette.lineSoft) .frame(height: 1) Text("\(count)") - .font(.system(size: 11, design: .monospaced)) + .font(.tjScaled( 11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } .padding(.horizontal, 20) @@ -303,7 +287,7 @@ struct ArchiveListView: View { TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始")) .frame(width: 240, height: 140) Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录")) - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text3) Spacer() } diff --git a/康康/Features/Archive/HealthExportDetailView.swift b/康康/Features/Archive/HealthExportDetailView.swift index 7321e32..f48d757 100644 --- a/康康/Features/Archive/HealthExportDetailView.swift +++ b/康康/Features/Archive/HealthExportDetailView.swift @@ -52,7 +52,7 @@ struct HealthExportDetailView: View { HStack(alignment: .center, spacing: 12) { Button { dismiss() } label: { Image(systemName: "xmark") - .font(.system(size: 16, weight: .semibold)) + .font(.tjScaled( 16, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .frame(width: 32, height: 32) .background(Circle().fill(Tj.Palette.sand2)) @@ -62,7 +62,7 @@ struct HealthExportDetailView: View { .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text(Self.absoluteDate(export.createdAt)) - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } Spacer() @@ -81,13 +81,13 @@ struct HealthExportDetailView: View { TjBadge(text: export.modelTag, style: .neutral) if export.decodeRate > 0 { Text(String(format: "%.1f tok/s", export.decodeRate)) - .font(.system(size: 11, design: .monospaced)) + .font(.tjScaled( 11, design: .monospaced)) .foregroundStyle(Tj.Palette.leaf) } Spacer() if let from = export.inferredTimeFromDate, let to = export.inferredTimeToDate { Text("\(Self.shortDate(from)) — \(Self.shortDate(to))") - .font(.system(size: 11, design: .monospaced)) + .font(.tjScaled( 11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } } @@ -96,10 +96,10 @@ struct HealthExportDetailView: View { private var promptBlock: some View { HStack(alignment: .top, spacing: 8) { Image(systemName: "quote.opening") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Text(export.prompt) - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text2) } .padding(12) @@ -119,7 +119,7 @@ struct HealthExportDetailView: View { ShareLink(item: export.content) { Label("分享", systemImage: "square.and.arrow.up") - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) .tracking(1) .foregroundStyle(Tj.Palette.ink) .padding(.horizontal, 14) @@ -134,7 +134,7 @@ struct HealthExportDetailView: View { showDeleteConfirm = true } label: { Image(systemName: "trash") - .font(.system(size: 15, weight: .medium)) + .font(.tjScaled( 15, weight: .medium)) .foregroundStyle(Tj.Palette.brick) .frame(width: 44, height: 44) .background(Circle().strokeBorder(Tj.Palette.brick.opacity(0.4), lineWidth: 1)) diff --git a/康康/Features/Archive/HealthExportListView.swift b/康康/Features/Archive/HealthExportListView.swift index b6807ed..12d5c9f 100644 --- a/康康/Features/Archive/HealthExportListView.swift +++ b/康康/Features/Archive/HealthExportListView.swift @@ -57,7 +57,7 @@ struct HealthExportListView: View { .font(.tjTitle(24)) .foregroundStyle(Tj.Palette.text) Text(exports.isEmpty ? "" : String(appLoc: "\(exports.count) 份")) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Spacer() TjLockChip() @@ -88,22 +88,22 @@ struct HealthExportRow: View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top) { Text(export.promptPreview) - .font(.system(size: 14, weight: .semibold)) + .font(.tjScaled( 14, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .lineLimit(2) .multilineTextAlignment(.leading) Spacer() Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .medium)) + .font(.tjScaled( 12, weight: .medium)) .foregroundStyle(Tj.Palette.text3) } HStack(spacing: 8) { Text(Self.relativeDate(export.createdAt)) - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) if export.decodeRate > 0 { Text(String(format: "%.1f tok/s", export.decodeRate)) - .font(.system(size: 10, design: .monospaced)) + .font(.tjScaled( 10, design: .monospaced)) .foregroundStyle(Tj.Palette.leaf) } Spacer() diff --git a/康康/Features/Archive/HealthExportSheet.swift b/康康/Features/Archive/HealthExportSheet.swift index 3b28f86..8b911d1 100644 --- a/康康/Features/Archive/HealthExportSheet.swift +++ b/康康/Features/Archive/HealthExportSheet.swift @@ -2,7 +2,7 @@ import SwiftUI import SwiftData /// 「导出身体档案」全屏 sheet。 -/// 状态机:idle → running(extractingIntent → retrieving → generating)→ completed / failed +/// 状态机:多轮问答 → running(retrieving → generating)→ completed / failed struct HealthExportSheet: View { @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss @@ -10,7 +10,8 @@ struct HealthExportSheet: View { /// 可选:从历史「重新生成」时传入(暂时未启用,W3 接)。 let initialPrompt: String - @State private var prompt: String = "" + @State private var turns: [HealthExportDialogueTurn] = [] + @State private var draftQuestion: String = "" @State private var phase: HealthExportService.Phase? @State private var content: String = "" @State private var rate: Double = 0 @@ -18,14 +19,25 @@ struct HealthExportSheet: View { @State private var error: Error? @State private var completed: Bool = false @State private var copiedFlash: Bool = false - @FocusState private var promptFocused: Bool + @State private var answeringTurnID: UUID? + @FocusState private var questionFocused: Bool init(initialPrompt: String = "") { self.initialPrompt = initialPrompt } - private var isRunning: Bool { phase != nil && !completed && error == nil } - private var isInputMode: Bool { phase == nil && !completed && error == nil } + private var isGeneratingReport: Bool { phase != nil && !completed && error == nil } + private var isAnswering: Bool { answeringTurnID != nil } + private var canAsk: Bool { + !isAnswering && + !isGeneratingReport && + !draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + private var canGenerateReport: Bool { + !isAnswering && + !isGeneratingReport && + turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) + } var body: some View { VStack(spacing: 0) { @@ -33,28 +45,20 @@ struct HealthExportSheet: View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 18) { - if isInputMode { - inputSection - } else { - promptEcho - if isRunning { phaseIndicator } - if !content.isEmpty { - MarkdownView(text: content) - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) - .fill(Tj.Palette.paper) - ) - .overlay( - RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) - .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) - ) - } - if let err = error { errorRow(err) } - // 锚点,让流式输出自动滚到底 - Color.clear.frame(height: 1).id("bottom") + introSection + + ForEach(turns) { turn in + dialogueBubble(turn) } + + if isGeneratingReport { phaseIndicator } + + if !content.isEmpty { + reportCard + } + + if let err = error { errorRow(err) } + Color.clear.frame(height: 1).id("bottom") } .padding(.horizontal, 20) .padding(.vertical, 16) @@ -64,13 +68,24 @@ struct HealthExportSheet: View { proxy.scrollTo("bottom", anchor: .bottom) } } + .onChange(of: turns) { _, _ in + withAnimation(.easeOut(duration: 0.12)) { + proxy.scrollTo("bottom", anchor: .bottom) + } + } + } + if completed { + actionRow + } else { + composer } - if completed { actionRow } } .background(Tj.Palette.sand.ignoresSafeArea()) .onAppear { - if prompt.isEmpty { prompt = initialPrompt } - if isInputMode { promptFocused = true } + if !initialPrompt.isEmpty, draftQuestion.isEmpty, turns.isEmpty { + draftQuestion = initialPrompt + } + questionFocused = true } .onDisappear { task?.cancel() } } @@ -81,17 +96,17 @@ struct HealthExportSheet: View { HStack(alignment: .center, spacing: 12) { Button { close() } label: { Image(systemName: "xmark") - .font(.system(size: 16, weight: .semibold)) + .font(.tjScaled( 16, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .frame(width: 32, height: 32) .background(Circle().fill(Tj.Palette.sand2)) } VStack(alignment: .leading, spacing: 2) { - Text("导出身体档案") + Text("身体档案") .font(.tjH2()) .foregroundStyle(Tj.Palette.text) - Text("给医生看的就诊摘要") - .font(.system(size: 11)) + Text("先问清楚,再整理给医生") + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } Spacer() @@ -105,82 +120,92 @@ struct HealthExportSheet: View { } } - // MARK: - Input section (idle) + // MARK: - Dialogue - private var inputSection: some View { + private var introSection: some View { VStack(alignment: .leading, spacing: 14) { - Text("说说你想给医生看什么") - .font(.system(size: 13, weight: .semibold)) + Text("围绕你的指标和健康日记提问") + .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) VStack(alignment: .leading, spacing: 6) { - Text("例:我感冒3天了,把最近一个月的健康情况给医生看") - .font(.system(size: 12)) + Text("例:最近血压波动大吗?") + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) - Text("例:最近血糖好像不稳,把过去三个月的化验单整理一下") - .font(.system(size: 12)) + Text("例:把我最近头晕、睡眠和指标变化整理给医生") + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } - ZStack(alignment: .topLeading) { - if prompt.isEmpty { - Text("在这里输入主诉……") - .font(.system(size: 15)) - .foregroundStyle(Tj.Palette.text3) - .padding(.horizontal, 14) - .padding(.vertical, 14) - .allowsHitTesting(false) + Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据") + .font(.tjScaled( 11)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + ) + } + + private func dialogueBubble(_ turn: HealthExportDialogueTurn) -> some View { + let isUser = turn.role == .user + return HStack(alignment: .top, spacing: 8) { + if isUser { Spacer(minLength: 44) } + VStack(alignment: .leading, spacing: 6) { + Text(turn.role.transcriptLabel) + .font(.tjScaled( 11, weight: .semibold)) + .foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3) + if turn.id == answeringTurnID && turn.text.isEmpty { + HStack(spacing: 8) { + ProgressView() + Text("正在查看本地记录…") + .font(.tjScaled( 13)) + .foregroundStyle(Tj.Palette.text3) + } + } else { + Text(turn.text) + .font(.tjScaled( 14)) + .lineSpacing(3) + .foregroundStyle(isUser ? Tj.Palette.paper : Tj.Palette.text) + .fixedSize(horizontal: false, vertical: true) } - TextEditor(text: $prompt) - .font(.system(size: 15)) - .foregroundStyle(Tj.Palette.text) - .scrollContentBackground(.hidden) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .frame(minHeight: 130) - .focused($promptFocused) } + .padding(12) + .frame(maxWidth: 300, alignment: .leading) .background( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) - .fill(Tj.Palette.paper) + .fill(isUser ? Tj.Palette.ink : Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) - .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + .strokeBorder(isUser ? Color.clear : Tj.Palette.lineSoft, lineWidth: 1) ) - - HStack { - Text("本地 RAG · Qwen3 1.7B · 不上传任何数据") - .font(.system(size: 11)) - .foregroundStyle(Tj.Palette.text3) - Spacer() - Button { start() } label: { - Text("生成报告") - } - .buttonStyle(TjPrimaryButton(height: 44, fontSize: 14)) - .disabled(prompt.trimmingCharacters(in: .whitespaces).isEmpty) - .opacity(prompt.trimmingCharacters(in: .whitespaces).isEmpty ? 0.5 : 1) - } + if !isUser { Spacer(minLength: 44) } } } - // MARK: - Prompt echo (after start) - - private var promptEcho: some View { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "quote.opening") - .font(.system(size: 12)) - .foregroundStyle(Tj.Palette.text3) - Text(prompt) - .font(.system(size: 13)) + private var reportCard: some View { + VStack(alignment: .leading, spacing: 10) { + Text("整理好的报告") + .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) - .lineLimit(3) + MarkdownView(text: content) } - .padding(12) + .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background( - RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) - .fill(Tj.Palette.sand2) + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) ) } @@ -197,11 +222,11 @@ struct HealthExportSheet: View { } if phase == .generating && rate > 0 { Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate)) - .font(.system(size: 11, design: .monospaced)) + .font(.tjScaled( 11, design: .monospaced)) .foregroundStyle(Tj.Palette.leaf) } else { Text(phase?.label ?? "") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } } @@ -213,7 +238,7 @@ struct HealthExportSheet: View { let fill = active ? Tj.Palette.ink : (done ? Tj.Palette.leaf : Tj.Palette.sand2) let fg = (active || done) ? Tj.Palette.paper : Tj.Palette.text3 return Text(p.label) - .font(.system(size: 11, weight: active ? .semibold : .regular)) + .font(.tjScaled( 11, weight: active ? .semibold : .regular)) .foregroundStyle(fg) .padding(.horizontal, 10) .padding(.vertical, 5) @@ -222,7 +247,7 @@ struct HealthExportSheet: View { private var arrow: some View { Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .semibold)) + .font(.tjScaled( 10, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) } @@ -243,7 +268,7 @@ struct HealthExportSheet: View { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(Tj.Palette.brick) Text(err.localizedDescription) - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text) } Button { reset() } label: { Text("返回修改") } @@ -268,7 +293,7 @@ struct HealthExportSheet: View { ShareLink(item: content) { Label("分享", systemImage: "square.and.arrow.up") - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) .tracking(1) .foregroundStyle(Tj.Palette.ink) .padding(.horizontal, 14) @@ -279,7 +304,7 @@ struct HealthExportSheet: View { Spacer() Button { regenerate() } label: { - Label("重新生成", systemImage: "arrow.clockwise") + Label("重新整理", systemImage: "arrow.clockwise") } .buttonStyle(TjPrimaryButton(height: 44, fontSize: 13, horizontalPadding: 16)) } @@ -291,19 +316,100 @@ struct HealthExportSheet: View { } } + private var composer: some View { + VStack(spacing: 10) { + HStack(spacing: 8) { + TextField("继续提问或补充情况…", text: $draftQuestion, axis: .vertical) + .font(.tjScaled( 14)) + .lineLimit(1...4) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(Tj.Palette.sand2) + ) + .focused($questionFocused) + .disabled(isAnswering || isGeneratingReport) + + Button { sendQuestion() } label: { + Image(systemName: "arrow.up") + .font(.tjScaled( 15, weight: .bold)) + .foregroundStyle(Tj.Palette.paper) + .frame(width: 40, height: 40) + .background(Circle().fill(canAsk ? Tj.Palette.ink : Tj.Palette.line)) + } + .disabled(!canAsk) + .accessibilityLabel("发送问题") + } + + Button { startReportGeneration() } label: { + Label("生成整理报告", systemImage: "doc.text.below.ecg") + } + .buttonStyle(TjPrimaryButton(height: 44, fontSize: 14)) + .disabled(!canGenerateReport) + .opacity(canGenerateReport ? 1 : 0.45) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Tj.Palette.paper) + .overlay(alignment: .top) { + Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) + } + } + // MARK: - Actions - private func start() { - let p = prompt.trimmingCharacters(in: .whitespacesAndNewlines) - guard !p.isEmpty else { return } - promptFocused = false + private func sendQuestion() { + let question = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines) + guard !question.isEmpty, !isAnswering, !isGeneratingReport else { return } + draftQuestion = "" + questionFocused = false + + let userTurn = HealthExportDialogueTurn.user(question) + let assistantTurn = HealthExportDialogueTurn.assistant("") + turns.append(userTurn) + turns.append(assistantTurn) + answeringTurnID = assistantTurn.id + + let conversationForPrompt = turns.filter { $0.id != assistantTurn.id } + let stream = HealthExportService.shared.answer( + question: question, + conversation: conversationForPrompt, + in: ctx + ) + task?.cancel() + task = Task { @MainActor in + do { + for try await chunk in stream { + appendToTurn(id: assistantTurn.id, text: chunk.text) + if chunk.decodeRate > 0 { rate = chunk.decodeRate } + } + answeringTurnID = nil + questionFocused = true + } catch { + answeringTurnID = nil + appendToTurn(id: assistantTurn.id, text: error.localizedDescription) + questionFocused = true + } + } + } + + private func appendToTurn(id: UUID, text: String) { + guard let idx = turns.firstIndex(where: { $0.id == id }) else { return } + turns[idx].text += text + } + + private func startReportGeneration() { + guard canGenerateReport else { return } + questionFocused = false content = "" rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示 error = nil completed = false - phase = .extractingIntent + phase = .retrieving - let stream = HealthExportService.shared.export(prompt: p, in: ctx) + let stream = HealthExportService.shared.export(conversation: turns, in: ctx) + task?.cancel() task = Task { @MainActor in do { for try await event in stream { @@ -326,7 +432,7 @@ struct HealthExportSheet: View { private func regenerate() { completed = false - start() + startReportGeneration() } private func reset() { @@ -337,7 +443,8 @@ struct HealthExportSheet: View { rate = 0 error = nil completed = false - promptFocused = true + answeringTurnID = nil + questionFocused = true } private func copy() { @@ -377,7 +484,7 @@ struct MarkdownView: View { case .h1(let s): VStack(alignment: .leading, spacing: 8) { Text(inline(s)) - .font(.system(size: 22, weight: .bold)) + .font(.tjScaled( 22, weight: .bold)) .foregroundStyle(Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) Rectangle() @@ -394,7 +501,7 @@ struct MarkdownView: View { .fill(Tj.Palette.brick) .frame(width: 3, height: 16) Text(inline(s)) - .font(.system(size: 16, weight: .semibold)) + .font(.tjScaled( 16, weight: .semibold)) .foregroundStyle(Tj.Palette.text) } .padding(.top, 10) @@ -404,10 +511,10 @@ struct MarkdownView: View { if let abnormalText = Self.extractAbnormal(s) { HStack(alignment: .firstTextBaseline, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.brick) Text(inline(abnormalText)) - .font(.system(size: 14, weight: .medium)) + .font(.tjScaled( 14, weight: .medium)) .foregroundStyle(Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) @@ -431,7 +538,7 @@ struct MarkdownView: View { .frame(width: 4, height: 4) .padding(.top, 6) Text(inline(s)) - .font(.system(size: 14)) + .font(.tjScaled( 14)) .foregroundStyle(Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) } @@ -440,7 +547,7 @@ struct MarkdownView: View { case .body(let s): Text(inline(s)) - .font(.system(size: 14)) + .font(.tjScaled( 14)) .lineSpacing(3) .foregroundStyle(Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) diff --git a/康康/Features/Calendar/CalendarOverviewView.swift b/康康/Features/Calendar/CalendarOverviewView.swift index 48a24cb..3a77735 100644 --- a/康康/Features/Calendar/CalendarOverviewView.swift +++ b/康康/Features/Calendar/CalendarOverviewView.swift @@ -82,7 +82,7 @@ struct CalendarOverviewView: View { } } label: { Text("回到今天") - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text3) } } @@ -90,7 +90,7 @@ struct CalendarOverviewView: View { if let onClose { Button(action: onClose) { Text("完成") - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) } } @@ -136,7 +136,7 @@ struct CalendarOverviewView: View { } } label: { Text(m.label) - .font(.system(size: 13, weight: mode == m ? .semibold : .regular)) + .font(.tjScaled( 13, weight: mode == m ? .semibold : .regular)) .foregroundStyle(mode == m ? Tj.Palette.paper : Tj.Palette.text) .frame(maxWidth: .infinity) .padding(.vertical, 9) @@ -157,7 +157,7 @@ struct CalendarOverviewView: View { HStack { Button { shiftAnchor(-1) } label: { Image(systemName: "chevron.left") - .font(.system(size: 16, weight: .semibold)) + .font(.tjScaled( 16, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .frame(width: 36, height: 36) .background(Circle().fill(Tj.Palette.paper)) @@ -177,7 +177,7 @@ struct CalendarOverviewView: View { Button { shiftAnchor(1) } label: { Image(systemName: "chevron.right") - .font(.system(size: 16, weight: .semibold)) + .font(.tjScaled( 16, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .frame(width: 36, height: 36) .background(Circle().fill(Tj.Palette.paper)) @@ -230,7 +230,7 @@ struct CalendarOverviewView: View { private var legend: some View { VStack(alignment: .leading, spacing: 8) { Text("图例") - .font(.system(size: 11, weight: .semibold)) + .font(.tjScaled( 11, weight: .semibold)) .tracking(0.5) .foregroundStyle(Tj.Palette.text3) HStack(spacing: 14) { @@ -249,7 +249,7 @@ struct CalendarOverviewView: View { .fill(color) .frame(width: 14, height: 6) Text(label) - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text2) } } diff --git a/康康/Features/Capture/CaptureReviewForm.swift b/康康/Features/Capture/CaptureReviewForm.swift index b65e3e5..9984172 100644 --- a/康康/Features/Capture/CaptureReviewForm.swift +++ b/康康/Features/Capture/CaptureReviewForm.swift @@ -40,7 +40,7 @@ struct CaptureReviewForm: View { .foregroundStyle(Tj.Palette.amber) VStack(alignment: .leading, spacing: 8) { Text(text) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text2) .fixedSize(horizontal: false, vertical: true) if let onReanalyze { @@ -48,7 +48,7 @@ struct CaptureReviewForm: View { onReanalyze() } label: { Label("重新识别", systemImage: "arrow.clockwise") - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) } .buttonStyle(.plain) .foregroundStyle(Tj.Palette.ink) @@ -131,7 +131,7 @@ struct CaptureReviewForm: View { private func labeledField(_ label: String, @ViewBuilder content: () -> C) -> some View { VStack(alignment: .leading, spacing: 4) { Text(label) - .font(.system(size: 11, weight: .medium)) + .font(.tjScaled( 11, weight: .medium)) .foregroundStyle(Tj.Palette.text3) content() } @@ -150,14 +150,14 @@ struct CaptureReviewForm: View { ) } label: { Label("加一项", systemImage: "plus.circle") - .font(.system(size: 12, weight: .medium)) + .font(.tjScaled( 12, weight: .medium)) } .buttonStyle(.plain) .foregroundStyle(Tj.Palette.ink) } if parsed.indicators.isEmpty { Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) .padding(.vertical, 8) } else { @@ -175,7 +175,7 @@ struct CaptureReviewForm: View { return VStack(spacing: 8) { HStack(spacing: 8) { TextField("指标名", text: binding.name) - .font(.system(size: 14, weight: .medium)) + .font(.tjScaled( 14, weight: .medium)) Button(role: .destructive) { parsed.indicators.removeAll { $0.id == id } } label: { @@ -187,7 +187,7 @@ struct CaptureReviewForm: View { HStack(spacing: 8) { TextField("数值", text: binding.value) .keyboardType(.decimalPad) - .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 14, weight: .semibold, design: .monospaced)) .frame(maxWidth: 90) TextField("单位", text: binding.unit) .frame(maxWidth: 80) @@ -247,7 +247,7 @@ struct CaptureReviewForm: View { private func sectionLabel(_ t: String) -> some View { Text(t) - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) } diff --git a/康康/Features/Capture/PhotoPickerSheet.swift b/康康/Features/Capture/PhotoPickerSheet.swift index 818e9c2..5ae7949 100644 --- a/康康/Features/Capture/PhotoPickerSheet.swift +++ b/康康/Features/Capture/PhotoPickerSheet.swift @@ -13,10 +13,10 @@ struct PhotoPickerSheet: View { var body: some View { VStack(spacing: 20) { Image(systemName: "photo.on.rectangle.angled") - .font(.system(size: 56)) + .font(.tjScaled( 56)) .foregroundStyle(Tj.Palette.text3) Text("模拟器没有摄像头,从相册选一张化验单/体检报告") - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text2) .multilineTextAlignment(.center) @@ -24,7 +24,7 @@ struct PhotoPickerSheet: View { maxSelectionCount: 5, matching: .images) { Text("从相册选 ≤5 张") - .font(.system(size: 14, weight: .semibold)) + .font(.tjScaled( 14, weight: .semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(Tj.Palette.ink) diff --git a/康康/Features/Capture/UnifiedCaptureFlow.swift b/康康/Features/Capture/UnifiedCaptureFlow.swift index ef3465c..05a406e 100644 --- a/康康/Features/Capture/UnifiedCaptureFlow.swift +++ b/康康/Features/Capture/UnifiedCaptureFlow.swift @@ -300,7 +300,12 @@ struct UnifiedCaptureFlow: View { status: ind.status, capturedAt: final.reportDate, report: report, - source: .report + source: .report, + sourcePageIndex: ind.sourcePageIndex, + sourceBoxX: ind.sourceBoxX, + sourceBoxY: ind.sourceBoxY, + sourceBoxWidth: ind.sourceBoxWidth, + sourceBoxHeight: ind.sourceBoxHeight ) ctx.insert(i) } @@ -346,16 +351,16 @@ private struct AnalyzingView: View { .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text("\(images.count) 页 · 100% 本地推理 · 已用 \(elapsed)s") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) if elapsed >= timeoutSeconds - 5 { Text("快超时了,>\(timeoutSeconds)s 会自动转为手动录入") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.amber) } } Button("取消识别 · 改为手动录入", action: onCancel) - .font(.system(size: 13, weight: .medium)) + .font(.tjScaled( 13, weight: .medium)) .foregroundStyle(Tj.Palette.text3) .padding(.top, 4) Spacer() @@ -375,7 +380,7 @@ private struct CaptureTipSheet: View { VStack(alignment: .leading, spacing: 16) { HStack(spacing: 10) { Image(systemName: "doc.viewfinder") - .font(.system(size: 28)) + .font(.tjScaled( 28)) .foregroundStyle(Tj.Palette.ink) Text("拍报告的小贴士") .font(.tjH2()) diff --git a/康康/Features/Diary/DiaryQuickSheet.swift b/康康/Features/Diary/DiaryQuickSheet.swift index a20e3d7..589dc2d 100644 --- a/康康/Features/Diary/DiaryQuickSheet.swift +++ b/康康/Features/Diary/DiaryQuickSheet.swift @@ -62,12 +62,12 @@ struct DiaryQuickSheet: View { .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } Spacer() Text("本机保存") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } .padding(.horizontal, 20) @@ -154,18 +154,18 @@ struct DiaryQuickSheet: View { // section header HStack(spacing: 6) { Image(systemName: "sparkles") - .font(.system(size: 11, weight: .semibold)) + .font(.tjScaled( 11, weight: .semibold)) .foregroundStyle(Tj.Palette.brick) sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺")) Spacer() if hasQuestions { Text("\(questions.count) 个建议") - .font(.system(size: 10, design: .monospaced)) + .font(.tjScaled( 10, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } if lastRate > 0 { Text(String(format: "%.1f tok/s", lastRate)) - .font(.system(size: 10, design: .monospaced)) + .font(.tjScaled( 10, design: .monospaced)) .foregroundStyle(Tj.Palette.leaf) } } @@ -187,10 +187,10 @@ struct DiaryQuickSheet: View { if exhaustedNote { HStack(spacing: 6) { Image(systemName: "checkmark.seal.fill") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.leaf) Text("已覆盖主要问诊维度;补充原文后可再追问") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) Spacer(minLength: 0) } @@ -219,11 +219,11 @@ struct DiaryQuickSheet: View { HStack(spacing: 10) { ProgressView().controlSize(.small) Text("AI 思考中… 本地推理,通常 5-10 秒") - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text2) Spacer() Button("取消") { cancelSuggestions() } - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) } .padding(.vertical, 11) @@ -253,13 +253,13 @@ struct DiaryQuickSheet: View { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(Tj.Palette.brick) Text(err.localizedDescription) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text) Spacer() } Button { requestSuggestions() } label: { Text("重试") - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.ink) } .buttonStyle(.plain) @@ -282,7 +282,7 @@ struct DiaryQuickSheet: View { Image(systemName: icon) Text(label) } - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3) .frame(maxWidth: .infinity) .padding(.vertical, 11) @@ -315,12 +315,12 @@ struct DiaryQuickSheet: View { HStack(spacing: 8) { HStack(spacing: 6) { Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath") - .font(.system(size: 11, weight: .semibold)) + .font(.tjScaled( 11, weight: .semibold)) .foregroundStyle(Tj.Palette.brick) Text(round == 1 ? String(appLoc: "第 1 轮 · \(count) 条") : String(appLoc: "第 \(round) 轮 · 基于你刚才更新的文本 · \(count) 条")) - .font(.system(size: 11, weight: .semibold)) + .font(.tjScaled( 11, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) } @@ -344,10 +344,10 @@ struct DiaryQuickSheet: View { return VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top, spacing: 8) { Text("\(index).") - .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 13, weight: .semibold, design: .monospaced)) .foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick) Text(question.q) - .font(.system(size: 13, weight: .medium)) + .font(.tjScaled( 13, weight: .medium)) .foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text) .strikethrough(adopted, color: Tj.Palette.text3) .fixedSize(horizontal: false, vertical: true) @@ -356,9 +356,9 @@ struct DiaryQuickSheet: View { if adopted { HStack(spacing: 4) { Image(systemName: "checkmark") - .font(.system(size: 10, weight: .bold)) + .font(.tjScaled( 10, weight: .bold)) Text("已采纳") - .font(.system(size: 11, weight: .semibold)) + .font(.tjScaled( 11, weight: .semibold)) } .foregroundStyle(Tj.Palette.leaf) .padding(.horizontal, 8) @@ -368,9 +368,9 @@ struct DiaryQuickSheet: View { Button { adopt(question) } label: { HStack(spacing: 4) { Image(systemName: "plus.circle.fill") - .font(.system(size: 12)) + .font(.tjScaled( 12)) Text("采纳") - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) } .foregroundStyle(Tj.Palette.paper) .padding(.horizontal, 10) @@ -390,10 +390,10 @@ struct DiaryQuickSheet: View { } else if !question.fill.isEmpty && !adopted { HStack(alignment: .top, spacing: 4) { Text("将追加:") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) Text(question.fill) - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text2) .fixedSize(horizontal: false, vertical: true) } @@ -416,7 +416,7 @@ struct DiaryQuickSheet: View { private func sectionLabel(_ text: String) -> some View { Text(text) - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) } diff --git a/康康/Features/Diary/QuestionFillPanel.swift b/康康/Features/Diary/QuestionFillPanel.swift index 808081b..e60ca38 100644 --- a/康康/Features/Diary/QuestionFillPanel.swift +++ b/康康/Features/Diary/QuestionFillPanel.swift @@ -99,7 +99,7 @@ struct QuestionFillPanel: View { VStack(alignment: .leading, spacing: 10) { // 实时预览:已填值高亮,未填槽浅色下划线提示。 previewText - .font(.system(size: 13)) + .font(.tjScaled( 13)) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) .padding(10) @@ -115,7 +115,7 @@ struct QuestionFillPanel: View { HStack(spacing: 8) { Button(action: onCancel) { Text("取消") - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) .frame(maxWidth: .infinity) .padding(.vertical, 9) @@ -134,9 +134,9 @@ struct QuestionFillPanel: View { } label: { HStack(spacing: 5) { Image(systemName: "text.append") - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) Text("加入记录") - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) } .foregroundStyle(Tj.Palette.paper) .frame(maxWidth: .infinity) @@ -180,7 +180,7 @@ struct QuestionFillPanel: View { private func slotEditor(index: Int, label: String, options: [String]) -> some View { VStack(alignment: .leading, spacing: 6) { Text(label) - .font(.system(size: 11, weight: .semibold)) + .font(.tjScaled( 11, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) if !options.isEmpty { @@ -189,7 +189,7 @@ struct QuestionFillPanel: View { let picked = bindingValue(index) == opt Button { values[index] = opt } label: { Text(opt) - .font(.system(size: 12, weight: picked ? .semibold : .regular)) + .font(.tjScaled( 12, weight: picked ? .semibold : .regular)) .foregroundStyle(picked ? Tj.Palette.paper : Tj.Palette.text) .padding(.horizontal, 10) .padding(.vertical, 5) @@ -208,7 +208,7 @@ struct QuestionFillPanel: View { } TextField(String(appLoc: "填写\(label)"), text: binding(index)) - .font(.system(size: 13)) + .font(.tjScaled( 13)) .padding(.horizontal, 12) .padding(.vertical, 9) .background( diff --git a/康康/Features/Home/HomeCalendarCard.swift b/康康/Features/Home/HomeCalendarCard.swift index fb7ff46..4f4fd58 100644 --- a/康康/Features/Home/HomeCalendarCard.swift +++ b/康康/Features/Home/HomeCalendarCard.swift @@ -85,10 +85,10 @@ struct HomeCalendarCard: View { Spacer() HStack(spacing: 3) { Text(summaryLine) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Image(systemName: "chevron.right") - .font(.system(size: 11, weight: .semibold)) + .font(.tjScaled( 11, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) } } @@ -118,7 +118,7 @@ struct HomeCalendarCard: View { } label: { VStack(spacing: 5) { Text(weekdayLabel(day)) - .font(.system(size: 10, weight: .medium)) + .font(.tjScaled( 10, weight: .medium)) .foregroundStyle(Tj.Palette.text3) ZStack { RoundedRectangle(cornerRadius: 9, style: .continuous) @@ -128,7 +128,7 @@ struct HomeCalendarCard: View { .strokeBorder(Tj.Palette.ink, lineWidth: 1.2) } Text("\(calendar.component(.day, from: day))") - .font(.system(size: 14, weight: isToday ? .bold : .regular)) + .font(.tjScaled( 14, weight: isToday ? .bold : .regular)) .foregroundStyle(isToday ? Tj.Palette.ink : Tj.Palette.text) } .frame(height: 38) diff --git a/康康/Features/Home/HomeView.swift b/康康/Features/Home/HomeView.swift index 2ef7fb9..d9d577f 100644 --- a/康康/Features/Home/HomeView.swift +++ b/康康/Features/Home/HomeView.swift @@ -71,7 +71,7 @@ struct HomeView: View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text(todayLine) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .tracking(1) .foregroundStyle(Tj.Palette.text3) Text(greetingWord) @@ -106,7 +106,7 @@ struct HomeView: View { Spacer() Button(action: onTapArchive) { Text("全部 ›") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } .buttonStyle(.plain) @@ -119,7 +119,7 @@ struct HomeView: View { ForEach(recentGrouped, id: \.section) { group in VStack(alignment: .leading, spacing: 8) { Text(group.section.label) - .font(.system(size: 11, weight: .semibold)) + .font(.tjScaled( 11, weight: .semibold)) .tracking(0.5) .foregroundStyle(Tj.Palette.text3) VStack(spacing: 10) { @@ -148,7 +148,7 @@ struct HomeView: View { private var emptyRecent: some View { HStack { Text("还没有任何记录,点底部 + 号开始第一条") - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text3) Spacer() } @@ -167,15 +167,15 @@ struct HomeView: View { .frame(width: 56, height: 56) VStack(alignment: .leading, spacing: 2) { Text("我的报告档案") - .font(.system(size: 14, weight: .semibold)) + .font(.tjScaled( 14, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Text("\(reports.count) 份 · \(indicators.count) 项指标 · 端侧加密") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } Spacer() Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) + .font(.tjScaled( 14, weight: .medium)) .foregroundStyle(Tj.Palette.text3) } .padding(14) diff --git a/康康/Features/Home/RecentItemRow.swift b/康康/Features/Home/RecentItemRow.swift index 2a4e0e2..6569cc0 100644 --- a/康康/Features/Home/RecentItemRow.swift +++ b/康康/Features/Home/RecentItemRow.swift @@ -34,12 +34,12 @@ struct RecentItemRow: View { VStack(alignment: .leading, spacing: 2) { Text("\(date) · \(type)") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .tracking(0.3) .foregroundStyle(Tj.Palette.text3) .lineLimit(1) Text(name) - .font(.system(size: 14, weight: .medium)) + .font(.tjScaled( 14, weight: .medium)) .foregroundStyle(Tj.Palette.text) .lineLimit(1) .truncationMode(.tail) @@ -47,7 +47,7 @@ struct RecentItemRow: View { Spacer(minLength: 8) if let value { Text(value) - .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 12, weight: .semibold, design: .monospaced)) .foregroundStyle(status.valueColor) .lineLimit(1) .fixedSize() diff --git a/康康/Features/Home/TodayRemindersCard.swift b/康康/Features/Home/TodayRemindersCard.swift index e49be61..5f07c83 100644 --- a/康康/Features/Home/TodayRemindersCard.swift +++ b/康康/Features/Home/TodayRemindersCard.swift @@ -61,12 +61,12 @@ struct TodayRemindersCard: View { .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text("\(count) 项") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Spacer() Button { showingCenter = true } label: { Text("全部 ›") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } .buttonStyle(.plain) @@ -77,14 +77,14 @@ struct TodayRemindersCard: View { let isPast = item.isPast(now: tick) return HStack(spacing: 12) { Text(item.timeLabel) - .font(.system(size: 14, weight: .semibold).monospacedDigit()) + .font(.tjScaled( 14, weight: .semibold).monospacedDigit()) .foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.ink) .frame(width: 46, alignment: .leading) Image(systemName: "bell.fill") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.amber) Text(item.title) - .font(.system(size: 15, weight: .medium)) + .font(.tjScaled( 15, weight: .medium)) .foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.text) .lineLimit(1) Spacer(minLength: 0) diff --git a/康康/Features/Indicator/CustomMetricEditor.swift b/康康/Features/Indicator/CustomMetricEditor.swift index 7522b6d..79e0db7 100644 --- a/康康/Features/Indicator/CustomMetricEditor.swift +++ b/康康/Features/Indicator/CustomMetricEditor.swift @@ -125,7 +125,7 @@ struct CustomMetricEditor: View { Spacer() if existing == nil { Text("保存后会出现在录入选项里") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } } @@ -147,10 +147,10 @@ struct CustomMetricEditor: View { if nameConflict != .none { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.amber) Text(nameConflict.warningText) - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.amber) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) @@ -175,7 +175,7 @@ struct CustomMetricEditor: View { sectionLabel(String(appLoc: "参考范围(可选)")) Spacer() Text("用于自动判定 正常/偏高/偏低") - .font(.system(size: 10)) + .font(.tjScaled( 10)) .foregroundStyle(Tj.Palette.text3) } HStack(spacing: 12) { @@ -188,10 +188,10 @@ struct CustomMetricEditor: View { private func rangeField(label: String, value: Binding, placeholder: String) -> some View { VStack(alignment: .leading, spacing: 4) { - Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3) + Text(label).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3) TextField(placeholder, text: value) .keyboardType(.decimalPad) - .font(.system(size: 16, weight: .medium, design: .monospaced)) + .font(.tjScaled( 16, weight: .medium, design: .monospaced)) .padding(.horizontal, 12).padding(.vertical, 10) .background(fieldBg).overlay(fieldBorder) } @@ -207,7 +207,7 @@ struct CustomMetricEditor: View { icon = sf } label: { Image(systemName: sf) - .font(.system(size: 20, weight: .medium)) + .font(.tjScaled( 20, weight: .medium)) .foregroundStyle(icon == sf ? Tj.Palette.paper : Tj.Palette.ink) .frame(maxWidth: .infinity, minHeight: 44) .background( @@ -239,7 +239,7 @@ struct CustomMetricEditor: View { Image(systemName: "trash") Text("删除这项自定义指标") } - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.brick) .frame(maxWidth: .infinity) .padding(.vertical, 12) @@ -282,7 +282,7 @@ struct CustomMetricEditor: View { .strokeBorder(Tj.Palette.line, lineWidth: 1) } private func sectionLabel(_ t: String) -> some View { - Text(t).font(.system(size: 12, weight: .semibold)).tracking(0.3) + Text(t).font(.tjScaled( 12, weight: .semibold)).tracking(0.3) .foregroundStyle(Tj.Palette.text2) } diff --git a/康康/Features/Indicator/IndicatorQuickSheet.swift b/康康/Features/Indicator/IndicatorQuickSheet.swift index 666f2b3..ae132f4 100644 --- a/康康/Features/Indicator/IndicatorQuickSheet.swift +++ b/康康/Features/Indicator/IndicatorQuickSheet.swift @@ -27,6 +27,10 @@ private let labPresets: [IndicatorPreset] = [ /// 无 seriesKey,不进 Trends。 /// 3. **自由输入** — name/value/unit/range 全自己填,status 手动选。 struct IndicatorQuickSheet: View { + /// 「拍照识别」入口回调。由 RootView 注入:关闭本表单 → 打开 QuickRegionCaptureFlow(相机→VL→存)。 + /// nil 时(如 Preview)不显示拍照按钮。 + var onRequestCamera: (() -> Void)? = nil + @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss @Query private var profiles: [UserProfile] @@ -103,6 +107,7 @@ struct IndicatorQuickSheet: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 20) { + cameraEntrySection monitorGridSection labPresetSection Divider().padding(.vertical, 4) @@ -161,13 +166,69 @@ struct IndicatorQuickSheet: View { .foregroundStyle(Tj.Palette.text) Spacer() Text("本地处理 · 永不上传") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } .padding(.horizontal, 20) .padding(.bottom, 16) } + /// 顶部「拍照识别」入口:并入原「异常项快拍」。点后由 RootView 切到相机 VL 流程。 + @ViewBuilder + private var cameraEntrySection: some View { + if let onRequestCamera { + VStack(alignment: .leading, spacing: 10) { + Button { + onRequestCamera() + } label: { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.brick) + Image(systemName: "camera.fill") + .font(.tjScaled(18, weight: .medium)) + .foregroundStyle(Tj.Palette.paper) + } + .frame(width: 44, height: 44) + + VStack(alignment: .leading, spacing: 2) { + Text("拍照识别") + .font(.tjScaled(15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text("拍化验单,VL 自动读出数值") + .font(.tjScaled(12)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer() + Image(systemName: "chevron.right") + .font(.tjScaled(14, weight: .medium)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(14) + .frame(maxWidth: .infinity) + .tjCard(bordered: true) + } + .buttonStyle(.plain) + + HStack(spacing: 8) { + line + Text("或手动填写") + .font(.tjScaled(11)) + .foregroundStyle(Tj.Palette.text3) + .fixedSize() + line + } + } + } + } + + private var line: some View { + Rectangle() + .fill(Tj.Palette.lineSoft) + .frame(height: 1) + .frame(maxWidth: .infinity) + } + private var monitorGridSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { @@ -217,18 +278,18 @@ struct IndicatorQuickSheet: View { } label: { HStack(spacing: 10) { Image(systemName: cm.icon) - .font(.system(size: 18, weight: .medium)) + .font(.tjScaled( 18, weight: .medium)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink) .frame(width: 32, height: 32) .background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft)) VStack(alignment: .leading, spacing: 1) { Text(cm.name) - .font(.system(size: 14, weight: selected ? .semibold : .medium)) + .font(.tjScaled( 14, weight: selected ? .semibold : .medium)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text) .lineLimit(1) Text("自定义") - .font(.system(size: 9, design: .monospaced)) + .font(.tjScaled( 9, design: .monospaced)) .foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3) } Spacer() @@ -260,14 +321,14 @@ struct IndicatorQuickSheet: View { } label: { HStack(spacing: 10) { Image(systemName: "plus") - .font(.system(size: 18, weight: .semibold)) + .font(.tjScaled( 18, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) .frame(width: 32, height: 32) .background( Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true) ) Text("自定义") - .font(.system(size: 14, weight: .medium)) + .font(.tjScaled( 14, weight: .medium)) .foregroundStyle(Tj.Palette.text2) Spacer() } @@ -293,13 +354,13 @@ struct IndicatorQuickSheet: View { } label: { HStack(spacing: 10) { Image(systemName: m.icon) - .font(.system(size: 18, weight: .medium)) + .font(.tjScaled( 18, weight: .medium)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink) .frame(width: 32, height: 32) .background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.amber.opacity(0.25))) Text(m.displayName) - .font(.system(size: 14, weight: selected ? .semibold : .medium)) + .font(.tjScaled( 14, weight: selected ? .semibold : .medium)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text) Spacer() } @@ -348,7 +409,7 @@ struct IndicatorQuickSheet: View { } HStack(spacing: 12) { bpField(label: String(appLoc: "收缩压"), value: $systolic, placeholder: "120") - Text("/").font(.system(size: 22, weight: .light)).foregroundStyle(Tj.Palette.text3) + Text("/").font(.tjScaled( 22, weight: .light)).foregroundStyle(Tj.Palette.text3) bpField(label: String(appLoc: "舒张压"), value: $diastolic, placeholder: "80") Text("mmHg").foregroundStyle(Tj.Palette.text3) } @@ -358,10 +419,10 @@ struct IndicatorQuickSheet: View { private func bpField(label: String, value: Binding, placeholder: String) -> some View { VStack(alignment: .leading, spacing: 4) { - Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3) + Text(label).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3) TextField(placeholder, text: value) .keyboardType(.decimalPad) - .font(.system(size: 20, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 20, weight: .semibold, design: .monospaced)) .multilineTextAlignment(.center) .padding(.vertical, 10) .frame(width: 90) @@ -380,11 +441,11 @@ struct IndicatorQuickSheet: View { let rangeText = "\(formatRange(sysRange)) / \(formatRange(diasRange))" return HStack(spacing: 4) { Text(rangeText) - .font(.system(size: 11, design: .monospaced)) + .font(.tjScaled( 11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) if personalized, let age = profile?.age { Text("· 按\(age)岁调整") - .font(.system(size: 10)) + .font(.tjScaled( 10)) .foregroundStyle(Tj.Palette.amber) } } @@ -427,7 +488,7 @@ struct IndicatorQuickSheet: View { sectionLabel(String(appLoc: "数值")) TextField(monitorFieldPlaceholder, text: $value) .keyboardType(.decimalPad) - .font(.system(size: 18, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 18, weight: .semibold, design: .monospaced)) .padding(.horizontal, 14) .padding(.vertical, 12) .background(fieldBg) @@ -475,7 +536,7 @@ struct IndicatorQuickSheet: View { return HStack(spacing: 4) { if personalized, let age = profile?.age { Text("按\(age)岁调整") - .font(.system(size: 10)) + .font(.tjScaled( 10)) .foregroundStyle(Tj.Palette.amber) } } @@ -500,7 +561,7 @@ struct IndicatorQuickSheet: View { statusBadge(s.label, color: s.color) } else { Text("待输入") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } } @@ -546,7 +607,7 @@ struct IndicatorQuickSheet: View { VStack(alignment: .leading, spacing: 12) { HStack { Text("时间") - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text2) Spacer() DatePicker("", selection: $reminderTime, @@ -558,11 +619,11 @@ struct IndicatorQuickSheet: View { VStack(alignment: .leading, spacing: 6) { HStack { Text("频率") - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text2) Spacer() Text(reminderFrequencyLabel) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } weekdayPickerRow @@ -581,11 +642,11 @@ struct IndicatorQuickSheet: View { if notifAuthBlocked { Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.brick) } else { Text("本机提醒 · 不发任何数据") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } } @@ -625,7 +686,7 @@ struct IndicatorQuickSheet: View { } } label: { Text(names[idx]) - .font(.system(size: 13, + .font(.tjScaled( 13, weight: reminderWeekdays.contains(w) ? .semibold : .regular)) .foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text) .frame(maxWidth: .infinity, minHeight: 32) @@ -647,7 +708,7 @@ struct IndicatorQuickSheet: View { private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text2) .padding(.horizontal, 10) .padding(.vertical, 4) @@ -755,7 +816,7 @@ struct IndicatorQuickSheet: View { private func sectionLabel(_ text: String) -> some View { Text(text) - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) } @@ -763,7 +824,7 @@ struct IndicatorQuickSheet: View { private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) - .font(.system(size: 13, weight: selected ? .semibold : .regular)) + .font(.tjScaled( 13, weight: selected ? .semibold : .regular)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text) .padding(.horizontal, 14) .padding(.vertical, 8) @@ -779,7 +840,7 @@ struct IndicatorQuickSheet: View { manualStatus = value } label: { Text(label) - .font(.system(size: 13, weight: selected ? .semibold : .regular)) + .font(.tjScaled( 13, weight: selected ? .semibold : .regular)) .foregroundStyle(selected ? Tj.Palette.paper : color) .padding(.horizontal, 14) .padding(.vertical, 8) @@ -792,7 +853,7 @@ struct IndicatorQuickSheet: View { private func statusBadge(_ label: String, color: Color) -> some View { Text(label) - .font(.system(size: 11, weight: .semibold)) + .font(.tjScaled( 11, weight: .semibold)) .foregroundStyle(color) .padding(.horizontal, 10) .padding(.vertical, 4) @@ -832,9 +893,9 @@ struct IndicatorQuickSheet: View { } label: { HStack(spacing: 3) { Text("已隐藏 \(hiddenSet.count)") - .font(.system(size: 11, weight: .medium)) + .font(.tjScaled( 11, weight: .medium)) Image(systemName: "chevron.right") - .font(.system(size: 9, weight: .semibold)) + .font(.tjScaled( 9, weight: .semibold)) } .foregroundStyle(Tj.Palette.text2) .padding(.horizontal, 10) @@ -1121,7 +1182,7 @@ private struct HiddenMonitorRestoreSheet: View { .foregroundStyle(Tj.Palette.text) Spacer() Button("完成") { dismiss() } - .font(.system(size: 14)) + .font(.tjScaled( 14)) .foregroundStyle(Tj.Palette.ink) } .padding(.horizontal, 20) @@ -1146,13 +1207,13 @@ private struct HiddenMonitorRestoreSheet: View { private func row(_ m: MonitorMetric) -> some View { HStack(spacing: 12) { Image(systemName: m.icon) - .font(.system(size: 16, weight: .medium)) + .font(.tjScaled( 16, weight: .medium)) .foregroundStyle(Tj.Palette.ink) .frame(width: 32, height: 32) .background(Circle().fill(Tj.Palette.amber.opacity(0.25))) Text(m.displayName) - .font(.system(size: 15, weight: .medium)) + .font(.tjScaled( 15, weight: .medium)) .foregroundStyle(Tj.Palette.text) Spacer() @@ -1160,7 +1221,7 @@ private struct HiddenMonitorRestoreSheet: View { Button("显示") { onRestore(m) } - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.paper) .padding(.horizontal, 14) .padding(.vertical, 6) diff --git a/康康/Features/Me/AboutView.swift b/康康/Features/Me/AboutView.swift index 1272d12..eda5dff 100644 --- a/康康/Features/Me/AboutView.swift +++ b/康康/Features/Me/AboutView.swift @@ -70,12 +70,12 @@ struct AboutView: View { } Text("康康 · 本地优先的健康档案 · \(versionText)") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) .padding(.top, 4) Text("本 App 仅供健康信息记录与参考,不能替代专业医疗意见。") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) @@ -98,7 +98,7 @@ struct AboutView: View { RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) .fill(Tj.Palette.sand2) Image(systemName: "heart.text.square.fill") - .font(.system(size: 34)) + .font(.tjScaled( 34)) .foregroundStyle(Tj.Palette.brick) } .frame(width: 72, height: 72) @@ -108,7 +108,7 @@ struct AboutView: View { .foregroundStyle(Tj.Palette.text) Text("本地优先的个人健康随记") - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text2) Text(versionText) @@ -133,10 +133,10 @@ struct AboutView: View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Image(systemName: icon) - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(tint) Text(title) - .font(.system(size: 16, weight: .semibold)) + .font(.tjScaled( 16, weight: .semibold)) .foregroundStyle(Tj.Palette.text) } content() @@ -148,7 +148,7 @@ struct AboutView: View { @ViewBuilder private func paragraph(_ text: String) -> some View { Text(text) - .font(.system(size: 14)) + .font(.tjScaled( 14)) .foregroundStyle(Tj.Palette.text2) .lineSpacing(5) .fixedSize(horizontal: false, vertical: true) @@ -161,7 +161,7 @@ struct AboutView: View { .frame(width: 5, height: 5) .padding(.top, 7) Text(text) - .font(.system(size: 14)) + .font(.tjScaled( 14)) .foregroundStyle(Tj.Palette.text2) .lineSpacing(5) .fixedSize(horizontal: false, vertical: true) diff --git a/康康/Features/Me/CustomMetricsListView.swift b/康康/Features/Me/CustomMetricsListView.swift index 3549e2e..63411b7 100644 --- a/康康/Features/Me/CustomMetricsListView.swift +++ b/康康/Features/Me/CustomMetricsListView.swift @@ -41,7 +41,7 @@ struct CustomMetricsListView: View { editingTarget = CustomMetricEditTarget(metric: nil) } label: { Image(systemName: "plus") - .font(.system(size: 16, weight: .semibold)) + .font(.tjScaled( 16, weight: .semibold)) } } } @@ -57,7 +57,7 @@ struct CustomMetricsListView: View { Image(systemName: "info.circle.fill") .foregroundStyle(Tj.Palette.text3) Text("自定义指标会出现在「+ 指标记录 → 长期监测」的 grid 里,可设提醒、进趋势") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text2) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) @@ -75,7 +75,7 @@ struct CustomMetricsListView: View { TjPlaceholder(label: String(appLoc: "还没有自定义指标")) .frame(width: 220, height: 130) Text("右上角 + 新建一个") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Spacer() } @@ -88,28 +88,28 @@ struct CustomMetricsListView: View { ZStack { Circle().fill(Tj.Palette.leafSoft) Image(systemName: m.icon) - .font(.system(size: 17, weight: .medium)) + .font(.tjScaled( 17, weight: .medium)) .foregroundStyle(Tj.Palette.ink) } .frame(width: 40, height: 40) VStack(alignment: .leading, spacing: 3) { Text(m.name) - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .lineLimit(1) HStack(spacing: 6) { if !m.unit.isEmpty { Text(m.unit) - .font(.system(size: 11, design: .monospaced)) + .font(.tjScaled( 11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } if !m.rangeText.isEmpty { Text("·") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) Text(m.rangeText) - .font(.system(size: 11, design: .monospaced)) + .font(.tjScaled( 11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } } @@ -119,10 +119,10 @@ struct CustomMetricsListView: View { VStack(alignment: .trailing, spacing: 2) { Text(count == 0 ? String(appLoc: "未使用") : String(appLoc: "用 \(count) 次")) - .font(.system(size: 11, weight: count > 0 ? .semibold : .regular)) + .font(.tjScaled( 11, weight: count > 0 ? .semibold : .regular)) .foregroundStyle(count > 0 ? Tj.Palette.ink : Tj.Palette.text3) Image(systemName: "chevron.right") - .font(.system(size: 11, weight: .medium)) + .font(.tjScaled( 11, weight: .medium)) .foregroundStyle(Tj.Palette.text3) } } diff --git a/康康/Features/Me/CustomReminderEditSheet.swift b/康康/Features/Me/CustomReminderEditSheet.swift index 5ef6866..22fc00d 100644 --- a/康康/Features/Me/CustomReminderEditSheet.swift +++ b/康康/Features/Me/CustomReminderEditSheet.swift @@ -142,7 +142,7 @@ struct CustomReminderEditSheet: View { private var skipHint: some View { Text(String(appLoc: "部分月份无此日,该月将跳过")) - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } @@ -169,7 +169,7 @@ struct CustomReminderEditSheet: View { second: 0, of: pickedTime) ?? pickedTime } label: { Text(String(format: "%d:%02d", preset.h, preset.m)) - .font(.system(size: 13, weight: on ? .semibold : .regular)) + .font(.tjScaled( 13, weight: on ? .semibold : .regular)) .foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text) .frame(maxWidth: .infinity, minHeight: 30) .background( @@ -203,7 +203,7 @@ struct CustomReminderEditSheet: View { if on { weekdays.remove(w) } else { weekdays.insert(w) } } label: { Text(names[idx]) - .font(.system(size: 13, weight: on ? .semibold : .regular)) + .font(.tjScaled( 13, weight: on ? .semibold : .regular)) .foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text) .frame(maxWidth: .infinity, minHeight: 30) .background( diff --git a/康康/Features/Me/FontSettingsView.swift b/康康/Features/Me/FontSettingsView.swift new file mode 100644 index 0000000..5b9231e --- /dev/null +++ b/康康/Features/Me/FontSettingsView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +/// 「我的 · 字体大小」选择页。面向视力不佳 / 老年用户:放大整个 App 字号。 +/// 选中即时生效(整树重建为新档位,无需重启,同语言切换机制)。 +/// 每行用各自档位渲染示例字,便于直接比较大小后再选。 +struct FontSettingsView: View { + @State private var manager = FontScaleManager.shared + + /// 示例字基准字号(行内按各档位倍率独立渲染,不随当前全局档位变化,保证可对比)。 + private let sampleBase: CGFloat = 17 + + var body: some View { + ScrollView { + VStack(spacing: 10) { + ForEach(FontScale.allCases) { option in + row(option) + } + + Text("放大后整个 App 的文字立即变大,无需重启。设置会被记住。") + .font(.tjScaled(12)) + .foregroundStyle(Tj.Palette.text3) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + .padding(.top, 6) + } + .padding(.horizontal, 16) + .padding(.vertical, 20) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + .navigationTitle("字体大小") + .navigationBarTitleDisplayMode(.inline) + } + + private func row(_ option: FontScale) -> some View { + let selected = manager.scale == option + return Button { + manager.set(option) + } label: { + HStack(spacing: 14) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(option.label) + .font(.system(size: 15, weight: selected ? .semibold : .regular)) + .foregroundStyle(Tj.Palette.text) + Text(option.detail) + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + // 示例字:按本档位倍率渲染,直接预览效果。 + Text("健康档案 Aa 123") + .font(.system(size: sampleBase * option.multiplier, weight: .medium)) + .foregroundStyle(Tj.Palette.text2) + .lineLimit(1) + .minimumScaleFactor(0.5) + } + + Spacer(minLength: 8) + + ZStack { + Circle() + .strokeBorder(selected ? Tj.Palette.ink : Tj.Palette.line, lineWidth: selected ? 0 : 1.5) + .background(Circle().fill(selected ? Tj.Palette.ink : Color.clear)) + .frame(width: 24, height: 24) + if selected { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Tj.Palette.paper) + } + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .tjCard() + } + .buttonStyle(.plain) + } +} + +#Preview { + NavigationStack { FontSettingsView() } +} diff --git a/康康/Features/Me/LanguageSettingsView.swift b/康康/Features/Me/LanguageSettingsView.swift index ac96f16..2bf9286 100644 --- a/康康/Features/Me/LanguageSettingsView.swift +++ b/康康/Features/Me/LanguageSettingsView.swift @@ -12,7 +12,7 @@ struct LanguageSettingsView: View { } Text("切换后整个 App 立即生效,无需重启。") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 4) @@ -40,14 +40,14 @@ struct LanguageSettingsView: View { .frame(width: 40, height: 40) Text(option.displayName) - .font(.system(size: 15, weight: selected ? .semibold : .regular)) + .font(.tjScaled( 15, weight: selected ? .semibold : .regular)) .foregroundStyle(Tj.Palette.text) Spacer() if selected { Image(systemName: "checkmark") - .font(.system(size: 14, weight: .semibold)) + .font(.tjScaled( 14, weight: .semibold)) .foregroundStyle(Tj.Palette.ink) } } @@ -64,11 +64,11 @@ struct LanguageSettingsView: View { switch option.pickerIcon { case .symbol(let name): Image(systemName: name) - .font(.system(size: 16)) + .font(.tjScaled( 16)) .foregroundStyle(fg) case .glyph(let g): Text(verbatim: g) - .font(.system(size: 17, weight: .semibold)) + .font(.tjScaled( 17, weight: .semibold)) .foregroundStyle(fg) } } diff --git a/康康/Features/Me/MeView.swift b/康康/Features/Me/MeView.swift index 5876f90..615813b 100644 --- a/康康/Features/Me/MeView.swift +++ b/康康/Features/Me/MeView.swift @@ -9,6 +9,7 @@ struct MeView: View { @State private var downloadService = ModelDownloadService.shared @State private var appLock = AppLock.shared @State private var lang = LanguageManager.shared + @State private var fontScale = FontScaleManager.shared // key 必须与 AppLock.enabledKey 一致。 @AppStorage("faceIDLockEnabled") private var lockEnabled = false @@ -37,6 +38,7 @@ struct MeView: View { customMetricsCard modelManagementCard languageCard + fontScaleCard faceIDCard NavigationLink { AboutView() @@ -74,23 +76,23 @@ struct MeView: View { Circle() .fill(Tj.Palette.amber.opacity(0.25)) Image(systemName: "person.crop.circle.fill") - .font(.system(size: 22)) + .font(.tjScaled( 22)) .foregroundStyle(Tj.Palette.ink) } .frame(width: 44, height: 44) VStack(alignment: .leading, spacing: 2) { Text("个人资料") - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Text(profileLine) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) .lineLimit(1) } Spacer() Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .medium)) + .font(.tjScaled( 13, weight: .medium)) .foregroundStyle(Tj.Palette.text3) } .padding(14) @@ -108,23 +110,23 @@ struct MeView: View { Circle() .fill(customMetrics.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft) Image(systemName: "slider.horizontal.3") - .font(.system(size: 18)) + .font(.tjScaled( 18)) .foregroundStyle(customMetrics.isEmpty ? Tj.Palette.text2 : Tj.Palette.ink) } .frame(width: 44, height: 44) VStack(alignment: .leading, spacing: 2) { Text("自定义指标") - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Text(customMetricsLine) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) .lineLimit(1) } Spacer() Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .medium)) + .font(.tjScaled( 13, weight: .medium)) .foregroundStyle(Tj.Palette.text3) } .padding(14) @@ -166,6 +168,17 @@ struct MeView: View { .buttonStyle(.plain) } + private var fontScaleCard: some View { + NavigationLink { + FontSettingsView() + } label: { + settingsCard(title: String(appLoc: "字体大小"), + detail: fontScale.scale.label, + icon: "textformat.size") + } + .buttonStyle(.plain) + } + // MARK: - Face ID 启动锁(可交互 Toggle 卡) private var faceIDCard: some View { @@ -173,17 +186,17 @@ struct MeView: View { ZStack { Circle().fill(lockEnabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2) Image(systemName: "faceid") - .font(.system(size: 18)) + .font(.tjScaled( 18)) .foregroundStyle(lockEnabled ? Tj.Palette.ink : Tj.Palette.text2) } .frame(width: 44, height: 44) VStack(alignment: .leading, spacing: 2) { Text("Face ID 启动锁") - .font(.system(size: 15, weight: .medium)) + .font(.tjScaled( 15, weight: .medium)) .foregroundStyle(Tj.Palette.text) Text(faceIDLine) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } Spacer() @@ -219,20 +232,20 @@ struct MeView: View { ZStack { Circle().fill(Tj.Palette.sand2) Image(systemName: icon) - .font(.system(size: 18)) + .font(.tjScaled( 18)) .foregroundStyle(Tj.Palette.text2) } .frame(width: 44, height: 44) Text(title) - .font(.system(size: 15, weight: .medium)) + .font(.tjScaled( 15, weight: .medium)) .foregroundStyle(Tj.Palette.text) Spacer() Text(detail) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .medium)) + .font(.tjScaled( 13, weight: .medium)) .foregroundStyle(Tj.Palette.text3) } .padding(14) diff --git a/康康/Features/Me/ModelManagementView.swift b/康康/Features/Me/ModelManagementView.swift index 2a33f4e..82e1e3f 100644 --- a/康康/Features/Me/ModelManagementView.swift +++ b/康康/Features/Me/ModelManagementView.swift @@ -43,7 +43,7 @@ struct ModelManagementView: View { if let importError { Text(importError) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.brick) .frame(maxWidth: .infinity, alignment: .leading) } @@ -86,10 +86,10 @@ struct ModelManagementView: View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 3) { Text(kind.displayName) - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Text(subtitle(kind)) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } Spacer() @@ -104,17 +104,17 @@ struct ModelManagementView: View { Spacer() Text(speedText(state)) } - .font(.system(size: 11, design: .monospaced)) + .font(.tjScaled( 11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } else { HStack { Text(formatBytes(ModelManifest.totalBytes(for: kind))) - .font(.system(size: 11, design: .monospaced)) + .font(.tjScaled( 11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) Spacer() if case .failed(let message) = state.phase { Text(message) - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.brick) .lineLimit(1) } @@ -156,7 +156,7 @@ struct ModelManagementView: View { Image(systemName: "checkmark.seal.fill") Text("两个模型都已就绪") } - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.leaf) .frame(maxWidth: .infinity) .padding(.vertical, 6) @@ -183,7 +183,7 @@ struct ModelManagementView: View { VStack(spacing: 8) { TjLockChip() Text("100% 本地推理 · 模型仅需下载一次") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } .frame(maxWidth: .infinity) diff --git a/康康/Features/Me/ModelSelfTestView.swift b/康康/Features/Me/ModelSelfTestView.swift index 24368ca..45dbdb1 100644 --- a/康康/Features/Me/ModelSelfTestView.swift +++ b/康康/Features/Me/ModelSelfTestView.swift @@ -37,11 +37,11 @@ struct ModelSelfTestView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 6) { Text("测试 PROMPT") - .font(.system(size: 11, weight: .semibold)) + .font(.tjScaled( 11, weight: .semibold)) .tracking(0.5) .foregroundStyle(Tj.Palette.text3) Text(prompt) - .font(.system(size: 14)) + .font(.tjScaled( 14)) .foregroundStyle(Tj.Palette.text) } .padding(14) @@ -50,13 +50,13 @@ struct ModelSelfTestView: View { HStack { Text(phase.label) - .font(.system(size: 13, weight: .medium)) + .font(.tjScaled( 13, weight: .medium)) .foregroundStyle(statusColor) .lineLimit(1) Spacer() if rate > 0 { Text(String(format: "%.1f tok/s", rate)) - .font(.system(size: 12, design: .monospaced)) + .font(.tjScaled( 12, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } } diff --git a/康康/Features/Me/RemindersListView.swift b/康康/Features/Me/RemindersListView.swift index 6151b2d..e5d9e5b 100644 --- a/康康/Features/Me/RemindersListView.swift +++ b/康康/Features/Me/RemindersListView.swift @@ -74,7 +74,7 @@ struct RemindersListView: View { private var header: some View { Text("新建提醒,或在记录指标时开启") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) .frame(maxWidth: .infinity, alignment: .leading) } @@ -89,7 +89,7 @@ struct RemindersListView: View { private func sectionLabel(_ text: String) -> some View { Text(text) - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 8) @@ -146,18 +146,18 @@ private struct CustomReminderRow: View { Circle() .fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2) Image(systemName: "bell.fill") - .font(.system(size: 16)) + .font(.tjScaled( 16)) .foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3) } .frame(width: 36, height: 36) VStack(alignment: .leading, spacing: 2) { Text(reminder.title) - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .lineLimit(1) Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } Spacer(minLength: 0) @@ -173,7 +173,7 @@ private struct CustomReminderRow: View { // 与指标提醒行的 28×28 展开按钮等宽,保证两类行的 Toggle 纵向对齐。 Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) .frame(width: 28, height: 28) } @@ -223,17 +223,17 @@ private struct ReminderRow: View { Circle() .fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2) Image(systemName: "bell.fill") - .font(.system(size: 16)) + .font(.tjScaled( 16)) .foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3) } .frame(width: 36, height: 36) VStack(alignment: .leading, spacing: 2) { Text(reminder.displayName) - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } @@ -248,7 +248,7 @@ private struct ReminderRow: View { onTapEdit() } label: { Image(systemName: isEditing ? "chevron.up" : "chevron.down") - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) .frame(width: 28, height: 28) } @@ -259,7 +259,7 @@ private struct ReminderRow: View { private var editingPanel: some View { VStack(alignment: .leading, spacing: 12) { HStack { - Text("时间").font(.system(size: 13)).foregroundStyle(Tj.Palette.text2) + Text("时间").font(.tjScaled( 13)).foregroundStyle(Tj.Palette.text2) Spacer() DatePicker("", selection: $pickedTime, displayedComponents: .hourAndMinute) .datePickerStyle(.compact) @@ -278,7 +278,7 @@ private struct ReminderRow: View { onDelete() } label: { Label("删除提醒", systemImage: "trash") - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.brick) } .buttonStyle(.plain) @@ -310,7 +310,7 @@ private struct ReminderRow: View { onChange() } label: { Text(names[idx]) - .font(.system(size: 13, + .font(.tjScaled( 13, weight: reminder.weekdays.contains(w) ? .semibold : .regular)) .foregroundStyle(reminder.weekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text) .frame(maxWidth: .infinity, minHeight: 30) diff --git a/康康/Features/Profile/ProfileEditView.swift b/康康/Features/Profile/ProfileEditView.swift index ff1e69a..15f07e5 100644 --- a/康康/Features/Profile/ProfileEditView.swift +++ b/康康/Features/Profile/ProfileEditView.swift @@ -35,9 +35,40 @@ struct ProfileEditView: View { private struct ProfileEditForm: View { @Environment(\.modelContext) private var ctx @Bindable var profile: UserProfile + @State private var healthImportDraft: HealthProfileImportDraft? + @State private var healthImportError: String? + @State private var isImportingHealthProfile = false var body: some View { Form { + Section { + Button { + importHealthProfile() + } label: { + HStack(spacing: 10) { + if isImportingHealthProfile { + ProgressView() + } else { + Image(systemName: "heart.text.square") + .foregroundStyle(Tj.Palette.ink) + } + VStack(alignment: .leading, spacing: 2) { + Text("从 Apple 健康导入") + .foregroundStyle(Tj.Palette.text) + Text("只读取生日、性别、身高、血型") + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text3) + } + } + } + .disabled(isImportingHealthProfile) + .accessibilityElement(children: .combine) + .accessibilityLabel("从 Apple 健康导入") + .accessibilityHint("读取生日、性别、身高和血型,确认后填入个人资料") + } footer: { + Text("导入前会先显示预览,确认后才覆盖个人资料。") + } + Section { BirthYearRow(profile: profile) SexRow(profile: profile) @@ -67,6 +98,90 @@ private struct ProfileEditForm: View { profile.updatedAt = .now try? ctx.save() } + .sheet(item: $healthImportDraft) { draft in + HealthProfileImportPreviewSheet( + draft: draft, + profile: profile + ) { + draft.apply(to: profile) + try? ctx.save() + healthImportDraft = nil + } + } + .alert("无法导入 Apple 健康资料", isPresented: Binding( + get: { healthImportError != nil }, + set: { if !$0 { healthImportError = nil } } + )) { + Button("好", role: .cancel) { healthImportError = nil } + } message: { + Text(healthImportError ?? "") + } + } + + private func importHealthProfile() { + guard !isImportingHealthProfile else { return } + isImportingHealthProfile = true + healthImportError = nil + Task { + do { + healthImportDraft = try await HealthProfileImportService.shared.fetchDraft() + } catch { + healthImportError = error.localizedDescription + } + isImportingHealthProfile = false + } + } +} + +private struct HealthProfileImportPreviewSheet: View { + @Environment(\.dismiss) private var dismiss + let draft: HealthProfileImportDraft + let profile: UserProfile + let onApply: () -> Void + + private var preview: HealthProfileImportPreview { + HealthProfileImportPreview(draft: draft, current: profile) + } + + var body: some View { + NavigationStack { + List { + Section { + ForEach(preview.fields, id: \.title) { field in + HStack(alignment: .firstTextBaseline) { + Text(field.title) + .foregroundStyle(Tj.Palette.text) + Spacer(minLength: 12) + VStack(alignment: .trailing, spacing: 4) { + Text(field.imported ?? "未读取到") + .foregroundStyle(field.imported == nil ? Tj.Palette.text3 : Tj.Palette.text) + Text("当前: \(field.current)") + .font(.tjScaled( 11)) + .foregroundStyle(Tj.Palette.text3) + } + } + } + } footer: { + Text("未读取到的字段不会修改。") + } + } + .navigationTitle("确认导入") + .navigationBarTitleDisplayMode(.inline) + .scrollContentBackground(.hidden) + .background(Tj.Palette.sand.ignoresSafeArea()) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("导入") { + onApply() + dismiss() + } + .fontWeight(.semibold) + } + } + } } } @@ -112,7 +227,7 @@ private struct BirthYearRow: View { Text(selectedLabel) .foregroundStyle(profile.birthYear == nil ? Tj.Palette.text3 : Tj.Palette.text2) Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) .rotationEffect(.degrees(expanded ? 90 : 0)) } @@ -212,7 +327,7 @@ private struct BMIFooter: View { var body: some View { if let bmi = profile.bmi { Text("BMI: \(String(format: "%.1f", bmi)) \(label(bmi))") - .font(.system(size: 11)) + .font(.tjScaled( 11)) } } @@ -282,7 +397,7 @@ private struct ChronicSection: View { private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) - .font(.system(size: 13, weight: selected ? .semibold : .regular)) + .font(.tjScaled( 13, weight: selected ? .semibold : .regular)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text) .padding(.horizontal, 12) .padding(.vertical, 6) diff --git a/康康/Features/Quick/QuickRegionCaptureFlow.swift b/康康/Features/Quick/QuickRegionCaptureFlow.swift index 6f71dae..36f7447 100644 --- a/康康/Features/Quick/QuickRegionCaptureFlow.swift +++ b/康康/Features/Quick/QuickRegionCaptureFlow.swift @@ -21,7 +21,8 @@ struct QuickRegionCaptureFlow: View { @State private var analyzeTask: Task? = nil /// VL 单次推理超时(防卡死);超时后 cancel 子任务,UI 转手动录入。 - private let analyzeTimeoutSeconds: Int = 30 + /// 整页化验单指标多、生成 token 多,30s 偏紧,放宽到 60s。 + private let analyzeTimeoutSeconds: Int = 60 enum Phase { case idle @@ -86,23 +87,42 @@ struct QuickRegionCaptureFlow: View { } } - // MARK: - 入口:相机(真机)/ 相册(模拟器) + // MARK: - 入口:整页文档扫描(真机)/ 相册(模拟器或不支持) + // 旧实现用 RegionCameraView 的「细条小框」(为 1-2 行异常项设计);并入「记录指标 · 拍照识别」后 + // 用户会拍整张化验单,塞进细条须离远拍 → 小字像素过低,VL 读不出。改用 VisionKit 整页扫描: + // 全分辨率 + 自动透视校正,VL 能读清整表。模拟器 / 不支持时回退相册选图。 @ViewBuilder private var captureEntry: some View { #if targetEnvironment(simulator) PhotoPickerSheet( - onFinish: { imgs in if let first = imgs.first { startAnalyze(image: first) } }, + onFinish: { imgs in handleScanned(imgs) }, onCancel: onClose ) #else - RegionCameraView( - onCapture: { startAnalyze(image: $0) }, - onCancel: onClose - ) + if DocumentScannerView.isSupported { + DocumentScannerView( + onFinish: { imgs in handleScanned(imgs) }, + onCancel: onClose + ) + } else { + PhotoPickerSheet( + onFinish: { imgs in handleScanned(imgs) }, + onCancel: onClose + ) + } #endif } + /// 扫描/选图回来:取首页跑识别(单张化验单通常一页);无图则关闭。 + private func handleScanned(_ images: [UIImage]) { + if let first = images.first { + startAnalyze(image: first) + } else { + onClose() + } + } + // MARK: - 识别 private func startAnalyze(image: UIImage) { @@ -110,12 +130,9 @@ struct QuickRegionCaptureFlow: View { phase = .analyzing(image: image) let timeout = analyzeTimeoutSeconds // 本类型默认 MainActor 隔离,Task{} 继承之,故内部 phase 写入都在主线程,直接赋值即可。 + // 新链路:Vision 端侧 OCR 取文本 → Qwen3-1.7B LLM 结构化抽指标(替代 3B VL 直读图)。 analyzeTask = Task { - guard let data = image.jpegData(compressionQuality: 0.9) else { - phase = .confirm(image: image, items: [], - warning: String(appLoc: "图片编码失败,手动补充或重拍")) - return - } + let timeoutWarn = String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍") let watchdog = Task { try? await Task.sleep(for: .seconds(timeout)) @@ -124,12 +141,25 @@ struct QuickRegionCaptureFlow: View { defer { watchdog.cancel() } do { - let parsed = try await CaptureService.shared.recognizeRegion(imageData: data) + // 1. 端侧 OCR + let text = try await OCRService.recognizeText(in: image) if Task.isCancelled { + phase = .confirm(image: image, items: [], warning: timeoutWarn); return + } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + #if DEBUG + print("🔤 [OCR] recognized text:\n\(trimmed)\n--- end OCR ---") + #endif + if trimmed.isEmpty { phase = .confirm(image: image, items: [], - warning: String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍")) + warning: String(appLoc: "没识别到文字,手动补充或重拍")) return } + // 2. LLM 解析文本 → 指标 + let parsed = try await CaptureService.shared.recognizeIndicators(fromOCRText: trimmed) + if Task.isCancelled { + phase = .confirm(image: image, items: [], warning: timeoutWarn); return + } let items = Self.buildItems(from: parsed) phase = .confirm( image: image, @@ -138,23 +168,23 @@ struct QuickRegionCaptureFlow: View { ) } catch CaptureError.modelNotReady { phase = .confirm(image: image, items: [], - warning: String(appLoc: "VL 模型未就绪,手动补充")) + warning: String(appLoc: "AI 模型未就绪,手动补充")) } catch let CaptureError.parseFailed(msg) { phase = .confirm(image: image, items: [], - warning: String(appLoc: "VL 输出无法解析:\(msg)")) + warning: String(appLoc: "解析失败:\(msg)")) } catch let CaptureError.inferenceFailed(msg) { phase = .confirm(image: image, items: [], - warning: Task.isCancelled - ? String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍") - : String(appLoc: "推理失败:\(msg)")) + warning: Task.isCancelled ? timeoutWarn + : String(appLoc: "识别失败:\(msg)")) } catch { phase = .confirm(image: image, items: [], - warning: String(appLoc: "未知错误:\(error.localizedDescription)")) + warning: Task.isCancelled ? timeoutWarn + : String(appLoc: "未知错误:\(error.localizedDescription)")) } } } - /// VL 结果 → 可编辑行,异常项(high/low)置顶、默认勾选。 + /// LLM 结果 → 可编辑行,异常项(high/low)置顶、默认勾选。 private static func buildItems(from parsed: [ParsedReport.ParsedIndicator]) -> [QuickRegionItem] { let mapped = parsed.map { QuickRegionItem(name: $0.name, value: $0.value, unit: $0.unit, @@ -233,16 +263,16 @@ private struct AnalyzingRegionView: View { .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text("100% 本地推理 · 已用 \(elapsed)s") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) if elapsed >= timeoutSeconds - 5 { Text("快超时了,>\(timeoutSeconds)s 会自动转手动录入") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.amber) } } Button("取消识别 · 改为手动录入", action: onCancel) - .font(.system(size: 13, weight: .medium)) + .font(.tjScaled( 13, weight: .medium)) .foregroundStyle(Tj.Palette.text3) .padding(.top, 4) Spacer() diff --git a/康康/Features/Quick/QuickRegionConfirmView.swift b/康康/Features/Quick/QuickRegionConfirmView.swift index 45114a3..9efaa9c 100644 --- a/康康/Features/Quick/QuickRegionConfirmView.swift +++ b/康康/Features/Quick/QuickRegionConfirmView.swift @@ -55,7 +55,7 @@ struct QuickRegionConfirmView: View { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(Tj.Palette.amber) Text(text) - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text2) Spacer() } @@ -70,11 +70,11 @@ struct QuickRegionConfirmView: View { VStack(alignment: .leading, spacing: 10) { HStack { Text("拍到的局部") - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) Spacer() Text("仅核对用 · 不保存照片") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } Image(uiImage: image) @@ -91,7 +91,7 @@ struct QuickRegionConfirmView: View { onRetake() } label: { Label("重拍", systemImage: "camera.rotate") - .font(.system(size: 13, weight: .medium)) + .font(.tjScaled( 13, weight: .medium)) .foregroundStyle(Tj.Palette.ink) } } @@ -102,7 +102,7 @@ struct QuickRegionConfirmView: View { private var timeCard: some View { VStack(alignment: .leading, spacing: 10) { Text("测量时间") - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) DatePicker("", selection: $capturedAt, in: ...Date.now) .datePickerStyle(.compact) @@ -116,7 +116,7 @@ struct QuickRegionConfirmView: View { VStack(alignment: .leading, spacing: 14) { HStack { Text("识别到的指标 (\(items.count))") - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) Spacer() Button { @@ -124,14 +124,14 @@ struct QuickRegionConfirmView: View { status: .high, include: true)) } label: { Label("加一项", systemImage: "plus.circle.fill") - .font(.system(size: 13, weight: .medium)) + .font(.tjScaled( 13, weight: .medium)) .foregroundStyle(Tj.Palette.ink) } } if items.isEmpty { Text("没有识别到指标,点「加一项」手动补充,或返回重拍") - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text3) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 20) @@ -153,17 +153,17 @@ struct QuickRegionConfirmView: View { item.wrappedValue.include.toggle() } label: { Image(systemName: item.wrappedValue.include ? "checkmark.circle.fill" : "circle") - .font(.system(size: 20)) + .font(.tjScaled( 20)) .foregroundStyle(item.wrappedValue.include ? Tj.Palette.ink : Tj.Palette.text3) } .buttonStyle(.plain) TextField(String(appLoc: "指标名"), text: item.name) - .font(.system(size: 15, weight: .medium)) + .font(.tjScaled( 15, weight: .medium)) if abnormal { Text(statusLabel(item.wrappedValue.status)) - .font(.system(size: 10, weight: .semibold)) + .font(.tjScaled( 10, weight: .semibold)) .foregroundStyle(statusColor(item.wrappedValue.status)) .padding(.horizontal, 7).padding(.vertical, 3) .background(Capsule().fill(statusColor(item.wrappedValue.status).opacity(0.16))) @@ -175,7 +175,7 @@ struct QuickRegionConfirmView: View { } } label: { Image(systemName: "trash") - .font(.system(size: 14)) + .font(.tjScaled( 14)) .foregroundStyle(Tj.Palette.brick) } } @@ -203,10 +203,10 @@ struct QuickRegionConfirmView: View { mono: Bool = false) -> some View { VStack(alignment: .leading, spacing: 4) { Text(label) - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) TextField("", text: text) - .font(.system(size: 14, weight: mono ? .semibold : .regular, + .font(.tjScaled( 14, weight: mono ? .semibold : .regular, design: mono ? .monospaced : .default)) .keyboardType(mono ? .decimalPad : .default) .textInputAutocapitalization(.never) @@ -234,7 +234,7 @@ struct QuickRegionConfirmView: View { item.wrappedValue.status = st } label: { Text(statusLabel(st)) - .font(.system(size: 12, weight: selected ? .semibold : .regular)) + .font(.tjScaled( 12, weight: selected ? .semibold : .regular)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text2) .padding(.horizontal, 12) .padding(.vertical, 6) diff --git a/康康/Features/Quick/RegionCameraView.swift b/康康/Features/Quick/RegionCameraView.swift index daa302f..9197da3 100644 --- a/康康/Features/Quick/RegionCameraView.swift +++ b/康康/Features/Quick/RegionCameraView.swift @@ -69,7 +69,7 @@ struct RegionCameraView: View { // 提示 Text("把异常项放进框里 · 对准一两行") - .font(.system(size: 13, weight: .medium)) + .font(.tjScaled( 13, weight: .medium)) .foregroundStyle(.white) .padding(.horizontal, 12) .padding(.vertical, 6) @@ -89,7 +89,7 @@ struct RegionCameraView: View { onCancel() } label: { Text("取消") - .font(.system(size: 16, weight: .medium)) + .font(.tjScaled( 16, weight: .medium)) .foregroundStyle(.white) .padding(.horizontal, 14) .padding(.vertical, 8) @@ -126,19 +126,19 @@ struct RegionCameraView: View { private var deniedView: some View { VStack(spacing: 16) { Image(systemName: "camera.fill") - .font(.system(size: 40)) + .font(.tjScaled( 40)) .foregroundStyle(.white.opacity(0.8)) Text("相机权限未开启") .font(.tjH2()) .foregroundStyle(.white) Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。") - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(.white.opacity(0.7)) .multilineTextAlignment(.center) .padding(.horizontal, 36) HStack(spacing: 12) { Button("取消") { onCancel() } - .font(.system(size: 15)) + .font(.tjScaled( 15)) .foregroundStyle(.white) .padding(.horizontal, 18).padding(.vertical, 10) .background(Capsule().strokeBorder(.white.opacity(0.5), lineWidth: 1)) @@ -147,7 +147,7 @@ struct RegionCameraView: View { UIApplication.shared.open(url) } } - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(.black) .padding(.horizontal, 18).padding(.vertical, 10) .background(Capsule().fill(.white)) diff --git a/康康/Features/Record/RecordSheet.swift b/康康/Features/Record/RecordSheet.swift index 250cf04..8cadcef 100644 --- a/康康/Features/Record/RecordSheet.swift +++ b/康康/Features/Record/RecordSheet.swift @@ -1,16 +1,18 @@ import SwiftUI enum RecordKind: String, Identifiable, CaseIterable { - case quick, indicator, archive, diary, symptom, reminder + case quick, indicator, healthExport, archive, diary, symptom, reminder var id: String { rawValue } /// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。 - static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .quick, .archive] + /// 注:`.quick`(异常项快拍)已并入 `.indicator`(记录指标)内的「拍照识别」,不再单列。 + static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .healthExport, .archive] var title: String { switch self { case .quick: return String(appLoc: "异常项快拍") case .indicator: return String(appLoc: "记录指标") + case .healthExport: return String(appLoc: "身体档案") case .archive: return String(appLoc: "体检报告归档") case .diary: return String(appLoc: "健康日记") case .symptom: return String(appLoc: "记录症状") @@ -20,7 +22,8 @@ enum RecordKind: String, Identifiable, CaseIterable { var subtitle: String { switch self { case .quick: return String(appLoc: "拍一张化验单,VL 自动识别") - case .indicator: return String(appLoc: "手动填一项指标(免拍照)") + case .indicator: return String(appLoc: "手动填写,或拍照自动识别") + case .healthExport: return String(appLoc: "多轮问答后生成给医生看的整理报告") case .archive: return String(appLoc: "完整保存整份报告(可多页)") case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助") case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束") @@ -31,6 +34,7 @@ enum RecordKind: String, Identifiable, CaseIterable { switch self { case .quick: return "camera.fill" case .indicator: return "number.square.fill" + case .healthExport: return "doc.text.below.ecg" case .archive: return "doc.fill" case .diary: return "heart.text.square" case .symptom: return "waveform.path.ecg" @@ -41,6 +45,7 @@ enum RecordKind: String, Identifiable, CaseIterable { switch self { case .quick: return Tj.Palette.brick case .indicator: return Tj.Palette.brick + case .healthExport: return Tj.Palette.ink case .archive: return Tj.Palette.ink case .diary: return Tj.Palette.leaf case .symptom: return Tj.Palette.amber @@ -66,7 +71,7 @@ struct RecordSheet: View { .foregroundStyle(Tj.Palette.text) Spacer() Text("本地处理 · 永不上传") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } .padding(.bottom, 14) @@ -83,22 +88,22 @@ struct RecordSheet: View { RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(kind.accent) Image(systemName: kind.icon) - .font(.system(size: 18, weight: .medium)) + .font(.tjScaled( 18, weight: .medium)) .foregroundStyle(Tj.Palette.paper) } .frame(width: 44, height: 44) VStack(alignment: .leading, spacing: 2) { Text(kind.title) - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Text(kind.subtitle) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } Spacer() Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) + .font(.tjScaled( 14, weight: .medium)) .foregroundStyle(Tj.Palette.text3) } .padding(16) diff --git a/康康/Features/Symptom/OngoingSymptomsCard.swift b/康康/Features/Symptom/OngoingSymptomsCard.swift index 2881277..fb06217 100644 --- a/康康/Features/Symptom/OngoingSymptomsCard.swift +++ b/康康/Features/Symptom/OngoingSymptomsCard.swift @@ -25,7 +25,7 @@ struct OngoingSymptomsCard: View { .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text("\(ongoing.count) 个") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Spacer() } @@ -51,12 +51,12 @@ struct OngoingSymptomsCard: View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Text(sym.name) - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) severityDot(sym.severity) } Text("已持续 \(formatDuration(interval))") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(isLong ? Tj.Palette.brick : Tj.Palette.text3) } Spacer(minLength: 8) @@ -64,7 +64,7 @@ struct OngoingSymptomsCard: View { ending = sym } label: { Text("结束") - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .padding(.horizontal, 12) .padding(.vertical, 6) diff --git a/康康/Features/Symptom/SymptomEndSheet.swift b/康康/Features/Symptom/SymptomEndSheet.swift index 8646a9b..a568f48 100644 --- a/康康/Features/Symptom/SymptomEndSheet.swift +++ b/康康/Features/Symptom/SymptomEndSheet.swift @@ -28,7 +28,7 @@ struct SymptomEndSheet: View { HStack { VStack(alignment: .leading, spacing: 4) { Text("结束症状") - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text3) Text(symptom.name) @@ -40,16 +40,16 @@ struct SymptomEndSheet: View { VStack(alignment: .leading, spacing: 6) { Text("开始于") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Text(symptom.startedAt.formatted(date: .abbreviated, time: .shortened)) - .font(.system(size: 14, weight: .medium)) + .font(.tjScaled( 14, weight: .medium)) .foregroundStyle(Tj.Palette.text) } VStack(alignment: .leading, spacing: 8) { Text("结束时间") - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) DatePicker("", selection: $endedAt, in: lowerBound...Date.now) @@ -59,11 +59,11 @@ struct SymptomEndSheet: View { HStack { Text("本次持续") - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text3) Spacer() Text(durationLabel) - .font(.system(size: 15, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 15, weight: .semibold, design: .monospaced)) .foregroundStyle(Tj.Palette.brick) } .padding(.horizontal, 14) diff --git a/康康/Features/Symptom/SymptomStartSheet.swift b/康康/Features/Symptom/SymptomStartSheet.swift index 71e0457..43aa579 100644 --- a/康康/Features/Symptom/SymptomStartSheet.swift +++ b/康康/Features/Symptom/SymptomStartSheet.swift @@ -69,7 +69,7 @@ struct SymptomStartSheet: View { .foregroundStyle(Tj.Palette.text) Spacer() Text("结束时再来点结束") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } .padding(.horizontal, 20) @@ -130,15 +130,15 @@ struct SymptomStartSheet: View { sectionLabel(String(appLoc: "强度")) Spacer() Text("\(Int(severity)) / 5") - .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 13, weight: .semibold, design: .monospaced)) .foregroundStyle(severityColor) } Slider(value: $severity, in: 1...5, step: 1) .tint(severityColor) HStack { - Text("轻微").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3) + Text("轻微").font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3) Spacer() - Text("剧烈").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3) + Text("剧烈").font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3) } } } @@ -190,7 +190,7 @@ struct SymptomStartSheet: View { private func sectionLabel(_ text: String) -> some View { Text(text) - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) } @@ -198,7 +198,7 @@ struct SymptomStartSheet: View { private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) - .font(.system(size: 13, weight: selected ? .semibold : .regular)) + .font(.tjScaled( 13, weight: selected ? .semibold : .regular)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text) .padding(.horizontal, 14) .padding(.vertical, 8) diff --git a/康康/Features/Timeline/TimelineEntryDetailView.swift b/康康/Features/Timeline/TimelineEntryDetailView.swift index 5bc44eb..132f2e5 100644 --- a/康康/Features/Timeline/TimelineEntryDetailView.swift +++ b/康康/Features/Timeline/TimelineEntryDetailView.swift @@ -52,6 +52,7 @@ struct TimelineEntryDetailView: View { let detail: TimelineDetail @State private var showDeleteConfirm = false + @State private var evidenceTarget: Indicator? var body: some View { VStack(spacing: 0) { @@ -77,6 +78,11 @@ struct TimelineEntryDetailView: View { } message: { Text("删除后无法恢复。") } + .sheet(item: $evidenceTarget) { indicator in + if let report = indicator.report { + EvidenceImagePreview(report: report, indicator: indicator) + } + } } // MARK: - 删除(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6) @@ -84,7 +90,7 @@ struct TimelineEntryDetailView: View { private var deleteButton: some View { Button(role: .destructive) { showDeleteConfirm = true } label: { Label(String(appLoc: "永久删除"), systemImage: "trash") - .font(.system(size: 12, weight: .medium)) + .font(.tjScaled( 12, weight: .medium)) .foregroundStyle(Tj.Palette.brick.opacity(0.8)) .padding(.horizontal, 14) .padding(.vertical, 8) @@ -136,7 +142,7 @@ struct TimelineEntryDetailView: View { HStack(spacing: 12) { Button { dismiss() } label: { Image(systemName: "xmark") - .font(.system(size: 16, weight: .semibold)) + .font(.tjScaled( 16, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .frame(width: 32, height: 32) .background(Circle().fill(Tj.Palette.sand2)) @@ -187,16 +193,19 @@ struct TimelineEntryDetailView: View { } HStack(alignment: .firstTextBaseline, spacing: 4) { Text(i.value) - .font(.system(size: 30, weight: .bold, design: .rounded)) + .font(.tjScaled( 30, weight: .bold, design: .rounded)) .foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick) if !i.unit.isEmpty { - Text(i.unit).font(.system(size: 14)).foregroundStyle(Tj.Palette.text3) + Text(i.unit).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3) } } divider if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) } field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt)) field(String(appLoc: "来源"), i.report?.title ?? i.source.label) + if let report = i.report { + evidenceButton(for: i, assets: report.assets) + } if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) } } } @@ -215,9 +224,9 @@ struct TimelineEntryDetailView: View { } HStack(alignment: .firstTextBaseline, spacing: 4) { Text("\(sys.value)/\(dia?.value ?? "—")") - .font(.system(size: 30, weight: .bold, design: .rounded)) + .font(.tjScaled( 30, weight: .bold, design: .rounded)) .foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick) - Text("mmHg").font(.system(size: 14)).foregroundStyle(Tj.Palette.text3) + Text("mmHg").font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3) } divider if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) } @@ -237,10 +246,10 @@ struct TimelineEntryDetailView: View { HStack(spacing: 8) { TjBadge(text: r.type.label, style: .neutral) Text(Self.dateText(r.reportDate)) - .font(.system(size: 12)).foregroundStyle(Tj.Palette.text3) + .font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) if !r.assets.isEmpty { Text(String(appLoc: "原图\(r.assets.count)张")) - .font(.system(size: 12)).foregroundStyle(Tj.Palette.text3) + .font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) } } if let inst = r.institution, !inst.isEmpty { @@ -251,8 +260,8 @@ struct TimelineEntryDetailView: View { if let sum = r.summary, !sum.isEmpty { card { Text(String(appLoc: "摘要")) - .font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2) - Text(sum).font(.system(size: 14)).foregroundStyle(Tj.Palette.text) + .font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2) + Text(sum).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) } } @@ -260,15 +269,18 @@ struct TimelineEntryDetailView: View { if !r.indicators.isEmpty { card { Text(String(appLoc: "指标")) - .font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2) + .font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2) ForEach(sorted) { ind in - HStack { - Text(ind.name).font(.system(size: 14)).foregroundStyle(Tj.Palette.text) - Spacer(minLength: 8) - Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)") - .font(.system(size: 13, design: .monospaced)) - .foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick) - statusChip(ind.status) + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(ind.name).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text) + Spacer(minLength: 8) + Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)") + .font(.tjScaled( 13, design: .monospaced)) + .foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick) + statusChip(ind.status) + } + evidenceButton(for: ind, assets: r.assets) } } } @@ -286,9 +298,9 @@ struct TimelineEntryDetailView: View { VStack(alignment: .leading, spacing: 16) { card { Text(Self.dateTimeText(d.createdAt)) - .font(.system(size: 12)).foregroundStyle(Tj.Palette.text3) + .font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3) Text(d.content) - .font(.system(size: 15)) + .font(.tjScaled( 15)) .foregroundStyle(Tj.Palette.text) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) @@ -309,7 +321,7 @@ struct TimelineEntryDetailView: View { Spacer() if s.isOngoing { Text(String(appLoc: "进行中")) - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.brick) .padding(.horizontal, 8).padding(.vertical, 4) .background(Capsule().fill(Tj.Palette.brick.opacity(0.14))) @@ -346,16 +358,36 @@ struct TimelineEntryDetailView: View { private func field(_ label: String, _ value: String) -> some View { HStack(alignment: .top, spacing: 12) { - Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text3) + Text(label).font(.tjScaled( 13)).foregroundStyle(Tj.Palette.text3) Spacer(minLength: 12) Text(value) - .font(.system(size: 14, weight: .medium)) + .font(.tjScaled( 14, weight: .medium)) .foregroundStyle(Tj.Palette.text) .multilineTextAlignment(.trailing) .fixedSize(horizontal: false, vertical: true) } } + @ViewBuilder + private func evidenceButton(for indicator: Indicator, assets: [Asset]) -> some View { + if indicator.hasEvidenceBox, + let page = indicator.sourcePageIndex, + assets.indices.contains(page) { + Button { + evidenceTarget = indicator + } label: { + Label(String(appLoc: "查看原图位置"), systemImage: "viewfinder") + .font(.tjScaled( 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.ink) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Capsule().fill(Tj.Palette.leaf.opacity(0.14))) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + private var divider: some View { Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) } @@ -370,8 +402,8 @@ struct TimelineEntryDetailView: View { case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = "" } return HStack(spacing: 3) { - if !arrow.isEmpty { Text(arrow).font(.system(size: 11, weight: .bold)) } - Text(text).font(.system(size: 12, weight: .semibold)) + if !arrow.isEmpty { Text(arrow).font(.tjScaled( 11, weight: .bold)) } + Text(text).font(.tjScaled( 12, weight: .semibold)) } .foregroundStyle(color) .padding(.horizontal, 8) @@ -387,3 +419,142 @@ struct TimelineEntryDetailView: View { d.formatted(.dateTime.year().month().day()) } } + +private struct EvidenceImagePreview: View { + @Environment(\.dismiss) private var dismiss + let report: Report + let indicator: Indicator + + @State private var selection: Int + + init(report: Report, indicator: Indicator) { + self.report = report + self.indicator = indicator + let page = indicator.sourcePageIndex ?? 0 + _selection = State(initialValue: min(max(page, 0), max(report.assets.count - 1, 0))) + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.tjScaled( 16, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .frame(width: 32, height: 32) + .background(Circle().fill(Tj.Palette.sand2)) + } + VStack(alignment: .leading, spacing: 2) { + Text(indicator.name) + .font(.tjScaled( 16, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text("第 \(selection + 1) 页 · 原图证据") + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background(Tj.Palette.sand) + .overlay(alignment: .bottom) { + Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) + } + + TabView(selection: $selection) { + ForEach(Array(report.assets.enumerated()), id: \.offset) { index, asset in + EvidenceImagePage( + asset: asset, + highlight: index == indicator.sourcePageIndex ? indicator.evidenceRect : nil + ) + .tag(index) + .padding(16) + } + } + .tabViewStyle(.page(indexDisplayMode: report.assets.count > 1 ? .automatic : .never)) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .presentationBackground(Tj.Palette.sand) + } +} + +private struct EvidenceImagePage: View { + let asset: Asset + let highlight: CGRect? + + private var image: UIImage? { + try? FileVault.shared.loadImage(relativePath: asset.relativePath) + } + + var body: some View { + GeometryReader { geo in + if let image { + ZStack { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(width: geo.size.width, height: geo.size.height) + if let highlight { + EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight) + } + } + .frame(width: geo.size.width, height: geo.size.height) + .background(Tj.Palette.paper) + .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + ) + } else { + TjPlaceholder(label: String(appLoc: "原图无法读取")) + .frame(width: geo.size.width, height: geo.size.height) + } + } + } +} + +private struct EvidenceHighlightOverlay: View { + let imageSize: CGSize + let normalizedRect: CGRect + + var body: some View { + GeometryReader { geo in + let fitted = fittedRect(imageSize: imageSize, containerSize: geo.size) + let rect = CGRect( + x: fitted.minX + normalizedRect.minX * fitted.width, + y: fitted.minY + normalizedRect.minY * fitted.height, + width: normalizedRect.width * fitted.width, + height: normalizedRect.height * fitted.height + ) + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Tj.Palette.brick.opacity(0.16)) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Tj.Palette.brick, lineWidth: 2) + ) + .frame(width: rect.width, height: rect.height) + .position(x: rect.midX, y: rect.midY) + .shadow(color: Tj.Palette.brick.opacity(0.24), radius: 8, y: 2) + } + .allowsHitTesting(false) + } + + private func fittedRect(imageSize: CGSize, containerSize: CGSize) -> CGRect { + guard imageSize.width > 0, + imageSize.height > 0, + containerSize.width > 0, + containerSize.height > 0 else { + return .zero + } + let scale = min(containerSize.width / imageSize.width, containerSize.height / imageSize.height) + let size = CGSize(width: imageSize.width * scale, height: imageSize.height * scale) + return CGRect( + x: (containerSize.width - size.width) / 2, + y: (containerSize.height - size.height) / 2, + width: size.width, + height: size.height + ) + } +} diff --git a/康康/Features/Timeline/TimelineRow.swift b/康康/Features/Timeline/TimelineRow.swift index 4fbd8fd..1b63955 100644 --- a/康康/Features/Timeline/TimelineRow.swift +++ b/康康/Features/Timeline/TimelineRow.swift @@ -9,7 +9,7 @@ struct TimelineRow: View { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(entry.kind.accent.opacity(0.12)) Image(systemName: entry.kind.icon) - .font(.system(size: 14, weight: .semibold)) + .font(.tjScaled( 14, weight: .semibold)) .foregroundStyle(entry.kind.accent) } .frame(width: 36, height: 36) @@ -25,12 +25,12 @@ struct TimelineRow: View { VStack(alignment: .leading, spacing: 2) { Text("\(entry.date.timelineLabel) · \(entry.subtitle)") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .tracking(0.3) .foregroundStyle(Tj.Palette.text3) .lineLimit(1) Text(entry.title) - .font(.system(size: 14, weight: .medium)) + .font(.tjScaled( 14, weight: .medium)) .foregroundStyle(Tj.Palette.text) .lineLimit(1) .truncationMode(.tail) @@ -38,7 +38,7 @@ struct TimelineRow: View { Spacer(minLength: 8) if let trailing = entry.trailing { Text(trailing) - .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 12, weight: .semibold, design: .monospaced)) .foregroundStyle(entry.trailingIsAlert ? Tj.Palette.brick : Tj.Palette.text2) .lineLimit(1) .fixedSize() diff --git a/康康/Features/Trends/CalendarMonthGrid.swift b/康康/Features/Trends/CalendarMonthGrid.swift index f5210d6..788e350 100644 --- a/康康/Features/Trends/CalendarMonthGrid.swift +++ b/康康/Features/Trends/CalendarMonthGrid.swift @@ -66,7 +66,7 @@ struct CalendarMonthGrid: View { HStack(spacing: 4) { ForEach(weekdayLabels, id: \.self) { w in Text(w) - .font(.system(size: 11, weight: .medium)) + .font(.tjScaled( 11, weight: .medium)) .foregroundStyle(Tj.Palette.text3) .frame(maxWidth: .infinity) } @@ -123,7 +123,7 @@ private struct DayCellView: View { VStack(spacing: 2) { Text("\(dayNumber)") - .font(.system(size: 13, + .font(.tjScaled( 13, weight: (isToday || isSelected) ? .bold : .regular, design: .default)) .foregroundStyle(textColor) @@ -137,7 +137,7 @@ private struct DayCellView: View { } if ranges.count > 2 { Text("+\(ranges.count - 2)") - .font(.system(size: 7, design: .monospaced)) + .font(.tjScaled( 7, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } } diff --git a/康康/Features/Trends/CalendarYearGrid.swift b/康康/Features/Trends/CalendarYearGrid.swift index 0d0ce82..f0871db 100644 --- a/康康/Features/Trends/CalendarYearGrid.swift +++ b/康康/Features/Trends/CalendarYearGrid.swift @@ -62,7 +62,7 @@ private struct MiniMonth: View { var body: some View { VStack(alignment: .leading, spacing: 6) { Text(monthLabel) - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text) LazyVGrid(columns: microColumns, spacing: 2) { diff --git a/康康/Features/Trends/DayDetailSheet.swift b/康康/Features/Trends/DayDetailSheet.swift index d1b3412..988dcd8 100644 --- a/康康/Features/Trends/DayDetailSheet.swift +++ b/康康/Features/Trends/DayDetailSheet.swift @@ -104,7 +104,7 @@ struct DayDetailContent: View { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 4) { Text(dateLine) - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .tracking(0.5) .foregroundStyle(Tj.Palette.text3) Text(dayLabel) @@ -114,7 +114,7 @@ struct DayDetailContent: View { Spacer() if totalCount > 0 { Text("\(totalCount) 条") - .font(.system(size: 12, design: .monospaced)) + .font(.tjScaled( 12, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } } @@ -140,11 +140,11 @@ struct DayDetailContent: View { VStack(alignment: .leading, spacing: 10) { HStack { Text(title) - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) Text("\(count)") - .font(.system(size: 11, design: .monospaced)) + .font(.tjScaled( 11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) Spacer() } @@ -162,17 +162,17 @@ struct DayDetailContent: View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(s.name) - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Text(state.badge) - .font(.system(size: 10, weight: .semibold)) + .font(.tjScaled( 10, weight: .semibold)) .foregroundStyle(state.badgeFg) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Capsule().fill(state.badgeBg)) } Text("\(state.subtitle) · 持续 \(formatDuration(s.duration))") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } Spacer(minLength: 6) @@ -181,7 +181,7 @@ struct DayDetailContent: View { endingSymptom = s } label: { Text("结束") - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .padding(.horizontal, 12) .padding(.vertical, 6) @@ -200,24 +200,24 @@ struct DayDetailContent: View { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(indicatorAccent(i).opacity(0.12)) Image(systemName: "drop.fill") - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(indicatorAccent(i)) } .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 2) { Text(i.name) - .font(.system(size: 14, weight: .medium)) + .font(.tjScaled( 14, weight: .medium)) .foregroundStyle(Tj.Palette.text) .lineLimit(1) if !i.range.isEmpty { Text("参考 \(i.range)") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } } Spacer(minLength: 6) Text("\(i.value) \(i.unit)\(arrow(i))") - .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 13, weight: .semibold, design: .monospaced)) .foregroundStyle(i.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick) .lineLimit(1) .fixedSize() @@ -235,23 +235,23 @@ struct DayDetailContent: View { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Tj.Palette.ink2.opacity(0.12)) Image(systemName: "doc.fill") - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.ink2) } .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 2) { Text(r.title) - .font(.system(size: 14, weight: .medium)) + .font(.tjScaled( 14, weight: .medium)) .foregroundStyle(Tj.Palette.text) .lineLimit(1) Text("\(r.type.label) · 共 \(r.pageCount) 页") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } Spacer(minLength: 6) if let summary { Text(summary) - .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 11, weight: .semibold, design: .monospaced)) .foregroundStyle(Tj.Palette.brick) } } @@ -263,7 +263,7 @@ struct DayDetailContent: View { VStack(alignment: .leading, spacing: 6) { HStack { Text(d.createdAt.formatted(date: .omitted, time: .shortened)) - .font(.system(size: 11, design: .monospaced)) + .font(.tjScaled( 11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) Spacer() } @@ -284,7 +284,7 @@ struct DayDetailContent: View { .frame(height: 90) .frame(maxWidth: 240) Text("点底部 + 号可以补一条") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } .padding(.vertical, 12) diff --git a/康康/Features/Trends/SeriesChartCard.swift b/康康/Features/Trends/SeriesChartCard.swift index e331bca..613dad9 100644 --- a/康康/Features/Trends/SeriesChartCard.swift +++ b/康康/Features/Trends/SeriesChartCard.swift @@ -66,10 +66,10 @@ struct SeriesChartCard: View { private var header: some View { HStack(alignment: .lastTextBaseline, spacing: 10) { Text(bucket.title) - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Text("\(allPoints.count) 条 · 近 \(daysSpanLabel)") - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) Spacer() latestValueBadge @@ -87,10 +87,10 @@ struct SeriesChartCard: View { } return HStack(spacing: 4) { Text(joined) - .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 14, weight: .semibold, design: .monospaced)) .foregroundStyle(anyAbnormal ? Tj.Palette.brick : Tj.Palette.text) Text(bucket.unit) - .font(.system(size: 10, design: .monospaced)) + .font(.tjScaled( 10, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } } @@ -142,7 +142,7 @@ struct SeriesChartCard: View { AxisGridLine().foregroundStyle(Tj.Palette.lineSoft) AxisValueLabel() .foregroundStyle(Tj.Palette.text3) - .font(.system(size: 10, design: .monospaced)) + .font(.tjScaled( 10, design: .monospaced)) } } .chartYScale(domain: valueDomain ?? 0...1) @@ -156,7 +156,7 @@ struct SeriesChartCard: View { .fill(line.color) .frame(width: 8, height: 8) Text(line.label ?? line.seriesKey) - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text2) } } diff --git a/康康/Features/Trends/TrendDetailView.swift b/康康/Features/Trends/TrendDetailView.swift index 0aee045..5d4327b 100644 --- a/康康/Features/Trends/TrendDetailView.swift +++ b/康康/Features/Trends/TrendDetailView.swift @@ -99,7 +99,7 @@ struct TrendDetailView: View { withAnimation(.snappy(duration: 0.2)) { range = r } } label: { Text(r.label) - .font(.system(size: 12, weight: range == r ? .semibold : .regular)) + .font(.tjScaled( 12, weight: range == r ? .semibold : .regular)) .foregroundStyle(range == r ? Tj.Palette.paper : Tj.Palette.text) .frame(maxWidth: .infinity) .padding(.vertical, 7) @@ -210,7 +210,7 @@ struct TrendDetailView: View { AxisGridLine().foregroundStyle(Tj.Palette.lineSoft) AxisValueLabel() .foregroundStyle(Tj.Palette.text3) - .font(.system(size: 10, design: .monospaced)) + .font(.tjScaled( 10, design: .monospaced)) } } .chartYScale(domain: valueDomain ?? 0...1) @@ -222,7 +222,7 @@ struct TrendDetailView: View { HStack(spacing: 5) { Circle().fill(line.color).frame(width: 8, height: 8) Text(line.label ?? line.seriesKey) - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text2) } } @@ -265,20 +265,20 @@ struct TrendDetailView: View { VStack(alignment: .leading, spacing: 10) { if filteredLines.count > 1, let label = line.label { Text(label) - .font(.system(size: 12, weight: .semibold)) + .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) } HStack(alignment: .firstTextBaseline, spacing: 6) { Text(latest.map { fmt($0.value) } ?? "—") - .font(.system(size: 28, weight: .bold, design: .monospaced)) + .font(.tjScaled( 28, weight: .bold, design: .monospaced)) .foregroundStyle((latest?.status ?? .normal) == .normal ? Tj.Palette.text : Tj.Palette.brick) Text(bucket.unit) - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Spacer() if let delta = deltaText(latest: latest, prev: prev) { Text(delta.text) - .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 13, weight: .semibold, design: .monospaced)) .foregroundStyle(delta.color) } } @@ -294,10 +294,10 @@ struct TrendDetailView: View { private func statCell(_ label: String, _ value: String) -> some View { VStack(spacing: 3) { Text(value) - .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 14, weight: .semibold, design: .monospaced)) .foregroundStyle(Tj.Palette.text) Text(label) - .font(.system(size: 10)) + .font(.tjScaled( 10)) .foregroundStyle(Tj.Palette.text3) } .frame(maxWidth: .infinity) @@ -323,10 +323,10 @@ struct TrendDetailView: View { private var aiPlaceholder: some View { HStack(spacing: 8) { Image(systemName: "sparkles") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Text("AI 趋势解读即将上线") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Spacer() } @@ -364,7 +364,7 @@ struct TrendDetailView: View { private var pointsList: some View { VStack(alignment: .leading, spacing: 10) { Text("全部记录") - .font(.system(size: 13, weight: .semibold)) + .font(.tjScaled( 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) VStack(spacing: 8) { ForEach(pointRows) { row in @@ -382,7 +382,7 @@ struct TrendDetailView: View { private func pointRowView(_ row: PointRow) -> some View { HStack(spacing: 12) { Text(row.day.formatted(.dateTime.year().month(.abbreviated).day())) - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text2) Spacer(minLength: 8) HStack(spacing: 10) { @@ -393,14 +393,14 @@ struct TrendDetailView: View { Circle().fill(line.color).frame(width: 6, height: 6) } Text(fmt(p.value) + arrow(p.status)) - .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 13, weight: .semibold, design: .monospaced)) .foregroundStyle(p.status == .normal ? Tj.Palette.text : Tj.Palette.brick) } } } } Image(systemName: "chevron.right") - .font(.system(size: 11, weight: .medium)) + .font(.tjScaled( 11, weight: .medium)) .foregroundStyle(Tj.Palette.text3) } .padding(12) diff --git a/康康/Features/Trends/TrendRow.swift b/康康/Features/Trends/TrendRow.swift index d10da9c..8eaac38 100644 --- a/康康/Features/Trends/TrendRow.swift +++ b/康康/Features/Trends/TrendRow.swift @@ -19,11 +19,11 @@ struct TrendRow: View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 3) { Text(bucket.title) - .font(.system(size: 15, weight: .semibold)) + .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .lineLimit(1) Text(subtitle) - .font(.system(size: 11)) + .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } @@ -34,17 +34,17 @@ struct TrendRow: View { VStack(alignment: .trailing, spacing: 2) { Text(latestValue) - .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .font(.tjScaled( 14, weight: .semibold, design: .monospaced)) .foregroundStyle(anyLatestAbnormal ? Tj.Palette.brick : Tj.Palette.text) .lineLimit(1) Text(bucket.unit) - .font(.system(size: 9, design: .monospaced)) + .font(.tjScaled( 9, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } .fixedSize() Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .medium)) + .font(.tjScaled( 12, weight: .medium)) .foregroundStyle(Tj.Palette.text3) } .padding(14) diff --git a/康康/Features/Trends/TrendsView.swift b/康康/Features/Trends/TrendsView.swift index 78d36df..da487bd 100644 --- a/康康/Features/Trends/TrendsView.swift +++ b/康康/Features/Trends/TrendsView.swift @@ -63,7 +63,7 @@ struct TrendsView: View { .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text("\(buckets.count) 项") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Spacer() } @@ -87,7 +87,7 @@ struct TrendsView: View { .frame(height: 120) .frame(maxWidth: 260) Text("同一指标记录满 2 次后,会在这里出现时间序列") - .font(.system(size: 12)) + .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) .multilineTextAlignment(.center) } diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings index a6bc3e1..06c9b31 100644 --- a/康康/Localizable.xcstrings +++ b/康康/Localizable.xcstrings @@ -1201,6 +1201,9 @@ } } } + }, + "AI 模型未就绪,手动补充" : { + }, "AI 没有给出建议,请稍后重试" : { "localizations" : { @@ -1267,6 +1270,9 @@ } } } + }, + "AI 趋势解读即将上线" : { + }, "AI 辅助 · 医生角度查漏补缺" : { "localizations" : { @@ -1311,6 +1317,9 @@ } } } + }, + "Apple 健康里没有可导入的生日、性别、身高或血型。" : { + }, "B 型" : { "localizations" : { @@ -1408,9 +1417,21 @@ } } } + }, + "s" : { + + }, + "series" : { + }, "start" : { + }, + "t" : { + + }, + "v" : { + }, "VL 模型尚未就绪" : { "localizations" : { @@ -1477,9 +1498,6 @@ } } } - }, - "VL 模型未就绪,手动补充" : { - }, "VL 输出无法解析:%@" : { "localizations" : { @@ -1591,6 +1609,9 @@ } } } + }, + "上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据" : { + }, "上限" : { "localizations" : { @@ -2101,6 +2122,9 @@ } } } + }, + "从 Apple 健康导入" : { + }, "从文件导入(离线)" : { "localizations" : { @@ -2502,8 +2526,12 @@ } } } + }, + "例:最近血压波动大吗?" : { + }, "例:最近血糖好像不稳,把过去三个月的化验单整理一下" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2526,6 +2554,7 @@ } }, "例:我感冒3天了,把最近一个月的健康情况给医生看" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2546,6 +2575,9 @@ } } } + }, + "例:把我最近头晕、睡眠和指标变化整理给医生" : { + }, "例如:< 3.40 或 3.9 - 6.1" : { "localizations" : { @@ -2877,6 +2909,9 @@ } } } + }, + "健康日历" : { + }, "健康日记" : { "localizations" : { @@ -2899,6 +2934,9 @@ } } } + }, + "健康档案 Aa 123" : { + }, "健康记录" : { "localizations" : { @@ -2966,6 +3004,9 @@ } } } + }, + "先问清楚,再整理给医生" : { + }, "免责声明" : { "localizations" : { @@ -3078,6 +3119,9 @@ } } } + }, + "全部记录" : { + }, "六" : { "localizations" : { @@ -3300,6 +3344,9 @@ } } } + }, + "最低" : { + }, "最近记录" : { "localizations" : { @@ -3322,6 +3369,9 @@ } } } + }, + "最高" : { + }, "冠心病" : { "localizations" : { @@ -3700,6 +3750,9 @@ } } } + }, + "化验指标趋势" : { + }, "化验项快捷(不进趋势)" : { "localizations" : { @@ -3814,6 +3867,9 @@ } } } + }, + "原图无法读取" : { + }, "去设置" : { @@ -3927,6 +3983,9 @@ } } } + }, + "发送问题" : { + }, "取消" : { "localizations" : { @@ -3993,6 +4052,9 @@ } } } + }, + "只读取生日、性别、身高、血型" : { + }, "可选开启 Face ID 启动锁,进一步保护隐私。" : { "localizations" : { @@ -4037,6 +4099,9 @@ } } } + }, + "同一指标记录满 2 次后,会在这里出现时间序列" : { + }, "名称" : { "localizations" : { @@ -4191,6 +4256,9 @@ } } } + }, + "围绕你的指标和健康日记提问" : { + }, "图例" : { "localizations" : { @@ -4257,9 +4325,6 @@ } } } - }, - "图片编码失败,手动补充或重拍" : { - }, "在「+ 新建 → 指标记录 → %@」记录一次" : { "localizations" : { @@ -4306,6 +4371,7 @@ } }, "在这里输入主诉……" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4486,6 +4552,9 @@ } } } + }, + "多轮问答后生成给医生看的整理报告" : { + }, "多页报告" : { "extractionState" : "stale", @@ -4531,6 +4600,9 @@ } } } + }, + "大" : { + }, "失眠" : { "localizations" : { @@ -4795,6 +4867,18 @@ } } } + }, + "字体大小" : { + + }, + "字号放大 20%" : { + + }, + "字号放大 40%" : { + + }, + "字号放大 60%" : { + }, "完成" : { "localizations" : { @@ -4928,6 +5012,12 @@ } } } + }, + "导入" : { + + }, + "导入前会先显示预览,确认后才覆盖个人资料。" : { + }, "导入失败:%@" : { "localizations" : { @@ -4974,27 +5064,8 @@ } } }, - "导出身体档案" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Export health profile" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "身体プロファイルをエクスポート" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "건강 프로필 내보내기" - } - } - } + "导出历史" : { + }, "将追加:" : { "localizations" : { @@ -5514,6 +5585,9 @@ } } } + }, + "平均" : { + }, "年" : { "localizations" : { @@ -5939,6 +6013,9 @@ } } } + }, + "当前: %@" : { + }, "当前用药" : { "localizations" : { @@ -6209,6 +6286,9 @@ } } } + }, + "患者" : { + }, "慢性肾病" : { "localizations" : { @@ -6299,6 +6379,7 @@ } }, "我的导出 · %lld 份" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -6363,6 +6444,9 @@ } } } + }, + "或手动填写" : { + }, "或者自己写" : { "localizations" : { @@ -6432,6 +6516,7 @@ } }, "手动填一项指标(免拍照)" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -6452,6 +6537,12 @@ } } } + }, + "手动填写,或拍照自动识别" : { + + }, + "手动记录" : { + }, "把异常项放进框里 · 对准一两行" : { @@ -6594,6 +6685,9 @@ }, "拍到的局部" : { + }, + "拍化验单,VL 自动读出数值" : { + }, "拍报告的小贴士" : { "localizations" : { @@ -6685,6 +6779,9 @@ } } } + }, + "拍照识别" : { + }, "拍照识别报告 → 结构化指标" : { "localizations" : { @@ -7357,6 +7454,9 @@ } } } + }, + "放大后整个 App 的文字立即变大,无需重启。设置会被记住。" : { + }, "数值" : { "localizations" : { @@ -7447,6 +7547,9 @@ } } } + }, + "整理好的报告" : { + }, "整页入框,避免裁切到指标" : { "localizations" : { @@ -7651,6 +7754,9 @@ } } } + }, + "无法导入 Apple 健康资料" : { + }, "日" : { "localizations" : { @@ -7990,6 +8096,9 @@ } } } + }, + "未读取到的字段不会修改。" : { + }, "未选" : { "localizations" : { @@ -8147,6 +8256,7 @@ } }, "本地 RAG · Qwen3 1.7B · 不上传任何数据" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -8366,6 +8476,12 @@ } } } + }, + "本月 %lld 天有记录" : { + + }, + "本月暂无记录" : { + }, "本机保存" : { "localizations" : { @@ -8580,6 +8696,12 @@ } } } + }, + "查看原图位置" : { + + }, + "标准" : { + }, "标签" : { @@ -8898,6 +9020,9 @@ } } } + }, + "正在查看本地记录…" : { + }, "正常" : { "localizations" : { @@ -9103,6 +9228,9 @@ }, "没有识别到指标,点「加一项」手动补充,或返回重拍" : { + }, + "没识别到文字,手动补充或重拍" : { + }, "没读出指标,手动补充或重拍" : { @@ -9238,6 +9366,9 @@ } } } + }, + "特大" : { + }, "状态" : { "localizations" : { @@ -9397,6 +9528,7 @@ } }, "生成报告" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -9440,8 +9572,12 @@ } } } + }, + "生成整理报告" : { + }, "生成新导出" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -9688,6 +9824,9 @@ }, "相机权限未开启" : { + }, + "确认导入" : { + }, "程度" : { @@ -9764,6 +9903,9 @@ } } } + }, + "第 %lld 页 · 原图证据" : { + }, "第 1 轮 · %lld 条" : { "localizations" : { @@ -10075,6 +10217,7 @@ } }, "给医生看的就诊摘要" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -10140,6 +10283,9 @@ } } } + }, + "继续提问或补充情况…" : { + }, "维生素 D" : { "extractionState" : "stale", @@ -10533,6 +10679,9 @@ } } } + }, + "解析失败:%@" : { + }, "解锁康康,查看你的健康档案" : { "localizations" : { @@ -11011,6 +11160,7 @@ } }, "说说你想给医生看什么" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -11053,6 +11203,9 @@ } } } + }, + "读取生日、性别、身高和血型,确认后填入个人资料" : { + }, "谷丙转氨酶" : { "extractionState" : "stale", @@ -11122,6 +11275,9 @@ } } } + }, + "超大" : { + }, "超过参考上限 0.44,属轻度偏高。建议关注饮食结构(减少动物脂肪摄入),3 个月内复查。若家族有心血管病史,可与医生沟通是否需要药物干预。" : { "extractionState" : "stale", @@ -11259,6 +11415,28 @@ } } }, + "身体档案" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Health profile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "身体プロファイル" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "건강 프로필" + } + } + } + }, "身体档案 · 历史导出" : { "localizations" : { "en" : { @@ -11504,6 +11682,15 @@ } } } + }, + "近1年" : { + + }, + "近3月" : { + + }, + "近6月" : { + }, "返回修改" : { "localizations" : { @@ -11570,6 +11757,9 @@ } } } + }, + "还没有可成趋势的指标" : { + }, "还没有导出过\n回到记录页右上角生成一份" : { "localizations" : { @@ -11703,6 +11893,9 @@ } } } + }, + "这台设备暂不支持读取 Apple 健康资料。" : { + }, "这是什么" : { "localizations" : { @@ -11841,8 +12034,12 @@ }, "重拍" : { + }, + "重新整理" : { + }, "重新生成" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -12262,6 +12459,9 @@ } } } + }, + "默认字号" : { + } }, "version" : "1.0" diff --git a/康康/Models/Models.swift b/康康/Models/Models.swift index b1eb410..dac774a 100644 --- a/康康/Models/Models.swift +++ b/康康/Models/Models.swift @@ -1,4 +1,5 @@ import Foundation +import CoreGraphics import SwiftData enum IndicatorStatus: String, Codable, CaseIterable { @@ -55,6 +56,14 @@ final class Indicator { /// 录入来源(IndicatorSource.rawValue)。带默认值 → SwiftData 轻量迁移,旧记录视为手动。 var sourceRaw: String = IndicatorSource.manual.rawValue + /// VL 从报告原图中定位到的指标证据。页码为 0-based;box 为原图归一化坐标(0...1)。 + /// 全部可选以兼容旧数据、手动录入和无定位的模型输出。 + var sourcePageIndex: Int? + var sourceBoxX: Double? + var sourceBoxY: Double? + var sourceBoxWidth: Double? + var sourceBoxHeight: Double? + init(name: String, value: String, unit: String, @@ -66,7 +75,12 @@ final class Indicator { asset: Asset? = nil, pinned: Bool = false, seriesKey: String? = nil, - source: IndicatorSource = .manual) { + source: IndicatorSource = .manual, + sourcePageIndex: Int? = nil, + sourceBoxX: Double? = nil, + sourceBoxY: Double? = nil, + sourceBoxWidth: Double? = nil, + sourceBoxHeight: Double? = nil) { self.name = name self.value = value self.unit = unit @@ -79,6 +93,11 @@ final class Indicator { self.pinned = pinned self.seriesKey = seriesKey self.sourceRaw = source.rawValue + self.sourcePageIndex = sourcePageIndex + self.sourceBoxX = sourceBoxX + self.sourceBoxY = sourceBoxY + self.sourceBoxWidth = sourceBoxWidth + self.sourceBoxHeight = sourceBoxHeight } var status: IndicatorStatus { @@ -88,6 +107,22 @@ final class Indicator { var source: IndicatorSource { IndicatorSource(rawValue: sourceRaw) ?? .manual } + + var hasEvidenceBox: Bool { + evidenceRect != nil && sourcePageIndex != nil + } + + var evidenceRect: CGRect? { + guard let x = sourceBoxX, + let y = sourceBoxY, + let width = sourceBoxWidth, + let height = sourceBoxHeight, + x >= 0, y >= 0, width > 0, height > 0, + x + width <= 1, y + height <= 1 else { + return nil + } + return CGRect(x: x, y: y, width: width, height: height) + } } @Model diff --git a/康康/RootView.swift b/康康/RootView.swift index 6ac4eb4..be77a8b 100644 --- a/康康/RootView.swift +++ b/康康/RootView.swift @@ -44,6 +44,7 @@ struct RootView: View { @State private var showDiary = false @State private var showIndicator = false @State private var showReminders = false + @State private var showHealthExport = false /// 统一的 tab 切换入口:按方向设定 pushEdge,再带动画改 tab。 /// 所有改 tab 的地方都走这里,保证过渡方向正确。 @@ -83,6 +84,7 @@ struct RootView: View { case .diary: showDiary = true case .indicator: showIndicator = true case .reminder: showReminders = true + case .healthExport: showHealthExport = true } } } @@ -94,12 +96,21 @@ struct RootView: View { DiaryQuickSheet() } .sheet(isPresented: $showIndicator) { - IndicatorQuickSheet() + // 「拍照识别」入口:关闭手输表单 → 打开异常项快拍 VL 流程(并入「记录指标」)。 + IndicatorQuickSheet(onRequestCamera: { + showIndicator = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + activeFlow = .quick + } + }) } .sheet(isPresented: $showReminders) { // 列表页依赖外层 NavigationStack 提供标题栏;sheet 形态补「完成」按钮。 NavigationStack { RemindersListView(presentedAsSheet: true) } } + .fullScreenCover(isPresented: $showHealthExport) { + HealthExportSheet() + } #if os(iOS) .fullScreenCover(item: $activeFlow) { flow in switch flow { @@ -176,12 +187,12 @@ private struct TabBar: View { .matchedGeometryEffect(id: "tabIndicator", in: indicatorNS) } Image(systemName: t.icon) - .font(.system(size: 18, weight: isActive ? .semibold : .regular)) + .font(.tjScaled( 18, weight: isActive ? .semibold : .regular)) } .frame(width: 50, height: slotHeight) Text(t.label) - .font(.system(size: 11, weight: isActive ? .semibold : .regular)) + .font(.tjScaled( 11, weight: isActive ? .semibold : .regular)) } .foregroundStyle(isActive ? Tj.Palette.ink : Tj.Palette.text3) .frame(maxWidth: .infinity) @@ -204,13 +215,13 @@ private struct TabBar: View { radius: 4, x: 0, y: 2) Image(systemName: "plus") - .font(.system(size: 16, weight: .semibold)) + .font(.tjScaled( 16, weight: .semibold)) .foregroundStyle(Tj.Palette.paper) } .frame(width: slotHeight, height: slotHeight) Text("新建") - .font(.system(size: 11, weight: .semibold)) + .font(.tjScaled( 11, weight: .semibold)) .foregroundStyle(Tj.Palette.ink) } .frame(maxWidth: .infinity) diff --git a/康康/Security/LockScreenView.swift b/康康/Security/LockScreenView.swift index 6c34415..8f882ad 100644 --- a/康康/Security/LockScreenView.swift +++ b/康康/Security/LockScreenView.swift @@ -25,7 +25,7 @@ struct LockScreenView: View { .fill(Tj.Palette.paper) .overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1)) Image(systemName: "lock.fill") - .font(.system(size: 34)) + .font(.tjScaled( 34)) .foregroundStyle(Tj.Palette.ink) } .frame(width: 92, height: 92) @@ -36,7 +36,7 @@ struct LockScreenView: View { .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text("你的健康档案已加密保护") - .font(.system(size: 13)) + .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text3) } @@ -72,7 +72,7 @@ struct PrivacyCoverView: View { .fill(Tj.Palette.paper) .overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1)) Image(systemName: "heart.text.square.fill") - .font(.system(size: 30)) + .font(.tjScaled( 30)) .foregroundStyle(Tj.Palette.ink) } .frame(width: 80, height: 80) diff --git a/康康/Services/CaptureService.swift b/康康/Services/CaptureService.swift index 527950a..a64163b 100644 --- a/康康/Services/CaptureService.swift +++ b/康康/Services/CaptureService.swift @@ -21,6 +21,11 @@ struct ParsedReport: Sendable { var unit: String var range: String var status: IndicatorStatus + var sourcePageIndex: Int? + var sourceBoxX: Double? + var sourceBoxY: Double? + var sourceBoxWidth: Double? + var sourceBoxHeight: Double? } /// 一项都没识别出来 = 视作失败,UI 走手动录入回退。 @@ -100,11 +105,16 @@ actor CaptureService { do { raw = try await AIRuntime.shared.analyzeReport( imageURLs: [tmpURL], - prompt: VLPrompts.regionExtraction() + prompt: VLPrompts.regionExtraction(), + // 整张化验单可能含十余项,512 token 会截断 → 解析失败。给足额度。 + maxTokens: 2048 ) } catch { throw CaptureError.inferenceFailed("\(error)") } + #if DEBUG + print("🔎 [recognizeRegion] image bytes=\(imageData.count), VL raw output:\n\(raw)\n--- end VL raw ---") + #endif do { return try CaptureService.parseIndicatorsJSON(raw) } catch let CaptureError.parseFailed(msg) { @@ -114,6 +124,56 @@ actor CaptureService { } } + /// 「拍照识别」OCR 链路:把 Vision OCR 出的纯文本交给 LLM(Qwen3-1.7B)结构化抽指标。 + /// 不建 Report、不留图;失败抛 `CaptureError`,UI 回退手动录入(§3.2)。 + /// 调用方(MainActor)先做 OCR,再把文本传进来——OCR 不需进 actor,也避免 UIImage 跨 actor。 + func recognizeIndicators(fromOCRText text: String) async throws -> [ParsedReport.ParsedIndicator] { + do { + try await AIRuntime.shared.prepare() // 载 LLM(会先卸 VL,OOM 闸门已处理) + } catch { + throw CaptureError.modelNotReady + } + + let prompt = VLPrompts.indicatorsFromText(text) + var collected = "" + do { + // 整张化验单十余项,给足 token;LLM 解码与任何 VL 解码由 AIRuntime 闸门串行。 + let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 2048) + for try await chunk in stream { + collected += chunk.text + } + } catch { + throw CaptureError.inferenceFailed("\(error)") + } + + // Qwen3 可能吐 ,先剥掉再抠 JSON。 + let cleaned = CaptureService.stripThink(collected) + #if DEBUG + print("🧠 [recognizeIndicators] LLM cleaned output:\n\(cleaned)\n--- end LLM ---") + #endif + do { + return try CaptureService.parseIndicatorsJSON(cleaned) + } catch let CaptureError.parseFailed(msg) { + throw CaptureError.parseFailed(msg) + } catch { + throw CaptureError.parseFailed("\(error)") + } + } + + /// 剥掉 Qwen3 的 (配对块 / 未闭合开标签 / 孤立闭标签),再 trim 顶部空白。 + /// 与 HealthExportService.stripThinkBlocks 同逻辑,但本类是非 MainActor actor,放一份 nonisolated 版避免跨隔离调用。 + nonisolated static func stripThink(_ raw: String) -> String { + var s = raw + while let openR = s.range(of: ""), + let closeR = s.range(of: "", range: openR.upperBound..") { s = String(s[..") { s = String(s[closeR.upperBound...]) } + while let first = s.first, first.isWhitespace { s.removeFirst() } + return s + } + /// VL 推理 + JSON 解析的纯阶段。assets 必须已写入 Vault。 private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport { do { @@ -344,7 +404,36 @@ actor CaptureService { let range = stringValue(d, keys: ["range", "reference", "reference_range", "ref", "参考", "参考值", "参考范围", "正常范围"]) ?? "" let statusRaw = stringValue(d, keys: ["status", "flag", "abnormal", "异常", "提示", "标记"]) let status = parseIndicatorStatus(raw: statusRaw, value: value, range: range) - return .init(name: name, value: value, unit: unit, range: range, status: status) + let evidence = parseEvidenceLocation(d) + return .init( + name: name, + value: value, + unit: unit, + range: range, + status: status, + sourcePageIndex: evidence?.pageIndex, + sourceBoxX: evidence?.box.x, + sourceBoxY: evidence?.box.y, + sourceBoxWidth: evidence?.box.width, + sourceBoxHeight: evidence?.box.height + ) + } + + private static func parseEvidenceLocation(_ d: [String: Any]) -> (pageIndex: Int, box: (x: Double, y: Double, width: Double, height: Double))? { + guard let page = intValue(d, keys: ["source_page", "sourcePage", "page", "页码", "来源页码"]), + page >= 1, + let box = numberArrayValue(d, keys: ["source_box", "sourceBox", "box", "bbox", "位置", "来源位置"]), + box.count == 4 else { + return nil + } + let x = box[0] + let y = box[1] + let width = box[2] + let height = box[3] + guard x >= 0, y >= 0, width > 0, height > 0, x + width <= 1, y + height <= 1 else { + return nil + } + return (page - 1, (x, y, width, height)) } private static func stringValue(_ d: [String: Any], keys: [String]) -> String? { @@ -359,6 +448,44 @@ actor CaptureService { return nil } + private static func intValue(_ d: [String: Any], keys: [String]) -> Int? { + for key in keys { + if let i = d[key] as? Int { + return i + } + if let n = d[key] as? NSNumber { + return n.intValue + } + if let s = d[key] as? String, let i = Int(s.trimmingCharacters(in: .whitespacesAndNewlines)) { + return i + } + } + return nil + } + + private static func numberArrayValue(_ d: [String: Any], keys: [String]) -> [Double]? { + for key in keys { + if let arr = d[key] as? [Double] { + return arr + } + if let arr = d[key] as? [NSNumber] { + return arr.map(\.doubleValue) + } + if let arr = d[key] as? [Any] { + let values = arr.compactMap { item -> Double? in + if let d = item as? Double { return d } + if let n = item as? NSNumber { return n.doubleValue } + if let s = item as? String { return Double(s.trimmingCharacters(in: .whitespacesAndNewlines)) } + return nil + } + if values.count == arr.count { + return values + } + } + } + return nil + } + private static func arrayValue(_ d: [String: Any], keys: [String]) -> [[String: Any]] { for key in keys { if let arr = d[key] as? [[String: Any]] { @@ -480,7 +607,12 @@ extension Report { status: p.status, capturedAt: reportDate, report: self, - source: .report + source: .report, + sourcePageIndex: p.sourcePageIndex, + sourceBoxX: p.sourceBoxX, + sourceBoxY: p.sourceBoxY, + sourceBoxWidth: p.sourceBoxWidth, + sourceBoxHeight: p.sourceBoxHeight ) ctx.insert(i) } diff --git a/康康/Services/ExportTrendBuilder.swift b/康康/Services/ExportTrendBuilder.swift new file mode 100644 index 0000000..36a3acf --- /dev/null +++ b/康康/Services/ExportTrendBuilder.swift @@ -0,0 +1,181 @@ +import Foundation + +/// 导出身体档案「## 指标趋势」段的一条趋势摘要。 +/// +/// 设计见 `docs/superpowers/specs/2026-06-07-export-indicator-trends-design.md`: +/// 对本次就诊相关、且时间窗内有 ≥2 次记录的指标,给一行确定性摘要 +/// (首值→末值 + 方向 + 时间跨度 + 次数),**不经 LLM**,与 `ReportCompareService` 同思路, +/// 从根上杜绝小模型编造趋势数字(§10#5 失败回退 / §12#6 禁止编造)。 +struct ExportTrend: Sendable { + + enum Direction: Sendable { + case up, down, flat + var arrow: String { + switch self { + case .up: return "↑" + case .down: return "↓" + case .flat: return "→" + } + } + } + + let title: String + let unit: String + /// "152→138" 或血压双值 "152/96→138/88"。 + let valueText: String + let direction: Direction + /// 参考范围文本,如 "90-140";无(单边范围解析不出 / 血压双范围)则 nil。 + let rangeText: String? + /// 首末两次记录之间的天数。 + let spanDays: Int + /// 时间窗内记录次数。 + let count: Int + /// 末值仍异常,或状态跨越了参考范围边界 → 行首加 ⚠️。 + let flagged: Bool + + /// 一行中文:`⚠️ 收缩压 152→138 mmHg ↓(参考 90-140),近 21 天 4 次` + func line() -> String { + var s = flagged ? "⚠️ " : "" + s += title + s += " \(valueText)" + if !unit.isEmpty { s += " \(unit)" } + s += " \(direction.arrow)" + if let r = rangeText, !r.isEmpty { s += "(参考 \(r))" } + s += ",近 \(spanDays) 天 \(count) 次" + return s + } +} + +enum ExportTrendBuilder { + + /// 平稳阈值:首末相对变化 < 5% 视为「平稳(→)」。 + static let flatThreshold = 0.05 + + /// 构建趋势摘要列表。 + /// - Parameters: + /// - allInWindow: 时间窗内**全部**指标(裁剪前)—— 用来还原完整时间序列。 + /// - relevant: 本次就诊**相关**指标集(裁剪后)—— 只对这些 series 出趋势。 + /// - profile: 用于解析性别相关的参考范围(交给 SeriesBucket)。 + /// - customMetrics: 自定义监测项,用于解析自定义 series 的名称/范围。 + /// - Returns: 已按「异常优先,其次最近」排序的趋势行。 + static func build(allInWindow: [Indicator], + relevant: [Indicator], + profile: UserProfile? = nil, + customMetrics: [CustomMonitorMetric] = []) -> [ExportTrend] { + let relevantIDs = Set(relevant.compactMap { bucketID(for: $0) }) + guard !relevantIDs.isEmpty else { return [] } + + // 复用 Trends 的分组逻辑:同 seriesKey 分组、血压合并、name+unit 回退、minPoints≥2、点按时间升序。 + let buckets = SeriesBucket.build(from: allInWindow, + profile: profile, + customMetrics: customMetrics, + minPoints: 2) + + let trends = buckets + .filter { relevantIDs.contains($0.id) } + .compactMap { trend(from: $0) } + + // 异常优先,其次最近。 + return trends.sorted { lhs, rhs in + if lhs.flagged != rhs.flagged { return lhs.flagged } + return lhs.spanDays >= rhs.spanDays // 仅作稳定次序,实际新近性已由 buckets 顺序保证 + } + } + + /// 指标 → 其所属 SeriesBucket 的 id(与 `SeriesBucket.build` 的 id 方案一致)。 + /// nil 表示该指标无法归入任何 series(空名)。 + static func bucketID(for i: Indicator) -> String? { + if let k = i.seriesKey, !k.isEmpty { + if k == "bp.systolic" || k == "bp.diastolic" { return "bp" } + return k + } + let nk = SeriesBucket.normalizedKey(name: i.name, unit: i.unit) + return nk.isEmpty ? nil : "lab:\(nk)" + } + + // MARK: - Private + + private static func trend(from bucket: SeriesBucket) -> ExportTrend? { + if bucket.id == "bp" { return bpTrend(from: bucket) } + + guard let line = bucket.lines.first, + line.points.count >= 2, + let first = line.points.first, + let last = line.points.last else { return nil } + + return ExportTrend( + title: bucket.title, + unit: bucket.unit, + valueText: "\(num(first.value))→\(num(last.value))", + direction: direction(first: first.value, last: last.value), + rangeText: rangeText(line.referenceRange), + spanDays: spanDays(first.date, last.date), + count: line.points.count, + flagged: last.status != .normal + || crossedBoundary(first: first.status, last: last.status) + ) + } + + /// 血压:收缩 + 舒张合成一行,方向以收缩压为准;不展示参考(收缩/舒张范围不同,保持简洁)。 + private static func bpTrend(from bucket: SeriesBucket) -> ExportTrend? { + guard let sys = bucket.lines.first(where: { $0.seriesKey == "bp.systolic" }), + sys.points.count >= 2, + let sFirst = sys.points.first, + let sLast = sys.points.last else { return nil } + + let dia = bucket.lines.first { $0.seriesKey == "bp.diastolic" } + let dFirst = dia?.points.first + let dLast = dia?.points.last + + let valueText: String + if let dFirst, let dLast { + valueText = "\(num(sFirst.value))/\(num(dFirst.value))→\(num(sLast.value))/\(num(dLast.value))" + } else { + valueText = "\(num(sFirst.value))→\(num(sLast.value))" + } + + let sysFlag = sLast.status != .normal + || crossedBoundary(first: sFirst.status, last: sLast.status) + let diaFlag = dLast.map { $0.status != .normal } ?? false + + return ExportTrend( + title: bucket.title, + unit: bucket.unit, + valueText: valueText, + direction: direction(first: sFirst.value, last: sLast.value), + rangeText: nil, + spanDays: spanDays(sFirst.date, sLast.date), + count: sys.points.count, + flagged: sysFlag || diaFlag + ) + } + + static func direction(first: Double, last: Double) -> ExportTrend.Direction { + let delta = last - first + let base = abs(first) + let rel = base > 0 ? abs(delta) / base : abs(delta) + if rel < flatThreshold { return .flat } + return delta > 0 ? .up : .down + } + + /// 状态是否跨越了参考范围边界(正常↔异常之间发生切换)。 + static func crossedBoundary(first: IndicatorStatus, last: IndicatorStatus) -> Bool { + (first == .normal) != (last == .normal) + } + + static func spanDays(_ from: Date, _ to: Date) -> Int { + let days = to.timeIntervalSince(from) / 86400 + return max(1, Int(days.rounded())) + } + + static func rangeText(_ r: ClosedRange?) -> String? { + guard let r else { return nil } + return "\(num(r.lowerBound))-\(num(r.upperBound))" + } + + /// 数值格式化:整数去小数点,其余去掉尾随 0(138.0→"138",6.10→"6.1")。 + static func num(_ v: Double) -> String { + if v.truncatingRemainder(dividingBy: 1) == 0 { return String(Int(v)) } + return String(format: "%g", v) + } +} diff --git a/康康/Services/HealthExportDialogue.swift b/康康/Services/HealthExportDialogue.swift new file mode 100644 index 0000000..891873b --- /dev/null +++ b/康康/Services/HealthExportDialogue.swift @@ -0,0 +1,47 @@ +import Foundation + +struct HealthExportDialogueTurn: Identifiable, Hashable, Sendable { + enum Role: String, Sendable { + case user + case assistant + + var transcriptLabel: String { + switch self { + case .user: return String(appLoc: "患者") + case .assistant: return String(appLoc: "康康") + } + } + } + + let id: UUID + var role: Role + var text: String + var createdAt: Date + + init(role: Role, text: String, createdAt: Date = .now, id: UUID = UUID()) { + self.id = id + self.role = role + self.text = text + self.createdAt = createdAt + } + + static func user(_ text: String) -> HealthExportDialogueTurn { + HealthExportDialogueTurn(role: .user, text: text) + } + + static func assistant(_ text: String) -> HealthExportDialogueTurn { + HealthExportDialogueTurn(role: .assistant, text: text) + } + + static func transcript(from turns: [HealthExportDialogueTurn]) -> String { + turns + .compactMap { turn -> String? in + let cleaned = turn.text + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\n", with: " ") + guard !cleaned.isEmpty else { return nil } + return "\(turn.role.transcriptLabel): \(cleaned)" + } + .joined(separator: "\n") + } +} diff --git a/康康/Services/HealthExportService.swift b/康康/Services/HealthExportService.swift index babeb93..f062acf 100644 --- a/康康/Services/HealthExportService.swift +++ b/康康/Services/HealthExportService.swift @@ -135,6 +135,13 @@ struct HealthExportService { throw ServiceError.generationFailed("模型未输出任何内容") } + // —— 追加确定性趋势段(不经 LLM,零编造) —— + let trendBlock = Self.trendSection(snapshot.trends) + if !trendBlock.isEmpty { + generated += trendBlock + continuation.yield(.token(TokenChunk(text: trendBlock, decodeRate: 0))) + } + // —— Phase 4: 持久化 —— let export = HealthExport( prompt: prompt, @@ -170,6 +177,146 @@ struct HealthExportService { } } + /// 多轮导出页的单轮问答。只回答,不入库。 + func answer(question: String, + conversation: [HealthExportDialogueTurn], + in modelContext: ModelContext) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { @MainActor in + do { + do { + try await AIRuntime.shared.prepare() + } catch { + throw ServiceError.modelNotReady + } + + let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext) + let dataJSON = Self.serializeData(snapshot: snapshot) + let transcript = HealthExportDialogueTurn.transcript(from: conversation) + let prompt = HealthExportPrompts.dialogueAnswer( + latestQuestion: question, + transcript: transcript, + dataJSON: dataJSON + ) + + var displayed = "" + var rawAccum = "" + let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 480) + for try await chunk in stream { + try Task.checkCancellation() + rawAccum += chunk.text + let clean = Self.stripThinkBlocks(rawAccum) + if clean.count > displayed.count, clean.hasPrefix(displayed) { + let delta = String(clean.dropFirst(displayed.count)) + displayed = clean + continuation.yield(TokenChunk(text: delta, decodeRate: chunk.decodeRate)) + } else if clean != displayed { + displayed = clean + } + } + + guard !displayed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ServiceError.generationFailed("模型未输出任何内容") + } + continuation.finish() + } catch is CancellationError { + continuation.finish(throwing: ServiceError.cancelled) + } catch let e as ServiceError { + continuation.finish(throwing: e) + } catch { + continuation.finish(throwing: ServiceError.generationFailed("\(error)")) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + /// 多轮导出页的最终报告生成。保存为现有 HealthExport 历史。 + func export(conversation: [HealthExportDialogueTurn], + in modelContext: ModelContext) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { @MainActor in + do { + do { + try await AIRuntime.shared.prepare() + } catch { + throw ServiceError.modelNotReady + } + + continuation.yield(.phaseChanged(.retrieving)) + let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext) + let dataJSON = Self.serializeData(snapshot: snapshot) + let transcript = HealthExportDialogueTurn.transcript(from: conversation) + try Task.checkCancellation() + + continuation.yield(.phaseChanged(.generating)) + let genPrompt = HealthExportPrompts.dialogueReportGeneration( + transcript: transcript, + dataJSON: dataJSON + ) + + var generated = "" + var rawAccum = "" + var lastRate: Double = 0 + let stream = await AIRuntime.shared.generate(prompt: genPrompt, maxTokens: 1200) + for try await chunk in stream { + try Task.checkCancellation() + if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } + rawAccum += chunk.text + let clean = Self.stripThinkBlocks(rawAccum) + if clean.count > generated.count, clean.hasPrefix(generated) { + let delta = String(clean.dropFirst(generated.count)) + generated = clean + continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate))) + } else if clean != generated { + generated = clean + } + } + + guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ServiceError.generationFailed("模型未输出任何内容") + } + + // —— 追加确定性趋势段(不经 LLM,零编造) —— + let trendBlock = Self.trendSection(snapshot.trends) + if !trendBlock.isEmpty { + generated += trendBlock + continuation.yield(.token(TokenChunk(text: trendBlock, decodeRate: 0))) + } + + let export = HealthExport( + prompt: transcript, + content: generated, + referencedIndicatorIDs: snapshot.indicators.map { Self.idString($0.persistentModelID) }, + referencedReportIDs: [], + referencedSymptomIDs: [], + referencedDiaryIDs: snapshot.diaries.map { Self.idString($0.persistentModelID) }, + inferredTimeFromDate: snapshot.fromDate, + inferredTimeToDate: snapshot.toDate, + inferredIntent: "dialogue_export", + inferredLabelCN: "对话整理", + modelTag: ModelKind.llm.rawValue, + decodeRate: lastRate + ) + modelContext.insert(export) + do { try modelContext.save() } catch { + print("[HealthExportService] save failed: \(error)") + } + continuation.yield(.phaseChanged(.completed)) + continuation.yield(.completed(persistentID: export.persistentModelID)) + continuation.finish() + } catch is CancellationError { + continuation.finish(throwing: ServiceError.cancelled) + } catch let e as ServiceError { + continuation.finish(throwing: e) + } catch { + continuation.finish(throwing: ServiceError.generationFailed("\(error)")) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + // MARK: - Phase 1: intent extraction struct Intent: Sendable { @@ -251,6 +398,8 @@ struct HealthExportService { var reports: [Report] var diaries: [DiaryEntry] var profile: UserProfile + /// 相关指标的趋势行(确定性计算,不进 LLM)。空 → 不渲染「## 指标趋势」段。 + var trends: [ExportTrend] = [] } /// 同步 SwiftData 查询。@MainActor。 @@ -265,7 +414,8 @@ struct HealthExportService { predicate: #Predicate { $0.capturedAt >= fromDate && $0.capturedAt <= toDate }, sortBy: [SortDescriptor(\.capturedAt, order: .reverse)] ) - var indicators = (try? ctx.fetch(indDesc)) ?? [] + let allInWindow = (try? ctx.fetch(indDesc)) ?? [] + var indicators = allInWindow if !intent.keywords.isEmpty { let filtered = indicators.filter { ind in intent.keywords.contains { kw in @@ -328,6 +478,14 @@ struct HealthExportService { // —— Profile(单例) —— let profile = UserProfileStore.loadOrCreate(in: ctx) + // —— 趋势(确定性,不进 LLM) —— + // 用全量 in-window 还原完整序列;裁剪后的 indicators 决定哪些 series 相关。 + let trends = ExportTrendBuilder.build( + allInWindow: allInWindow, + relevant: indicators, + profile: profile + ) + return Snapshot( fromDate: fromDate, toDate: toDate, @@ -335,8 +493,44 @@ struct HealthExportService { symptoms: symptoms, reports: reports, diaries: diaries, + profile: profile, + trends: trends + ) + } + + /// 多轮导出使用全量指标 + 健康日记作为上下文。为控制 prompt 体积,日记正文在序列化阶段截断。 + static func retrieveDialogueSnapshot(ctx: ModelContext) -> Snapshot { + let indicatorDesc = FetchDescriptor( + sortBy: [SortDescriptor(\.capturedAt, order: .reverse)] + ) + let diaryDesc = FetchDescriptor( + sortBy: [SortDescriptor(\.createdAt, order: .reverse)] + ) + let indicators = (try? ctx.fetch(indicatorDesc)) ?? [] + let diaries = (try? ctx.fetch(diaryDesc)) ?? [] + let profile = UserProfileStore.loadOrCreate(in: ctx) + + let dates = indicators.map(\.capturedAt) + diaries.map(\.createdAt) + let fromDate = dates.min() ?? Date() + let toDate = dates.max() ?? Date() + + // 多轮导出用全量指标,全部视为相关。 + let trends = ExportTrendBuilder.build( + allInWindow: indicators, + relevant: indicators, profile: profile ) + + return Snapshot( + fromDate: fromDate, + toDate: toDate, + indicators: indicators, + symptoms: [], + reports: [], + diaries: diaries, + profile: profile, + trends: trends + ) } // MARK: - Phase 3: serialize data for prompt @@ -480,6 +674,12 @@ struct HealthExportService { """ } + /// 把趋势行拼成追加到 LLM 输出末尾的「## 指标趋势」段。空 → 返回空串(整段省略)。 + static func trendSection(_ trends: [ExportTrend]) -> String { + guard !trends.isEmpty else { return "" } + return "\n\n## 指标趋势\n" + trends.map { $0.line() }.joined(separator: "\n") + } + // MARK: - Helpers /// 把 SwiftData persistentModelID 编成稳定字符串。 diff --git a/康康/Services/HealthProfileImportService.swift b/康康/Services/HealthProfileImportService.swift new file mode 100644 index 0000000..b313536 --- /dev/null +++ b/康康/Services/HealthProfileImportService.swift @@ -0,0 +1,188 @@ +import Foundation +import HealthKit + +struct HealthProfileImportDraft: Identifiable, Equatable { + let id = UUID() + var birthYear: Int? + var biologicalSexRaw: String? + var heightCM: Int? + var bloodTypeRaw: String? + + var hasAnyImportableField: Bool { + birthYear != nil || + biologicalSexRaw != nil || + heightCM != nil || + bloodTypeRaw != nil + } + + func apply(to profile: UserProfile, now: Date = .now) { + if let birthYear { profile.birthYear = birthYear } + if let biologicalSexRaw { profile.biologicalSexRaw = biologicalSexRaw } + if let heightCM { profile.heightCM = heightCM } + if let bloodTypeRaw { profile.bloodTypeRaw = bloodTypeRaw } + profile.updatedAt = now + } +} + +struct HealthProfileImportPreview { + struct Field: Equatable { + let title: String + let current: String + let imported: String? + + var willUpdate: Bool { + guard let imported else { return false } + return imported != current + } + } + + let birthYear: Field + let sex: Field + let height: Field + let bloodType: Field + + var fields: [Field] { [birthYear, sex, height, bloodType] } + + init(draft: HealthProfileImportDraft, current profile: UserProfile) { + birthYear = Field( + title: String(appLoc: "出生年份"), + current: profile.birthYear.map(String.init) ?? String(appLoc: "未设置"), + imported: draft.birthYear.map(String.init) + ) + sex = Field( + title: String(appLoc: "性别"), + current: profile.sex.label, + imported: draft.biologicalSexRaw.map { Self.sexLabel(raw: $0) } + ) + height = Field( + title: String(appLoc: "身高"), + current: profile.heightCM.map { "\($0)cm" } ?? String(appLoc: "未设置"), + imported: draft.heightCM.map { "\($0)cm" } + ) + bloodType = Field( + title: String(appLoc: "血型"), + current: profile.bloodTypeRaw.isEmpty ? String(appLoc: "未设置") : "\(profile.bloodTypeRaw)型", + imported: draft.bloodTypeRaw.map { "\($0)型" } + ) + } + + private static func sexLabel(raw: String) -> String { + (UserProfile.Sex(rawValue: raw) ?? .undisclosed).label + } +} + +enum HealthProfileImportError: LocalizedError { + case unavailable + case noReadableFields + + var errorDescription: String? { + switch self { + case .unavailable: + return String(appLoc: "这台设备暂不支持读取 Apple 健康资料。") + case .noReadableFields: + return String(appLoc: "Apple 健康里没有可导入的生日、性别、身高或血型。") + } + } +} + +struct HealthProfileImportService { + static let shared = HealthProfileImportService() + + private let store = HKHealthStore() + + func fetchDraft() async throws -> HealthProfileImportDraft { + guard HKHealthStore.isHealthDataAvailable() else { + throw HealthProfileImportError.unavailable + } + + let readTypes = readObjectTypes() + try await requestReadAuthorization(for: readTypes) + + async let birthYear = readBirthYear() + async let sex = readBiologicalSexRaw() + async let height = readLatestHeightCM() + async let bloodType = readBloodTypeRaw() + + let draft = HealthProfileImportDraft( + birthYear: try await birthYear, + biologicalSexRaw: try await sex, + heightCM: try await height, + bloodTypeRaw: try await bloodType + ) + guard draft.hasAnyImportableField else { + throw HealthProfileImportError.noReadableFields + } + return draft + } + + private func readObjectTypes() -> Set { + var types: Set = [ + HKObjectType.characteristicType(forIdentifier: .dateOfBirth)!, + HKObjectType.characteristicType(forIdentifier: .biologicalSex)!, + HKObjectType.characteristicType(forIdentifier: .bloodType)!, + ] + if let height = HKObjectType.quantityType(forIdentifier: .height) { + types.insert(height) + } + return types + } + + private func requestReadAuthorization(for readTypes: Set) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + store.requestAuthorization(toShare: [], read: readTypes) { _, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } + + private func readBirthYear() throws -> Int? { + try store.dateOfBirthComponents().year + } + + private func readBiologicalSexRaw() throws -> String? { + switch try store.biologicalSex().biologicalSex { + case .female: return "female" + case .male: return "male" + default: return nil + } + } + + private func readBloodTypeRaw() throws -> String? { + switch try store.bloodType().bloodType { + case .aPositive, .aNegative: return "A" + case .bPositive, .bNegative: return "B" + case .abPositive, .abNegative: return "AB" + case .oPositive, .oNegative: return "O" + default: return nil + } + } + + private func readLatestHeightCM() async throws -> Int? { + guard let heightType = HKObjectType.quantityType(forIdentifier: .height) else { + return nil + } + + let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) + return try await withCheckedThrowingContinuation { continuation in + let query = HKSampleQuery( + sampleType: heightType, + predicate: nil, + limit: 1, + sortDescriptors: [sort] + ) { _, samples, error in + if let error { + continuation.resume(throwing: error) + return + } + let sample = samples?.first as? HKQuantitySample + let cm = sample?.quantity.doubleValue(for: .meterUnit(with: .centi)) + continuation.resume(returning: cm.map { Int($0.rounded()) }) + } + store.execute(query) + } + } +} diff --git a/康康/Services/OCRService.swift b/康康/Services/OCRService.swift new file mode 100644 index 0000000..26762e9 --- /dev/null +++ b/康康/Services/OCRService.swift @@ -0,0 +1,69 @@ +import Foundation +import Vision +import UIKit + +enum OCRError: Error { + case noImage +} + +/// 端侧文字识别(Apple Vision,100% 本地,无网络)。 +/// 用于「记录指标 · 拍照识别」:VL 直接读密集小字不稳,改为先 OCR 出文本,再交 LLM 结构化。 +enum OCRService { + + /// 识别图中文字,按阅读顺序(自上而下、行内自左而右)拼成纯文本。 + /// 中英文混排;表格行会尽量保持在同一行,便于 LLM 把「指标名 数值 范围 单位」对齐解析。 + static func recognizeText(in cgImage: CGImage) async throws -> String { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + DispatchQueue.global(qos: .userInitiated).async { + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.usesLanguageCorrection = true + // 中文(简/繁)+ 英文;化验单常见中英文与数字混排。 + request.recognitionLanguages = ["zh-Hans", "zh-Hant", "en-US"] + let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up, options: [:]) + do { + try handler.perform([request]) + let obs = (request.results as? [VNRecognizedTextObservation]) ?? [] + cont.resume(returning: assemble(obs)) + } catch { + cont.resume(throwing: error) + } + } + } + } + + /// UIImage 便捷入口。 + static func recognizeText(in image: UIImage) async throws -> String { + guard let cg = image.cgImage else { throw OCRError.noImage } + return try await recognizeText(in: cg) + } + + /// 把散落的 observation 还原成阅读顺序文本。 + /// Vision 坐标系原点在左下、y 向上;按 midY 降序分行(同行 y 接近),行内按 minX 升序。 + private static func assemble(_ obs: [VNRecognizedTextObservation]) -> String { + let items: [(rect: CGRect, text: String)] = obs.compactMap { o in + guard let t = o.topCandidates(1).first?.string, !t.isEmpty else { return nil } + return (o.boundingBox, t) + } + guard !items.isEmpty else { return "" } + + let sorted = items.sorted { $0.rect.midY > $1.rect.midY } + let yTol: CGFloat = 0.012 // 行高容差(归一化坐标);同一行的 cell midY 差异通常 < 此值 + var rows: [[(rect: CGRect, text: String)]] = [] + var rowY: [CGFloat] = [] // 各行内 midY 的运行平均,做锚点比单看首元素更稳(抗轻微行漂移) + for item in sorted { + if let i = rows.indices.last, abs(rowY[i] - item.rect.midY) < yTol { + rows[i].append(item) + rowY[i] = (rowY[i] * CGFloat(rows[i].count - 1) + item.rect.midY) / CGFloat(rows[i].count) + } else { + rows.append([item]) + rowY.append(item.rect.midY) + } + } + return rows.map { row in + row.sorted { $0.rect.minX < $1.rect.minX } + .map(\.text) + .joined(separator: " ") + }.joined(separator: "\n") + } +} diff --git a/康康/康康.entitlements b/康康/康康.entitlements index ff111f6..ed2f37f 100644 --- a/康康/康康.entitlements +++ b/康康/康康.entitlements @@ -10,5 +10,7 @@ --> com.apple.developer.kernel.increased-memory-limit + com.apple.developer.healthkit + diff --git a/康康Tests/CaptureServiceJSONTests.swift b/康康Tests/CaptureServiceJSONTests.swift index 571d64c..2c648a7 100644 --- a/康康Tests/CaptureServiceJSONTests.swift +++ b/康康Tests/CaptureServiceJSONTests.swift @@ -123,6 +123,32 @@ struct CaptureServiceJSONTests { #expect(parsed.indicators.first?.status == .high) } + @Test func parsesIndicatorEvidenceLocation() throws { + let raw = """ + {"title":"t","type":"lab","report_date":"2026-05-01","page_count":2,"indicators":[{"name":"尿酸","value":"486","unit":"μmol/L","range":"208 - 428","status":"high","source_page":2,"source_box":[0.18,0.42,0.68,0.49]}]} + """ + let parsed = try CaptureService.parseReportJSON(raw, pageCount: 2) + let indicator = try #require(parsed.indicators.first) + #expect(indicator.sourcePageIndex == 1) + #expect(indicator.sourceBoxX == 0.18) + #expect(indicator.sourceBoxY == 0.42) + #expect(indicator.sourceBoxWidth == 0.68) + #expect(indicator.sourceBoxHeight == 0.49) + } + + @Test func ignoresInvalidIndicatorEvidenceLocation() throws { + let raw = """ + {"indicators":[{"name":"尿酸","value":"486","unit":"μmol/L","range":"208 - 428","status":"high","source_page":0,"source_box":[-1,0.42,0.68,1.5]}]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + let indicator = try #require(parsed.indicators.first) + #expect(indicator.sourcePageIndex == nil) + #expect(indicator.sourceBoxX == nil) + #expect(indicator.sourceBoxY == nil) + #expect(indicator.sourceBoxWidth == nil) + #expect(indicator.sourceBoxHeight == nil) + } + @Test func infersStatusFromValueAndReferenceRangeWhenStatusMissing() throws { let raw = """ {"indicators":[ diff --git a/康康Tests/ExportTrendBuilderTests.swift b/康康Tests/ExportTrendBuilderTests.swift new file mode 100644 index 0000000..44a1842 --- /dev/null +++ b/康康Tests/ExportTrendBuilderTests.swift @@ -0,0 +1,112 @@ +import Testing +import Foundation +@testable import 康康 + +/// `ExportTrendBuilder` 是纯函数,覆盖:方向判定、血压合并、相关性过滤、 +/// 点数 <2 过滤、跨参考范围边界标记、非数值点丢弃、整行文案格式。 +struct ExportTrendBuilderTests { + + private func ind( + name: String = "血糖", + value: String, + unit: String = "mmol/L", + range: String = "3.9-6.1", + status: IndicatorStatus = .normal, + daysAgo: Int, + seriesKey: String? = nil + ) -> Indicator { + let date = Calendar.current.date(byAdding: .day, value: -daysAgo, to: .now)! + return Indicator(name: name, value: value, unit: unit, range: range, + status: status, capturedAt: date, seriesKey: seriesKey) + } + + @Test func upDirectionFlaggedAndLineFormat() { + let items = [ + ind(value: "5.2", status: .normal, daysAgo: 27), + ind(value: "6.8", status: .high, daysAgo: 0), + ] + let trends = ExportTrendBuilder.build(allInWindow: items, relevant: items) + let t = try! #require(trends.first) + #expect(t.direction == .up) + #expect(t.flagged) // 末值 high + #expect(t.count == 2) + #expect(t.valueText == "5.2→6.8") + #expect(t.rangeText == "3.9-6.1") + #expect(t.line() == "⚠️ 血糖 5.2→6.8 mmol/L ↑(参考 3.9-6.1),近 27 天 2 次") + } + + @Test func downDirection() { + let items = [ + ind(value: "6.0", daysAgo: 10), + ind(value: "5.0", daysAgo: 1), + ] + let t = try! #require(ExportTrendBuilder.build(allInWindow: items, relevant: items).first) + #expect(t.direction == .down) + #expect(!t.flagged) // 两点都 normal + } + + @Test func flatWithinThreshold() { + // (5.1-5.0)/5.0 = 0.02 < 0.05 → 平稳 + let items = [ + ind(value: "5.0", daysAgo: 5), + ind(value: "5.1", daysAgo: 1), + ] + let t = try! #require(ExportTrendBuilder.build(allInWindow: items, relevant: items).first) + #expect(t.direction == .flat) + } + + @Test func filtersSeriesWithFewerThanTwoPoints() { + let items = [ind(value: "5.0", daysAgo: 1)] + #expect(ExportTrendBuilder.build(allInWindow: items, relevant: items).isEmpty) + } + + @Test func excludesIrrelevantSeries() { + let glucose = [ + ind(name: "血糖", value: "5.0", unit: "mmol/L", daysAgo: 3), + ind(name: "血糖", value: "5.5", unit: "mmol/L", daysAgo: 1), + ] + let weight = [ + ind(name: "体重", value: "68", unit: "kg", range: "", daysAgo: 3), + ind(name: "体重", value: "67", unit: "kg", range: "", daysAgo: 1), + ] + // weight 有 ≥2 点,但不在 relevant 集 → 不出趋势 + let trends = ExportTrendBuilder.build(allInWindow: glucose + weight, relevant: glucose) + #expect(trends.count == 1) + #expect(trends.first?.title == "血糖") + } + + @Test func bloodPressureMergesToSingleLine() { + let items = [ + ind(name: "收缩压", value: "150", unit: "mmHg", range: "", daysAgo: 20, seriesKey: "bp.systolic"), + ind(name: "舒张压", value: "95", unit: "mmHg", range: "", daysAgo: 20, seriesKey: "bp.diastolic"), + ind(name: "收缩压", value: "138", unit: "mmHg", range: "", daysAgo: 1, seriesKey: "bp.systolic"), + ind(name: "舒张压", value: "88", unit: "mmHg", range: "", daysAgo: 1, seriesKey: "bp.diastolic"), + ] + let t = try! #require(ExportTrendBuilder.build(allInWindow: items, relevant: items).first) + #expect(t.title == "血压") + #expect(t.unit == "mmHg") + #expect(t.valueText == "150/95→138/88") + #expect(t.direction == .down) // 收缩压为准 + #expect(t.rangeText == nil) // 血压双范围不展示 + } + + @Test func flaggedWhenStatusCrossesBoundary() { + // 首高末正常:跨参考范围边界 → 仍 flagged,提示医生注意变化 + let items = [ + ind(value: "6.8", status: .high, daysAgo: 5), + ind(value: "5.5", status: .normal, daysAgo: 1), + ] + let t = try! #require(ExportTrendBuilder.build(allInWindow: items, relevant: items).first) + #expect(t.flagged) + } + + @Test func nonNumericPointDropped() { + let items = [ + ind(value: "高", daysAgo: 3), + ind(value: "5.0", daysAgo: 2), + ind(value: "5.5", daysAgo: 1), + ] + let t = try! #require(ExportTrendBuilder.build(allInWindow: items, relevant: items).first) + #expect(t.count == 2) // "高" 解析失败被丢 + } +} diff --git a/康康Tests/HealthExportDialogueTests.swift b/康康Tests/HealthExportDialogueTests.swift new file mode 100644 index 0000000..3dea586 --- /dev/null +++ b/康康Tests/HealthExportDialogueTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing +@testable import 康康 + +struct HealthExportDialogueTests { + @Test func dialogueTranscriptKeepsTurnOrderAndRoles() { + let turns: [HealthExportDialogueTurn] = [ + .user("我最近头晕,帮我看看"), + .assistant("我会结合你的指标和日记整理。"), + .user("重点看血压") + ] + + let transcript = HealthExportDialogueTurn.transcript(from: turns) + + #expect(transcript.contains("患者: 我最近头晕,帮我看看")) + #expect(transcript.contains("康康: 我会结合你的指标和日记整理。")) + #expect(transcript.contains("患者: 重点看血压")) + #expect(transcript.range(of: "患者: 我最近头晕")!.lowerBound < transcript.range(of: "患者: 重点看血压")!.lowerBound) + } + + @Test func dialogueTranscriptDropsEmptyTurns() { + let turns: [HealthExportDialogueTurn] = [ + .user(" "), + .assistant("请补充想看的问题"), + .user("\n最近三个月\n") + ] + + let transcript = HealthExportDialogueTurn.transcript(from: turns) + + #expect(!transcript.contains("患者: ")) + #expect(transcript.contains("康康: 请补充想看的问题")) + #expect(transcript.contains("患者: 最近三个月")) + } +} diff --git a/康康Tests/HealthExportPromptTests.swift b/康康Tests/HealthExportPromptTests.swift new file mode 100644 index 0000000..7290840 --- /dev/null +++ b/康康Tests/HealthExportPromptTests.swift @@ -0,0 +1,28 @@ +import Testing +@testable import 康康 + +struct HealthExportPromptTests { + @Test func dialogueAnswerPromptContainsQuestionTranscriptAndData() { + let prompt = HealthExportPrompts.dialogueAnswer( + latestQuestion: "最近血压怎么样?", + transcript: "患者: 最近头晕", + dataJSON: #"{"indicators":[{"name":"收缩压"}],"diaries":[{"excerpt":"昨晚没睡好"}]}"# + ) + + #expect(prompt.contains("最近血压怎么样?")) + #expect(prompt.contains("患者: 最近头晕")) + #expect(prompt.contains("收缩压")) + #expect(prompt.contains("昨晚没睡好")) + } + + @Test func dialogueReportPromptContainsTranscriptAndFixedReportInstruction() { + let prompt = HealthExportPrompts.dialogueReportGeneration( + transcript: "患者: 帮我整理给医生\n康康: 已查看记录", + dataJSON: #"{"indicators":[],"diaries":[]}"# + ) + + #expect(prompt.contains("多轮对话")) + #expect(prompt.contains("帮我整理给医生")) + #expect(prompt.contains("严格 Markdown")) + } +} diff --git a/康康Tests/HealthProfileImportTests.swift b/康康Tests/HealthProfileImportTests.swift new file mode 100644 index 0000000..464e22d --- /dev/null +++ b/康康Tests/HealthProfileImportTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing +@testable import 康康 + +struct HealthProfileImportTests { + @Test func previewKeepsExistingValuesWhenHealthKitValueIsMissing() { + let existing = UserProfile( + birthYear: 1988, + biologicalSexRaw: "female", + heightCM: 162, + bloodTypeRaw: "A" + ) + let draft = HealthProfileImportDraft(heightCM: 175) + + let preview = HealthProfileImportPreview(draft: draft, current: existing) + + #expect(preview.birthYear.current == "1988") + #expect(preview.birthYear.imported == nil) + #expect(preview.birthYear.willUpdate == false) + #expect(preview.sex.imported == nil) + #expect(preview.height.imported == "175cm") + #expect(preview.height.willUpdate == true) + #expect(preview.bloodType.imported == nil) + } + + @Test func applyOnlyOverwritesFieldsPresentInDraft() { + let profile = UserProfile( + birthYear: 1988, + biologicalSexRaw: "female", + heightCM: 162, + bloodTypeRaw: "A" + ) + let draft = HealthProfileImportDraft( + birthYear: 1990, + biologicalSexRaw: "male", + heightCM: nil, + bloodTypeRaw: "O" + ) + + draft.apply(to: profile, now: Date(timeIntervalSince1970: 123)) + + #expect(profile.birthYear == 1990) + #expect(profile.biologicalSexRaw == "male") + #expect(profile.heightCM == 162) + #expect(profile.bloodTypeRaw == "O") + #expect(profile.updatedAt == Date(timeIntervalSince1970: 123)) + } + + @Test func emptyDraftReportsNoImportableFields() { + let draft = HealthProfileImportDraft() + + #expect(draft.hasAnyImportableField == false) + } +} diff --git a/康康Tests/ModelsSchemaTests.swift b/康康Tests/ModelsSchemaTests.swift index 1f88352..8158fae 100644 --- a/康康Tests/ModelsSchemaTests.swift +++ b/康康Tests/ModelsSchemaTests.swift @@ -1,6 +1,7 @@ import Testing import SwiftData import Foundation +import CoreGraphics @testable import 康康 struct ModelsSchemaTests { @@ -138,6 +139,36 @@ struct ModelsSchemaTests { #expect(i.seriesKey == nil) } + @Test func indicatorEvidenceLocationRoundtrip() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + + let indicator = Indicator( + name: "尿酸", + value: "486", + unit: "μmol/L", + range: "208 - 428", + status: .high, + source: .report, + sourcePageIndex: 1, + sourceBoxX: 0.18, + sourceBoxY: 0.42, + sourceBoxWidth: 0.68, + sourceBoxHeight: 0.08 + ) + ctx.insert(indicator) + try ctx.save() + + let fetched = try #require(try ctx.fetch(FetchDescriptor()).first) + #expect(fetched.sourcePageIndex == 1) + #expect(fetched.sourceBoxX == 0.18) + #expect(fetched.sourceBoxY == 0.42) + #expect(fetched.sourceBoxWidth == 0.68) + #expect(fetched.sourceBoxHeight == 0.08) + #expect(fetched.hasEvidenceBox) + #expect(fetched.evidenceRect?.width == 0.68) + } + @Test func userProfileSchemaPersistsAcrossSave() throws { let container = try makeContainer() let ctx = ModelContext(container)