缺少代码差异信息,无法生成具体的commit message。请提供code differences内容以便分析并生成符合Angular规范的提交信息。

当您提供代码差异后,我将按照以下格式生成:

```
<type>(<scope>): <subject>

<body>
```

其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
This commit is contained in:
link2026
2026-06-07 14:17:18 +08:00
parent 074d99715d
commit 77a4ee1c37
66 changed files with 2676 additions and 548 deletions

View File

@@ -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;

View File

@@ -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
"""
}
} }

View File

@@ -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}}
"""# """#
} }

View 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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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()
} }

View File

@@ -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))

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)
} }
} }

View File

@@ -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)
} }

View File

@@ -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)

View File

@@ -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())

View File

@@ -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)
} }

View File

@@ -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(

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)
} }

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
} }
} }

View File

@@ -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(

View 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() }
}

View File

@@ -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)
} }
} }

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
} }
} }

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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))

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
)
}
}

View File

@@ -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()

View File

@@ -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)
} }
} }

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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)
} }
} }

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
} }

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
} }

View 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
/// "152138" "152/96138/88"
let valueText: String
let direction: Direction
/// , "90-140";( / ) nil
let rangeText: String?
///
let spanDays: Int
///
let count: Int
/// ,
let flagged: Bool
/// :` 152138 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 退minPoints2
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)
}
}

View 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")
}
}

View File

@@ -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

View 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)
}
}
}

View 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")
}
}

View File

@@ -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>

View File

@@ -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":[

View 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) // ""
}
}

View 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("患者: 最近三个月"))
}
}

View 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"))
}
}

View 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)
}
}

View File

@@ -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)