缺少代码差异信息,无法生成具体的commit message。请提供code differences内容以便分析并生成符合Angular规范的提交信息。
当您提供代码差异后,我将按照以下格式生成: ``` <type>(<scope>): <subject> <body> ``` 其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
This commit is contained in:
@@ -410,7 +410,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -421,6 +421,8 @@
|
|||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
|
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
|
||||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
|
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
|
||||||
|
INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。";
|
||||||
|
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
@@ -462,7 +464,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -473,6 +475,8 @@
|
|||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
|
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
|
||||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
|
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
|
||||||
|
INFOPLIST_KEY_NSHealthShareUsageDescription = "康康会读取 Apple 健康中的生日、性别、身高和血型,用于本地填充个人资料,不会上传。";
|
||||||
|
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "康康不会写入 Apple 健康数据。此说明用于满足 HealthKit 权限校验,你的健康资料只保留在本机。";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
@@ -512,7 +516,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
@@ -539,7 +543,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
@@ -565,7 +569,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
@@ -591,7 +595,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
|||||||
@@ -114,4 +114,72 @@ enum HealthExportPrompts {
|
|||||||
/no_think
|
/no_think
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 多轮导出对话
|
||||||
|
|
||||||
|
/// 多轮导出页里,用户每次提问时用这个 prompt 回答。
|
||||||
|
/// 输入上下文限定为本地指标 + 健康日记,回答只做解释/归纳,不持久化。
|
||||||
|
static func dialogueAnswer(latestQuestion: String,
|
||||||
|
transcript: String,
|
||||||
|
dataJSON: String) -> String {
|
||||||
|
"""
|
||||||
|
你是康康的本地健康档案助手。请根据【本地健康记录】回答用户最新问题。
|
||||||
|
|
||||||
|
铁律:
|
||||||
|
- 只能使用【本地健康记录】和【多轮对话】里已有的信息。
|
||||||
|
- 禁止诊断、禁止用药/剂量建议、禁止急诊判断。
|
||||||
|
- 数据里没有的信息,直接说「记录里没有」,不要编造。
|
||||||
|
- 重点围绕指标和健康日记做大白话解释,回答要短,最多 5 条要点。
|
||||||
|
- 如果用户的目标是给医生看,可以提示稍后点击「生成整理报告」。
|
||||||
|
|
||||||
|
【本地健康记录】:
|
||||||
|
\(dataJSON)
|
||||||
|
|
||||||
|
【多轮对话】:
|
||||||
|
\(transcript.isEmpty ? "无" : transcript)
|
||||||
|
|
||||||
|
【用户最新问题】:
|
||||||
|
\(latestQuestion)
|
||||||
|
|
||||||
|
直接输出中文回答,不要 Markdown 标题,不要 <think>:
|
||||||
|
/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,不要思考过程,不要 <think>:
|
||||||
|
/no_think
|
||||||
|
"""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ enum VLPrompts {
|
|||||||
/// "value": "3.84",
|
/// "value": "3.84",
|
||||||
/// "unit": "mmol/L",
|
/// "unit": "mmol/L",
|
||||||
/// "range": "< 3.40",
|
/// "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,
|
"value": string,
|
||||||
"unit": string,
|
"unit": string,
|
||||||
"range": 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}})。下面示例里的日期只是格式参考,不要直接抄。
|
- report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。
|
||||||
- 不要发明指标。数值看不清的整行跳过;但**没有参考范围不是跳过的理由**,结论页叙述式文字(如「总胆红素: 23.0(μmol/L)↑」)同样要提取,range 填 "",status 按箭头/「偏高」等标记判断。
|
- 不要发明指标。数值看不清的整行跳过;但**没有参考范围不是跳过的理由**,结论页叙述式文字(如「总胆红素: 23.0(μmol/L)↑」)同样要提取,range 填 "",status 按箭头/「偏高」等标记判断。
|
||||||
- 化验单一般 type = "lab",体检套餐 = "checkup"。
|
- 化验单一般 type = "lab",体检套餐 = "checkup"。
|
||||||
|
- source_page 是该指标所在图片页码,从 1 开始。
|
||||||
|
- source_box 是该指标整行在该页图片里的归一化矩形 [x,y,width,height],左上角为 (0,0),右下角为 (1,1)。尽量框住指标名、数值、单位、参考范围和异常标记所在整行;不确定位置时填 [0,0,0,0]。
|
||||||
|
|
||||||
示例 1(化验单 · 单项):
|
示例 1(化验单 · 单项):
|
||||||
输入: 一张化验单照片,只能看清「低密度脂蛋白 3.84 mmol/L 参考 <3.40」
|
输入: 一张化验单照片,只能看清「低密度脂蛋白 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(体检 · 多项):
|
示例 2(体检 · 多项):
|
||||||
输入: 一份春季体检,3 项可读
|
输入: 一份春季体检,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:
|
现在请识别图片并输出 JSON:
|
||||||
"""#
|
"""#
|
||||||
@@ -138,5 +144,59 @@ JSON schema(严格):
|
|||||||
{"indicators":[{"name":"总胆红素","value":"23.0","unit":"μmol/L","range":"","status":"high"}]}
|
{"indicators":[{"name":"总胆红素","value":"23.0","unit":"μmol/L","range":"","status":"high"}]}
|
||||||
|
|
||||||
现在请识别这张局部照片并输出 JSON:
|
现在请识别这张局部照片并输出 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}}
|
||||||
"""#
|
"""#
|
||||||
}
|
}
|
||||||
|
|||||||
64
康康/App/FontScale.swift
Normal file
64
康康/App/FontScale.swift
Normal file
@@ -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
|
||||||
@@ -4,6 +4,7 @@ import SwiftData
|
|||||||
@main
|
@main
|
||||||
struct KangkangApp: App {
|
struct KangkangApp: App {
|
||||||
@State private var lang = LanguageManager.shared
|
@State private var lang = LanguageManager.shared
|
||||||
|
@State private var fontScale = FontScaleManager.shared
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// 启动即给 MLX 显存缓存设上限,配合 entitlement + LLM/VL 互斥卸载防 jetsam OOM。
|
// 启动即给 MLX 显存缓存设上限,配合 entitlement + LLM/VL 互斥卸载防 jetsam OOM。
|
||||||
@@ -98,7 +99,8 @@ struct KangkangApp: App {
|
|||||||
AppLockContainer {
|
AppLockContainer {
|
||||||
RootView()
|
RootView()
|
||||||
.environment(\.locale, lang.locale)
|
.environment(\.locale, lang.locale)
|
||||||
.id(lang.current) // 语言切换 → 整树重建,即时生效
|
// 语言 / 字体档位切换 → 整树重建,即时生效(固定字号经 tjScaled 读新倍率)。
|
||||||
|
.id("\(lang.current.rawValue)-\(fontScale.scale.rawValue)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modelContainer(sharedModelContainer)
|
.modelContainer(sharedModelContainer)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ struct TjLockChip: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "lock.fill")
|
Image(systemName: "lock.fill")
|
||||||
.font(.system(size: 9, weight: .semibold))
|
.font(.tjScaled( 9, weight: .semibold))
|
||||||
Text("本地加密")
|
Text("本地加密")
|
||||||
.font(.system(size: 10))
|
.font(.tjScaled( 10))
|
||||||
.tracking(0.5)
|
.tracking(0.5)
|
||||||
}
|
}
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
@@ -44,7 +44,7 @@ struct TjBadge: View {
|
|||||||
var style: TjBadgeStyle = .neutral
|
var style: TjBadgeStyle = .neutral
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 10, weight: .semibold))
|
.font(.tjScaled( 10, weight: .semibold))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(style.fg)
|
.foregroundStyle(style.fg)
|
||||||
.padding(.horizontal, 7)
|
.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))
|
DiagonalStripes(spacing: 7, color: dark ? Color.white.opacity(0.04) : Color.black.opacity(0.05))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.tracking(0.5)
|
.tracking(0.5)
|
||||||
.foregroundStyle(dark ? Color.white.opacity(0.5) : Tj.Palette.text3)
|
.foregroundStyle(dark ? Color.white.opacity(0.5) : Tj.Palette.text3)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -101,7 +101,7 @@ struct TjPrimaryButton: ButtonStyle {
|
|||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
.font(.system(size: fontSize, weight: .semibold))
|
.font(.tjScaled( fontSize, weight: .semibold))
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
.padding(.horizontal, horizontalPadding)
|
.padding(.horizontal, horizontalPadding)
|
||||||
@@ -118,7 +118,7 @@ struct TjGhostButton: ButtonStyle {
|
|||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
.font(.system(size: fontSize, weight: .semibold))
|
.font(.tjScaled( fontSize, weight: .semibold))
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
.padding(.horizontal, horizontalPadding)
|
.padding(.horizontal, horizontalPadding)
|
||||||
|
|||||||
@@ -39,10 +39,18 @@ enum Tj {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Font {
|
extension Font {
|
||||||
static func tjTitle(_ size: CGFloat = 30) -> Font { .system(size: size, weight: .bold, design: .default) }
|
/// 全 App 字体的唯一缩放出口。按全局档位 `appFontScale` 放大字号(老年/视力辅助)。
|
||||||
static func tjH2(_ size: CGFloat = 18) -> Font { .system(size: size, weight: .bold, design: .default) }
|
/// 所有固定字号都经 `.system(size:)` → 机械迁移为 `.tjScaled(` 走这里;改档位 + 根视图重建即全局生效。
|
||||||
static func tjMono(_ size: CGFloat = 11) -> Font { .system(size: size, weight: .regular, design: .monospaced) }
|
static func tjScaled(_ size: CGFloat,
|
||||||
static func tjSerifBody(_ size: CGFloat = 17) -> Font { .system(size: size, weight: .regular, design: .default) }
|
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 {
|
extension View {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ struct ArchiveListView: View {
|
|||||||
@State private var filter: TimelineKind? = nil
|
@State private var filter: TimelineKind? = nil
|
||||||
@State private var endingSymptom: Symptom?
|
@State private var endingSymptom: Symptom?
|
||||||
@State private var selectedEntry: TimelineEntry?
|
@State private var selectedEntry: TimelineEntry?
|
||||||
@State private var showExportSheet = false
|
|
||||||
@State private var route: Route?
|
@State private var route: Route?
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -110,9 +109,6 @@ struct ArchiveListView: View {
|
|||||||
TimelineEntryDetailView(detail: d)
|
TimelineEntryDetailView(detail: d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showExportSheet) {
|
|
||||||
HealthExportSheet()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -150,36 +146,24 @@ struct ArchiveListView: View {
|
|||||||
.font(.tjTitle(26))
|
.font(.tjTitle(26))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount) 条"))
|
Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount) 条"))
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
Menu {
|
|
||||||
Button {
|
|
||||||
showExportSheet = true
|
|
||||||
} label: {
|
|
||||||
Label("生成新导出", systemImage: "doc.text.below.ecg")
|
|
||||||
}
|
|
||||||
if !exports.isEmpty {
|
if !exports.isEmpty {
|
||||||
Button {
|
Button { route = .exports } label: {
|
||||||
route = .exports
|
|
||||||
} label: {
|
|
||||||
Label("我的导出 · \(exports.count) 份", systemImage: "clock.arrow.circlepath")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "doc.text.below.ecg")
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
Text("导出身体档案")
|
Text("导出历史")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.font(.system(size: 9, weight: .semibold))
|
|
||||||
}
|
}
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 7)
|
.padding(.vertical, 7)
|
||||||
.background(Capsule().fill(Tj.Palette.ink))
|
.background(Capsule().fill(Tj.Palette.ink))
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,19 +201,19 @@ struct ArchiveListView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
Circle().fill(reminderEnabledCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
Circle().fill(reminderEnabledCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||||
Image(systemName: "bell.fill")
|
Image(systemName: "bell.fill")
|
||||||
.font(.system(size: 16))
|
.font(.tjScaled( 16))
|
||||||
.foregroundStyle(reminderEnabledCount > 0 ? Tj.Palette.ink : Tj.Palette.text3)
|
.foregroundStyle(reminderEnabledCount > 0 ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(reminderCountLabel)
|
Text(reminderCountLabel)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if !reminderTitlePreview.isEmpty {
|
if !reminderTitlePreview.isEmpty {
|
||||||
Text(reminderTitleLine)
|
Text(reminderTitleLine)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
@@ -238,7 +222,7 @@ struct ArchiveListView: View {
|
|||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
@@ -265,7 +249,7 @@ struct ArchiveListView: View {
|
|||||||
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Text(label)
|
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)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -282,14 +266,14 @@ struct ArchiveListView: View {
|
|||||||
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
|
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text(section.label)
|
Text(section.label)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.tracking(0.5)
|
.tracking(0.5)
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Tj.Palette.lineSoft)
|
.fill(Tj.Palette.lineSoft)
|
||||||
.frame(height: 1)
|
.frame(height: 1)
|
||||||
Text("\(count)")
|
Text("\(count)")
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -303,7 +287,7 @@ struct ArchiveListView: View {
|
|||||||
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
|
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
|
||||||
.frame(width: 240, height: 140)
|
.frame(width: 240, height: 140)
|
||||||
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
|
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ struct HealthExportDetailView: View {
|
|||||||
HStack(alignment: .center, spacing: 12) {
|
HStack(alignment: .center, spacing: 12) {
|
||||||
Button { dismiss() } label: {
|
Button { dismiss() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.background(Circle().fill(Tj.Palette.sand2))
|
.background(Circle().fill(Tj.Palette.sand2))
|
||||||
@@ -62,7 +62,7 @@ struct HealthExportDetailView: View {
|
|||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text(Self.absoluteDate(export.createdAt))
|
Text(Self.absoluteDate(export.createdAt))
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -81,13 +81,13 @@ struct HealthExportDetailView: View {
|
|||||||
TjBadge(text: export.modelTag, style: .neutral)
|
TjBadge(text: export.modelTag, style: .neutral)
|
||||||
if export.decodeRate > 0 {
|
if export.decodeRate > 0 {
|
||||||
Text(String(format: "%.1f tok/s", export.decodeRate))
|
Text(String(format: "%.1f tok/s", export.decodeRate))
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.leaf)
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if let from = export.inferredTimeFromDate, let to = export.inferredTimeToDate {
|
if let from = export.inferredTimeFromDate, let to = export.inferredTimeToDate {
|
||||||
Text("\(Self.shortDate(from)) — \(Self.shortDate(to))")
|
Text("\(Self.shortDate(from)) — \(Self.shortDate(to))")
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,10 +96,10 @@ struct HealthExportDetailView: View {
|
|||||||
private var promptBlock: some View {
|
private var promptBlock: some View {
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
Image(systemName: "quote.opening")
|
Image(systemName: "quote.opening")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text(export.prompt)
|
Text(export.prompt)
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
@@ -119,7 +119,7 @@ struct HealthExportDetailView: View {
|
|||||||
|
|
||||||
ShareLink(item: export.content) {
|
ShareLink(item: export.content) {
|
||||||
Label("分享", systemImage: "square.and.arrow.up")
|
Label("分享", systemImage: "square.and.arrow.up")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
@@ -134,7 +134,7 @@ struct HealthExportDetailView: View {
|
|||||||
showDeleteConfirm = true
|
showDeleteConfirm = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "trash")
|
Image(systemName: "trash")
|
||||||
.font(.system(size: 15, weight: .medium))
|
.font(.tjScaled( 15, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
.background(Circle().strokeBorder(Tj.Palette.brick.opacity(0.4), lineWidth: 1))
|
.background(Circle().strokeBorder(Tj.Palette.brick.opacity(0.4), lineWidth: 1))
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ struct HealthExportListView: View {
|
|||||||
.font(.tjTitle(24))
|
.font(.tjTitle(24))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text(exports.isEmpty ? "" : String(appLoc: "\(exports.count) 份"))
|
Text(exports.isEmpty ? "" : String(appLoc: "\(exports.count) 份"))
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
TjLockChip()
|
TjLockChip()
|
||||||
@@ -88,22 +88,22 @@ struct HealthExportRow: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Text(export.promptPreview)
|
Text(export.promptPreview)
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.tjScaled( 14, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.tjScaled( 12, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text(Self.relativeDate(export.createdAt))
|
Text(Self.relativeDate(export.createdAt))
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
if export.decodeRate > 0 {
|
if export.decodeRate > 0 {
|
||||||
Text(String(format: "%.1f tok/s", export.decodeRate))
|
Text(String(format: "%.1f tok/s", export.decodeRate))
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.tjScaled( 10, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.leaf)
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
/// 「导出身体档案」全屏 sheet。
|
/// 「导出身体档案」全屏 sheet。
|
||||||
/// 状态机:idle → running(extractingIntent → retrieving → generating)→ completed / failed
|
/// 状态机:多轮问答 → running(retrieving → generating)→ completed / failed
|
||||||
struct HealthExportSheet: View {
|
struct HealthExportSheet: View {
|
||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -10,7 +10,8 @@ struct HealthExportSheet: View {
|
|||||||
/// 可选:从历史「重新生成」时传入(暂时未启用,W3 接)。
|
/// 可选:从历史「重新生成」时传入(暂时未启用,W3 接)。
|
||||||
let initialPrompt: String
|
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 phase: HealthExportService.Phase?
|
||||||
@State private var content: String = ""
|
@State private var content: String = ""
|
||||||
@State private var rate: Double = 0
|
@State private var rate: Double = 0
|
||||||
@@ -18,14 +19,25 @@ struct HealthExportSheet: View {
|
|||||||
@State private var error: Error?
|
@State private var error: Error?
|
||||||
@State private var completed: Bool = false
|
@State private var completed: Bool = false
|
||||||
@State private var copiedFlash: 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 = "") {
|
init(initialPrompt: String = "") {
|
||||||
self.initialPrompt = initialPrompt
|
self.initialPrompt = initialPrompt
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isRunning: Bool { phase != nil && !completed && error == nil }
|
private var isGeneratingReport: Bool { phase != nil && !completed && error == nil }
|
||||||
private var isInputMode: 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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -33,29 +45,21 @@ struct HealthExportSheet: View {
|
|||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
if isInputMode {
|
introSection
|
||||||
inputSection
|
|
||||||
} else {
|
ForEach(turns) { turn in
|
||||||
promptEcho
|
dialogueBubble(turn)
|
||||||
if isRunning { phaseIndicator }
|
}
|
||||||
|
|
||||||
|
if isGeneratingReport { phaseIndicator }
|
||||||
|
|
||||||
if !content.isEmpty {
|
if !content.isEmpty {
|
||||||
MarkdownView(text: content)
|
reportCard
|
||||||
.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) }
|
if let err = error { errorRow(err) }
|
||||||
// 锚点,让流式输出自动滚到底
|
|
||||||
Color.clear.frame(height: 1).id("bottom")
|
Color.clear.frame(height: 1).id("bottom")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, 16)
|
||||||
}
|
}
|
||||||
@@ -64,13 +68,24 @@ struct HealthExportSheet: View {
|
|||||||
proxy.scrollTo("bottom", anchor: .bottom)
|
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())
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if prompt.isEmpty { prompt = initialPrompt }
|
if !initialPrompt.isEmpty, draftQuestion.isEmpty, turns.isEmpty {
|
||||||
if isInputMode { promptFocused = true }
|
draftQuestion = initialPrompt
|
||||||
|
}
|
||||||
|
questionFocused = true
|
||||||
}
|
}
|
||||||
.onDisappear { task?.cancel() }
|
.onDisappear { task?.cancel() }
|
||||||
}
|
}
|
||||||
@@ -81,17 +96,17 @@ struct HealthExportSheet: View {
|
|||||||
HStack(alignment: .center, spacing: 12) {
|
HStack(alignment: .center, spacing: 12) {
|
||||||
Button { close() } label: {
|
Button { close() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.background(Circle().fill(Tj.Palette.sand2))
|
.background(Circle().fill(Tj.Palette.sand2))
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("导出身体档案")
|
Text("身体档案")
|
||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("给医生看的就诊摘要")
|
Text("先问清楚,再整理给医生")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -105,41 +120,29 @@ struct HealthExportSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Input section (idle)
|
// MARK: - Dialogue
|
||||||
|
|
||||||
private var inputSection: some View {
|
private var introSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
Text("说说你想给医生看什么")
|
Text("围绕你的指标和健康日记提问")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("例:我感冒3天了,把最近一个月的健康情况给医生看")
|
Text("例:最近血压波动大吗?")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text("例:最近血糖好像不稳,把过去三个月的化验单整理一下")
|
Text("例:把我最近头晕、睡眠和指标变化整理给医生")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
|
|
||||||
ZStack(alignment: .topLeading) {
|
Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
|
||||||
if prompt.isEmpty {
|
.font(.tjScaled( 11))
|
||||||
Text("在这里输入主诉……")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
|
||||||
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(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
.fill(Tj.Palette.paper)
|
.fill(Tj.Palette.paper)
|
||||||
@@ -148,39 +151,61 @@ struct HealthExportSheet: View {
|
|||||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
HStack {
|
private func dialogueBubble(_ turn: HealthExportDialogueTurn) -> some View {
|
||||||
Text("本地 RAG · Qwen3 1.7B · 不上传任何数据")
|
let isUser = turn.role == .user
|
||||||
.font(.system(size: 11))
|
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)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
|
||||||
Button { start() } label: {
|
|
||||||
Text("生成报告")
|
|
||||||
}
|
}
|
||||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
|
} else {
|
||||||
.disabled(prompt.trimmingCharacters(in: .whitespaces).isEmpty)
|
Text(turn.text)
|
||||||
.opacity(prompt.trimmingCharacters(in: .whitespaces).isEmpty ? 0.5 : 1)
|
.font(.tjScaled( 14))
|
||||||
|
.lineSpacing(3)
|
||||||
|
.foregroundStyle(isUser ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 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))
|
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
|
||||||
.lineLimit(3)
|
|
||||||
}
|
|
||||||
.padding(12)
|
.padding(12)
|
||||||
|
.frame(maxWidth: 300, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(isUser ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(isUser ? Color.clear : Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
if !isUser { Spacer(minLength: 44) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var reportCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("整理好的报告")
|
||||||
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
MarkdownView(text: content)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
.fill(Tj.Palette.sand2)
|
.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 {
|
if phase == .generating && rate > 0 {
|
||||||
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
|
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.leaf)
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
} else {
|
} else {
|
||||||
Text(phase?.label ?? "")
|
Text(phase?.label ?? "")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.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 fill = active ? Tj.Palette.ink : (done ? Tj.Palette.leaf : Tj.Palette.sand2)
|
||||||
let fg = (active || done) ? Tj.Palette.paper : Tj.Palette.text3
|
let fg = (active || done) ? Tj.Palette.paper : Tj.Palette.text3
|
||||||
return Text(p.label)
|
return Text(p.label)
|
||||||
.font(.system(size: 11, weight: active ? .semibold : .regular))
|
.font(.tjScaled( 11, weight: active ? .semibold : .regular))
|
||||||
.foregroundStyle(fg)
|
.foregroundStyle(fg)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 5)
|
||||||
@@ -222,7 +247,7 @@ struct HealthExportSheet: View {
|
|||||||
|
|
||||||
private var arrow: some View {
|
private var arrow: some View {
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 10, weight: .semibold))
|
.font(.tjScaled( 10, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +268,7 @@ struct HealthExportSheet: View {
|
|||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
Text(err.localizedDescription)
|
Text(err.localizedDescription)
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
}
|
}
|
||||||
Button { reset() } label: { Text("返回修改") }
|
Button { reset() } label: { Text("返回修改") }
|
||||||
@@ -268,7 +293,7 @@ struct HealthExportSheet: View {
|
|||||||
|
|
||||||
ShareLink(item: content) {
|
ShareLink(item: content) {
|
||||||
Label("分享", systemImage: "square.and.arrow.up")
|
Label("分享", systemImage: "square.and.arrow.up")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
@@ -279,7 +304,7 @@ struct HealthExportSheet: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Button { regenerate() } label: {
|
Button { regenerate() } label: {
|
||||||
Label("重新生成", systemImage: "arrow.clockwise")
|
Label("重新整理", systemImage: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 13, horizontalPadding: 16))
|
.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
|
// MARK: - Actions
|
||||||
|
|
||||||
private func start() {
|
private func sendQuestion() {
|
||||||
let p = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
let question = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !p.isEmpty else { return }
|
guard !question.isEmpty, !isAnswering, !isGeneratingReport else { return }
|
||||||
promptFocused = false
|
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 = ""
|
content = ""
|
||||||
rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示
|
rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示
|
||||||
error = nil
|
error = nil
|
||||||
completed = false
|
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
|
task = Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
for try await event in stream {
|
for try await event in stream {
|
||||||
@@ -326,7 +432,7 @@ struct HealthExportSheet: View {
|
|||||||
|
|
||||||
private func regenerate() {
|
private func regenerate() {
|
||||||
completed = false
|
completed = false
|
||||||
start()
|
startReportGeneration()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reset() {
|
private func reset() {
|
||||||
@@ -337,7 +443,8 @@ struct HealthExportSheet: View {
|
|||||||
rate = 0
|
rate = 0
|
||||||
error = nil
|
error = nil
|
||||||
completed = false
|
completed = false
|
||||||
promptFocused = true
|
answeringTurnID = nil
|
||||||
|
questionFocused = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func copy() {
|
private func copy() {
|
||||||
@@ -377,7 +484,7 @@ struct MarkdownView: View {
|
|||||||
case .h1(let s):
|
case .h1(let s):
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(inline(s))
|
Text(inline(s))
|
||||||
.font(.system(size: 22, weight: .bold))
|
.font(.tjScaled( 22, weight: .bold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
Rectangle()
|
Rectangle()
|
||||||
@@ -394,7 +501,7 @@ struct MarkdownView: View {
|
|||||||
.fill(Tj.Palette.brick)
|
.fill(Tj.Palette.brick)
|
||||||
.frame(width: 3, height: 16)
|
.frame(width: 3, height: 16)
|
||||||
Text(inline(s))
|
Text(inline(s))
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
}
|
}
|
||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
@@ -404,10 +511,10 @@ struct MarkdownView: View {
|
|||||||
if let abnormalText = Self.extractAbnormal(s) {
|
if let abnormalText = Self.extractAbnormal(s) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
Text(inline(abnormalText))
|
Text(inline(abnormalText))
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -431,7 +538,7 @@ struct MarkdownView: View {
|
|||||||
.frame(width: 4, height: 4)
|
.frame(width: 4, height: 4)
|
||||||
.padding(.top, 6)
|
.padding(.top, 6)
|
||||||
Text(inline(s))
|
Text(inline(s))
|
||||||
.font(.system(size: 14))
|
.font(.tjScaled( 14))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
@@ -440,7 +547,7 @@ struct MarkdownView: View {
|
|||||||
|
|
||||||
case .body(let s):
|
case .body(let s):
|
||||||
Text(inline(s))
|
Text(inline(s))
|
||||||
.font(.system(size: 14))
|
.font(.tjScaled( 14))
|
||||||
.lineSpacing(3)
|
.lineSpacing(3)
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ struct CalendarOverviewView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text("回到今天")
|
Text("回到今天")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ struct CalendarOverviewView: View {
|
|||||||
if let onClose {
|
if let onClose {
|
||||||
Button(action: onClose) {
|
Button(action: onClose) {
|
||||||
Text("完成")
|
Text("完成")
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ struct CalendarOverviewView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(m.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)
|
.foregroundStyle(mode == m ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 9)
|
.padding(.vertical, 9)
|
||||||
@@ -157,7 +157,7 @@ struct CalendarOverviewView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Button { shiftAnchor(-1) } label: {
|
Button { shiftAnchor(-1) } label: {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
.background(Circle().fill(Tj.Palette.paper))
|
.background(Circle().fill(Tj.Palette.paper))
|
||||||
@@ -177,7 +177,7 @@ struct CalendarOverviewView: View {
|
|||||||
|
|
||||||
Button { shiftAnchor(1) } label: {
|
Button { shiftAnchor(1) } label: {
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
.background(Circle().fill(Tj.Palette.paper))
|
.background(Circle().fill(Tj.Palette.paper))
|
||||||
@@ -230,7 +230,7 @@ struct CalendarOverviewView: View {
|
|||||||
private var legend: some View {
|
private var legend: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("图例")
|
Text("图例")
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.tracking(0.5)
|
.tracking(0.5)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
@@ -249,7 +249,7 @@ struct CalendarOverviewView: View {
|
|||||||
.fill(color)
|
.fill(color)
|
||||||
.frame(width: 14, height: 6)
|
.frame(width: 14, height: 6)
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ struct CaptureReviewForm: View {
|
|||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
if let onReanalyze {
|
if let onReanalyze {
|
||||||
@@ -48,7 +48,7 @@ struct CaptureReviewForm: View {
|
|||||||
onReanalyze()
|
onReanalyze()
|
||||||
} label: {
|
} label: {
|
||||||
Label("重新识别", systemImage: "arrow.clockwise")
|
Label("重新识别", systemImage: "arrow.clockwise")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
@@ -131,7 +131,7 @@ struct CaptureReviewForm: View {
|
|||||||
private func labeledField<C: View>(_ label: String, @ViewBuilder content: () -> C) -> some View {
|
private func labeledField<C: View>(_ label: String, @ViewBuilder content: () -> C) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.tjScaled( 11, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
@@ -150,14 +150,14 @@ struct CaptureReviewForm: View {
|
|||||||
)
|
)
|
||||||
} label: {
|
} label: {
|
||||||
Label("加一项", systemImage: "plus.circle")
|
Label("加一项", systemImage: "plus.circle")
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.tjScaled( 12, weight: .medium))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
if parsed.indicators.isEmpty {
|
if parsed.indicators.isEmpty {
|
||||||
Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片")
|
Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
} else {
|
} else {
|
||||||
@@ -175,7 +175,7 @@ struct CaptureReviewForm: View {
|
|||||||
return VStack(spacing: 8) {
|
return VStack(spacing: 8) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
TextField("指标名", text: binding.name)
|
TextField("指标名", text: binding.name)
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
parsed.indicators.removeAll { $0.id == id }
|
parsed.indicators.removeAll { $0.id == id }
|
||||||
} label: {
|
} label: {
|
||||||
@@ -187,7 +187,7 @@ struct CaptureReviewForm: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
TextField("数值", text: binding.value)
|
TextField("数值", text: binding.value)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
.font(.tjScaled( 14, weight: .semibold, design: .monospaced))
|
||||||
.frame(maxWidth: 90)
|
.frame(maxWidth: 90)
|
||||||
TextField("单位", text: binding.unit)
|
TextField("单位", text: binding.unit)
|
||||||
.frame(maxWidth: 80)
|
.frame(maxWidth: 80)
|
||||||
@@ -247,7 +247,7 @@ struct CaptureReviewForm: View {
|
|||||||
|
|
||||||
private func sectionLabel(_ t: String) -> some View {
|
private func sectionLabel(_ t: String) -> some View {
|
||||||
Text(t)
|
Text(t)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ struct PhotoPickerSheet: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
Image(systemName: "photo.on.rectangle.angled")
|
Image(systemName: "photo.on.rectangle.angled")
|
||||||
.font(.system(size: 56))
|
.font(.tjScaled( 56))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text("模拟器没有摄像头,从相册选一张化验单/体检报告")
|
Text("模拟器没有摄像头,从相册选一张化验单/体检报告")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ struct PhotoPickerSheet: View {
|
|||||||
maxSelectionCount: 5,
|
maxSelectionCount: 5,
|
||||||
matching: .images) {
|
matching: .images) {
|
||||||
Text("从相册选 ≤5 张")
|
Text("从相册选 ≤5 张")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.tjScaled( 14, weight: .semibold))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(Tj.Palette.ink)
|
.background(Tj.Palette.ink)
|
||||||
|
|||||||
@@ -300,7 +300,12 @@ struct UnifiedCaptureFlow: View {
|
|||||||
status: ind.status,
|
status: ind.status,
|
||||||
capturedAt: final.reportDate,
|
capturedAt: final.reportDate,
|
||||||
report: report,
|
report: report,
|
||||||
source: .report
|
source: .report,
|
||||||
|
sourcePageIndex: ind.sourcePageIndex,
|
||||||
|
sourceBoxX: ind.sourceBoxX,
|
||||||
|
sourceBoxY: ind.sourceBoxY,
|
||||||
|
sourceBoxWidth: ind.sourceBoxWidth,
|
||||||
|
sourceBoxHeight: ind.sourceBoxHeight
|
||||||
)
|
)
|
||||||
ctx.insert(i)
|
ctx.insert(i)
|
||||||
}
|
}
|
||||||
@@ -346,16 +351,16 @@ private struct AnalyzingView: View {
|
|||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("\(images.count) 页 · 100% 本地推理 · 已用 \(elapsed)s")
|
Text("\(images.count) 页 · 100% 本地推理 · 已用 \(elapsed)s")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
if elapsed >= timeoutSeconds - 5 {
|
if elapsed >= timeoutSeconds - 5 {
|
||||||
Text("快超时了,>\(timeoutSeconds)s 会自动转为手动录入")
|
Text("快超时了,>\(timeoutSeconds)s 会自动转为手动录入")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button("取消识别 · 改为手动录入", action: onCancel)
|
Button("取消识别 · 改为手动录入", action: onCancel)
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -375,7 +380,7 @@ private struct CaptureTipSheet: View {
|
|||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: "doc.viewfinder")
|
Image(systemName: "doc.viewfinder")
|
||||||
.font(.system(size: 28))
|
.font(.tjScaled( 28))
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
Text("拍报告的小贴士")
|
Text("拍报告的小贴士")
|
||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
|
|||||||
@@ -62,12 +62,12 @@ struct DiaryQuickSheet: View {
|
|||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
|
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("本机保存")
|
Text("本机保存")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -154,18 +154,18 @@ struct DiaryQuickSheet: View {
|
|||||||
// section header
|
// section header
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "sparkles")
|
Image(systemName: "sparkles")
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
|
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
|
||||||
Spacer()
|
Spacer()
|
||||||
if hasQuestions {
|
if hasQuestions {
|
||||||
Text("\(questions.count) 个建议")
|
Text("\(questions.count) 个建议")
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.tjScaled( 10, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
if lastRate > 0 {
|
if lastRate > 0 {
|
||||||
Text(String(format: "%.1f tok/s", lastRate))
|
Text(String(format: "%.1f tok/s", lastRate))
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.tjScaled( 10, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.leaf)
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,10 +187,10 @@ struct DiaryQuickSheet: View {
|
|||||||
if exhaustedNote {
|
if exhaustedNote {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "checkmark.seal.fill")
|
Image(systemName: "checkmark.seal.fill")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.leaf)
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
Text("已覆盖主要问诊维度;补充原文后可再追问")
|
Text("已覆盖主要问诊维度;补充原文后可再追问")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
@@ -219,11 +219,11 @@ struct DiaryQuickSheet: View {
|
|||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
Text("AI 思考中… 本地推理,通常 5-10 秒")
|
Text("AI 思考中… 本地推理,通常 5-10 秒")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("取消") { cancelSuggestions() }
|
Button("取消") { cancelSuggestions() }
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 11)
|
.padding(.vertical, 11)
|
||||||
@@ -253,13 +253,13 @@ struct DiaryQuickSheet: View {
|
|||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
Text(err.localizedDescription)
|
Text(err.localizedDescription)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
Button { requestSuggestions() } label: {
|
Button { requestSuggestions() } label: {
|
||||||
Text("重试")
|
Text("重试")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -282,7 +282,7 @@ struct DiaryQuickSheet: View {
|
|||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
Text(label)
|
Text(label)
|
||||||
}
|
}
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
.foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 11)
|
.padding(.vertical, 11)
|
||||||
@@ -315,12 +315,12 @@ struct DiaryQuickSheet: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath")
|
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)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
Text(round == 1
|
Text(round == 1
|
||||||
? String(appLoc: "第 1 轮 · \(count) 条")
|
? String(appLoc: "第 1 轮 · \(count) 条")
|
||||||
: String(appLoc: "第 \(round) 轮 · 基于你刚才更新的文本 · \(count) 条"))
|
: String(appLoc: "第 \(round) 轮 · 基于你刚才更新的文本 · \(count) 条"))
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
@@ -344,10 +344,10 @@ struct DiaryQuickSheet: View {
|
|||||||
return VStack(alignment: .leading, spacing: 6) {
|
return VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
Text("\(index).")
|
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)
|
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick)
|
||||||
Text(question.q)
|
Text(question.q)
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text)
|
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text)
|
||||||
.strikethrough(adopted, color: Tj.Palette.text3)
|
.strikethrough(adopted, color: Tj.Palette.text3)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -356,9 +356,9 @@ struct DiaryQuickSheet: View {
|
|||||||
if adopted {
|
if adopted {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 10, weight: .bold))
|
.font(.tjScaled( 10, weight: .bold))
|
||||||
Text("已采纳")
|
Text("已采纳")
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
}
|
}
|
||||||
.foregroundStyle(Tj.Palette.leaf)
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
@@ -368,9 +368,9 @@ struct DiaryQuickSheet: View {
|
|||||||
Button { adopt(question) } label: {
|
Button { adopt(question) } label: {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
Text("采纳")
|
Text("采纳")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
}
|
}
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
@@ -390,10 +390,10 @@ struct DiaryQuickSheet: View {
|
|||||||
} else if !question.fill.isEmpty && !adopted {
|
} else if !question.fill.isEmpty && !adopted {
|
||||||
HStack(alignment: .top, spacing: 4) {
|
HStack(alignment: .top, spacing: 4) {
|
||||||
Text("将追加:")
|
Text("将追加:")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text(question.fill)
|
Text(question.fill)
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
@@ -416,7 +416,7 @@ struct DiaryQuickSheet: View {
|
|||||||
|
|
||||||
private func sectionLabel(_ text: String) -> some View {
|
private func sectionLabel(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ struct QuestionFillPanel: View {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
// 实时预览:已填值高亮,未填槽浅色下划线提示。
|
// 实时预览:已填值高亮,未填槽浅色下划线提示。
|
||||||
previewText
|
previewText
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(10)
|
.padding(10)
|
||||||
@@ -115,7 +115,7 @@ struct QuestionFillPanel: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Button(action: onCancel) {
|
Button(action: onCancel) {
|
||||||
Text("取消")
|
Text("取消")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 9)
|
.padding(.vertical, 9)
|
||||||
@@ -134,9 +134,9 @@ struct QuestionFillPanel: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 5) {
|
HStack(spacing: 5) {
|
||||||
Image(systemName: "text.append")
|
Image(systemName: "text.append")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
Text("加入记录")
|
Text("加入记录")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
}
|
}
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -180,7 +180,7 @@ struct QuestionFillPanel: View {
|
|||||||
private func slotEditor(index: Int, label: String, options: [String]) -> some View {
|
private func slotEditor(index: Int, label: String, options: [String]) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
|
||||||
if !options.isEmpty {
|
if !options.isEmpty {
|
||||||
@@ -189,7 +189,7 @@ struct QuestionFillPanel: View {
|
|||||||
let picked = bindingValue(index) == opt
|
let picked = bindingValue(index) == opt
|
||||||
Button { values[index] = opt } label: {
|
Button { values[index] = opt } label: {
|
||||||
Text(opt)
|
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)
|
.foregroundStyle(picked ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 5)
|
||||||
@@ -208,7 +208,7 @@ struct QuestionFillPanel: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
TextField(String(appLoc: "填写\(label)"), text: binding(index))
|
TextField(String(appLoc: "填写\(label)"), text: binding(index))
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 9)
|
.padding(.vertical, 9)
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
@@ -85,10 +85,10 @@ struct HomeCalendarCard: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
Text(summaryLine)
|
Text(summaryLine)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ struct HomeCalendarCard: View {
|
|||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 5) {
|
VStack(spacing: 5) {
|
||||||
Text(weekdayLabel(day))
|
Text(weekdayLabel(day))
|
||||||
.font(.system(size: 10, weight: .medium))
|
.font(.tjScaled( 10, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
ZStack {
|
ZStack {
|
||||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||||
@@ -128,7 +128,7 @@ struct HomeCalendarCard: View {
|
|||||||
.strokeBorder(Tj.Palette.ink, lineWidth: 1.2)
|
.strokeBorder(Tj.Palette.ink, lineWidth: 1.2)
|
||||||
}
|
}
|
||||||
Text("\(calendar.component(.day, from: day))")
|
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)
|
.foregroundStyle(isToday ? Tj.Palette.ink : Tj.Palette.text)
|
||||||
}
|
}
|
||||||
.frame(height: 38)
|
.frame(height: 38)
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ struct HomeView: View {
|
|||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(todayLine)
|
Text(todayLine)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.tracking(1)
|
.tracking(1)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text(greetingWord)
|
Text(greetingWord)
|
||||||
@@ -106,7 +106,7 @@ struct HomeView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
Button(action: onTapArchive) {
|
Button(action: onTapArchive) {
|
||||||
Text("全部 ›")
|
Text("全部 ›")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -119,7 +119,7 @@ struct HomeView: View {
|
|||||||
ForEach(recentGrouped, id: \.section) { group in
|
ForEach(recentGrouped, id: \.section) { group in
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(group.section.label)
|
Text(group.section.label)
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.tracking(0.5)
|
.tracking(0.5)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
@@ -148,7 +148,7 @@ struct HomeView: View {
|
|||||||
private var emptyRecent: some View {
|
private var emptyRecent: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text("还没有任何记录,点底部 + 号开始第一条")
|
Text("还没有任何记录,点底部 + 号开始第一条")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -167,15 +167,15 @@ struct HomeView: View {
|
|||||||
.frame(width: 56, height: 56)
|
.frame(width: 56, height: 56)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("我的报告档案")
|
Text("我的报告档案")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.tjScaled( 14, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("\(reports.count) 份 · \(indicators.count) 项指标 · 端侧加密")
|
Text("\(reports.count) 份 · \(indicators.count) 项指标 · 端侧加密")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ struct RecentItemRow: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("\(date) · \(type)")
|
Text("\(date) · \(type)")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(name)
|
Text(name)
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
@@ -47,7 +47,7 @@ struct RecentItemRow: View {
|
|||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
if let value {
|
if let value {
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
.font(.tjScaled( 12, weight: .semibold, design: .monospaced))
|
||||||
.foregroundStyle(status.valueColor)
|
.foregroundStyle(status.valueColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
|
|||||||
@@ -61,12 +61,12 @@ struct TodayRemindersCard: View {
|
|||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("\(count) 项")
|
Text("\(count) 项")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button { showingCenter = true } label: {
|
Button { showingCenter = true } label: {
|
||||||
Text("全部 ›")
|
Text("全部 ›")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -77,14 +77,14 @@ struct TodayRemindersCard: View {
|
|||||||
let isPast = item.isPast(now: tick)
|
let isPast = item.isPast(now: tick)
|
||||||
return HStack(spacing: 12) {
|
return HStack(spacing: 12) {
|
||||||
Text(item.timeLabel)
|
Text(item.timeLabel)
|
||||||
.font(.system(size: 14, weight: .semibold).monospacedDigit())
|
.font(.tjScaled( 14, weight: .semibold).monospacedDigit())
|
||||||
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.ink)
|
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.ink)
|
||||||
.frame(width: 46, alignment: .leading)
|
.frame(width: 46, alignment: .leading)
|
||||||
Image(systemName: "bell.fill")
|
Image(systemName: "bell.fill")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.amber)
|
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.amber)
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
.font(.system(size: 15, weight: .medium))
|
.font(.tjScaled( 15, weight: .medium))
|
||||||
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.text)
|
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ struct CustomMetricEditor: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
Text("保存后会出现在录入选项里")
|
Text("保存后会出现在录入选项里")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,10 +147,10 @@ struct CustomMetricEditor: View {
|
|||||||
if nameConflict != .none {
|
if nameConflict != .none {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
Text(nameConflict.warningText)
|
Text(nameConflict.warningText)
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -175,7 +175,7 @@ struct CustomMetricEditor: View {
|
|||||||
sectionLabel(String(appLoc: "参考范围(可选)"))
|
sectionLabel(String(appLoc: "参考范围(可选)"))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("用于自动判定 正常/偏高/偏低")
|
Text("用于自动判定 正常/偏高/偏低")
|
||||||
.font(.system(size: 10))
|
.font(.tjScaled( 10))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -188,10 +188,10 @@ struct CustomMetricEditor: View {
|
|||||||
|
|
||||||
private func rangeField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
private func rangeField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
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)
|
TextField(placeholder, text: value)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
.font(.tjScaled( 16, weight: .medium, design: .monospaced))
|
||||||
.padding(.horizontal, 12).padding(.vertical, 10)
|
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||||
.background(fieldBg).overlay(fieldBorder)
|
.background(fieldBg).overlay(fieldBorder)
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ struct CustomMetricEditor: View {
|
|||||||
icon = sf
|
icon = sf
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: sf)
|
Image(systemName: sf)
|
||||||
.font(.system(size: 20, weight: .medium))
|
.font(.tjScaled( 20, weight: .medium))
|
||||||
.foregroundStyle(icon == sf ? Tj.Palette.paper : Tj.Palette.ink)
|
.foregroundStyle(icon == sf ? Tj.Palette.paper : Tj.Palette.ink)
|
||||||
.frame(maxWidth: .infinity, minHeight: 44)
|
.frame(maxWidth: .infinity, minHeight: 44)
|
||||||
.background(
|
.background(
|
||||||
@@ -239,7 +239,7 @@ struct CustomMetricEditor: View {
|
|||||||
Image(systemName: "trash")
|
Image(systemName: "trash")
|
||||||
Text("删除这项自定义指标")
|
Text("删除这项自定义指标")
|
||||||
}
|
}
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
@@ -282,7 +282,7 @@ struct CustomMetricEditor: View {
|
|||||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||||
}
|
}
|
||||||
private func sectionLabel(_ t: String) -> some View {
|
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)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ private let labPresets: [IndicatorPreset] = [
|
|||||||
/// 无 seriesKey,不进 Trends。
|
/// 无 seriesKey,不进 Trends。
|
||||||
/// 3. **自由输入** — name/value/unit/range 全自己填,status 手动选。
|
/// 3. **自由输入** — name/value/unit/range 全自己填,status 手动选。
|
||||||
struct IndicatorQuickSheet: View {
|
struct IndicatorQuickSheet: View {
|
||||||
|
/// 「拍照识别」入口回调。由 RootView 注入:关闭本表单 → 打开 QuickRegionCaptureFlow(相机→VL→存)。
|
||||||
|
/// nil 时(如 Preview)不显示拍照按钮。
|
||||||
|
var onRequestCamera: (() -> Void)? = nil
|
||||||
|
|
||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Query private var profiles: [UserProfile]
|
@Query private var profiles: [UserProfile]
|
||||||
@@ -103,6 +107,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
cameraEntrySection
|
||||||
monitorGridSection
|
monitorGridSection
|
||||||
labPresetSection
|
labPresetSection
|
||||||
Divider().padding(.vertical, 4)
|
Divider().padding(.vertical, 4)
|
||||||
@@ -161,13 +166,69 @@ struct IndicatorQuickSheet: View {
|
|||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("本地处理 · 永不上传")
|
Text("本地处理 · 永不上传")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 16)
|
.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 {
|
private var monitorGridSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -217,18 +278,18 @@ struct IndicatorQuickSheet: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: cm.icon)
|
Image(systemName: cm.icon)
|
||||||
.font(.system(size: 18, weight: .medium))
|
.font(.tjScaled( 18, weight: .medium))
|
||||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft))
|
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(cm.name)
|
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)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text("自定义")
|
Text("自定义")
|
||||||
.font(.system(size: 9, design: .monospaced))
|
.font(.tjScaled( 9, design: .monospaced))
|
||||||
.foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3)
|
.foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -260,14 +321,14 @@ struct IndicatorQuickSheet: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.system(size: 18, weight: .semibold))
|
.font(.tjScaled( 18, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.background(
|
.background(
|
||||||
Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true)
|
Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true)
|
||||||
)
|
)
|
||||||
Text("自定义")
|
Text("自定义")
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -293,13 +354,13 @@ struct IndicatorQuickSheet: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: m.icon)
|
Image(systemName: m.icon)
|
||||||
.font(.system(size: 18, weight: .medium))
|
.font(.tjScaled( 18, weight: .medium))
|
||||||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.amber.opacity(0.25)))
|
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.amber.opacity(0.25)))
|
||||||
|
|
||||||
Text(m.displayName)
|
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)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -348,7 +409,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
bpField(label: String(appLoc: "收缩压"), value: $systolic, placeholder: "120")
|
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")
|
bpField(label: String(appLoc: "舒张压"), value: $diastolic, placeholder: "80")
|
||||||
Text("mmHg").foregroundStyle(Tj.Palette.text3)
|
Text("mmHg").foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
@@ -358,10 +419,10 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private func bpField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
private func bpField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
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)
|
TextField(placeholder, text: value)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
.font(.system(size: 20, weight: .semibold, design: .monospaced))
|
.font(.tjScaled( 20, weight: .semibold, design: .monospaced))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.frame(width: 90)
|
.frame(width: 90)
|
||||||
@@ -380,11 +441,11 @@ struct IndicatorQuickSheet: View {
|
|||||||
let rangeText = "\(formatRange(sysRange)) / \(formatRange(diasRange))"
|
let rangeText = "\(formatRange(sysRange)) / \(formatRange(diasRange))"
|
||||||
return HStack(spacing: 4) {
|
return HStack(spacing: 4) {
|
||||||
Text(rangeText)
|
Text(rangeText)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
if personalized, let age = profile?.age {
|
if personalized, let age = profile?.age {
|
||||||
Text("· 按\(age)岁调整")
|
Text("· 按\(age)岁调整")
|
||||||
.font(.system(size: 10))
|
.font(.tjScaled( 10))
|
||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,7 +488,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
sectionLabel(String(appLoc: "数值"))
|
sectionLabel(String(appLoc: "数值"))
|
||||||
TextField(monitorFieldPlaceholder, text: $value)
|
TextField(monitorFieldPlaceholder, text: $value)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
.font(.system(size: 18, weight: .semibold, design: .monospaced))
|
.font(.tjScaled( 18, weight: .semibold, design: .monospaced))
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(fieldBg)
|
.background(fieldBg)
|
||||||
@@ -475,7 +536,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
return HStack(spacing: 4) {
|
return HStack(spacing: 4) {
|
||||||
if personalized, let age = profile?.age {
|
if personalized, let age = profile?.age {
|
||||||
Text("按\(age)岁调整")
|
Text("按\(age)岁调整")
|
||||||
.font(.system(size: 10))
|
.font(.tjScaled( 10))
|
||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,7 +561,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
statusBadge(s.label, color: s.color)
|
statusBadge(s.label, color: s.color)
|
||||||
} else {
|
} else {
|
||||||
Text("待输入")
|
Text("待输入")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -546,7 +607,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("时间")
|
Text("时间")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer()
|
Spacer()
|
||||||
DatePicker("", selection: $reminderTime,
|
DatePicker("", selection: $reminderTime,
|
||||||
@@ -558,11 +619,11 @@ struct IndicatorQuickSheet: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("频率")
|
Text("频率")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(reminderFrequencyLabel)
|
Text(reminderFrequencyLabel)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
weekdayPickerRow
|
weekdayPickerRow
|
||||||
@@ -581,11 +642,11 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
if notifAuthBlocked {
|
if notifAuthBlocked {
|
||||||
Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开")
|
Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
} else {
|
} else {
|
||||||
Text("本机提醒 · 不发任何数据")
|
Text("本机提醒 · 不发任何数据")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -625,7 +686,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(names[idx])
|
Text(names[idx])
|
||||||
.font(.system(size: 13,
|
.font(.tjScaled( 13,
|
||||||
weight: reminderWeekdays.contains(w) ? .semibold : .regular))
|
weight: reminderWeekdays.contains(w) ? .semibold : .regular))
|
||||||
.foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
|
.foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.frame(maxWidth: .infinity, minHeight: 32)
|
.frame(maxWidth: .infinity, minHeight: 32)
|
||||||
@@ -647,7 +708,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View {
|
private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
@@ -755,7 +816,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private func sectionLabel(_ text: String) -> some View {
|
private func sectionLabel(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
@@ -763,7 +824,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Text(label)
|
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)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -779,7 +840,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
manualStatus = value
|
manualStatus = value
|
||||||
} label: {
|
} label: {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
|
||||||
.foregroundStyle(selected ? Tj.Palette.paper : color)
|
.foregroundStyle(selected ? Tj.Palette.paper : color)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -792,7 +853,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private func statusBadge(_ label: String, color: Color) -> some View {
|
private func statusBadge(_ label: String, color: Color) -> some View {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(color)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
@@ -832,9 +893,9 @@ struct IndicatorQuickSheet: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
Text("已隐藏 \(hiddenSet.count)")
|
Text("已隐藏 \(hiddenSet.count)")
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.tjScaled( 11, weight: .medium))
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 9, weight: .semibold))
|
.font(.tjScaled( 9, weight: .semibold))
|
||||||
}
|
}
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
@@ -1121,7 +1182,7 @@ private struct HiddenMonitorRestoreSheet: View {
|
|||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("完成") { dismiss() }
|
Button("完成") { dismiss() }
|
||||||
.font(.system(size: 14))
|
.font(.tjScaled( 14))
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -1146,13 +1207,13 @@ private struct HiddenMonitorRestoreSheet: View {
|
|||||||
private func row(_ m: MonitorMetric) -> some View {
|
private func row(_ m: MonitorMetric) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: m.icon)
|
Image(systemName: m.icon)
|
||||||
.font(.system(size: 16, weight: .medium))
|
.font(.tjScaled( 16, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.background(Circle().fill(Tj.Palette.amber.opacity(0.25)))
|
.background(Circle().fill(Tj.Palette.amber.opacity(0.25)))
|
||||||
|
|
||||||
Text(m.displayName)
|
Text(m.displayName)
|
||||||
.font(.system(size: 15, weight: .medium))
|
.font(.tjScaled( 15, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -1160,7 +1221,7 @@ private struct HiddenMonitorRestoreSheet: View {
|
|||||||
Button("显示") {
|
Button("显示") {
|
||||||
onRestore(m)
|
onRestore(m)
|
||||||
}
|
}
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|||||||
@@ -70,12 +70,12 @@ struct AboutView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text("康康 · 本地优先的健康档案 · \(versionText)")
|
Text("康康 · 本地优先的健康档案 · \(versionText)")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
|
|
||||||
Text("本 App 仅供健康信息记录与参考,不能替代专业医疗意见。")
|
Text("本 App 仅供健康信息记录与参考,不能替代专业医疗意见。")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -98,7 +98,7 @@ struct AboutView: View {
|
|||||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
.fill(Tj.Palette.sand2)
|
.fill(Tj.Palette.sand2)
|
||||||
Image(systemName: "heart.text.square.fill")
|
Image(systemName: "heart.text.square.fill")
|
||||||
.font(.system(size: 34))
|
.font(.tjScaled( 34))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
}
|
}
|
||||||
.frame(width: 72, height: 72)
|
.frame(width: 72, height: 72)
|
||||||
@@ -108,7 +108,7 @@ struct AboutView: View {
|
|||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
|
||||||
Text("本地优先的个人健康随记")
|
Text("本地优先的个人健康随记")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
|
||||||
Text(versionText)
|
Text(versionText)
|
||||||
@@ -133,10 +133,10 @@ struct AboutView: View {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(tint)
|
.foregroundStyle(tint)
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
}
|
}
|
||||||
content()
|
content()
|
||||||
@@ -148,7 +148,7 @@ struct AboutView: View {
|
|||||||
|
|
||||||
@ViewBuilder private func paragraph(_ text: String) -> some View {
|
@ViewBuilder private func paragraph(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 14))
|
.font(.tjScaled( 14))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.lineSpacing(5)
|
.lineSpacing(5)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -161,7 +161,7 @@ struct AboutView: View {
|
|||||||
.frame(width: 5, height: 5)
|
.frame(width: 5, height: 5)
|
||||||
.padding(.top, 7)
|
.padding(.top, 7)
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 14))
|
.font(.tjScaled( 14))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.lineSpacing(5)
|
.lineSpacing(5)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ struct CustomMetricsListView: View {
|
|||||||
editingTarget = CustomMetricEditTarget(metric: nil)
|
editingTarget = CustomMetricEditTarget(metric: nil)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus")
|
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")
|
Image(systemName: "info.circle.fill")
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text("自定义指标会出现在「+ 指标记录 → 长期监测」的 grid 里,可设提醒、进趋势")
|
Text("自定义指标会出现在「+ 指标记录 → 长期监测」的 grid 里,可设提醒、进趋势")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -75,7 +75,7 @@ struct CustomMetricsListView: View {
|
|||||||
TjPlaceholder(label: String(appLoc: "还没有自定义指标"))
|
TjPlaceholder(label: String(appLoc: "还没有自定义指标"))
|
||||||
.frame(width: 220, height: 130)
|
.frame(width: 220, height: 130)
|
||||||
Text("右上角 + 新建一个")
|
Text("右上角 + 新建一个")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -88,28 +88,28 @@ struct CustomMetricsListView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
Circle().fill(Tj.Palette.leafSoft)
|
Circle().fill(Tj.Palette.leafSoft)
|
||||||
Image(systemName: m.icon)
|
Image(systemName: m.icon)
|
||||||
.font(.system(size: 17, weight: .medium))
|
.font(.tjScaled( 17, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(m.name)
|
Text(m.name)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
if !m.unit.isEmpty {
|
if !m.unit.isEmpty {
|
||||||
Text(m.unit)
|
Text(m.unit)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
if !m.rangeText.isEmpty {
|
if !m.rangeText.isEmpty {
|
||||||
Text("·")
|
Text("·")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text(m.rangeText)
|
Text(m.rangeText)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,10 +119,10 @@ struct CustomMetricsListView: View {
|
|||||||
|
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
Text(count == 0 ? String(appLoc: "未使用") : String(appLoc: "用 \(count) 次"))
|
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)
|
.foregroundStyle(count > 0 ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.tjScaled( 11, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ struct CustomReminderEditSheet: View {
|
|||||||
|
|
||||||
private var skipHint: some View {
|
private var skipHint: some View {
|
||||||
Text(String(appLoc: "部分月份无此日,该月将跳过"))
|
Text(String(appLoc: "部分月份无此日,该月将跳过"))
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ struct CustomReminderEditSheet: View {
|
|||||||
second: 0, of: pickedTime) ?? pickedTime
|
second: 0, of: pickedTime) ?? pickedTime
|
||||||
} label: {
|
} label: {
|
||||||
Text(String(format: "%d:%02d", preset.h, preset.m))
|
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)
|
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.frame(maxWidth: .infinity, minHeight: 30)
|
.frame(maxWidth: .infinity, minHeight: 30)
|
||||||
.background(
|
.background(
|
||||||
@@ -203,7 +203,7 @@ struct CustomReminderEditSheet: View {
|
|||||||
if on { weekdays.remove(w) } else { weekdays.insert(w) }
|
if on { weekdays.remove(w) } else { weekdays.insert(w) }
|
||||||
} label: {
|
} label: {
|
||||||
Text(names[idx])
|
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)
|
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.frame(maxWidth: .infinity, minHeight: 30)
|
.frame(maxWidth: .infinity, minHeight: 30)
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
81
康康/Features/Me/FontSettingsView.swift
Normal file
81
康康/Features/Me/FontSettingsView.swift
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 「我的 · 字体大小」选择页。面向视力不佳 / 老年用户:放大整个 App 字号。
|
||||||
|
/// 选中即时生效(整树重建为新档位,无需重启,同语言切换机制)。
|
||||||
|
/// 每行用各自档位渲染示例字,便于直接比较大小后再选。
|
||||||
|
struct FontSettingsView: View {
|
||||||
|
@State private var manager = FontScaleManager.shared
|
||||||
|
|
||||||
|
/// 示例字基准字号(行内按各档位倍率独立渲染,不随当前全局档位变化,保证可对比)。
|
||||||
|
private let sampleBase: CGFloat = 17
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(FontScale.allCases) { option in
|
||||||
|
row(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("放大后整个 App 的文字立即变大,无需重启。设置会被记住。")
|
||||||
|
.font(.tjScaled(12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationTitle("字体大小")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ option: FontScale) -> some View {
|
||||||
|
let selected = manager.scale == option
|
||||||
|
return Button {
|
||||||
|
manager.set(option)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(option.label)
|
||||||
|
.font(.system(size: 15, weight: selected ? .semibold : .regular))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(option.detail)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
// 示例字:按本档位倍率渲染,直接预览效果。
|
||||||
|
Text("健康档案 Aa 123")
|
||||||
|
.font(.system(size: sampleBase * option.multiplier, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(selected ? Tj.Palette.ink : Tj.Palette.line, lineWidth: selected ? 0 : 1.5)
|
||||||
|
.background(Circle().fill(selected ? Tj.Palette.ink : Color.clear))
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
if selected {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack { FontSettingsView() }
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ struct LanguageSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text("切换后整个 App 立即生效,无需重启。")
|
Text("切换后整个 App 立即生效,无需重启。")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
@@ -40,14 +40,14 @@ struct LanguageSettingsView: View {
|
|||||||
.frame(width: 40, height: 40)
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
Text(option.displayName)
|
Text(option.displayName)
|
||||||
.font(.system(size: 15, weight: selected ? .semibold : .regular))
|
.font(.tjScaled( 15, weight: selected ? .semibold : .regular))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if selected {
|
if selected {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.tjScaled( 14, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,11 +64,11 @@ struct LanguageSettingsView: View {
|
|||||||
switch option.pickerIcon {
|
switch option.pickerIcon {
|
||||||
case .symbol(let name):
|
case .symbol(let name):
|
||||||
Image(systemName: name)
|
Image(systemName: name)
|
||||||
.font(.system(size: 16))
|
.font(.tjScaled( 16))
|
||||||
.foregroundStyle(fg)
|
.foregroundStyle(fg)
|
||||||
case .glyph(let g):
|
case .glyph(let g):
|
||||||
Text(verbatim: g)
|
Text(verbatim: g)
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.tjScaled( 17, weight: .semibold))
|
||||||
.foregroundStyle(fg)
|
.foregroundStyle(fg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct MeView: View {
|
|||||||
@State private var downloadService = ModelDownloadService.shared
|
@State private var downloadService = ModelDownloadService.shared
|
||||||
@State private var appLock = AppLock.shared
|
@State private var appLock = AppLock.shared
|
||||||
@State private var lang = LanguageManager.shared
|
@State private var lang = LanguageManager.shared
|
||||||
|
@State private var fontScale = FontScaleManager.shared
|
||||||
// key 必须与 AppLock.enabledKey 一致。
|
// key 必须与 AppLock.enabledKey 一致。
|
||||||
@AppStorage("faceIDLockEnabled") private var lockEnabled = false
|
@AppStorage("faceIDLockEnabled") private var lockEnabled = false
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ struct MeView: View {
|
|||||||
customMetricsCard
|
customMetricsCard
|
||||||
modelManagementCard
|
modelManagementCard
|
||||||
languageCard
|
languageCard
|
||||||
|
fontScaleCard
|
||||||
faceIDCard
|
faceIDCard
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
AboutView()
|
AboutView()
|
||||||
@@ -74,23 +76,23 @@ struct MeView: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(Tj.Palette.amber.opacity(0.25))
|
.fill(Tj.Palette.amber.opacity(0.25))
|
||||||
Image(systemName: "person.crop.circle.fill")
|
Image(systemName: "person.crop.circle.fill")
|
||||||
.font(.system(size: 22))
|
.font(.tjScaled( 22))
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("个人资料")
|
Text("个人资料")
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text(profileLine)
|
Text(profileLine)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
@@ -108,23 +110,23 @@ struct MeView: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(customMetrics.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft)
|
.fill(customMetrics.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft)
|
||||||
Image(systemName: "slider.horizontal.3")
|
Image(systemName: "slider.horizontal.3")
|
||||||
.font(.system(size: 18))
|
.font(.tjScaled( 18))
|
||||||
.foregroundStyle(customMetrics.isEmpty ? Tj.Palette.text2 : Tj.Palette.ink)
|
.foregroundStyle(customMetrics.isEmpty ? Tj.Palette.text2 : Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("自定义指标")
|
Text("自定义指标")
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text(customMetricsLine)
|
Text(customMetricsLine)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
@@ -166,6 +168,17 @@ struct MeView: View {
|
|||||||
.buttonStyle(.plain)
|
.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 卡)
|
// MARK: - Face ID 启动锁(可交互 Toggle 卡)
|
||||||
|
|
||||||
private var faceIDCard: some View {
|
private var faceIDCard: some View {
|
||||||
@@ -173,17 +186,17 @@ struct MeView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
Circle().fill(lockEnabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
Circle().fill(lockEnabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||||
Image(systemName: "faceid")
|
Image(systemName: "faceid")
|
||||||
.font(.system(size: 18))
|
.font(.tjScaled( 18))
|
||||||
.foregroundStyle(lockEnabled ? Tj.Palette.ink : Tj.Palette.text2)
|
.foregroundStyle(lockEnabled ? Tj.Palette.ink : Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Face ID 启动锁")
|
Text("Face ID 启动锁")
|
||||||
.font(.system(size: 15, weight: .medium))
|
.font(.tjScaled( 15, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text(faceIDLine)
|
Text(faceIDLine)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -219,20 +232,20 @@ struct MeView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
Circle().fill(Tj.Palette.sand2)
|
Circle().fill(Tj.Palette.sand2)
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 18))
|
.font(.tjScaled( 18))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 15, weight: .medium))
|
.font(.tjScaled( 15, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(detail)
|
Text(detail)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ struct ModelManagementView: View {
|
|||||||
|
|
||||||
if let importError {
|
if let importError {
|
||||||
Text(importError)
|
Text(importError)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
@@ -86,10 +86,10 @@ struct ModelManagementView: View {
|
|||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(kind.displayName)
|
Text(kind.displayName)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text(subtitle(kind))
|
Text(subtitle(kind))
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -104,17 +104,17 @@ struct ModelManagementView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
Text(speedText(state))
|
Text(speedText(state))
|
||||||
}
|
}
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
} else {
|
} else {
|
||||||
HStack {
|
HStack {
|
||||||
Text(formatBytes(ModelManifest.totalBytes(for: kind)))
|
Text(formatBytes(ModelManifest.totalBytes(for: kind)))
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
if case .failed(let message) = state.phase {
|
if case .failed(let message) = state.phase {
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ struct ModelManagementView: View {
|
|||||||
Image(systemName: "checkmark.seal.fill")
|
Image(systemName: "checkmark.seal.fill")
|
||||||
Text("两个模型都已就绪")
|
Text("两个模型都已就绪")
|
||||||
}
|
}
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.leaf)
|
.foregroundStyle(Tj.Palette.leaf)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
@@ -183,7 +183,7 @@ struct ModelManagementView: View {
|
|||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
TjLockChip()
|
TjLockChip()
|
||||||
Text("100% 本地推理 · 模型仅需下载一次")
|
Text("100% 本地推理 · 模型仅需下载一次")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ struct ModelSelfTestView: View {
|
|||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("测试 PROMPT")
|
Text("测试 PROMPT")
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.tracking(0.5)
|
.tracking(0.5)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text(prompt)
|
Text(prompt)
|
||||||
.font(.system(size: 14))
|
.font(.tjScaled( 14))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
@@ -50,13 +50,13 @@ struct ModelSelfTestView: View {
|
|||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(phase.label)
|
Text(phase.label)
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(statusColor)
|
.foregroundStyle(statusColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
if rate > 0 {
|
if rate > 0 {
|
||||||
Text(String(format: "%.1f tok/s", rate))
|
Text(String(format: "%.1f tok/s", rate))
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.tjScaled( 12, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ struct RemindersListView: View {
|
|||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
Text("新建提醒,或在记录指标时开启")
|
Text("新建提醒,或在记录指标时开启")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ struct RemindersListView: View {
|
|||||||
|
|
||||||
private func sectionLabel(_ text: String) -> some View {
|
private func sectionLabel(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
@@ -146,18 +146,18 @@ private struct CustomReminderRow: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||||
Image(systemName: "bell.fill")
|
Image(systemName: "bell.fill")
|
||||||
.font(.system(size: 16))
|
.font(.tjScaled( 16))
|
||||||
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(reminder.title)
|
Text(reminder.title)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
|
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -173,7 +173,7 @@ private struct CustomReminderRow: View {
|
|||||||
|
|
||||||
// 与指标提醒行的 28×28 展开按钮等宽,保证两类行的 Toggle 纵向对齐。
|
// 与指标提醒行的 28×28 展开按钮等宽,保证两类行的 Toggle 纵向对齐。
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.frame(width: 28, height: 28)
|
.frame(width: 28, height: 28)
|
||||||
}
|
}
|
||||||
@@ -223,17 +223,17 @@ private struct ReminderRow: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||||
Image(systemName: "bell.fill")
|
Image(systemName: "bell.fill")
|
||||||
.font(.system(size: 16))
|
.font(.tjScaled( 16))
|
||||||
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(reminder.displayName)
|
Text(reminder.displayName)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
|
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ private struct ReminderRow: View {
|
|||||||
onTapEdit()
|
onTapEdit()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: isEditing ? "chevron.up" : "chevron.down")
|
Image(systemName: isEditing ? "chevron.up" : "chevron.down")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.frame(width: 28, height: 28)
|
.frame(width: 28, height: 28)
|
||||||
}
|
}
|
||||||
@@ -259,7 +259,7 @@ private struct ReminderRow: View {
|
|||||||
private var editingPanel: some View {
|
private var editingPanel: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("时间").font(.system(size: 13)).foregroundStyle(Tj.Palette.text2)
|
Text("时间").font(.tjScaled( 13)).foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer()
|
Spacer()
|
||||||
DatePicker("", selection: $pickedTime, displayedComponents: .hourAndMinute)
|
DatePicker("", selection: $pickedTime, displayedComponents: .hourAndMinute)
|
||||||
.datePickerStyle(.compact)
|
.datePickerStyle(.compact)
|
||||||
@@ -278,7 +278,7 @@ private struct ReminderRow: View {
|
|||||||
onDelete()
|
onDelete()
|
||||||
} label: {
|
} label: {
|
||||||
Label("删除提醒", systemImage: "trash")
|
Label("删除提醒", systemImage: "trash")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -310,7 +310,7 @@ private struct ReminderRow: View {
|
|||||||
onChange()
|
onChange()
|
||||||
} label: {
|
} label: {
|
||||||
Text(names[idx])
|
Text(names[idx])
|
||||||
.font(.system(size: 13,
|
.font(.tjScaled( 13,
|
||||||
weight: reminder.weekdays.contains(w) ? .semibold : .regular))
|
weight: reminder.weekdays.contains(w) ? .semibold : .regular))
|
||||||
.foregroundStyle(reminder.weekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
|
.foregroundStyle(reminder.weekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.frame(maxWidth: .infinity, minHeight: 30)
|
.frame(maxWidth: .infinity, minHeight: 30)
|
||||||
|
|||||||
@@ -35,9 +35,40 @@ struct ProfileEditView: View {
|
|||||||
private struct ProfileEditForm: View {
|
private struct ProfileEditForm: View {
|
||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
@Bindable var profile: UserProfile
|
@Bindable var profile: UserProfile
|
||||||
|
@State private var healthImportDraft: HealthProfileImportDraft?
|
||||||
|
@State private var healthImportError: String?
|
||||||
|
@State private var isImportingHealthProfile = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
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 {
|
Section {
|
||||||
BirthYearRow(profile: profile)
|
BirthYearRow(profile: profile)
|
||||||
SexRow(profile: profile)
|
SexRow(profile: profile)
|
||||||
@@ -67,6 +98,90 @@ private struct ProfileEditForm: View {
|
|||||||
profile.updatedAt = .now
|
profile.updatedAt = .now
|
||||||
try? ctx.save()
|
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)
|
Text(selectedLabel)
|
||||||
.foregroundStyle(profile.birthYear == nil ? Tj.Palette.text3 : Tj.Palette.text2)
|
.foregroundStyle(profile.birthYear == nil ? Tj.Palette.text3 : Tj.Palette.text2)
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.rotationEffect(.degrees(expanded ? 90 : 0))
|
.rotationEffect(.degrees(expanded ? 90 : 0))
|
||||||
}
|
}
|
||||||
@@ -212,7 +327,7 @@ private struct BMIFooter: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
if let bmi = profile.bmi {
|
if let bmi = profile.bmi {
|
||||||
Text("BMI: \(String(format: "%.1f", bmi)) \(label(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 {
|
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Text(label)
|
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)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ struct QuickRegionCaptureFlow: View {
|
|||||||
@State private var analyzeTask: Task<Void, Never>? = nil
|
@State private var analyzeTask: Task<Void, Never>? = nil
|
||||||
|
|
||||||
/// VL 单次推理超时(防卡死);超时后 cancel 子任务,UI 转手动录入。
|
/// VL 单次推理超时(防卡死);超时后 cancel 子任务,UI 转手动录入。
|
||||||
private let analyzeTimeoutSeconds: Int = 30
|
/// 整页化验单指标多、生成 token 多,30s 偏紧,放宽到 60s。
|
||||||
|
private let analyzeTimeoutSeconds: Int = 60
|
||||||
|
|
||||||
enum Phase {
|
enum Phase {
|
||||||
case idle
|
case idle
|
||||||
@@ -86,23 +87,42 @@ struct QuickRegionCaptureFlow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 入口:相机(真机)/ 相册(模拟器)
|
// MARK: - 入口:整页文档扫描(真机)/ 相册(模拟器或不支持)
|
||||||
|
|
||||||
|
// 旧实现用 RegionCameraView 的「细条小框」(为 1-2 行异常项设计);并入「记录指标 · 拍照识别」后
|
||||||
|
// 用户会拍整张化验单,塞进细条须离远拍 → 小字像素过低,VL 读不出。改用 VisionKit 整页扫描:
|
||||||
|
// 全分辨率 + 自动透视校正,VL 能读清整表。模拟器 / 不支持时回退相册选图。
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var captureEntry: some View {
|
private var captureEntry: some View {
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
PhotoPickerSheet(
|
PhotoPickerSheet(
|
||||||
onFinish: { imgs in if let first = imgs.first { startAnalyze(image: first) } },
|
onFinish: { imgs in handleScanned(imgs) },
|
||||||
onCancel: onClose
|
onCancel: onClose
|
||||||
)
|
)
|
||||||
#else
|
#else
|
||||||
RegionCameraView(
|
if DocumentScannerView.isSupported {
|
||||||
onCapture: { startAnalyze(image: $0) },
|
DocumentScannerView(
|
||||||
|
onFinish: { imgs in handleScanned(imgs) },
|
||||||
onCancel: onClose
|
onCancel: onClose
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
PhotoPickerSheet(
|
||||||
|
onFinish: { imgs in handleScanned(imgs) },
|
||||||
|
onCancel: onClose
|
||||||
|
)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 扫描/选图回来:取首页跑识别(单张化验单通常一页);无图则关闭。
|
||||||
|
private func handleScanned(_ images: [UIImage]) {
|
||||||
|
if let first = images.first {
|
||||||
|
startAnalyze(image: first)
|
||||||
|
} else {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 识别
|
// MARK: - 识别
|
||||||
|
|
||||||
private func startAnalyze(image: UIImage) {
|
private func startAnalyze(image: UIImage) {
|
||||||
@@ -110,12 +130,9 @@ struct QuickRegionCaptureFlow: View {
|
|||||||
phase = .analyzing(image: image)
|
phase = .analyzing(image: image)
|
||||||
let timeout = analyzeTimeoutSeconds
|
let timeout = analyzeTimeoutSeconds
|
||||||
// 本类型默认 MainActor 隔离,Task{} 继承之,故内部 phase 写入都在主线程,直接赋值即可。
|
// 本类型默认 MainActor 隔离,Task{} 继承之,故内部 phase 写入都在主线程,直接赋值即可。
|
||||||
|
// 新链路:Vision 端侧 OCR 取文本 → Qwen3-1.7B LLM 结构化抽指标(替代 3B VL 直读图)。
|
||||||
analyzeTask = Task {
|
analyzeTask = Task {
|
||||||
guard let data = image.jpegData(compressionQuality: 0.9) else {
|
let timeoutWarn = String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍")
|
||||||
phase = .confirm(image: image, items: [],
|
|
||||||
warning: String(appLoc: "图片编码失败,手动补充或重拍"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let watchdog = Task {
|
let watchdog = Task {
|
||||||
try? await Task.sleep(for: .seconds(timeout))
|
try? await Task.sleep(for: .seconds(timeout))
|
||||||
@@ -124,12 +141,25 @@ struct QuickRegionCaptureFlow: View {
|
|||||||
defer { watchdog.cancel() }
|
defer { watchdog.cancel() }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let parsed = try await CaptureService.shared.recognizeRegion(imageData: data)
|
// 1. 端侧 OCR
|
||||||
|
let text = try await OCRService.recognizeText(in: image)
|
||||||
if Task.isCancelled {
|
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: [],
|
phase = .confirm(image: image, items: [],
|
||||||
warning: String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍"))
|
warning: String(appLoc: "没识别到文字,手动补充或重拍"))
|
||||||
return
|
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)
|
let items = Self.buildItems(from: parsed)
|
||||||
phase = .confirm(
|
phase = .confirm(
|
||||||
image: image,
|
image: image,
|
||||||
@@ -138,23 +168,23 @@ struct QuickRegionCaptureFlow: View {
|
|||||||
)
|
)
|
||||||
} catch CaptureError.modelNotReady {
|
} catch CaptureError.modelNotReady {
|
||||||
phase = .confirm(image: image, items: [],
|
phase = .confirm(image: image, items: [],
|
||||||
warning: String(appLoc: "VL 模型未就绪,手动补充"))
|
warning: String(appLoc: "AI 模型未就绪,手动补充"))
|
||||||
} catch let CaptureError.parseFailed(msg) {
|
} catch let CaptureError.parseFailed(msg) {
|
||||||
phase = .confirm(image: image, items: [],
|
phase = .confirm(image: image, items: [],
|
||||||
warning: String(appLoc: "VL 输出无法解析:\(msg)"))
|
warning: String(appLoc: "解析失败:\(msg)"))
|
||||||
} catch let CaptureError.inferenceFailed(msg) {
|
} catch let CaptureError.inferenceFailed(msg) {
|
||||||
phase = .confirm(image: image, items: [],
|
phase = .confirm(image: image, items: [],
|
||||||
warning: Task.isCancelled
|
warning: Task.isCancelled ? timeoutWarn
|
||||||
? String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍")
|
: String(appLoc: "识别失败:\(msg)"))
|
||||||
: String(appLoc: "推理失败:\(msg)"))
|
|
||||||
} catch {
|
} catch {
|
||||||
phase = .confirm(image: image, items: [],
|
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] {
|
private static func buildItems(from parsed: [ParsedReport.ParsedIndicator]) -> [QuickRegionItem] {
|
||||||
let mapped = parsed.map {
|
let mapped = parsed.map {
|
||||||
QuickRegionItem(name: $0.name, value: $0.value, unit: $0.unit,
|
QuickRegionItem(name: $0.name, value: $0.value, unit: $0.unit,
|
||||||
@@ -233,16 +263,16 @@ private struct AnalyzingRegionView: View {
|
|||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("100% 本地推理 · 已用 \(elapsed)s")
|
Text("100% 本地推理 · 已用 \(elapsed)s")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
if elapsed >= timeoutSeconds - 5 {
|
if elapsed >= timeoutSeconds - 5 {
|
||||||
Text("快超时了,>\(timeoutSeconds)s 会自动转手动录入")
|
Text("快超时了,>\(timeoutSeconds)s 会自动转手动录入")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button("取消识别 · 改为手动录入", action: onCancel)
|
Button("取消识别 · 改为手动录入", action: onCancel)
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ struct QuickRegionConfirmView: View {
|
|||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -70,11 +70,11 @@ struct QuickRegionConfirmView: View {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("拍到的局部")
|
Text("拍到的局部")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("仅核对用 · 不保存照片")
|
Text("仅核对用 · 不保存照片")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Image(uiImage: image)
|
Image(uiImage: image)
|
||||||
@@ -91,7 +91,7 @@ struct QuickRegionConfirmView: View {
|
|||||||
onRetake()
|
onRetake()
|
||||||
} label: {
|
} label: {
|
||||||
Label("重拍", systemImage: "camera.rotate")
|
Label("重拍", systemImage: "camera.rotate")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ struct QuickRegionConfirmView: View {
|
|||||||
private var timeCard: some View {
|
private var timeCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("测量时间")
|
Text("测量时间")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
DatePicker("", selection: $capturedAt, in: ...Date.now)
|
DatePicker("", selection: $capturedAt, in: ...Date.now)
|
||||||
.datePickerStyle(.compact)
|
.datePickerStyle(.compact)
|
||||||
@@ -116,7 +116,7 @@ struct QuickRegionConfirmView: View {
|
|||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("识别到的指标 (\(items.count))")
|
Text("识别到的指标 (\(items.count))")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
@@ -124,14 +124,14 @@ struct QuickRegionConfirmView: View {
|
|||||||
status: .high, include: true))
|
status: .high, include: true))
|
||||||
} label: {
|
} label: {
|
||||||
Label("加一项", systemImage: "plus.circle.fill")
|
Label("加一项", systemImage: "plus.circle.fill")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if items.isEmpty {
|
if items.isEmpty {
|
||||||
Text("没有识别到指标,点「加一项」手动补充,或返回重拍")
|
Text("没有识别到指标,点「加一项」手动补充,或返回重拍")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.vertical, 20)
|
.padding(.vertical, 20)
|
||||||
@@ -153,17 +153,17 @@ struct QuickRegionConfirmView: View {
|
|||||||
item.wrappedValue.include.toggle()
|
item.wrappedValue.include.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: item.wrappedValue.include ? "checkmark.circle.fill" : "circle")
|
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)
|
.foregroundStyle(item.wrappedValue.include ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
TextField(String(appLoc: "指标名"), text: item.name)
|
TextField(String(appLoc: "指标名"), text: item.name)
|
||||||
.font(.system(size: 15, weight: .medium))
|
.font(.tjScaled( 15, weight: .medium))
|
||||||
|
|
||||||
if abnormal {
|
if abnormal {
|
||||||
Text(statusLabel(item.wrappedValue.status))
|
Text(statusLabel(item.wrappedValue.status))
|
||||||
.font(.system(size: 10, weight: .semibold))
|
.font(.tjScaled( 10, weight: .semibold))
|
||||||
.foregroundStyle(statusColor(item.wrappedValue.status))
|
.foregroundStyle(statusColor(item.wrappedValue.status))
|
||||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||||
.background(Capsule().fill(statusColor(item.wrappedValue.status).opacity(0.16)))
|
.background(Capsule().fill(statusColor(item.wrappedValue.status).opacity(0.16)))
|
||||||
@@ -175,7 +175,7 @@ struct QuickRegionConfirmView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "trash")
|
Image(systemName: "trash")
|
||||||
.font(.system(size: 14))
|
.font(.tjScaled( 14))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,10 +203,10 @@ struct QuickRegionConfirmView: View {
|
|||||||
mono: Bool = false) -> some View {
|
mono: Bool = false) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
TextField("", text: text)
|
TextField("", text: text)
|
||||||
.font(.system(size: 14, weight: mono ? .semibold : .regular,
|
.font(.tjScaled( 14, weight: mono ? .semibold : .regular,
|
||||||
design: mono ? .monospaced : .default))
|
design: mono ? .monospaced : .default))
|
||||||
.keyboardType(mono ? .decimalPad : .default)
|
.keyboardType(mono ? .decimalPad : .default)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
@@ -234,7 +234,7 @@ struct QuickRegionConfirmView: View {
|
|||||||
item.wrappedValue.status = st
|
item.wrappedValue.status = st
|
||||||
} label: {
|
} label: {
|
||||||
Text(statusLabel(st))
|
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)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text2)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ struct RegionCameraView: View {
|
|||||||
|
|
||||||
// 提示
|
// 提示
|
||||||
Text("把异常项放进框里 · 对准一两行")
|
Text("把异常项放进框里 · 对准一两行")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
@@ -89,7 +89,7 @@ struct RegionCameraView: View {
|
|||||||
onCancel()
|
onCancel()
|
||||||
} label: {
|
} label: {
|
||||||
Text("取消")
|
Text("取消")
|
||||||
.font(.system(size: 16, weight: .medium))
|
.font(.tjScaled( 16, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -126,19 +126,19 @@ struct RegionCameraView: View {
|
|||||||
private var deniedView: some View {
|
private var deniedView: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "camera.fill")
|
Image(systemName: "camera.fill")
|
||||||
.font(.system(size: 40))
|
.font(.tjScaled( 40))
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
Text("相机权限未开启")
|
Text("相机权限未开启")
|
||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。")
|
Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.horizontal, 36)
|
.padding(.horizontal, 36)
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Button("取消") { onCancel() }
|
Button("取消") { onCancel() }
|
||||||
.font(.system(size: 15))
|
.font(.tjScaled( 15))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 18).padding(.vertical, 10)
|
.padding(.horizontal, 18).padding(.vertical, 10)
|
||||||
.background(Capsule().strokeBorder(.white.opacity(0.5), lineWidth: 1))
|
.background(Capsule().strokeBorder(.white.opacity(0.5), lineWidth: 1))
|
||||||
@@ -147,7 +147,7 @@ struct RegionCameraView: View {
|
|||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(.black)
|
.foregroundStyle(.black)
|
||||||
.padding(.horizontal, 18).padding(.vertical, 10)
|
.padding(.horizontal, 18).padding(.vertical, 10)
|
||||||
.background(Capsule().fill(.white))
|
.background(Capsule().fill(.white))
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum RecordKind: String, Identifiable, CaseIterable {
|
enum RecordKind: String, Identifiable, CaseIterable {
|
||||||
case quick, indicator, archive, diary, symptom, reminder
|
case quick, indicator, healthExport, archive, diary, symptom, reminder
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
/// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。
|
/// 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 {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .quick: return String(appLoc: "异常项快拍")
|
case .quick: return String(appLoc: "异常项快拍")
|
||||||
case .indicator: return String(appLoc: "记录指标")
|
case .indicator: return String(appLoc: "记录指标")
|
||||||
|
case .healthExport: return String(appLoc: "身体档案")
|
||||||
case .archive: return String(appLoc: "体检报告归档")
|
case .archive: return String(appLoc: "体检报告归档")
|
||||||
case .diary: return String(appLoc: "健康日记")
|
case .diary: return String(appLoc: "健康日记")
|
||||||
case .symptom: return String(appLoc: "记录症状")
|
case .symptom: return String(appLoc: "记录症状")
|
||||||
@@ -20,7 +22,8 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
|||||||
var subtitle: String {
|
var subtitle: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .quick: return String(appLoc: "拍一张化验单,VL 自动识别")
|
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 .archive: return String(appLoc: "完整保存整份报告(可多页)")
|
||||||
case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助")
|
case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助")
|
||||||
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
|
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
|
||||||
@@ -31,6 +34,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .quick: return "camera.fill"
|
case .quick: return "camera.fill"
|
||||||
case .indicator: return "number.square.fill"
|
case .indicator: return "number.square.fill"
|
||||||
|
case .healthExport: return "doc.text.below.ecg"
|
||||||
case .archive: return "doc.fill"
|
case .archive: return "doc.fill"
|
||||||
case .diary: return "heart.text.square"
|
case .diary: return "heart.text.square"
|
||||||
case .symptom: return "waveform.path.ecg"
|
case .symptom: return "waveform.path.ecg"
|
||||||
@@ -41,6 +45,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .quick: return Tj.Palette.brick
|
case .quick: return Tj.Palette.brick
|
||||||
case .indicator: return Tj.Palette.brick
|
case .indicator: return Tj.Palette.brick
|
||||||
|
case .healthExport: return Tj.Palette.ink
|
||||||
case .archive: return Tj.Palette.ink
|
case .archive: return Tj.Palette.ink
|
||||||
case .diary: return Tj.Palette.leaf
|
case .diary: return Tj.Palette.leaf
|
||||||
case .symptom: return Tj.Palette.amber
|
case .symptom: return Tj.Palette.amber
|
||||||
@@ -66,7 +71,7 @@ struct RecordSheet: View {
|
|||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("本地处理 · 永不上传")
|
Text("本地处理 · 永不上传")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 14)
|
.padding(.bottom, 14)
|
||||||
@@ -83,22 +88,22 @@ struct RecordSheet: View {
|
|||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
.fill(kind.accent)
|
.fill(kind.accent)
|
||||||
Image(systemName: kind.icon)
|
Image(systemName: kind.icon)
|
||||||
.font(.system(size: 18, weight: .medium))
|
.font(.tjScaled( 18, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
}
|
}
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(kind.title)
|
Text(kind.title)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text(kind.subtitle)
|
Text(kind.subtitle)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ struct OngoingSymptomsCard: View {
|
|||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("\(ongoing.count) 个")
|
Text("\(ongoing.count) 个")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -51,12 +51,12 @@ struct OngoingSymptomsCard: View {
|
|||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text(sym.name)
|
Text(sym.name)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
severityDot(sym.severity)
|
severityDot(sym.severity)
|
||||||
}
|
}
|
||||||
Text("已持续 \(formatDuration(interval))")
|
Text("已持续 \(formatDuration(interval))")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(isLong ? Tj.Palette.brick : Tj.Palette.text3)
|
.foregroundStyle(isLong ? Tj.Palette.brick : Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
@@ -64,7 +64,7 @@ struct OngoingSymptomsCard: View {
|
|||||||
ending = sym
|
ending = sym
|
||||||
} label: {
|
} label: {
|
||||||
Text("结束")
|
Text("结束")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ struct SymptomEndSheet: View {
|
|||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("结束症状")
|
Text("结束症状")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text(symptom.name)
|
Text(symptom.name)
|
||||||
@@ -40,16 +40,16 @@ struct SymptomEndSheet: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("开始于")
|
Text("开始于")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text(symptom.startedAt.formatted(date: .abbreviated, time: .shortened))
|
Text(symptom.startedAt.formatted(date: .abbreviated, time: .shortened))
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("结束时间")
|
Text("结束时间")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
DatePicker("", selection: $endedAt, in: lowerBound...Date.now)
|
DatePicker("", selection: $endedAt, in: lowerBound...Date.now)
|
||||||
@@ -59,11 +59,11 @@ struct SymptomEndSheet: View {
|
|||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("本次持续")
|
Text("本次持续")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(durationLabel)
|
Text(durationLabel)
|
||||||
.font(.system(size: 15, weight: .semibold, design: .monospaced))
|
.font(.tjScaled( 15, weight: .semibold, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ struct SymptomStartSheet: View {
|
|||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("结束时再来点结束")
|
Text("结束时再来点结束")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -130,15 +130,15 @@ struct SymptomStartSheet: View {
|
|||||||
sectionLabel(String(appLoc: "强度"))
|
sectionLabel(String(appLoc: "强度"))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(Int(severity)) / 5")
|
Text("\(Int(severity)) / 5")
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
|
||||||
.foregroundStyle(severityColor)
|
.foregroundStyle(severityColor)
|
||||||
}
|
}
|
||||||
Slider(value: $severity, in: 1...5, step: 1)
|
Slider(value: $severity, in: 1...5, step: 1)
|
||||||
.tint(severityColor)
|
.tint(severityColor)
|
||||||
HStack {
|
HStack {
|
||||||
Text("轻微").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
Text("轻微").font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
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 {
|
private func sectionLabel(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,7 @@ struct SymptomStartSheet: View {
|
|||||||
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Text(label)
|
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)
|
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ struct TimelineEntryDetailView: View {
|
|||||||
let detail: TimelineDetail
|
let detail: TimelineDetail
|
||||||
|
|
||||||
@State private var showDeleteConfirm = false
|
@State private var showDeleteConfirm = false
|
||||||
|
@State private var evidenceTarget: Indicator?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -77,6 +78,11 @@ struct TimelineEntryDetailView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("删除后无法恢复。")
|
Text("删除后无法恢复。")
|
||||||
}
|
}
|
||||||
|
.sheet(item: $evidenceTarget) { indicator in
|
||||||
|
if let report = indicator.report {
|
||||||
|
EvidenceImagePreview(report: report, indicator: indicator)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 删除(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6)
|
// MARK: - 删除(永久:SwiftData 硬删 + Vault 原图 unlink,见 CLAUDE.md §6)
|
||||||
@@ -84,7 +90,7 @@ struct TimelineEntryDetailView: View {
|
|||||||
private var deleteButton: some View {
|
private var deleteButton: some View {
|
||||||
Button(role: .destructive) { showDeleteConfirm = true } label: {
|
Button(role: .destructive) { showDeleteConfirm = true } label: {
|
||||||
Label(String(appLoc: "永久删除"), systemImage: "trash")
|
Label(String(appLoc: "永久删除"), systemImage: "trash")
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.tjScaled( 12, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.brick.opacity(0.8))
|
.foregroundStyle(Tj.Palette.brick.opacity(0.8))
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -136,7 +142,7 @@ struct TimelineEntryDetailView: View {
|
|||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Button { dismiss() } label: {
|
Button { dismiss() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.background(Circle().fill(Tj.Palette.sand2))
|
.background(Circle().fill(Tj.Palette.sand2))
|
||||||
@@ -187,16 +193,19 @@ struct TimelineEntryDetailView: View {
|
|||||||
}
|
}
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
Text(i.value)
|
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)
|
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||||
if !i.unit.isEmpty {
|
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
|
divider
|
||||||
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
|
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
|
||||||
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
|
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
|
||||||
field(String(appLoc: "来源"), i.report?.title ?? i.source.label)
|
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) }
|
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,9 +224,9 @@ struct TimelineEntryDetailView: View {
|
|||||||
}
|
}
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
Text("\(sys.value)/\(dia?.value ?? "—")")
|
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)
|
.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
|
divider
|
||||||
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
|
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
|
||||||
@@ -237,10 +246,10 @@ struct TimelineEntryDetailView: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
TjBadge(text: r.type.label, style: .neutral)
|
TjBadge(text: r.type.label, style: .neutral)
|
||||||
Text(Self.dateText(r.reportDate))
|
Text(Self.dateText(r.reportDate))
|
||||||
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
|
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||||
if !r.assets.isEmpty {
|
if !r.assets.isEmpty {
|
||||||
Text(String(appLoc: "原图\(r.assets.count)张"))
|
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 {
|
if let inst = r.institution, !inst.isEmpty {
|
||||||
@@ -251,8 +260,8 @@ struct TimelineEntryDetailView: View {
|
|||||||
if let sum = r.summary, !sum.isEmpty {
|
if let sum = r.summary, !sum.isEmpty {
|
||||||
card {
|
card {
|
||||||
Text(String(appLoc: "摘要"))
|
Text(String(appLoc: "摘要"))
|
||||||
.font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||||
Text(sum).font(.system(size: 14)).foregroundStyle(Tj.Palette.text)
|
Text(sum).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,16 +269,19 @@ struct TimelineEntryDetailView: View {
|
|||||||
if !r.indicators.isEmpty {
|
if !r.indicators.isEmpty {
|
||||||
card {
|
card {
|
||||||
Text(String(appLoc: "指标"))
|
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
|
ForEach(sorted) { ind in
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(ind.name).font(.system(size: 14)).foregroundStyle(Tj.Palette.text)
|
Text(ind.name).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text)
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)")
|
Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)")
|
||||||
.font(.system(size: 13, design: .monospaced))
|
.font(.tjScaled( 13, design: .monospaced))
|
||||||
.foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
|
.foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
|
||||||
statusChip(ind.status)
|
statusChip(ind.status)
|
||||||
}
|
}
|
||||||
|
evidenceButton(for: ind, assets: r.assets)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,9 +298,9 @@ struct TimelineEntryDetailView: View {
|
|||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
card {
|
card {
|
||||||
Text(Self.dateTimeText(d.createdAt))
|
Text(Self.dateTimeText(d.createdAt))
|
||||||
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
|
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||||
Text(d.content)
|
Text(d.content)
|
||||||
.font(.system(size: 15))
|
.font(.tjScaled( 15))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -309,7 +321,7 @@ struct TimelineEntryDetailView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
if s.isOngoing {
|
if s.isOngoing {
|
||||||
Text(String(appLoc: "进行中"))
|
Text(String(appLoc: "进行中"))
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||||
.background(Capsule().fill(Tj.Palette.brick.opacity(0.14)))
|
.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 {
|
private func field(_ label: String, _ value: String) -> some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
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)
|
Spacer(minLength: 12)
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.multilineTextAlignment(.trailing)
|
.multilineTextAlignment(.trailing)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.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 {
|
private var divider: some View {
|
||||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
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 = ""
|
case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = ""
|
||||||
}
|
}
|
||||||
return HStack(spacing: 3) {
|
return HStack(spacing: 3) {
|
||||||
if !arrow.isEmpty { Text(arrow).font(.system(size: 11, weight: .bold)) }
|
if !arrow.isEmpty { Text(arrow).font(.tjScaled( 11, weight: .bold)) }
|
||||||
Text(text).font(.system(size: 12, weight: .semibold))
|
Text(text).font(.tjScaled( 12, weight: .semibold))
|
||||||
}
|
}
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(color)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
@@ -387,3 +419,142 @@ struct TimelineEntryDetailView: View {
|
|||||||
d.formatted(.dateTime.year().month().day())
|
d.formatted(.dateTime.year().month().day())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct EvidenceImagePreview: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
let report: Report
|
||||||
|
let indicator: Indicator
|
||||||
|
|
||||||
|
@State private var selection: Int
|
||||||
|
|
||||||
|
init(report: Report, indicator: Indicator) {
|
||||||
|
self.report = report
|
||||||
|
self.indicator = indicator
|
||||||
|
let page = indicator.sourcePageIndex ?? 0
|
||||||
|
_selection = State(initialValue: min(max(page, 0), max(report.assets.count - 1, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(Circle().fill(Tj.Palette.sand2))
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(indicator.name)
|
||||||
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("第 \(selection + 1) 页 · 原图证据")
|
||||||
|
.font(.tjScaled( 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(Tj.Palette.sand)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
TabView(selection: $selection) {
|
||||||
|
ForEach(Array(report.assets.enumerated()), id: \.offset) { index, asset in
|
||||||
|
EvidenceImagePage(
|
||||||
|
asset: asset,
|
||||||
|
highlight: index == indicator.sourcePageIndex ? indicator.evidenceRect : nil
|
||||||
|
)
|
||||||
|
.tag(index)
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: report.assets.count > 1 ? .automatic : .never))
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationBackground(Tj.Palette.sand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EvidenceImagePage: View {
|
||||||
|
let asset: Asset
|
||||||
|
let highlight: CGRect?
|
||||||
|
|
||||||
|
private var image: UIImage? {
|
||||||
|
try? FileVault.shared.loadImage(relativePath: asset.relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
if let image {
|
||||||
|
ZStack {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: geo.size.width, height: geo.size.height)
|
||||||
|
if let highlight {
|
||||||
|
EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: geo.size.width, height: geo.size.height)
|
||||||
|
.background(Tj.Palette.paper)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
TjPlaceholder(label: String(appLoc: "原图无法读取"))
|
||||||
|
.frame(width: geo.size.width, height: geo.size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EvidenceHighlightOverlay: View {
|
||||||
|
let imageSize: CGSize
|
||||||
|
let normalizedRect: CGRect
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let fitted = fittedRect(imageSize: imageSize, containerSize: geo.size)
|
||||||
|
let rect = CGRect(
|
||||||
|
x: fitted.minX + normalizedRect.minX * fitted.width,
|
||||||
|
y: fitted.minY + normalizedRect.minY * fitted.height,
|
||||||
|
width: normalizedRect.width * fitted.width,
|
||||||
|
height: normalizedRect.height * fitted.height
|
||||||
|
)
|
||||||
|
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||||
|
.fill(Tj.Palette.brick.opacity(0.16))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||||
|
.stroke(Tj.Palette.brick, lineWidth: 2)
|
||||||
|
)
|
||||||
|
.frame(width: rect.width, height: rect.height)
|
||||||
|
.position(x: rect.midX, y: rect.midY)
|
||||||
|
.shadow(color: Tj.Palette.brick.opacity(0.24), radius: 8, y: 2)
|
||||||
|
}
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fittedRect(imageSize: CGSize, containerSize: CGSize) -> CGRect {
|
||||||
|
guard imageSize.width > 0,
|
||||||
|
imageSize.height > 0,
|
||||||
|
containerSize.width > 0,
|
||||||
|
containerSize.height > 0 else {
|
||||||
|
return .zero
|
||||||
|
}
|
||||||
|
let scale = min(containerSize.width / imageSize.width, containerSize.height / imageSize.height)
|
||||||
|
let size = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
|
||||||
|
return CGRect(
|
||||||
|
x: (containerSize.width - size.width) / 2,
|
||||||
|
y: (containerSize.height - size.height) / 2,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ struct TimelineRow: View {
|
|||||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
.fill(entry.kind.accent.opacity(0.12))
|
.fill(entry.kind.accent.opacity(0.12))
|
||||||
Image(systemName: entry.kind.icon)
|
Image(systemName: entry.kind.icon)
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.tjScaled( 14, weight: .semibold))
|
||||||
.foregroundStyle(entry.kind.accent)
|
.foregroundStyle(entry.kind.accent)
|
||||||
}
|
}
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
@@ -25,12 +25,12 @@ struct TimelineRow: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("\(entry.date.timelineLabel) · \(entry.subtitle)")
|
Text("\(entry.date.timelineLabel) · \(entry.subtitle)")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(entry.title)
|
Text(entry.title)
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
@@ -38,7 +38,7 @@ struct TimelineRow: View {
|
|||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
if let trailing = entry.trailing {
|
if let trailing = entry.trailing {
|
||||||
Text(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)
|
.foregroundStyle(entry.trailingIsAlert ? Tj.Palette.brick : Tj.Palette.text2)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ struct CalendarMonthGrid: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
ForEach(weekdayLabels, id: \.self) { w in
|
ForEach(weekdayLabels, id: \.self) { w in
|
||||||
Text(w)
|
Text(w)
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.tjScaled( 11, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ private struct DayCellView: View {
|
|||||||
|
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
Text("\(dayNumber)")
|
Text("\(dayNumber)")
|
||||||
.font(.system(size: 13,
|
.font(.tjScaled( 13,
|
||||||
weight: (isToday || isSelected) ? .bold : .regular,
|
weight: (isToday || isSelected) ? .bold : .regular,
|
||||||
design: .default))
|
design: .default))
|
||||||
.foregroundStyle(textColor)
|
.foregroundStyle(textColor)
|
||||||
@@ -137,7 +137,7 @@ private struct DayCellView: View {
|
|||||||
}
|
}
|
||||||
if ranges.count > 2 {
|
if ranges.count > 2 {
|
||||||
Text("+\(ranges.count - 2)")
|
Text("+\(ranges.count - 2)")
|
||||||
.font(.system(size: 7, design: .monospaced))
|
.font(.tjScaled( 7, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ private struct MiniMonth: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(monthLabel)
|
Text(monthLabel)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
|
||||||
LazyVGrid(columns: microColumns, spacing: 2) {
|
LazyVGrid(columns: microColumns, spacing: 2) {
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ struct DayDetailContent: View {
|
|||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(dateLine)
|
Text(dateLine)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.tracking(0.5)
|
.tracking(0.5)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text(dayLabel)
|
Text(dayLabel)
|
||||||
@@ -114,7 +114,7 @@ struct DayDetailContent: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
if totalCount > 0 {
|
if totalCount > 0 {
|
||||||
Text("\(totalCount) 条")
|
Text("\(totalCount) 条")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.tjScaled( 12, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,11 +140,11 @@ struct DayDetailContent: View {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.tracking(0.3)
|
.tracking(0.3)
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Text("\(count)")
|
Text("\(count)")
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -162,17 +162,17 @@ struct DayDetailContent: View {
|
|||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(s.name)
|
Text(s.name)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text(state.badge)
|
Text(state.badge)
|
||||||
.font(.system(size: 10, weight: .semibold))
|
.font(.tjScaled( 10, weight: .semibold))
|
||||||
.foregroundStyle(state.badgeFg)
|
.foregroundStyle(state.badgeFg)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.background(Capsule().fill(state.badgeBg))
|
.background(Capsule().fill(state.badgeBg))
|
||||||
}
|
}
|
||||||
Text("\(state.subtitle) · 持续 \(formatDuration(s.duration))")
|
Text("\(state.subtitle) · 持续 \(formatDuration(s.duration))")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 6)
|
Spacer(minLength: 6)
|
||||||
@@ -181,7 +181,7 @@ struct DayDetailContent: View {
|
|||||||
endingSymptom = s
|
endingSymptom = s
|
||||||
} label: {
|
} label: {
|
||||||
Text("结束")
|
Text("结束")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
@@ -200,24 +200,24 @@ struct DayDetailContent: View {
|
|||||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
.fill(indicatorAccent(i).opacity(0.12))
|
.fill(indicatorAccent(i).opacity(0.12))
|
||||||
Image(systemName: "drop.fill")
|
Image(systemName: "drop.fill")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(indicatorAccent(i))
|
.foregroundStyle(indicatorAccent(i))
|
||||||
}
|
}
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(i.name)
|
Text(i.name)
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if !i.range.isEmpty {
|
if !i.range.isEmpty {
|
||||||
Text("参考 \(i.range)")
|
Text("参考 \(i.range)")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 6)
|
Spacer(minLength: 6)
|
||||||
Text("\(i.value) \(i.unit)\(arrow(i))")
|
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)
|
.foregroundStyle(i.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
@@ -235,23 +235,23 @@ struct DayDetailContent: View {
|
|||||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
.fill(Tj.Palette.ink2.opacity(0.12))
|
.fill(Tj.Palette.ink2.opacity(0.12))
|
||||||
Image(systemName: "doc.fill")
|
Image(systemName: "doc.fill")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.ink2)
|
.foregroundStyle(Tj.Palette.ink2)
|
||||||
}
|
}
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(r.title)
|
Text(r.title)
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text("\(r.type.label) · 共 \(r.pageCount) 页")
|
Text("\(r.type.label) · 共 \(r.pageCount) 页")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 6)
|
Spacer(minLength: 6)
|
||||||
if let summary {
|
if let summary {
|
||||||
Text(summary)
|
Text(summary)
|
||||||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
.font(.tjScaled( 11, weight: .semibold, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.brick)
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,7 +263,7 @@ struct DayDetailContent: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(d.createdAt.formatted(date: .omitted, time: .shortened))
|
Text(d.createdAt.formatted(date: .omitted, time: .shortened))
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -284,7 +284,7 @@ struct DayDetailContent: View {
|
|||||||
.frame(height: 90)
|
.frame(height: 90)
|
||||||
.frame(maxWidth: 240)
|
.frame(maxWidth: 240)
|
||||||
Text("点底部 + 号可以补一条")
|
Text("点底部 + 号可以补一条")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
|
|||||||
@@ -66,10 +66,10 @@ struct SeriesChartCard: View {
|
|||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack(alignment: .lastTextBaseline, spacing: 10) {
|
HStack(alignment: .lastTextBaseline, spacing: 10) {
|
||||||
Text(bucket.title)
|
Text(bucket.title)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("\(allPoints.count) 条 · 近 \(daysSpanLabel)")
|
Text("\(allPoints.count) 条 · 近 \(daysSpanLabel)")
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
latestValueBadge
|
latestValueBadge
|
||||||
@@ -87,10 +87,10 @@ struct SeriesChartCard: View {
|
|||||||
}
|
}
|
||||||
return HStack(spacing: 4) {
|
return HStack(spacing: 4) {
|
||||||
Text(joined)
|
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)
|
.foregroundStyle(anyAbnormal ? Tj.Palette.brick : Tj.Palette.text)
|
||||||
Text(bucket.unit)
|
Text(bucket.unit)
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.tjScaled( 10, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ struct SeriesChartCard: View {
|
|||||||
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
|
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
|
||||||
AxisValueLabel()
|
AxisValueLabel()
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.tjScaled( 10, design: .monospaced))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.chartYScale(domain: valueDomain ?? 0...1)
|
.chartYScale(domain: valueDomain ?? 0...1)
|
||||||
@@ -156,7 +156,7 @@ struct SeriesChartCard: View {
|
|||||||
.fill(line.color)
|
.fill(line.color)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
Text(line.label ?? line.seriesKey)
|
Text(line.label ?? line.seriesKey)
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ struct TrendDetailView: View {
|
|||||||
withAnimation(.snappy(duration: 0.2)) { range = r }
|
withAnimation(.snappy(duration: 0.2)) { range = r }
|
||||||
} label: {
|
} label: {
|
||||||
Text(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)
|
.foregroundStyle(range == r ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 7)
|
.padding(.vertical, 7)
|
||||||
@@ -210,7 +210,7 @@ struct TrendDetailView: View {
|
|||||||
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
|
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
|
||||||
AxisValueLabel()
|
AxisValueLabel()
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.tjScaled( 10, design: .monospaced))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.chartYScale(domain: valueDomain ?? 0...1)
|
.chartYScale(domain: valueDomain ?? 0...1)
|
||||||
@@ -222,7 +222,7 @@ struct TrendDetailView: View {
|
|||||||
HStack(spacing: 5) {
|
HStack(spacing: 5) {
|
||||||
Circle().fill(line.color).frame(width: 8, height: 8)
|
Circle().fill(line.color).frame(width: 8, height: 8)
|
||||||
Text(line.label ?? line.seriesKey)
|
Text(line.label ?? line.seriesKey)
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,20 +265,20 @@ struct TrendDetailView: View {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
if filteredLines.count > 1, let label = line.label {
|
if filteredLines.count > 1, let label = line.label {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.tjScaled( 12, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
}
|
}
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
Text(latest.map { fmt($0.value) } ?? "—")
|
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)
|
.foregroundStyle((latest?.status ?? .normal) == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||||
Text(bucket.unit)
|
Text(bucket.unit)
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
if let delta = deltaText(latest: latest, prev: prev) {
|
if let delta = deltaText(latest: latest, prev: prev) {
|
||||||
Text(delta.text)
|
Text(delta.text)
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
|
||||||
.foregroundStyle(delta.color)
|
.foregroundStyle(delta.color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,10 +294,10 @@ struct TrendDetailView: View {
|
|||||||
private func statCell(_ label: String, _ value: String) -> some View {
|
private func statCell(_ label: String, _ value: String) -> some View {
|
||||||
VStack(spacing: 3) {
|
VStack(spacing: 3) {
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
.font(.tjScaled( 14, weight: .semibold, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 10))
|
.font(.tjScaled( 10))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -323,10 +323,10 @@ struct TrendDetailView: View {
|
|||||||
private var aiPlaceholder: some View {
|
private var aiPlaceholder: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "sparkles")
|
Image(systemName: "sparkles")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text("AI 趋势解读即将上线")
|
Text("AI 趋势解读即将上线")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -364,7 +364,7 @@ struct TrendDetailView: View {
|
|||||||
private var pointsList: some View {
|
private var pointsList: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("全部记录")
|
Text("全部记录")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.tjScaled( 13, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
ForEach(pointRows) { row in
|
ForEach(pointRows) { row in
|
||||||
@@ -382,7 +382,7 @@ struct TrendDetailView: View {
|
|||||||
private func pointRowView(_ row: PointRow) -> some View {
|
private func pointRowView(_ row: PointRow) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Text(row.day.formatted(.dateTime.year().month(.abbreviated).day()))
|
Text(row.day.formatted(.dateTime.year().month(.abbreviated).day()))
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
@@ -393,14 +393,14 @@ struct TrendDetailView: View {
|
|||||||
Circle().fill(line.color).frame(width: 6, height: 6)
|
Circle().fill(line.color).frame(width: 6, height: 6)
|
||||||
}
|
}
|
||||||
Text(fmt(p.value) + arrow(p.status))
|
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)
|
.foregroundStyle(p.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.tjScaled( 11, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ struct TrendRow: View {
|
|||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(bucket.title)
|
Text(bucket.title)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.system(size: 11))
|
.font(.tjScaled( 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,17 +34,17 @@ struct TrendRow: View {
|
|||||||
|
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
Text(latestValue)
|
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)
|
.foregroundStyle(anyLatestAbnormal ? Tj.Palette.brick : Tj.Palette.text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(bucket.unit)
|
Text(bucket.unit)
|
||||||
.font(.system(size: 9, design: .monospaced))
|
.font(.tjScaled( 9, design: .monospaced))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.tjScaled( 12, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ struct TrendsView: View {
|
|||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("\(buckets.count) 项")
|
Text("\(buckets.count) 项")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ struct TrendsView: View {
|
|||||||
.frame(height: 120)
|
.frame(height: 120)
|
||||||
.frame(maxWidth: 260)
|
.frame(maxWidth: 260)
|
||||||
Text("同一指标记录满 2 次后,会在这里出现时间序列")
|
Text("同一指标记录满 2 次后,会在这里出现时间序列")
|
||||||
.font(.system(size: 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1201,6 +1201,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"AI 模型未就绪,手动补充" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"AI 没有给出建议,请稍后重试" : {
|
"AI 没有给出建议,请稍后重试" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1267,6 +1270,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"AI 趋势解读即将上线" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"AI 辅助 · 医生角度查漏补缺" : {
|
"AI 辅助 · 医生角度查漏补缺" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1311,6 +1317,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Apple 健康里没有可导入的生日、性别、身高或血型。" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"B 型" : {
|
"B 型" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1408,9 +1417,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"s" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"series" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"start" : {
|
"start" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"t" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"v" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"VL 模型尚未就绪" : {
|
"VL 模型尚未就绪" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1477,9 +1498,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"VL 模型未就绪,手动补充" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"VL 输出无法解析:%@" : {
|
"VL 输出无法解析:%@" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1591,6 +1609,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"上限" : {
|
"上限" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2101,6 +2122,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"从 Apple 健康导入" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"从文件导入(离线)" : {
|
"从文件导入(离线)" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2502,8 +2526,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"例:最近血压波动大吗?" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"例:最近血糖好像不稳,把过去三个月的化验单整理一下" : {
|
"例:最近血糖好像不稳,把过去三个月的化验单整理一下" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2526,6 +2554,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"例:我感冒3天了,把最近一个月的健康情况给医生看" : {
|
"例:我感冒3天了,把最近一个月的健康情况给医生看" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2546,6 +2575,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"例:把我最近头晕、睡眠和指标变化整理给医生" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"例如:< 3.40 或 3.9 - 6.1" : {
|
"例如:< 3.40 或 3.9 - 6.1" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2877,6 +2909,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"健康日历" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"健康日记" : {
|
"健康日记" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2899,6 +2934,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"健康档案 Aa 123" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"健康记录" : {
|
"健康记录" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2966,6 +3004,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"先问清楚,再整理给医生" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"免责声明" : {
|
"免责声明" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3078,6 +3119,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"全部记录" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"六" : {
|
"六" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3300,6 +3344,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"最低" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"最近记录" : {
|
"最近记录" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3322,6 +3369,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"最高" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"冠心病" : {
|
"冠心病" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3700,6 +3750,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"化验指标趋势" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"化验项快捷(不进趋势)" : {
|
"化验项快捷(不进趋势)" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3814,6 +3867,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"原图无法读取" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"去设置" : {
|
"去设置" : {
|
||||||
|
|
||||||
@@ -3927,6 +3983,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"发送问题" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"取消" : {
|
"取消" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3993,6 +4052,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"只读取生日、性别、身高、血型" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"可选开启 Face ID 启动锁,进一步保护隐私。" : {
|
"可选开启 Face ID 启动锁,进一步保护隐私。" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4037,6 +4099,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"同一指标记录满 2 次后,会在这里出现时间序列" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"名称" : {
|
"名称" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4191,6 +4256,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"围绕你的指标和健康日记提问" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"图例" : {
|
"图例" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4257,9 +4325,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"图片编码失败,手动补充或重拍" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"在「+ 新建 → 指标记录 → %@」记录一次" : {
|
"在「+ 新建 → 指标记录 → %@」记录一次" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4306,6 +4371,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"在这里输入主诉……" : {
|
"在这里输入主诉……" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -4486,6 +4552,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"多轮问答后生成给医生看的整理报告" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"多页报告" : {
|
"多页报告" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -4531,6 +4600,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"大" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"失眠" : {
|
"失眠" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4795,6 +4867,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"字体大小" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"字号放大 20%" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"字号放大 40%" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"字号放大 60%" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"完成" : {
|
"完成" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4928,6 +5012,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"导入" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"导入前会先显示预览,确认后才覆盖个人资料。" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"导入失败:%@" : {
|
"导入失败:%@" : {
|
||||||
"localizations" : {
|
"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" : {
|
"localizations" : {
|
||||||
@@ -5514,6 +5585,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"平均" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"年" : {
|
"年" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -5939,6 +6013,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"当前: %@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"当前用药" : {
|
"当前用药" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -6209,6 +6286,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"患者" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"慢性肾病" : {
|
"慢性肾病" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -6299,6 +6379,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"我的导出 · %lld 份" : {
|
"我的导出 · %lld 份" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -6363,6 +6444,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"或手动填写" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"或者自己写" : {
|
"或者自己写" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -6432,6 +6516,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"手动填一项指标(免拍照)" : {
|
"手动填一项指标(免拍照)" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -6452,6 +6537,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"手动填写,或拍照自动识别" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"手动记录" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"把异常项放进框里 · 对准一两行" : {
|
"把异常项放进框里 · 对准一两行" : {
|
||||||
|
|
||||||
@@ -6594,6 +6685,9 @@
|
|||||||
},
|
},
|
||||||
"拍到的局部" : {
|
"拍到的局部" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"拍化验单,VL 自动读出数值" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"拍报告的小贴士" : {
|
"拍报告的小贴士" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -6685,6 +6779,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"拍照识别" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"拍照识别报告 → 结构化指标" : {
|
"拍照识别报告 → 结构化指标" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -7357,6 +7454,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"放大后整个 App 的文字立即变大,无需重启。设置会被记住。" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"数值" : {
|
"数值" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -7447,6 +7547,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"整理好的报告" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"整页入框,避免裁切到指标" : {
|
"整页入框,避免裁切到指标" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -7651,6 +7754,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"无法导入 Apple 健康资料" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"日" : {
|
"日" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -7990,6 +8096,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"未读取到的字段不会修改。" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"未选" : {
|
"未选" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -8147,6 +8256,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"本地 RAG · Qwen3 1.7B · 不上传任何数据" : {
|
"本地 RAG · Qwen3 1.7B · 不上传任何数据" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -8366,6 +8476,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"本月 %lld 天有记录" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"本月暂无记录" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"本机保存" : {
|
"本机保存" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -8580,6 +8696,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"查看原图位置" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"标准" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"标签" : {
|
"标签" : {
|
||||||
|
|
||||||
@@ -8898,6 +9020,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"正在查看本地记录…" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"正常" : {
|
"正常" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -9103,6 +9228,9 @@
|
|||||||
},
|
},
|
||||||
"没有识别到指标,点「加一项」手动补充,或返回重拍" : {
|
"没有识别到指标,点「加一项」手动补充,或返回重拍" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"没识别到文字,手动补充或重拍" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"没读出指标,手动补充或重拍" : {
|
"没读出指标,手动补充或重拍" : {
|
||||||
|
|
||||||
@@ -9238,6 +9366,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"特大" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"状态" : {
|
"状态" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -9397,6 +9528,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"生成报告" : {
|
"生成报告" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -9440,8 +9572,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"生成整理报告" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"生成新导出" : {
|
"生成新导出" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -9688,6 +9824,9 @@
|
|||||||
},
|
},
|
||||||
"相机权限未开启" : {
|
"相机权限未开启" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"确认导入" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"程度" : {
|
"程度" : {
|
||||||
|
|
||||||
@@ -9764,6 +9903,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"第 %lld 页 · 原图证据" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"第 1 轮 · %lld 条" : {
|
"第 1 轮 · %lld 条" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -10075,6 +10217,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"给医生看的就诊摘要" : {
|
"给医生看的就诊摘要" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -10140,6 +10283,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"继续提问或补充情况…" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"维生素 D" : {
|
"维生素 D" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -10533,6 +10679,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"解析失败:%@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"解锁康康,查看你的健康档案" : {
|
"解锁康康,查看你的健康档案" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -11011,6 +11160,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"说说你想给医生看什么" : {
|
"说说你想给医生看什么" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -11053,6 +11203,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"读取生日、性别、身高和血型,确认后填入个人资料" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"谷丙转氨酶" : {
|
"谷丙转氨酶" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -11122,6 +11275,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"超大" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"超过参考上限 0.44,属轻度偏高。建议关注饮食结构(减少动物脂肪摄入),3 个月内复查。若家族有心血管病史,可与医生沟通是否需要药物干预。" : {
|
"超过参考上限 0.44,属轻度偏高。建议关注饮食结构(减少动物脂肪摄入),3 个月内复查。若家族有心血管病史,可与医生沟通是否需要药物干预。" : {
|
||||||
"extractionState" : "stale",
|
"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" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -11504,6 +11682,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"近1年" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"近3月" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"近6月" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"返回修改" : {
|
"返回修改" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -11570,6 +11757,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"还没有可成趋势的指标" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"还没有导出过\n回到记录页右上角生成一份" : {
|
"还没有导出过\n回到记录页右上角生成一份" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -11703,6 +11893,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"这台设备暂不支持读取 Apple 健康资料。" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"这是什么" : {
|
"这是什么" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -11841,8 +12034,12 @@
|
|||||||
},
|
},
|
||||||
"重拍" : {
|
"重拍" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"重新整理" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"重新生成" : {
|
"重新生成" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -12262,6 +12459,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"默认字号" : {
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.0"
|
"version" : "1.0"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
enum IndicatorStatus: String, Codable, CaseIterable {
|
enum IndicatorStatus: String, Codable, CaseIterable {
|
||||||
@@ -55,6 +56,14 @@ final class Indicator {
|
|||||||
/// 录入来源(IndicatorSource.rawValue)。带默认值 → SwiftData 轻量迁移,旧记录视为手动。
|
/// 录入来源(IndicatorSource.rawValue)。带默认值 → SwiftData 轻量迁移,旧记录视为手动。
|
||||||
var sourceRaw: String = IndicatorSource.manual.rawValue
|
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,
|
init(name: String,
|
||||||
value: String,
|
value: String,
|
||||||
unit: String,
|
unit: String,
|
||||||
@@ -66,7 +75,12 @@ final class Indicator {
|
|||||||
asset: Asset? = nil,
|
asset: Asset? = nil,
|
||||||
pinned: Bool = false,
|
pinned: Bool = false,
|
||||||
seriesKey: String? = nil,
|
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.name = name
|
||||||
self.value = value
|
self.value = value
|
||||||
self.unit = unit
|
self.unit = unit
|
||||||
@@ -79,6 +93,11 @@ final class Indicator {
|
|||||||
self.pinned = pinned
|
self.pinned = pinned
|
||||||
self.seriesKey = seriesKey
|
self.seriesKey = seriesKey
|
||||||
self.sourceRaw = source.rawValue
|
self.sourceRaw = source.rawValue
|
||||||
|
self.sourcePageIndex = sourcePageIndex
|
||||||
|
self.sourceBoxX = sourceBoxX
|
||||||
|
self.sourceBoxY = sourceBoxY
|
||||||
|
self.sourceBoxWidth = sourceBoxWidth
|
||||||
|
self.sourceBoxHeight = sourceBoxHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
var status: IndicatorStatus {
|
var status: IndicatorStatus {
|
||||||
@@ -88,6 +107,22 @@ final class Indicator {
|
|||||||
var source: IndicatorSource {
|
var source: IndicatorSource {
|
||||||
IndicatorSource(rawValue: sourceRaw) ?? .manual
|
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
|
@Model
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ struct RootView: View {
|
|||||||
@State private var showDiary = false
|
@State private var showDiary = false
|
||||||
@State private var showIndicator = false
|
@State private var showIndicator = false
|
||||||
@State private var showReminders = false
|
@State private var showReminders = false
|
||||||
|
@State private var showHealthExport = false
|
||||||
|
|
||||||
/// 统一的 tab 切换入口:按方向设定 pushEdge,再带动画改 tab。
|
/// 统一的 tab 切换入口:按方向设定 pushEdge,再带动画改 tab。
|
||||||
/// 所有改 tab 的地方都走这里,保证过渡方向正确。
|
/// 所有改 tab 的地方都走这里,保证过渡方向正确。
|
||||||
@@ -83,6 +84,7 @@ struct RootView: View {
|
|||||||
case .diary: showDiary = true
|
case .diary: showDiary = true
|
||||||
case .indicator: showIndicator = true
|
case .indicator: showIndicator = true
|
||||||
case .reminder: showReminders = true
|
case .reminder: showReminders = true
|
||||||
|
case .healthExport: showHealthExport = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,12 +96,21 @@ struct RootView: View {
|
|||||||
DiaryQuickSheet()
|
DiaryQuickSheet()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showIndicator) {
|
.sheet(isPresented: $showIndicator) {
|
||||||
IndicatorQuickSheet()
|
// 「拍照识别」入口:关闭手输表单 → 打开异常项快拍 VL 流程(并入「记录指标」)。
|
||||||
|
IndicatorQuickSheet(onRequestCamera: {
|
||||||
|
showIndicator = false
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
|
activeFlow = .quick
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showReminders) {
|
.sheet(isPresented: $showReminders) {
|
||||||
// 列表页依赖外层 NavigationStack 提供标题栏;sheet 形态补「完成」按钮。
|
// 列表页依赖外层 NavigationStack 提供标题栏;sheet 形态补「完成」按钮。
|
||||||
NavigationStack { RemindersListView(presentedAsSheet: true) }
|
NavigationStack { RemindersListView(presentedAsSheet: true) }
|
||||||
}
|
}
|
||||||
|
.fullScreenCover(isPresented: $showHealthExport) {
|
||||||
|
HealthExportSheet()
|
||||||
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.fullScreenCover(item: $activeFlow) { flow in
|
.fullScreenCover(item: $activeFlow) { flow in
|
||||||
switch flow {
|
switch flow {
|
||||||
@@ -176,12 +187,12 @@ private struct TabBar: View {
|
|||||||
.matchedGeometryEffect(id: "tabIndicator", in: indicatorNS)
|
.matchedGeometryEffect(id: "tabIndicator", in: indicatorNS)
|
||||||
}
|
}
|
||||||
Image(systemName: t.icon)
|
Image(systemName: t.icon)
|
||||||
.font(.system(size: 18, weight: isActive ? .semibold : .regular))
|
.font(.tjScaled( 18, weight: isActive ? .semibold : .regular))
|
||||||
}
|
}
|
||||||
.frame(width: 50, height: slotHeight)
|
.frame(width: 50, height: slotHeight)
|
||||||
|
|
||||||
Text(t.label)
|
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)
|
.foregroundStyle(isActive ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -204,13 +215,13 @@ private struct TabBar: View {
|
|||||||
radius: 4, x: 0, y: 2)
|
radius: 4, x: 0, y: 2)
|
||||||
|
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.paper)
|
.foregroundStyle(Tj.Palette.paper)
|
||||||
}
|
}
|
||||||
.frame(width: slotHeight, height: slotHeight)
|
.frame(width: slotHeight, height: slotHeight)
|
||||||
|
|
||||||
Text("新建")
|
Text("新建")
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ struct LockScreenView: View {
|
|||||||
.fill(Tj.Palette.paper)
|
.fill(Tj.Palette.paper)
|
||||||
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||||
Image(systemName: "lock.fill")
|
Image(systemName: "lock.fill")
|
||||||
.font(.system(size: 34))
|
.font(.tjScaled( 34))
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
.frame(width: 92, height: 92)
|
.frame(width: 92, height: 92)
|
||||||
@@ -36,7 +36,7 @@ struct LockScreenView: View {
|
|||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("你的健康档案已加密保护")
|
Text("你的健康档案已加密保护")
|
||||||
.font(.system(size: 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ struct PrivacyCoverView: View {
|
|||||||
.fill(Tj.Palette.paper)
|
.fill(Tj.Palette.paper)
|
||||||
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||||
Image(systemName: "heart.text.square.fill")
|
Image(systemName: "heart.text.square.fill")
|
||||||
.font(.system(size: 30))
|
.font(.tjScaled( 30))
|
||||||
.foregroundStyle(Tj.Palette.ink)
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
}
|
}
|
||||||
.frame(width: 80, height: 80)
|
.frame(width: 80, height: 80)
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ struct ParsedReport: Sendable {
|
|||||||
var unit: String
|
var unit: String
|
||||||
var range: String
|
var range: String
|
||||||
var status: IndicatorStatus
|
var status: IndicatorStatus
|
||||||
|
var sourcePageIndex: Int?
|
||||||
|
var sourceBoxX: Double?
|
||||||
|
var sourceBoxY: Double?
|
||||||
|
var sourceBoxWidth: Double?
|
||||||
|
var sourceBoxHeight: Double?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一项都没识别出来 = 视作失败,UI 走手动录入回退。
|
/// 一项都没识别出来 = 视作失败,UI 走手动录入回退。
|
||||||
@@ -100,11 +105,16 @@ actor CaptureService {
|
|||||||
do {
|
do {
|
||||||
raw = try await AIRuntime.shared.analyzeReport(
|
raw = try await AIRuntime.shared.analyzeReport(
|
||||||
imageURLs: [tmpURL],
|
imageURLs: [tmpURL],
|
||||||
prompt: VLPrompts.regionExtraction()
|
prompt: VLPrompts.regionExtraction(),
|
||||||
|
// 整张化验单可能含十余项,512 token 会截断 → 解析失败。给足额度。
|
||||||
|
maxTokens: 2048
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
throw CaptureError.inferenceFailed("\(error)")
|
throw CaptureError.inferenceFailed("\(error)")
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
|
print("🔎 [recognizeRegion] image bytes=\(imageData.count), VL raw output:\n\(raw)\n--- end VL raw ---")
|
||||||
|
#endif
|
||||||
do {
|
do {
|
||||||
return try CaptureService.parseIndicatorsJSON(raw)
|
return try CaptureService.parseIndicatorsJSON(raw)
|
||||||
} catch let CaptureError.parseFailed(msg) {
|
} 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 可能吐 <think>…</think>,先剥掉再抠 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 的 <think>…</think>(配对块 / 未闭合开标签 / 孤立闭标签),再 trim 顶部空白。
|
||||||
|
/// 与 HealthExportService.stripThinkBlocks 同逻辑,但本类是非 MainActor actor,放一份 nonisolated 版避免跨隔离调用。
|
||||||
|
nonisolated static func stripThink(_ raw: String) -> String {
|
||||||
|
var s = raw
|
||||||
|
while let openR = s.range(of: "<think>"),
|
||||||
|
let closeR = s.range(of: "</think>", range: openR.upperBound..<s.endIndex) {
|
||||||
|
s.removeSubrange(openR.lowerBound..<closeR.upperBound)
|
||||||
|
}
|
||||||
|
if let openR = s.range(of: "<think>") { s = String(s[..<openR.lowerBound]) }
|
||||||
|
if let closeR = s.range(of: "</think>") { s = String(s[closeR.upperBound...]) }
|
||||||
|
while let first = s.first, first.isWhitespace { s.removeFirst() }
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
/// VL 推理 + JSON 解析的纯阶段。assets 必须已写入 Vault。
|
/// VL 推理 + JSON 解析的纯阶段。assets 必须已写入 Vault。
|
||||||
private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
|
private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
|
||||||
do {
|
do {
|
||||||
@@ -344,7 +404,36 @@ actor CaptureService {
|
|||||||
let range = stringValue(d, keys: ["range", "reference", "reference_range", "ref", "参考", "参考值", "参考范围", "正常范围"]) ?? ""
|
let range = stringValue(d, keys: ["range", "reference", "reference_range", "ref", "参考", "参考值", "参考范围", "正常范围"]) ?? ""
|
||||||
let statusRaw = stringValue(d, keys: ["status", "flag", "abnormal", "异常", "提示", "标记"])
|
let statusRaw = stringValue(d, keys: ["status", "flag", "abnormal", "异常", "提示", "标记"])
|
||||||
let status = parseIndicatorStatus(raw: statusRaw, value: value, range: range)
|
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? {
|
private static func stringValue(_ d: [String: Any], keys: [String]) -> String? {
|
||||||
@@ -359,6 +448,44 @@ actor CaptureService {
|
|||||||
return nil
|
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]] {
|
private static func arrayValue(_ d: [String: Any], keys: [String]) -> [[String: Any]] {
|
||||||
for key in keys {
|
for key in keys {
|
||||||
if let arr = d[key] as? [[String: Any]] {
|
if let arr = d[key] as? [[String: Any]] {
|
||||||
@@ -480,7 +607,12 @@ extension Report {
|
|||||||
status: p.status,
|
status: p.status,
|
||||||
capturedAt: reportDate,
|
capturedAt: reportDate,
|
||||||
report: self,
|
report: self,
|
||||||
source: .report
|
source: .report,
|
||||||
|
sourcePageIndex: p.sourcePageIndex,
|
||||||
|
sourceBoxX: p.sourceBoxX,
|
||||||
|
sourceBoxY: p.sourceBoxY,
|
||||||
|
sourceBoxWidth: p.sourceBoxWidth,
|
||||||
|
sourceBoxHeight: p.sourceBoxHeight
|
||||||
)
|
)
|
||||||
ctx.insert(i)
|
ctx.insert(i)
|
||||||
}
|
}
|
||||||
|
|||||||
181
康康/Services/ExportTrendBuilder.swift
Normal file
181
康康/Services/ExportTrendBuilder.swift
Normal file
@@ -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<Double>?) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
康康/Services/HealthExportDialogue.swift
Normal file
47
康康/Services/HealthExportDialogue.swift
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,6 +135,13 @@ struct HealthExportService {
|
|||||||
throw ServiceError.generationFailed("模型未输出任何内容")
|
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: 持久化 ——
|
// —— Phase 4: 持久化 ——
|
||||||
let export = HealthExport(
|
let export = HealthExport(
|
||||||
prompt: prompt,
|
prompt: prompt,
|
||||||
@@ -170,6 +177,146 @@ struct HealthExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 多轮导出页的单轮问答。只回答,不入库。
|
||||||
|
func answer(question: String,
|
||||||
|
conversation: [HealthExportDialogueTurn],
|
||||||
|
in modelContext: ModelContext) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||||
|
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<Event, Error> {
|
||||||
|
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
|
// MARK: - Phase 1: intent extraction
|
||||||
|
|
||||||
struct Intent: Sendable {
|
struct Intent: Sendable {
|
||||||
@@ -251,6 +398,8 @@ struct HealthExportService {
|
|||||||
var reports: [Report]
|
var reports: [Report]
|
||||||
var diaries: [DiaryEntry]
|
var diaries: [DiaryEntry]
|
||||||
var profile: UserProfile
|
var profile: UserProfile
|
||||||
|
/// 相关指标的趋势行(确定性计算,不进 LLM)。空 → 不渲染「## 指标趋势」段。
|
||||||
|
var trends: [ExportTrend] = []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步 SwiftData 查询。@MainActor。
|
/// 同步 SwiftData 查询。@MainActor。
|
||||||
@@ -265,7 +414,8 @@ struct HealthExportService {
|
|||||||
predicate: #Predicate { $0.capturedAt >= fromDate && $0.capturedAt <= toDate },
|
predicate: #Predicate { $0.capturedAt >= fromDate && $0.capturedAt <= toDate },
|
||||||
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
|
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
|
||||||
)
|
)
|
||||||
var indicators = (try? ctx.fetch(indDesc)) ?? []
|
let allInWindow = (try? ctx.fetch(indDesc)) ?? []
|
||||||
|
var indicators = allInWindow
|
||||||
if !intent.keywords.isEmpty {
|
if !intent.keywords.isEmpty {
|
||||||
let filtered = indicators.filter { ind in
|
let filtered = indicators.filter { ind in
|
||||||
intent.keywords.contains { kw in
|
intent.keywords.contains { kw in
|
||||||
@@ -328,6 +478,14 @@ struct HealthExportService {
|
|||||||
// —— Profile(单例) ——
|
// —— Profile(单例) ——
|
||||||
let profile = UserProfileStore.loadOrCreate(in: ctx)
|
let profile = UserProfileStore.loadOrCreate(in: ctx)
|
||||||
|
|
||||||
|
// —— 趋势(确定性,不进 LLM) ——
|
||||||
|
// 用全量 in-window 还原完整序列;裁剪后的 indicators 决定哪些 series 相关。
|
||||||
|
let trends = ExportTrendBuilder.build(
|
||||||
|
allInWindow: allInWindow,
|
||||||
|
relevant: indicators,
|
||||||
|
profile: profile
|
||||||
|
)
|
||||||
|
|
||||||
return Snapshot(
|
return Snapshot(
|
||||||
fromDate: fromDate,
|
fromDate: fromDate,
|
||||||
toDate: toDate,
|
toDate: toDate,
|
||||||
@@ -335,8 +493,44 @@ struct HealthExportService {
|
|||||||
symptoms: symptoms,
|
symptoms: symptoms,
|
||||||
reports: reports,
|
reports: reports,
|
||||||
diaries: diaries,
|
diaries: diaries,
|
||||||
|
profile: profile,
|
||||||
|
trends: trends
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 多轮导出使用全量指标 + 健康日记作为上下文。为控制 prompt 体积,日记正文在序列化阶段截断。
|
||||||
|
static func retrieveDialogueSnapshot(ctx: ModelContext) -> Snapshot {
|
||||||
|
let indicatorDesc = FetchDescriptor<Indicator>(
|
||||||
|
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
|
||||||
|
)
|
||||||
|
let diaryDesc = FetchDescriptor<DiaryEntry>(
|
||||||
|
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
|
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
|
// 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
|
// MARK: - Helpers
|
||||||
|
|
||||||
/// 把 SwiftData persistentModelID 编成稳定字符串。
|
/// 把 SwiftData persistentModelID 编成稳定字符串。
|
||||||
|
|||||||
188
康康/Services/HealthProfileImportService.swift
Normal file
188
康康/Services/HealthProfileImportService.swift
Normal file
@@ -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<HKObjectType> {
|
||||||
|
var types: Set<HKObjectType> = [
|
||||||
|
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<HKObjectType>) async throws {
|
||||||
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
康康/Services/OCRService.swift
Normal file
69
康康/Services/OCRService.swift
Normal file
@@ -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<String, Error>) 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,5 +10,7 @@
|
|||||||
-->
|
-->
|
||||||
<key>com.apple.developer.kernel.increased-memory-limit</key>
|
<key>com.apple.developer.kernel.increased-memory-limit</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.developer.healthkit</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -123,6 +123,32 @@ struct CaptureServiceJSONTests {
|
|||||||
#expect(parsed.indicators.first?.status == .high)
|
#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 {
|
@Test func infersStatusFromValueAndReferenceRangeWhenStatusMissing() throws {
|
||||||
let raw = """
|
let raw = """
|
||||||
{"indicators":[
|
{"indicators":[
|
||||||
|
|||||||
112
康康Tests/ExportTrendBuilderTests.swift
Normal file
112
康康Tests/ExportTrendBuilderTests.swift
Normal file
@@ -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) // "高" 解析失败被丢
|
||||||
|
}
|
||||||
|
}
|
||||||
34
康康Tests/HealthExportDialogueTests.swift
Normal file
34
康康Tests/HealthExportDialogueTests.swift
Normal file
@@ -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("患者: 最近三个月"))
|
||||||
|
}
|
||||||
|
}
|
||||||
28
康康Tests/HealthExportPromptTests.swift
Normal file
28
康康Tests/HealthExportPromptTests.swift
Normal file
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
54
康康Tests/HealthProfileImportTests.swift
Normal file
54
康康Tests/HealthProfileImportTests.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Testing
|
import Testing
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
@testable import 康康
|
@testable import 康康
|
||||||
|
|
||||||
struct ModelsSchemaTests {
|
struct ModelsSchemaTests {
|
||||||
@@ -138,6 +139,36 @@ struct ModelsSchemaTests {
|
|||||||
#expect(i.seriesKey == nil)
|
#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<Indicator>()).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 {
|
@Test func userProfileSchemaPersistsAcrossSave() throws {
|
||||||
let container = try makeContainer()
|
let container = try makeContainer()
|
||||||
let ctx = ModelContext(container)
|
let ctx = ModelContext(container)
|
||||||
|
|||||||
Reference in New Issue
Block a user