feat: 国际化(i18n) en/ja/ko + App 内语言切换

主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施:Localizable.xcstrings(String Catalog,sourceLanguage=zh-Hans)
  + pbxproj developmentRegion/knownRegions 注册 en/ja/ko
- 全部硬编码 Locale("zh_CN") → Locale.current;中文 dateFormat → Date.FormatStyle(跟随系统)
- UI 中文字面量统一为 String(appLoc:)(显式绑定所选语言 bundle+locale,即时切换)
  Text 字面量走环境 \.locale + Bundle 重定向
- 549 个 catalog key 全部 en/ja/ko 翻译完成(0 未翻译)
- App 内语言切换:我的 → 语言(LanguageManager + 即时生效,无需重启)
- 双用预设(症状/监测指标/慢病)本地化:static→computed 避免缓存

注:本提交为 WIP,一并打包了并行进行的功能模块
(HealthExport 健康导出、Security/Face ID 锁、DiaryAssist 日记 AI 辅助)
及 App 图标、CLAUDE.md、docs/scripts。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
link2026
2026-05-30 10:28:24 +08:00
parent 910ca99f21
commit d2c77d5c51
84 changed files with 15643 additions and 699 deletions

View File

@@ -84,7 +84,7 @@ VL prompt 必须:
## 4. 模型分发
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
- 总体积 ~3GB,WiFi 提示必须有
- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)

View File

@@ -0,0 +1,130 @@
# Face ID 启动锁 — 设计文档
**日期**:2026-05-30(W2)
**作者**:link2026 + Claude
**关联卖点**:#4 隐私三件套(系统级加密 + Face ID + 永久删除)
**优先级**:P1(CLAUDE.md §6 / §8 / §11,原排期 W5 末,提前实现)
---
## 1. 一句话定位
可选的 Face ID/Touch ID 启动锁(默认关)。开启后,冷启动与「后台超过 1 分钟再回前台」都需要系统认证才能进入 App;失败可用设备密码兜底。完全基于系统 `LocalAuthentication`,不自造任何密码学(对齐红线 §10.2)。
---
## 2. 设计决策(已与用户确认)
| 决策点 | 选择 |
|---|---|
| 锁屏时机 | 冷启动 + 后台超过宽限才重锁 |
| 后台宽限 | 60 秒 |
| 认证策略 | `.deviceOwnerAuthentication`(Face ID/Touch ID 优先,自动跳设备密码兜底,避免锁死) |
| 默认状态 | 关(§6) |
| 开关位置 | 「我的」Tab 现有的 Face ID 卡,改为可交互 Toggle |
| 任务切换器隐私遮罩 | 加,**仅锁开启时生效**(进 `.inactive`/`.background` 盖品牌遮罩,防多任务快照泄露;默认关用户无感) |
**关于 §6「截屏黑屏防护…不做」**:那条针对的是**截图防护**(iOS 无官方 API);本设计的任务切换器遮罩是 `.inactive` 盖视图,是官方支持的标准做法,性质不同。
---
## 3. 架构
```
KangkangApp
└─ WindowGroup { AppLockContainer { RootView() } } ← 仅包一层,RootView 零改动(§10.7)
┌─────────────┴──────────────────────────────┐
│ AppLockContainer<Content> │
│ @Environment(\.scenePhase) │
│ 渲染 content │
│ .overlay { if isLocked → LockScreen}│
│ .overlay { else if showsCover → PrivacyCover}│
│ onAppear → handleAppear(); │
│ onChange(scenePhase) → handleScenePhase() │
└─────────────────────────────────────────────┘
│ 读写
┌─────────────┴──────────────────────────────┐
│ AppLock.shared (@MainActor @Observable) │ ← Security/AppLock.swift
│ enabled ←→ UserDefaults("faceIDLockEnabled")│
│ isLocked / showsPrivacyCover │
│ biometryAvailable / biometryLabel │
│ gracePeriod = 60s,lastBackgroundedAt │
│ authenticate() / enableWithAuth() / disable()│
└──────────────────────────────────────────────┘
```
单例写法与项目既有 `ModelDownloadService.shared` 一致(`@MainActor @Observable final class` + `static let shared`)。
---
## 4. 触发逻辑(状态机)
| scenePhase / 事件 | 行为 |
|---|---|
| 容器 `onAppear`(冷启动) | `enabled` 为真且尚未冷启动锁过 → `isLocked = true` + 触发认证 |
| `.background` | `lastBackgroundedAt = now`;`showsPrivacyCover = enabled` |
| `.inactive`(任务切换器) | `showsPrivacyCover = enabled && !isLocked` |
| `.active` | 隐藏遮罩;若 `enabled && !isLocked && 离开 > 60s``isLocked = true`;若 `isLocked` → 触发认证;清空 `lastBackgroundedAt` |
| 认证成功 | `isLocked = false` |
| 认证失败/取消 | 保持锁定,锁屏提供「解锁」按钮重试(`isAuthenticating` 防重入,不重复弹窗) |
冷启动时 scenePhase 初值为 `.active` 不触发 `onChange`,由 `handleAppear()` 负责冷启动锁;两路触发由 `isAuthenticating` 守卫去重。
---
## 5. 能力探测与兜底
- `refreshAvailability()`:`LAContext.canEvaluatePolicy(.deviceOwnerAuthentication)``biometryAvailable`;读 `biometryType` 决定文案(Face ID / Touch ID / 密码)。
- 设备未设密码/无生物识别 → `biometryAvailable = false`,「我的」开关置灰,副标题「本设备未设置 Face ID 或密码」。
- 认证全程系统弹窗;失败/取消不抛错给 UI,只是停留锁屏。
---
## 6. 文件清单
| 文件 | 改动 |
|---|---|
| `康康/Security/AppLock.swift` | **新增**:单例 + LAContext 封装 + 触发逻辑 |
| `康康/Security/AppLockContainer.swift` | **新增**:包裹层 + scenePhase 驱动 + 两个 overlay |
| `康康/Security/LockScreenView.swift` | **新增**:`LockScreenView` + `PrivacyCoverView` |
| `康康/App/KangkangApp.swift` | `RootView()``AppLockContainer { RootView() }` |
| `康康/Features/Me/MeView.swift` | 静态 Face ID 卡 → 可交互 Toggle 卡 |
| `康康.xcodeproj/project.pbxproj` | 加 `INFOPLIST_KEY_NSFaceIDUsageDescription`(Debug + Release) |
工程用文件系统同步组,新增 `Security/` 下的源文件自动纳入编译,无需手改 pbxproj 注册。
---
## 7. UI
锁屏(`LockScreenView`,全遮罩,走 Tj tokens):
```
🔒 (lock glyph)
康康 已锁定
你的健康档案已加密保护
[ Face ID 解锁 ] ← onAppear 自动触发一次认证;按钮文案随设备能力变
```
隐私遮罩(`PrivacyCoverView`):品牌色底 + app 名,无交互,仅用于遮挡多任务快照。
「我的」Face ID 卡:Toggle 开启时先认证一次(成功才置 `enabled`),关闭直接关。副标题动态:「已开启 · Face ID」/「关闭」/「本设备未设置 Face ID 或密码」。
---
## 8. 红线对齐(CLAUDE.md §10)
- 不自造密码学,只用系统 `LocalAuthentication`
- 默认关,可选开关 ✅
- 不引云 ✅
- 不重构 Tab/RecordSheet 骨架,只加一层包裹 ✅
- 清单内功能(§6/§8/§11 明列 Face ID 启动锁)✅
---
## 9. 测试与验收
- 单元测试价值低(核心是系统弹窗 + scenePhase),不强求;`AppLock` 的宽限判定逻辑可抽纯函数测(可选)。
- **真机验收**:① 开关开启走 Face ID;② 杀进程冷启动需认证;③ 后台 <60s 回来不锁、>60s 回来锁;④ 多任务切换器快照被遮罩;⑤ 关 Face ID 录入(模拟失败)能跳设备密码;⑥ 默认关时全程无感。
- 模拟器:Features → Face ID → Enrolled / Matching Face 可模拟。

68
scripts/fetch-qwen3vl.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# 下载 Qwen3-VL-4B-Instruct-4bit(MLX 4bit)全量文件到本地镜像目录,并逐个校验字节数。
# 字节数权威来源:康康/AI/ModelManifest.swift(HF API blobs=true,2026-05 核对)。
# 用法: bash scripts/fetch-qwen3vl.sh
set -uo pipefail
REPO="mlx-community/Qwen3-VL-4B-Instruct-4bit"
BASE="https://huggingface.co/${REPO}/resolve/main"
# 目标 = 康康仓库内的 Models/(已被 .gitignore 忽略,App 旁路导入也认这个目录名)。
# 可用环境变量 KK_MODELS_DIR 覆盖根目录(如指向另一块盘)。
ROOT="${KK_MODELS_DIR:-/Users/xuhuayong/apps/康康/Models}"
DEST="$ROOT/Qwen3-VL-4B-Instruct-4bit"
mkdir -p "$DEST"
# 文件名:期望字节数(与 ModelManifest.swift 的 .vl 清单一一对应)
FILES=(
"config.json:7137"
"model.safetensors:3093767283"
"model.safetensors.index.json:64742"
"tokenizer.json:11422654"
"tokenizer_config.json:5445"
"vocab.json:2776833"
"merges.txt:1671853"
"special_tokens_map.json:613"
"added_tokens.json:707"
"generation_config.json:269"
"chat_template.json:5502"
"chat_template.jinja:5292"
"preprocessor_config.json:782"
"video_preprocessor_config.json:817"
)
fsize() { stat -f%z "$1" 2>/dev/null || echo 0; }
fail=0
for entry in "${FILES[@]}"; do
name="${entry%%:*}"; want="${entry##*:}"; out="$DEST/$name"
if [[ -f "$out" && "$(fsize "$out")" == "$want" ]]; then
echo "SKIP $name (已完整 $want)"; continue
fi
echo "GET $name (期望 $want 字节)"
curl -fL -C - --retry 5 --retry-delay 3 --connect-timeout 30 \
-o "$out" "$BASE/$name" || { echo " !! 下载失败 $name"; fail=1; continue; }
have="$(fsize "$out")"
if [[ "$have" != "$want" ]]; then
echo " !! 字节不符 $name: 实得 $have / 期望 $want"; fail=1
else
echo " OK $name $have"
fi
done
# 大权重额外做 SHA256 校验(HF LFS oid,密码学级,字节数相同也能查出脏数据)。
WEIGHT_SHA="90eeb02604181dbcccd0a30a1f550a4a8928ca7dcbee4aee1449239306cfdfca"
if [[ -f "$DEST/model.safetensors" ]]; then
echo "校验 model.safetensors SHA256(约需 10 余秒)..."
got="$(shasum -a 256 "$DEST/model.safetensors" | awk '{print $1}')"
if [[ "$got" == "$WEIGHT_SHA" ]]; then
echo " ✓ SHA256 匹配"
else
echo " !! SHA256 不符: 实得 $got / 期望 $WEIGHT_SHA"; fail=1
fi
fi
echo "================================================"
total=$(du -sh "$DEST" 2>/dev/null | cut -f1)
echo "目录: $DEST (合计 $total)"
if [[ "$fail" == "0" ]]; then echo "✅ 全部 14 个文件下载并校验通过(权重含 SHA256)"; else echo "❌ 有文件失败,重跑本脚本可断点续传"; fi
exit "$fail"

53
scripts/upload-qwen3vl.sh Normal file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# 把本地 Models/Qwen3-VL-4B-Instruct-4bit/ 的 14 个文件上传到模型分发服务器,
# 使 App 的「模型管理 · 下载」能拉到新 VL 模型(否则用户点下载会 404)。
#
# 服务器:Caddy(file_server browse),web 根 = /srv/models,SSH = root@101.132.124.52。
# App 下载 URL 形如:https://file.myv0.com/Qwen3-VL-4B-Instruct-4bit/<file>
# → openresty(终止 HTTPS)回源到 Caddy :80(root /srv/models)。
# → 所以远端目标目录 = /srv/models/Qwen3-VL-4B-Instruct-4bit/。
#
# 认证:已用 ssh-copy-id 装好本机公钥,走免密 key;脚本内不含任何密码。
# 用法: bash scripts/upload-qwen3vl.sh
set -euo pipefail
LOCAL_DIR="/Users/xuhuayong/apps/康康/Models/Qwen3-VL-4B-Instruct-4bit"
SSH_HOST="root@101.132.124.52"
REMOTE_ROOT="/srv/models"
REMOTE_SUBDIR="Qwen3-VL-4B-Instruct-4bit"
REMOTE_DIR="$REMOTE_ROOT/$REMOTE_SUBDIR"
# 上传前本地完整性自检(逐字节,14 文件全 SKIP 才算齐)。
bash "$(dirname "$0")/fetch-qwen3vl.sh" >/dev/null || { echo "本地文件不完整,先跑 fetch-qwen3vl.sh 修复再上传"; exit 1; }
echo "本地 14 文件校验通过,开始上传 → $SSH_HOST:$REMOTE_DIR/"
ssh -o ConnectTimeout=20 "$SSH_HOST" "mkdir -p '$REMOTE_DIR'"
# rsync 断点续传(-P=--partial --progress),--inplace 适合大文件。
# 注意:macOS 自带 rsync 2.6.9 不支持 --info=progress2,用 -P 即可。
rsync -avP --inplace \
-e "ssh -o ConnectTimeout=20" \
"$LOCAL_DIR/" "$SSH_HOST:$REMOTE_DIR/"
echo "✅ rsync 上传完成,开始远端校验..."
# 远端逐文件大小核对(与本地 ModelManifest 的 14 文件一致)。
ssh "$SSH_HOST" "cd '$REMOTE_DIR' && ls -la && echo '--- 总大小 ---' && du -sh ."
cat <<'TIP'
──────────────────────────────────────────────
上传完成。建议再从公网验证一次(应全部 HTTP 200,content-length 与本地一致):
for f in config.json model.safetensors model.safetensors.index.json \
tokenizer.json tokenizer_config.json vocab.json merges.txt \
special_tokens_map.json added_tokens.json generation_config.json \
chat_template.json chat_template.jinja preprocessor_config.json \
video_preprocessor_config.json; do
curl -sI "https://file.myv0.com/Qwen3-VL-4B-Instruct-4bit/$f" \
| awk -v F="$f" '/^HTTP/{c=$2} tolower($1)=="content-length:"{s=$2} END{printf "%-32s %s %s\n",F,c,s}'
done
旧模型 Qwen2.5-VL-3B 仍在服务器上;确认新版可用后再删旧目录:
ssh root@101.132.124.52 'rm -rf /srv/models/Qwen2.5-VL-3B-Instruct-4bit'
──────────────────────────────────────────────
TIP

View File

@@ -199,11 +199,14 @@
};
};
buildConfigurationList = 5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "康康" */;
developmentRegion = en;
developmentRegion = "zh-Hans";
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
"zh-Hans",
ja,
ko,
);
mainGroup = 5E463CF02FC403BB0089145B;
minimizedProjectReferenceProxies = 1;
@@ -413,6 +416,10 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -458,6 +465,10 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;

View File

@@ -7,9 +7,9 @@ enum AIRuntimeError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .notReady: return "AI 模型尚未准备好"
case .modelLoadFailed(let m): return "模型加载失败:\(m)"
case .inferenceFailed(let m): return "推理失败:\(m)"
case .notReady: return String(appLoc: "AI 模型尚未准备好")
case .modelLoadFailed(let m): return String(appLoc: "模型加载失败:\(m)")
case .inferenceFailed(let m): return String(appLoc: "推理失败:\(m)")
}
}
}

View File

@@ -7,9 +7,9 @@ enum DownloadError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .badStatus(let code):
return "下载失败(HTTP \(code))"
return String(appLoc: "下载失败(HTTP \(code))")
case .sizeMismatch(let expected, let got):
return "文件大小校验失败(预期 \(expected),实际 \(got))"
return String(appLoc: "文件大小校验失败(预期 \(expected),实际 \(got))")
}
}
}

View File

@@ -30,18 +30,27 @@ enum ModelManifest {
ModelFile(path: "added_tokens.json", bytes: 707),
]
case .vl:
// Qwen3-VL-4B-Instruct-4bit: mlx-community blob
// (HF API blobs=true,2026-05 ),
// :( README.md / .gitattributes),
// mlx-vlm , VLMModelFactory
// chat_template(.json + .jinja ) video ,
// swift-transformers / Qwen3VLProcessor
return [
ModelFile(path: "config.json", bytes: 1_659),
ModelFile(path: "model.safetensors", bytes: 3_073_720_461),
ModelFile(path: "model.safetensors.index.json", bytes: 108_307),
ModelFile(path: "tokenizer.json", bytes: 11_421_896),
ModelFile(path: "tokenizer_config.json", bytes: 7_256),
ModelFile(path: "config.json", bytes: 7_137),
ModelFile(path: "model.safetensors", bytes: 3_093_767_283),
ModelFile(path: "model.safetensors.index.json", bytes: 64_742),
ModelFile(path: "tokenizer.json", bytes: 11_422_654),
ModelFile(path: "tokenizer_config.json", bytes: 5_445),
ModelFile(path: "vocab.json", bytes: 2_776_833),
ModelFile(path: "merges.txt", bytes: 1_671_853),
ModelFile(path: "special_tokens_map.json", bytes: 613),
ModelFile(path: "added_tokens.json", bytes: 605),
ModelFile(path: "chat_template.json", bytes: 1_050),
ModelFile(path: "preprocessor_config.json", bytes: 350),
ModelFile(path: "added_tokens.json", bytes: 707),
ModelFile(path: "generation_config.json", bytes: 269),
ModelFile(path: "chat_template.json", bytes: 5_502),
ModelFile(path: "chat_template.jinja", bytes: 5_292),
ModelFile(path: "preprocessor_config.json", bytes: 782),
ModelFile(path: "video_preprocessor_config.json", bytes: 817),
]
}
}

View File

@@ -3,12 +3,12 @@ import Foundation
nonisolated enum ModelKind: String, CaseIterable {
/// HuggingFace mlx-community , Models/
case llm = "Qwen3-1.7B-4bit"
case vl = "Qwen2.5-VL-3B-Instruct-4bit"
case vl = "Qwen3-VL-4B-Instruct-4bit"
var displayName: String {
switch self {
case .llm: return "Qwen3-1.7B"
case .vl: return "Qwen2.5-VL-3B"
case .vl: return "Qwen3-VL-4B"
}
}
@@ -132,7 +132,7 @@ enum ModelStoreError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .missingConfig:
return "所选文件夹缺少 config.json,不是有效的模型目录"
return String(appLoc: "所选文件夹缺少 config.json,不是有效的模型目录")
}
}
}

View File

@@ -0,0 +1,76 @@
import Foundation
/// , LLM 3-4
/// JSON, question dim()+ q()+ fill()
///
/// `dim`( 2026-05-30 prompt ):
/// 1.7B ,
/// , dim,,
/// ,
enum DiaryAssistPrompts {
/// ;UI
/// /, few-shot
static let dimensions: [String] = [
"起病诱因", "症状性质", "伴随症状", "加重缓解",
"持续频率", "既往家族史", "用药过敏", "生活方式",
]
/// - content:
/// - coveredDimensions: (),
///
static func suggest(content: String, coveredDimensions: [String] = []) -> String {
let covered = coveredDimensions.filter { !$0.isEmpty }
let coveredLine = covered.isEmpty ? "" : covered.joined(separator: "")
let excludeRule = covered.isEmpty
? ""
: "\n- 本轮【严禁】选择这些已覆盖维度:\(covered.joined(separator: ""));只能从其余维度里挑。"
return """
你是社区医生的小助手。患者写了一段身体状态的健康记录,信息可能不够完整。
请从医生问诊角度提出 3-4 个最值得追问的问题,帮患者把这条记录补全。
【问诊维度清单】每个问题必须正好归属其中一个,并用 dim 标注:
1. 起病诱因 —— 何时开始、有无诱因
2. 症状性质 —— 部位、性质、程度
3. 伴随症状 —— 是否伴随其他不适
4. 加重缓解 —— 什么情况下加重或缓解
5. 持续频率 —— 持续多久、多频繁、是否反复发作
6. 既往家族史 —— 以前是否有类似、家族相关史
7. 用药过敏 —— 在服药物、过敏史
8. 生活方式 —— 睡眠、饮食、运动习惯、压力
硬性规则:
- 本轮每个问题必须来自【不同】维度,严禁两条落在同一维度(例如不能两条都问"")。\(excludeRule)
- 只问【最新记录】里还没写明的事。方括号 `[xxx]` 表示该话题已被提出、只是细节待填,【不要】再作为新问题重复它。
- 不给诊断、不给用药建议、不写「建议就医」。
- q ≤ 20 字,像真人医生在问;fill 是采纳后追加到原文的中文补充句,可含方括号占位符如 [时间] [部位]。
- 至少 3 条,最多 4 条。
只输出严格 JSON,不要解释、不要 markdown 围栏、不要 <think> 标签。结构:
{"questions":[{"dim":"<>","q":"<>","fill":"<>"}]}
示例 1(第一轮,记录:头痛了一上午):
{"questions":[
{"dim":"","q":"?","fill":" [] ,"},
{"dim":"","q":"?","fill":"/ [//],"},
{"dim":"","q":"?","fill":" [],"},
{"dim":"","q":"?","fill":" [] [],"}
]}
示例 2(后续轮,已覆盖维度:起病诱因、症状性质、伴随症状):
{"questions":[
{"dim":"","q":"?","fill":"[/] [/],"},
{"dim":"","q":"?","fill":"/ [/],"},
{"dim":"","q":"?","fill":" [/,],"}
]}
现在输出 JSON。
已覆盖维度(必须避开):\(coveredLine)
【最新记录】:
\(content)
Output: /no_think
"""
}
}

View File

@@ -0,0 +1,91 @@
import Foundation
/// LLM prompt:
/// 1. `intentExtraction` + /, JSON
/// 2. `reportGeneration` Markdown
///
/// `HealthExportService`(§3.2 退线:
/// JSON 30 + ,)
enum HealthExportPrompts {
// MARK: -
/// `intentExtraction(userPrompt:)`
/// :
/// ```json
/// {"time_range_days":30,
/// "keywords":["",""],
/// "symptom_keywords":["",""],
/// "intent":"cold_consult",
/// "intent_label_cn":""}
/// ```
static func intentExtraction(userPrompt: String) -> String {
"""
你是健康数据助手。读用户的请求,只输出严格 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
字段说明(全部必填):
{
"time_range_days": int, // 回溯天数,默认 30,最大 365
"keywords": [string], // 指标关键词(中文,如「血压」「血糖」「体温」「肝功」),无则 []
"symptom_keywords": [string], // 症状关键词,无则 []
"intent": string, // 英文 snake_case 标签,如 "cold_consult"
"intent_label_cn": string // 中文短语,会作为报告标题副题,如 ""
}
规则:
- 时间未指定 → 30
- 「最近一个月」→ 30,「最近三个月」→ 90,「最近半年」→ 180
- 关键词要中文,常见健康指标 / 症状词
- intent 简短,4-25 字符,小写下划线
示例 1:
User: 我感冒3天了,要把最近一个月的健康情况给医生看
Output: {"time_range_days":30,"keywords":["","",""],"symptom_keywords":["","","",""],"intent":"cold_consult","intent_label_cn":""}
示例 2:
User: 我最近血糖好像不稳,把上次体检前后的化验单整理一下
Output: {"time_range_days":90,"keywords":["","",""],"symptom_keywords":[],"intent":"glucose_review","intent_label_cn":""}
现在请输出 JSON:
User: \(userPrompt)
Output: /no_think
"""
}
// MARK: -
/// `reportGeneration(userPrompt:intentLabelCN:dataJSON:)` Markdown
static func reportGeneration(userPrompt: String,
intentLabelCN: String,
dataJSON: String) -> String {
let labelLine = intentLabelCN.isEmpty
? "# 就诊摘要"
: "# 就诊摘要 — \(intentLabelCN)"
return """
你正在帮患者撰写一份给社区医生看的就诊摘要。要求:
- 严格输出 Markdown,标题用 # / ##,不要 markdown 围栏
- 只用「数据」中给出的信息,数据缺失就写「无记录」
- 不要给诊断意见、用药建议或「建议就医」之类的话
- 引用数值时保留单位 + 参考范围,异常项前加 ⚠️
- 全文中文,简洁,医生 30 秒内能扫完
- 不要复述「数据」二字,不要输出 JSON
结构(严格按以下 6 段):
\(labelLine)
## 主诉
## 患者背景
## 近期症状(按时间倒序)
## 关键指标(异常项优先)
## 在服药与过敏
## 患者疑问
数据:
\(dataJSON)
患者原话:\(userPrompt)
现在请生成 Markdown(直接输出,不要思考过程,不要 <think> 标签):
/no_think
"""
}
}

View File

@@ -1,6 +1,6 @@
import Foundation
/// VL (Qwen2.5-VL) / prompt
/// VL (Qwen3-VL) / prompt
/// : JSON,markdown
/// CaptureService 退(§3.2 退线)
enum VLPrompts {
@@ -27,9 +27,21 @@ enum VLPrompts {
/// ```
/// `kind` UI indicators A2() B3()
static let reportExtraction: String = #"""
/// VL "", few-shot ,
/// prompt,退
static func reportExtraction(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 reportExtractionTemplate.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
}
private static let reportExtractionTemplate: String = #"""
你是一个医学体检报告识别助手。请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
今天的日期是 {{TODAY}}。
JSON schema(严格):
{
"title": string,
@@ -52,7 +64,8 @@ JSON schema(严格):
规则:
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"
- range 字段保留原文(如 "< 3.40""3.9 - 6.1""0 - 5"),不要解析成区间对象。
- 无法识别的字段填空字符串(institution / summary)或合理默认值(report_date 用今天)
- 无法识别的字段填空字符串(institution / summary)。
- report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。
- 不要发明指标。看不清的整行跳过。
- 化验单一般 type = "lab",体检套餐 = "checkup"

View File

@@ -3,7 +3,7 @@ import MLX
import MLXVLM
import MLXLMCommon
/// MLX VL (Qwen2.5-VL)
/// MLX VL (Qwen3-VL)
/// LLMSession actor , AIRuntime
actor VLSession {
let container: ModelContainer

View File

@@ -3,6 +3,8 @@ import SwiftData
@main
struct KangkangApp: App {
@State private var lang = LanguageManager.shared
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Indicator.self,
@@ -39,7 +41,11 @@ struct KangkangApp: App {
var body: some Scene {
WindowGroup {
RootView()
AppLockContainer {
RootView()
.environment(\.locale, lang.locale)
.id(lang.current) // ,
}
}
.modelContainer(sharedModelContainer)
}

View File

@@ -0,0 +1,127 @@
import SwiftUI
import ObjectiveC
/// App `system` = ; .lproj / String Catalog
enum AppLanguage: String, CaseIterable, Identifiable {
case system
case zhHans = "zh-Hans"
case en
case ja
case ko
var id: String { rawValue }
/// ****(,), App
var displayName: String {
switch self {
case .system: return String(appLoc: "跟随系统")
case .zhHans: return "简体中文"
case .en: return "English"
case .ja: return "日本語"
case .ko: return "한국어"
}
}
/// nil = ; .lproj / Locale
var localeIdentifier: String? {
self == .system ? nil : rawValue
}
}
/// App : lproj bundle locale
/// - `Text("")` `\.locale`(+ Bundle );
/// - `String(appLoc:)` bundle/locale, `.current` ,
/// `.id(current)` ,
@Observable
final class LanguageManager {
static let shared = LanguageManager()
private let storageKey = "appLanguage"
private(set) var current: AppLanguage
/// .lproj bundle(system .main),
private(set) var lprojBundle: Bundle = .main
/// locale(system .autoupdatingCurrent)
private(set) var resolvedLocale: Locale = .autoupdatingCurrent
/// SwiftUI 使(/ + Text )
var locale: Locale { resolvedLocale }
private init() {
let saved = UserDefaults.standard.string(forKey: storageKey)
current = AppLanguage(rawValue: saved ?? "") ?? .system
apply()
}
func set(_ language: AppLanguage) {
guard language != current else { return }
current = language
UserDefaults.standard.set(language.rawValue, forKey: storageKey)
// AppleLanguages:, App
if let id = language.localeIdentifier {
UserDefaults.standard.set([id], forKey: "AppleLanguages")
} else {
UserDefaults.standard.removeObject(forKey: "AppleLanguages")
}
apply()
}
private func apply() {
if let id = current.localeIdentifier {
resolvedLocale = Locale(identifier: id)
if let path = Bundle.main.path(forResource: id, ofType: "lproj"),
let b = Bundle(path: path) {
lprojBundle = b
} else {
lprojBundle = .main
}
} else {
resolvedLocale = .autoupdatingCurrent
lprojBundle = .main
}
Bundle.redirectMain(to: current.localeIdentifier)
}
}
extension String {
/// · ()
/// `String(localized:)`, bundle + locale,
/// `Locale.current`(/)
init(appLoc key: String.LocalizationValue) {
let m = LanguageManager.shared
self = String(localized: key, bundle: m.lprojBundle, locale: m.resolvedLocale)
}
}
// MARK: - Bundle ( Text / NSLocalizedString )
/// key(,)
private var redirectBundleKey: UInt8 = 0
/// `Bundle.main` .lproj
private final class LocalizedMainBundle: Bundle, @unchecked Sendable {
override func localizedString(forKey key: String, value: String?, table tableName: String?) -> String {
if let target = objc_getAssociatedObject(self, &redirectBundleKey) as? Bundle {
return target.localizedString(forKey: key, value: value, table: tableName)
}
return super.localizedString(forKey: key, value: value, table: tableName)
}
}
extension Bundle {
/// language == nil ()
static func redirectMain(to language: String?) {
if !(Bundle.main is LocalizedMainBundle) {
object_setClass(Bundle.main, LocalizedMainBundle.self)
}
let target: Bundle?
if let language,
let path = Bundle.main.path(forResource: language, ofType: "lproj"),
let bundle = Bundle(path: path) {
target = bundle
} else {
target = nil
}
objc_setAssociatedObject(Bundle.main, &redirectBundleKey, target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 807 KiB

View File

@@ -14,8 +14,13 @@ struct ArchiveListView: View {
@Query(sort: \Symptom.startedAt, order: .reverse)
private var symptoms: [Symptom]
@Query(sort: \HealthExport.createdAt, order: .reverse)
private var exports: [HealthExport]
@State private var filter: TimelineKind? = nil
@State private var endingSymptom: Symptom?
@State private var showExportSheet = false
@State private var showExportList = false
@MainActor
private var allEntries: [TimelineEntry] {
@@ -35,6 +40,15 @@ struct ArchiveListView: View {
private var totalCount: Int { allEntries.count }
var body: some View {
NavigationStack {
content
.navigationDestination(isPresented: $showExportList) {
HealthExportListView()
}
}
}
private var content: some View {
VStack(alignment: .leading, spacing: 0) {
header
.padding(.horizontal, 20)
@@ -71,6 +85,9 @@ struct ArchiveListView: View {
.sheet(item: $endingSymptom) { sym in
SymptomEndSheet(symptom: sym)
}
.fullScreenCover(isPresented: $showExportSheet) {
HealthExportSheet()
}
}
@ViewBuilder
@@ -93,17 +110,44 @@ struct ArchiveListView: View {
Text("记录")
.font(.tjTitle(26))
.foregroundStyle(Tj.Palette.text)
Text(totalCount == 0 ? "" : "\(totalCount)")
Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount)"))
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
Menu {
Button {
showExportSheet = true
} label: {
Label("生成新导出", systemImage: "doc.text.below.ecg")
}
if !exports.isEmpty {
Button {
showExportList = true
} label: {
Label("我的导出 · \(exports.count)", systemImage: "clock.arrow.circlepath")
}
}
} label: {
HStack(spacing: 6) {
Image(systemName: "doc.text.below.ecg")
.font(.system(size: 12, weight: .semibold))
Text("导出")
.font(.system(size: 13, weight: .semibold))
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(Tj.Palette.ink))
}
}
}
private var filterChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
chip(label: "全部", selected: filter == nil) { filter = nil }
chip(label: String(appLoc: "全部"), selected: filter == nil) { filter = nil }
ForEach(TimelineKind.allCases) { kind in
chip(label: kind.label, selected: filter == kind) {
filter = filter == kind ? nil : kind
@@ -152,9 +196,9 @@ struct ArchiveListView: View {
private var emptyState: some View {
VStack(spacing: 14) {
Spacer()
TjPlaceholder(label: "还没有任何记录\n点底部 + 号开始")
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
.frame(width: 240, height: 140)
Text(filter == nil ? "记录会按时间归类显示" : "这个类别下没有记录")
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text3)
Spacer()
@@ -166,6 +210,8 @@ struct ArchiveListView: View {
#Preview {
ArchiveListView()
.modelContainer(for: [
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self,
HealthExport.self, ChatTurn.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self
], inMemory: true)
}

View File

@@ -48,8 +48,8 @@ struct B1GuideView: View {
.padding(.bottom, 26)
VStack(spacing: 12) {
OptCard(title: "单张报告", sub: "一张图,几秒搞定", hint: "化验单 · 处方", badge: nil, action: onSingle)
OptCard(title: "多页报告", sub: "像扫描文档一样翻页拍摄", hint: "体检报告 · 影像报告", badge: "推荐", action: onMulti)
OptCard(title: String(appLoc: "单张报告"), sub: String(appLoc: "一张图,几秒搞定"), hint: String(appLoc: "化验单 · 处方"), badge: nil, action: onSingle)
OptCard(title: String(appLoc: "多页报告"), sub: String(appLoc: "像扫描文档一样翻页拍摄"), hint: String(appLoc: "体检报告 · 影像报告"), badge: String(appLoc: "推荐"), action: onMulti)
}
Spacer(minLength: 18)

View File

@@ -63,16 +63,16 @@ struct B2ScanView: View {
private var reportRows: [(String, String, String)] {
[
("总胆固醇", "5.42", "3.105.18"),
("甘油三酯", "1.78", "0.451.70"),
("低密度脂蛋白", "3.84↑", "<3.40"),
("高密度脂蛋白", "1.21", ">1.04"),
("载脂蛋白 A1", "1.42", "1.001.60"),
("载脂蛋白 B", "1.04", "0.551.05"),
("谷丙转氨酶", "28", "950"),
("谷草转氨酶", "24", "1540"),
("空腹血糖", "5.4", "3.96.1"),
("糖化血红蛋白", "5.7", "4.06.0"),
(String(appLoc: "总胆固醇"), "5.42", "3.105.18"),
(String(appLoc: "甘油三酯"), "1.78", "0.451.70"),
(String(appLoc: "低密度脂蛋白"), "3.84↑", "<3.40"),
(String(appLoc: "高密度脂蛋白"), "1.21", ">1.04"),
(String(appLoc: "载脂蛋白 A1"), "1.42", "1.001.60"),
(String(appLoc: "载脂蛋白 B"), "1.04", "0.551.05"),
(String(appLoc: "谷丙转氨酶"), "28", "950"),
(String(appLoc: "谷草转氨酶"), "24", "1540"),
(String(appLoc: "空腹血糖"), "5.4", "3.96.1"),
(String(appLoc: "糖化血红蛋白"), "5.7", "4.06.0"),
]
}

View File

@@ -5,7 +5,13 @@ struct B3MetaView: View {
var onBack: () -> Void
@State private var selectedType = 0
private let types = ["体检报告", "化验单", "影像报告", "处方", "其他"]
private let types = [
String(appLoc: "体检报告"),
String(appLoc: "化验单"),
String(appLoc: "影像报告"),
String(appLoc: "处方"),
String(appLoc: "其他"),
]
var body: some View {
VStack(spacing: 0) {

View File

@@ -10,11 +10,11 @@ struct B4ProgressView: View {
@State private var elapsed: Double = 0.2
private let lineLabels = [
"正在本地识别第 1 / 3 页…",
"正在本地识别第 2 / 3 页…",
"正在本地识别第 3 / 3 页…",
"提取指标 · 共 28 项",
"生成整体摘要…",
String(appLoc: "正在本地识别第 1 / 3 页…"),
String(appLoc: "正在本地识别第 2 / 3 页…"),
String(appLoc: "正在本地识别第 3 / 3 页…"),
String(appLoc: "提取指标 · 共 28 项"),
String(appLoc: "生成整体摘要…"),
]
var body: some View {
@@ -127,7 +127,7 @@ struct B4ProgressView: View {
}
private var speedBadge: some View {
Text(String(format: "已处理 %.1fs · 比云端快 4.2×", elapsed))
Text(String(format: String(appLoc: "已处理 %.1fs · 比云端快 4.2×"), elapsed))
.font(.system(size: 10, design: .monospaced))
.tracking(0.6)
.foregroundStyle(Color.white.opacity(0.75))

View File

@@ -17,11 +17,11 @@ struct B5ResultView: View {
@State private var normalsExpanded = false
let abnormal: [B5IndicatorData] = [
.init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high,
note: "超过参考上限 0.44。建议关注饮食结构3 个月内复查。"),
.init(name: "甘油三酯 TG", value: "1.78", unit: "mmol/L", range: "0.451.70", status: .high, note: nil),
.init(name: "尿酸 UA", value: "428", unit: "μmol/L", range: "150420", status: .high, note: nil),
.init(name: "维生素 D", value: "18", unit: "ng/mL", range: "30100", status: .low, note: nil),
.init(name: String(appLoc: "低密度脂蛋白胆固醇"), value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high,
note: String(appLoc: "超过参考上限 0.44。建议关注饮食结构3 个月内复查。")),
.init(name: String(appLoc: "甘油三酯 TG"), value: "1.78", unit: "mmol/L", range: "0.451.70", status: .high, note: nil),
.init(name: String(appLoc: "尿酸 UA"), value: "428", unit: "μmol/L", range: "150420", status: .high, note: nil),
.init(name: String(appLoc: "维生素 D"), value: "18", unit: "ng/mL", range: "30100", status: .low, note: nil),
]
let normalCount = 24
@@ -33,7 +33,7 @@ struct B5ResultView: View {
VStack(alignment: .leading, spacing: 0) {
reportMeta.padding(.bottom, 16)
summaryCard.padding(.bottom, 18)
SectionLabel("异常项", count: abnormal.count, accent: .brick)
SectionLabel(String(appLoc: "异常项"), count: abnormal.count, accent: .brick)
.padding(.bottom, 10)
VStack(spacing: 8) {
ForEach(Array(abnormal.enumerated()), id: \.offset) { idx, it in
@@ -44,7 +44,7 @@ struct B5ResultView: View {
}
.padding(.bottom, 18)
SectionLabel("正常项", count: normalCount, accent: .leaf)
SectionLabel(String(appLoc: "正常项"), count: normalCount, accent: .leaf)
.padding(.bottom, 10)
normalCollapsed
}
@@ -97,7 +97,7 @@ struct B5ResultView: View {
private var reportMeta: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
TjBadge(text: "体检报告", style: .ink)
TjBadge(text: String(appLoc: "体检报告"), style: .ink)
Text("3 页")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
@@ -130,10 +130,10 @@ struct B5ResultView: View {
.padding(.bottom, 12)
HStack(spacing: 14) {
Stat(n: "28", label: "总项")
Stat(n: "3", label: "偏高", tone: .brick)
Stat(n: "1", label: "偏低", tone: .amber)
Stat(n: "24", label: "正常", tone: .leaf)
Stat(n: "28", label: String(appLoc: "总项"))
Stat(n: "3", label: String(appLoc: "偏高"), tone: .brick)
Stat(n: "1", label: String(appLoc: "偏低"), tone: .amber)
Stat(n: "24", label: String(appLoc: "正常"), tone: .leaf)
}
.padding(.bottom, 14)
@@ -253,9 +253,9 @@ private struct IndicatorRow: View {
}
var statusWord: String {
switch item.status {
case .high: return "偏高"
case .low: return "偏低"
case .normal: return "正常"
case .high: return String(appLoc: "偏高")
case .low: return String(appLoc: "偏低")
case .normal: return String(appLoc: "正常")
}
}
var valueColor: Color {

View File

@@ -0,0 +1,189 @@
import SwiftUI
import SwiftData
/// Markdown + / /
struct HealthExportDetailView: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
let export: HealthExport
@State private var copiedFlash: Bool = false
@State private var showDeleteConfirm = false
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 16) {
metaBar
promptBlock
MarkdownView(text: export.content)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
actionRow
}
.background(Tj.Palette.sand.ignoresSafeArea())
.alert("永久删除这份导出?", isPresented: $showDeleteConfirm) {
Button("删除", role: .destructive) {
ctx.delete(export)
try? ctx.save()
dismiss()
}
Button("取消", role: .cancel) {}
} message: {
Text("删除后无法恢复。源记录(指标、症状等)不受影响。")
}
}
private var header: some View {
HStack(alignment: .center, spacing: 12) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
VStack(alignment: .leading, spacing: 2) {
Text("身体档案 · 历史导出")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text(Self.absoluteDate(export.createdAt))
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
TjLockChip()
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Tj.Palette.sand)
.overlay(alignment: .bottom) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
private var metaBar: some View {
HStack(spacing: 10) {
TjBadge(text: export.modelTag, style: .neutral)
if export.decodeRate > 0 {
Text(String(format: "%.1f tok/s", export.decodeRate))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
Spacer()
if let from = export.inferredTimeFromDate, let to = export.inferredTimeToDate {
Text("\(Self.shortDate(from))\(Self.shortDate(to))")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
}
private var promptBlock: some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "quote.opening")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Text(export.prompt)
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
)
}
private var actionRow: some View {
HStack(spacing: 10) {
Button { copy() } label: {
Label(copiedFlash ? "已复制" : "复制", systemImage: copiedFlash ? "checkmark" : "doc.on.doc")
}
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
ShareLink(item: export.content) {
Label("分享", systemImage: "square.and.arrow.up")
.font(.system(size: 13, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 14)
.frame(height: 44)
.background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1))
}
Spacer()
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Image(systemName: "trash")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Tj.Palette.brick)
.frame(width: 44, height: 44)
.background(Circle().strokeBorder(Tj.Palette.brick.opacity(0.4), lineWidth: 1))
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Tj.Palette.paper)
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
private func copy() {
UIPasteboard.general.string = export.content
copiedFlash = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
copiedFlash = false
}
}
private static func absoluteDate(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day().hour().minute())
}
private static func shortDate(_ d: Date) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "MM-dd"
return f.string(from: d)
}
}
#Preview {
let exp = HealthExport(
prompt: "我感冒3天了,把最近一个月的健康情况给医生看",
content: """
# 就诊摘要 — 感冒就诊
## 主诉
患者男,38 岁,感冒 3 天未愈。
## 患者背景
- 高血压 2 年
- 在服药:缬沙坦 80mg qd
""",
inferredTimeFromDate: Calendar.current.date(byAdding: .day, value: -30, to: .now),
inferredTimeToDate: .now,
inferredIntent: "cold_consult",
decodeRate: 24.3
)
return HealthExportDetailView(export: exp)
}

View File

@@ -0,0 +1,137 @@
import SwiftUI
import SwiftData
/// ArchiveListView strip
struct HealthExportListView: View {
@Environment(\.modelContext) private var ctx
@Query(sort: \HealthExport.createdAt, order: .reverse)
private var exports: [HealthExport]
@State private var selected: HealthExport?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 14)
if exports.isEmpty {
empty
} else {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(exports) { exp in
Button {
selected = exp
} label: {
HealthExportRow(export: exp)
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
delete(exp)
} label: {
Label("删除", systemImage: "trash")
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("我的导出")
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $selected) { exp in
HealthExportDetailView(export: exp)
}
}
private var header: some View {
HStack(alignment: .lastTextBaseline) {
Text("我的导出")
.font(.tjTitle(24))
.foregroundStyle(Tj.Palette.text)
Text(exports.isEmpty ? "" : String(appLoc: "\(exports.count)"))
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
TjLockChip()
}
}
private var empty: some View {
VStack(spacing: 12) {
Spacer()
TjPlaceholder(label: String(appLoc: "还没有导出过\n回到记录页右上角生成一份"))
.frame(width: 240, height: 140)
Spacer()
}
.frame(maxWidth: .infinity)
}
private func delete(_ exp: HealthExport) {
ctx.delete(exp)
try? ctx.save()
}
}
///
struct HealthExportRow: View {
let export: HealthExport
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
Text(export.promptPreview)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(2)
.multilineTextAlignment(.leading)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
HStack(spacing: 8) {
Text(Self.relativeDate(export.createdAt))
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
if export.decodeRate > 0 {
Text(String(format: "%.1f tok/s", export.decodeRate))
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
Spacer()
if let label = export.inferredIntent {
TjBadge(text: label, style: .neutral)
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.tjCard()
}
static func relativeDate(_ d: Date) -> String {
let f = RelativeDateTimeFormatter()
f.locale = Locale.current
f.unitsStyle = .full
return f.localizedString(for: d, relativeTo: .now)
}
}
#Preview {
NavigationStack {
HealthExportListView()
}
.modelContainer(for: [
Indicator.self, Report.self, DiaryEntry.self, Asset.self,
ChatTurn.self, Symptom.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self, HealthExport.self
], inMemory: true)
}

View File

@@ -0,0 +1,548 @@
import SwiftUI
import SwiftData
/// sheet
/// :idle running(extractingIntent retrieving generating) completed / failed
struct HealthExportSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
/// :(,W3 )
let initialPrompt: String
@State private var prompt: String = ""
@State private var phase: HealthExportService.Phase?
@State private var content: String = ""
@State private var rate: Double = 0
@State private var task: Task<Void, Never>?
@State private var error: Error?
@State private var completed: Bool = false
@State private var copiedFlash: Bool = false
@FocusState private var promptFocused: Bool
init(initialPrompt: String = "") {
self.initialPrompt = initialPrompt
}
private var isRunning: Bool { phase != nil && !completed && error == nil }
private var isInputMode: Bool { phase == nil && !completed && error == nil }
var body: some View {
VStack(spacing: 0) {
header
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if isInputMode {
inputSection
} else {
promptEcho
if isRunning { phaseIndicator }
if !content.isEmpty {
MarkdownView(text: content)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
if let err = error { errorRow(err) }
// ,
Color.clear.frame(height: 1).id("bottom")
}
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
.onChange(of: content) { _, _ in
withAnimation(.easeOut(duration: 0.12)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
if completed { actionRow }
}
.background(Tj.Palette.sand.ignoresSafeArea())
.onAppear {
if prompt.isEmpty { prompt = initialPrompt }
if isInputMode { promptFocused = true }
}
.onDisappear { task?.cancel() }
}
// MARK: - Header
private var header: some View {
HStack(alignment: .center, spacing: 12) {
Button { close() } label: {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
VStack(alignment: .leading, spacing: 2) {
Text("导出身体档案")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("给医生看的就诊摘要")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
TjLockChip()
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Tj.Palette.sand)
.overlay(alignment: .bottom) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
// MARK: - Input section (idle)
private var inputSection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("说说你想给医生看什么")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
VStack(alignment: .leading, spacing: 6) {
Text("例:我感冒3天了,把最近一个月的健康情况给医生看")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Text("例:最近血糖好像不稳,把过去三个月的化验单整理一下")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
ZStack(alignment: .topLeading) {
if prompt.isEmpty {
Text("在这里输入主诉……")
.font(.system(size: 15))
.foregroundStyle(Tj.Palette.text3)
.padding(.horizontal, 14)
.padding(.vertical, 14)
.allowsHitTesting(false)
}
TextEditor(text: $prompt)
.font(.system(size: 15))
.foregroundStyle(Tj.Palette.text)
.scrollContentBackground(.hidden)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(minHeight: 130)
.focused($promptFocused)
}
.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)
)
HStack {
Text("本地 RAG · Qwen3 1.7B · 不上传任何数据")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Spacer()
Button { start() } label: {
Text("生成报告")
}
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
.disabled(prompt.trimmingCharacters(in: .whitespaces).isEmpty)
.opacity(prompt.trimmingCharacters(in: .whitespaces).isEmpty ? 0.5 : 1)
}
}
}
// 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)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
)
}
// MARK: - Phase indicator
private var phaseIndicator: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
phasePill(.extractingIntent)
arrow
phasePill(.retrieving)
arrow
phasePill(.generating)
}
if phase == .generating && rate > 0 {
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
} else {
Text(phase?.label ?? "")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
}
}
private func phasePill(_ p: HealthExportService.Phase) -> some View {
let active = (p == phase)
let done = phaseOrder(p) < phaseOrder(phase ?? .extractingIntent)
let fill = active ? Tj.Palette.ink : (done ? Tj.Palette.leaf : Tj.Palette.sand2)
let fg = (active || done) ? Tj.Palette.paper : Tj.Palette.text3
return Text(p.label)
.font(.system(size: 11, weight: active ? .semibold : .regular))
.foregroundStyle(fg)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().fill(fill))
}
private var arrow: some View {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
private func phaseOrder(_ p: HealthExportService.Phase) -> Int {
switch p {
case .extractingIntent: return 0
case .retrieving: return 1
case .generating: return 2
case .completed: return 3
}
}
// MARK: - Error
private func errorRow(_ err: Error) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.brick)
Text(err.localizedDescription)
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text)
}
Button { reset() } label: { Text("返回修改") }
.buttonStyle(TjGhostButton(height: 40, fontSize: 13))
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.6))
)
}
// MARK: - Action row (completed)
private var actionRow: some View {
HStack(spacing: 10) {
Button { copy() } label: {
Label(copiedFlash ? "已复制" : "复制", systemImage: copiedFlash ? "checkmark" : "doc.on.doc")
}
.buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))
ShareLink(item: content) {
Label("分享", systemImage: "square.and.arrow.up")
.font(.system(size: 13, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 14)
.frame(height: 44)
.background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1))
}
Spacer()
Button { regenerate() } label: {
Label("重新生成", systemImage: "arrow.clockwise")
}
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 13, horizontalPadding: 16))
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Tj.Palette.paper)
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
// MARK: - Actions
private func start() {
let p = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !p.isEmpty else { return }
promptFocused = false
content = ""
error = nil
completed = false
phase = .extractingIntent
let stream = HealthExportService.shared.export(prompt: p, in: ctx)
task = Task { @MainActor in
do {
for try await event in stream {
switch event {
case .phaseChanged(let ph):
phase = ph
case .token(let chunk):
content += chunk.text
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
case .completed:
completed = true
}
}
} catch {
self.error = error
self.phase = nil
}
}
}
private func regenerate() {
completed = false
start()
}
private func reset() {
task?.cancel()
task = nil
phase = nil
content = ""
rate = 0
error = nil
completed = false
promptFocused = true
}
private func copy() {
UIPasteboard.general.string = content
copiedFlash = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
copiedFlash = false
}
}
private func close() {
task?.cancel()
dismiss()
}
}
// MARK: - Markdown ()
/// Markdown ,
/// : `# ``## ``-` `****`( AttributedString inline )
/// prompt LLM
struct MarkdownView: View {
let text: String
var body: some View {
let blocks = Self.parse(text)
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
renderBlock(block)
}
}
}
@ViewBuilder
private func renderBlock(_ block: Block) -> some View {
switch block {
case .h1(let s):
VStack(alignment: .leading, spacing: 8) {
Text(inline(s))
.font(.system(size: 22, weight: .bold))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
Rectangle()
.fill(Tj.Palette.ink)
.frame(height: 1)
.frame(maxWidth: .infinity)
}
.padding(.top, 2)
.padding(.bottom, 4)
case .h2(let s):
HStack(alignment: .center, spacing: 8) {
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(Tj.Palette.brick)
.frame(width: 3, height: 16)
Text(inline(s))
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
}
.padding(.top, 10)
.padding(.bottom, 2)
case .bullet(let s):
if let abnormalText = Self.extractAbnormal(s) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.brick)
Text(inline(abnormalText))
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.55))
)
.overlay(alignment: .leading) {
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(Tj.Palette.brick)
.frame(width: 3)
}
} else {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Circle()
.fill(Tj.Palette.text3)
.frame(width: 4, height: 4)
.padding(.top, 6)
Text(inline(s))
.font(.system(size: 14))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.leading, 2)
}
case .body(let s):
Text(inline(s))
.font(.system(size: 14))
.lineSpacing(3)
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
case .gap:
Spacer().frame(height: 4)
}
}
/// bullet , strip
/// nil()
private static func extractAbnormal(_ s: String) -> String? {
let trimmed = s.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("⚠️") {
return trimmed.replacingOccurrences(of: "⚠️", with: "")
.trimmingCharacters(in: .whitespaces)
}
// LLM ,
let abnormalSignals = ["偏高", "偏低", "异常", "过高", "过低"]
for sig in abnormalSignals where trimmed.contains(sig) {
return trimmed
}
return nil
}
private func inline(_ s: String) -> AttributedString {
// **bold** / *italic* / [text](url) AttributedString markdown
if let attr = try? AttributedString(
markdown: s,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
) {
return attr
}
return AttributedString(s)
}
// MARK: -
enum Block {
case h1(String)
case h2(String)
case bullet(String)
case body(String)
case gap
}
static func parse(_ raw: String) -> [Block] {
var out: [Block] = []
let lines = raw.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")
for line in lines {
let t = line.trimmingCharacters(in: .whitespaces)
if t.isEmpty {
// gap
if case .gap = out.last { continue }
out.append(.gap)
continue
}
if t.hasPrefix("# ") {
out.append(.h1(String(t.dropFirst(2))))
} else if t.hasPrefix("## ") {
out.append(.h2(String(t.dropFirst(3))))
} else if t.hasPrefix("### ") {
out.append(.h2(String(t.dropFirst(4))))
} else if t.hasPrefix("- ") || t.hasPrefix("* ") {
out.append(.bullet(String(t.dropFirst(2))))
} else {
out.append(.body(t))
}
}
return out
}
}
#Preview("HealthExportSheet · 空状态") {
HealthExportSheet()
.modelContainer(for: [
Indicator.self, Report.self, DiaryEntry.self, Asset.self,
ChatTurn.self, Symptom.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self, HealthExport.self
], inMemory: true)
}
#Preview("MarkdownView · 演示") {
ScrollView {
MarkdownView(text: """
# 就诊摘要 — 感冒就诊
## 主诉
患者男,38 岁,感冒 3 天未愈,主诉鼻塞、咳嗽、低烧。
## 患者背景
- 高血压 2 年
- 在服药:**缬沙坦 80mg qd**
- 过敏:青霉素
## 近期症状
- 2026-05-24 感冒(进行中,severity 2):鼻塞、低烧
- 2026-05-20 头痛(已结束)
## 关键指标
- ⚠️ 收缩压 142 mmHg (参考 <140) — 2026-05-26
- 体温 37.2 ℃ (参考 36-37) — 2026-05-25
""")
.padding()
}
.background(Tj.Palette.sand)
}

View File

@@ -10,6 +10,8 @@ struct CaptureReviewForm: View {
let warning: String?
let onSave: (ParsedReport) -> Void
let onCancel: () -> Void
/// assets () nil,banner
var onReanalyze: (() -> Void)? = nil
var body: some View {
ScrollView {
@@ -36,10 +38,22 @@ struct CaptureReviewForm: View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.amber)
Text(text)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
VStack(alignment: .leading, spacing: 8) {
Text(text)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
if let onReanalyze {
Button {
onReanalyze()
} label: {
Label("重新识别", systemImage: "arrow.clockwise")
.font(.system(size: 12, weight: .semibold))
}
.buttonStyle(.plain)
.foregroundStyle(Tj.Palette.ink)
}
}
Spacer(minLength: 0)
}
.padding(12)
@@ -53,7 +67,7 @@ struct CaptureReviewForm: View {
private var pageThumbnails: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("已保存 \(assets.count) 页(端侧加密)")
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
@@ -78,13 +92,13 @@ struct CaptureReviewForm: View {
private var metaSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionLabel("基本信息")
sectionLabel(String(appLoc: "基本信息"))
VStack(spacing: 10) {
labeledField("标题") {
labeledField(String(appLoc: "标题")) {
TextField("如:春季年度体检", text: $parsed.title)
.textFieldStyle(.plain)
}
labeledField("类型") {
labeledField(String(appLoc: "类型")) {
Picker("", selection: $parsed.typeRaw) {
ForEach(ReportType.allCases, id: \.rawValue) { t in
Text(t.label).tag(t.rawValue)
@@ -92,18 +106,18 @@ struct CaptureReviewForm: View {
}
.pickerStyle(.segmented)
}
labeledField("报告日期") {
labeledField(String(appLoc: "报告日期")) {
DatePicker("", selection: $parsed.reportDate,
in: ...Date.now,
displayedComponents: .date)
.datePickerStyle(.compact)
.labelsHidden()
.environment(\.locale, Locale(identifier: "zh_CN"))
.environment(\.locale, Locale.current)
}
labeledField("机构(可选)") {
labeledField(String(appLoc: "机构(可选)")) {
TextField("如:协和医院", text: $parsed.institution)
}
labeledField("摘要(可选)") {
labeledField(String(appLoc: "摘要(可选)")) {
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
.lineLimit(1...3)
}
@@ -128,7 +142,7 @@ struct CaptureReviewForm: View {
private var indicatorSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
sectionLabel("指标(\(parsed.indicators.count) 项)")
sectionLabel(String(appLoc: "指标(\(parsed.indicators.count) 项)"))
Spacer()
Button {
parsed.indicators.append(

View File

@@ -1,6 +1,7 @@
import SwiftUI
import SwiftData
import UIKit
import Combine
/// VL ( + )
/// , A1-A3 / B1-B5 mockup
@@ -16,11 +17,17 @@ struct UnifiedCaptureFlow: View {
@Environment(\.modelContext) private var ctx
let onClose: () -> Void
@AppStorage("hasSeenCaptureTip") private var hasSeenCaptureTip: Bool = false
@State private var phase: Phase = .idle
@State private var analyzeTask: Task<Void, Never>? = nil
@State private var showTip: Bool = false
/// VL (); cancel ,UI 退
private let analyzeTimeoutSeconds: Int = 30
enum Phase {
case idle
case analyzing(images: [UIImage])
case analyzing(images: [UIImage], assets: [FileVault.SavedAsset]?)
case editing(parsed: ParsedReport,
assets: [FileVault.SavedAsset],
warning: String?)
@@ -32,20 +39,30 @@ struct UnifiedCaptureFlow: View {
.background(Tj.Palette.sand.ignoresSafeArea())
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { onClose() }
Button("取消") { cancelAll() }
.foregroundStyle(Tj.Palette.text)
}
}
.navigationTitle(phaseTitle)
.navigationBarTitleDisplayMode(.inline)
}
.onAppear {
if !hasSeenCaptureTip { showTip = true }
}
.sheet(isPresented: $showTip) {
CaptureTipSheet(onDismiss: {
hasSeenCaptureTip = true
showTip = false
})
.presentationDetents([.medium])
}
}
private var phaseTitle: String {
switch phase {
case .idle: return "拍摄报告"
case .analyzing: return "本地识别中…"
case .editing: return "核对识别结果"
case .idle: return String(appLoc: "拍摄报告")
case .analyzing: return String(appLoc: "本地识别中…")
case .editing: return String(appLoc: "核对识别结果")
}
}
@@ -54,21 +71,57 @@ struct UnifiedCaptureFlow: View {
switch phase {
case .idle:
captureEntry
case .analyzing(let images):
AnalyzingView(images: images)
case .analyzing(let images, _):
AnalyzingView(
images: images,
timeoutSeconds: analyzeTimeoutSeconds,
onCancel: {
analyzeTask?.cancel()
analyzeTask = nil
phase = .idle
}
)
case .editing(let parsed, let assets, let warning):
CaptureReviewForm(
parsed: parsed,
assets: assets,
warning: warning,
onSave: { final in saveAll(parsed: final, assets: assets) },
onCancel: onClose
onCancel: cancelAll,
onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) }
)
}
}
// MARK: -
/// + SwiftData Vault , sheet
/// (),
/// (§6), Vault
/// .analyzing/.editing assets;.idle ,
private func cancelAll() {
analyzeTask?.cancel()
analyzeTask = nil
switch phase {
case .idle:
break
case .analyzing(_, let maybeAssets):
if let assets = maybeAssets { removeOrphans(assets) }
case .editing(_, let assets, _):
removeOrphans(assets)
}
onClose()
}
private func removeOrphans(_ assets: [FileVault.SavedAsset]) {
for a in assets {
try? FileVault.shared.remove(relativePath: a.relativePath)
}
}
// MARK: - : /
@ViewBuilder
private var captureEntry: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
@@ -95,54 +148,124 @@ struct UnifiedCaptureFlow: View {
private func startAnalyze(images: [UIImage]) {
guard !images.isEmpty else { onClose(); return }
phase = .analyzing(images: images)
Task {
do {
let result = try await CaptureService.shared.analyze(images: images)
await MainActor.run {
phase = .editing(
parsed: result.parsed,
assets: result.assets,
warning: result.parsed.isEmpty
? "识别没有读出指标,请手动补充"
: nil
)
}
} catch let CaptureError.parseFailed(msg) {
// :, indicators ,assets
await fallbackToManual(images: images, msg: "VL 输出无法解析:\(msg)")
} catch let CaptureError.inferenceFailed(msg) {
await fallbackToManual(images: images, msg: "推理失败:\(msg)")
} catch let CaptureError.modelNotReady {
await fallbackToManual(images: images, msg: "VL 模型未就绪,先手动录入")
} catch CaptureError.writeAssetFailed {
analyzeTask?.cancel()
phase = .analyzing(images: images, assets: nil)
let timeout = analyzeTimeoutSeconds
analyzeTask = Task {
// Step 1: Vault
// UI , CaptureService.analyze /退,
// assets phase ,cancelAll ,editingFallback
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
// :,View dismisscancelAll
// phase .analyzing(_, nil),
if Task.isCancelled {
for a in assets { try? FileVault.shared.remove(relativePath: a.relativePath) }
return
}
guard !assets.isEmpty else {
await MainActor.run {
phase = .editing(
parsed: .empty(),
assets: [],
warning: "图片保存失败,手动录入并保留文本"
warning: String(appLoc: "图片保存失败,手动录入并保留文本")
)
}
return
}
// assets phase,使
await MainActor.run {
if case .analyzing(let imgs, _) = phase {
phase = .analyzing(images: imgs, assets: assets)
}
}
// Step 2: VL (timeout cancel ,VLSession token break)
let watchdog = Task {
try? await Task.sleep(for: .seconds(timeout))
analyzeTask?.cancel()
}
defer { watchdog.cancel() }
do {
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
if Task.isCancelled {
await editingFallback(assets: assets,
msg: String(appLoc: "识别超时(>\(timeout)s),先手动录入"))
return
}
await MainActor.run {
phase = .editing(
parsed: parsed,
assets: assets,
warning: parsed.isEmpty ? String(appLoc: "识别没有读出指标,请手动补充") : nil
)
}
} catch let CaptureError.parseFailed(msg) {
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
await editingFallback(assets: assets,
msg: Task.isCancelled
? String(appLoc: "识别超时(>\(timeout)s),先手动录入")
: String(appLoc: "推理失败:\(msg)"))
} catch CaptureError.modelNotReady {
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪,先手动录入"))
} catch {
await fallbackToManual(images: images, msg: "未知错误:\(error.localizedDescription)")
await editingFallback(assets: assets,
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
}
}
}
private func fallbackToManual(images: [UIImage], msg: String) async {
// 便 VL , Vault( CaptureService.analyze 1 )
// writeAsset (modelNotReady / inferenceFailed),
// ,
var assets: [FileVault.SavedAsset] = []
for img in images {
if let a = try? FileVault.shared.writeJPEG(img) { assets.append(a) }
/// : assets,, VL
private func reanalyze(assets: [FileVault.SavedAsset]) {
analyzeTask?.cancel()
// UIImage,AnalyzingView
let thumbnails: [UIImage] = assets.compactMap {
try? FileVault.shared.loadImage(relativePath: $0.relativePath)
}
phase = .analyzing(images: thumbnails, assets: assets)
let timeout = analyzeTimeoutSeconds
analyzeTask = Task {
let watchdog = Task {
try? await Task.sleep(for: .seconds(timeout))
analyzeTask?.cancel()
}
defer { watchdog.cancel() }
do {
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
if Task.isCancelled {
await editingFallback(assets: assets,
msg: String(appLoc: "识别超时(>\(timeout)s),保留旧编辑"))
return
}
await MainActor.run {
phase = .editing(
parsed: parsed,
assets: assets,
warning: parsed.isEmpty ? String(appLoc: "重新识别没有读出新指标") : nil
)
}
} catch CaptureError.modelNotReady {
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪"))
} catch let CaptureError.parseFailed(msg) {
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
await editingFallback(assets: assets,
msg: Task.isCancelled
? String(appLoc: "识别超时(>\(timeout)s)")
: String(appLoc: "推理失败:\(msg)"))
} catch {
await editingFallback(assets: assets,
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
}
}
}
/// reanalyze editing, assets parsed
private func editingFallback(assets: [FileVault.SavedAsset], msg: String) async {
await MainActor.run {
phase = .editing(
parsed: .empty(),
assets: assets,
warning: msg
)
phase = .editing(parsed: .empty(), assets: assets, warning: msg)
}
}
@@ -151,7 +274,7 @@ struct UnifiedCaptureFlow: View {
private func saveAll(parsed final: ParsedReport,
assets: [FileVault.SavedAsset]) {
let report = Report(
title: final.title.isEmpty ? "拍摄识别" : final.title,
title: final.title.isEmpty ? String(appLoc: "拍摄识别") : final.title,
type: ReportType(rawValue: final.typeRaw) ?? .other,
reportDate: final.reportDate,
institution: final.institution.isEmpty ? nil : final.institution,
@@ -190,6 +313,11 @@ struct UnifiedCaptureFlow: View {
private struct AnalyzingView: View {
let images: [UIImage]
let timeoutSeconds: Int
let onCancel: () -> Void
@State private var elapsed: Int = 0
private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack(spacing: 20) {
@@ -216,13 +344,72 @@ private struct AnalyzingView: View {
Text("本地识别中")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(images.count) 页 · 100% 本地推理")
Text("\(images.count) 页 · 100% 本地推理 · 已用 \(elapsed)s")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
if elapsed >= timeoutSeconds - 5 {
Text("快超时了,>\(timeoutSeconds)s 会自动转为手动录入")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.amber)
}
}
Button("取消识别 · 改为手动录入", action: onCancel)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
.padding(.top, 4)
Spacer()
}
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onReceive(tick) { _ in elapsed += 1 }
}
}
// MARK: - 使
private struct CaptureTipSheet: View {
let onDismiss: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 10) {
Image(systemName: "doc.viewfinder")
.font(.system(size: 28))
.foregroundStyle(Tj.Palette.ink)
Text("拍报告的小贴士")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
}
VStack(alignment: .leading, spacing: 12) {
tip(String(appLoc: "纸张铺平,避免反光、阴影"))
tip(String(appLoc: "整页入框,避免裁切到指标"))
tip(String(appLoc: "多页报告可连拍,系统自动透视校正"))
tip(String(appLoc: "识别全程在本地,图片不会上传"))
}
Spacer()
Button {
onDismiss()
} label: {
Text("我知道了,开始拍")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
}
private func tip(_ text: String) -> some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Tj.Palette.leaf)
.padding(.top, 2)
Text(text)
.font(.tjSerifBody())
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
}
}

View File

@@ -1,6 +1,10 @@
import SwiftUI
import SwiftData
/// sheet
/// DiaryEntry @Model;UI/, AI :
/// Qwen3 3-4 ,
/// q LLM ; row +
struct DiaryQuickSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@@ -8,9 +12,35 @@ struct DiaryQuickSheet: View {
@State private var content: String = ""
@State private var createdAt: Date = .now
private var canSubmit: Bool {
/// AI
enum AssistPhase {
case idle //
case loading // LLM
case ready // , / /
case failed(Error) //
}
@State private var phase: AssistPhase = .idle
@State private var questions: [DiaryAssistService.Question] = []
@State private var lastRate: Double = 0
@State private var currentRound: Int = 0
/// (question.dim), prompt
@State private var coveredDims: Set<String> = []
@State private var suggestTask: Task<Void, Never>?
/// sheet detent large,
/// medium,()
@State private var detent: PresentationDetent = .large
@FocusState private var contentFocused: Bool
private var hasContent: Bool {
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var hasQuestions: Bool { !questions.isEmpty }
private var isLoading: Bool {
if case .loading = phase { return true }
return false
}
private var canRequestSuggest: Bool { hasContent && !isLoading }
private var canSubmit: Bool { hasContent }
var body: some View {
VStack(spacing: 0) {
@@ -21,44 +51,70 @@ struct DiaryQuickSheet: View {
.padding(.bottom, 14)
HStack {
Text("写日记")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
VStack(alignment: .leading, spacing: 2) {
Text("健康记录")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Text("本机保存")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
.padding(.bottom, 14)
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("内容")
TextField("今天怎么样?", text: $content, axis: .vertical)
.lineLimit(4...10)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "内容"))
TextField("今天身体怎么样?吃了什么药、有什么感觉?",
text: $content, axis: .vertical)
.lineLimit(3...8)
.focused($contentFocused)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
assistSection
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "时间"))
DatePicker("", selection: $createdAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
}
// , question
Color.clear.frame(height: 1).id("assist-bottom")
}
.padding(.horizontal, 20)
.padding(.bottom, 6)
}
VStack(alignment: .leading, spacing: 8) {
sectionLabel("时间")
DatePicker("", selection: $createdAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
.scrollDismissesKeyboard(.interactively)
.onChange(of: questions.count) { old, new in
guard new > old else { return }
// round divider( N ,
// questions)
let roundId = "round-\(questions[old].round)"
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
withAnimation(.easeOut(duration: 0.25)) {
proxy.scrollTo(roundId, anchor: .top)
}
}
}
}
.padding(.horizontal, 20)
Spacer(minLength: 12)
HStack(spacing: 12) {
Button("取消") { dismiss() }
@@ -76,12 +132,258 @@ struct DiaryQuickSheet: View {
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.medium, .large])
.presentationDetents([.medium, .large], selection: $detent)
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.onDisappear { suggestTask?.cancel() }
}
// MARK: - AI
@ViewBuilder
private var assistSection: some View {
VStack(alignment: .leading, spacing: 10) {
// section header
HStack(spacing: 6) {
Image(systemName: "sparkles")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
Spacer()
if hasQuestions {
Text("\(questions.count) 个建议")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
if lastRate > 0 {
Text(String(format: "%.1f tok/s", lastRate))
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
}
// questions (,)
if hasQuestions {
VStack(spacing: 8) {
ForEach(Array(questions.enumerated()), id: \.element.id) { idx, q in
if idx == 0 || questions[idx - 1].round != q.round {
roundDivider(round: q.round,
count: questions.filter { $0.round == q.round }.count)
.id("round-\(q.round)")
}
questionRow(index: roundLocalIndex(at: idx), question: q)
}
}
}
// ()
phaseFooter
}
}
@ViewBuilder
private var phaseFooter: some View {
switch phase {
case .idle:
assistPrimaryButton(
icon: "sparkles",
label: canRequestSuggest
? String(appLoc: "让 AI 帮我想想还能记什么")
: String(appLoc: "先写几个字,AI 来帮忙补充"),
enabled: canRequestSuggest,
action: requestSuggestions
)
case .loading:
HStack(spacing: 10) {
ProgressView().controlSize(.small)
Text("AI 思考中… 本地推理,通常 5-10 秒")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Button("取消") { cancelSuggestions() }
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.vertical, 11)
.padding(.horizontal, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
case .ready:
assistPrimaryButton(
icon: "arrow.clockwise",
label: canRequestSuggest
? String(appLoc: "再问一轮 · 让 AI 从新角度追问")
: String(appLoc: "更新一下原文,再让 AI 继续追问"),
enabled: canRequestSuggest,
action: requestSuggestions
)
case .failed(let err):
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.brick)
Text(err.localizedDescription)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text)
Spacer()
}
Button { requestSuggestions() } label: {
Text("重试")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
.buttonStyle(.plain)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.5))
)
}
}
private func assistPrimaryButton(icon: String,
label: String,
enabled: Bool,
action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: icon)
Text(label)
}
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3)
.frame(maxWidth: .infinity)
.padding(.vertical, 11)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(
enabled ? Tj.Palette.ink : Tj.Palette.line,
style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
)
)
}
.buttonStyle(.plain)
.disabled(!enabled)
}
/// questions list idx question, round (1-based)
private func roundLocalIndex(at idx: Int) -> Int {
let target = questions[idx].round
var count = 0
for i in 0...idx where questions[i].round == target {
count += 1
}
return count
}
/// N LLM
private func roundDivider(round: Int, count: Int) -> some View {
HStack(spacing: 8) {
HStack(spacing: 6) {
Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text(round == 1
? String(appLoc: "第 1 轮 · \(count)")
: String(appLoc: "\(round) 轮 · 基于你刚才更新的文本 · \(count)"))
.font(.system(size: 11, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
Rectangle()
.fill(Tj.Palette.line)
.frame(height: 1)
.mask(
HStack(spacing: 3) {
ForEach(0..<60, id: \.self) { _ in
Rectangle().frame(width: 3, height: 1)
}
}
)
}
.padding(.top, round == 1 ? 0 : 6)
}
private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View {
let adopted = question.adopted
return VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 8) {
Text("\(index).")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick)
Text(question.q)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text)
.strikethrough(adopted, color: Tj.Palette.text3)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 4)
if adopted {
HStack(spacing: 4) {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold))
Text("已采纳")
.font(.system(size: 11, weight: .semibold))
}
.foregroundStyle(Tj.Palette.leaf)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(Capsule().fill(Tj.Palette.leafSoft))
} else {
Button { adopt(question) } label: {
HStack(spacing: 4) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 12))
Text("采纳")
.font(.system(size: 12, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Capsule().fill(Tj.Palette.ink))
}
.buttonStyle(.plain)
}
}
if !question.fill.isEmpty && !adopted {
HStack(alignment: .top, spacing: 4) {
Text("将追加:")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Text(question.fill)
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.leading, 22)
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(adopted ? Tj.Palette.sand2 : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
// MARK: - Actions
private func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
@@ -89,6 +391,88 @@ struct DiaryQuickSheet: View {
.foregroundStyle(Tj.Palette.text2)
}
/// AI (coveredDims) LLM,
/// ,
private func requestSuggestions() {
suggestTask?.cancel()
let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
let covered = Array(coveredDims)
// 1.
contentFocused = false
// 2. sheet large( medium AI)
if detent != .large {
withAnimation(.snappy(duration: 0.25)) {
detent = .large
}
}
phase = .loading
suggestTask = Task { @MainActor in
do {
let result = try await DiaryAssistService.shared.suggest(
content: snapshotContent,
coveredDimensions: covered
)
if Task.isCancelled { return }
// ( LLM ); prompt
let existing = Set(questions.map { Self.normalize($0.q) })
let nextRound = currentRound + 1
let fresh = result.questions
.filter { !existing.contains(Self.normalize($0.q)) }
.map { q -> DiaryAssistService.Question in
var stamped = q
stamped.round = nextRound
return stamped
}
withAnimation(.snappy(duration: 0.2)) {
questions.append(contentsOf: fresh)
for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) }
lastRate = result.decodeRate
currentRound = nextRound
phase = .ready
}
} catch is CancellationError {
if !Task.isCancelled {
phase = hasQuestions ? .ready : .idle
}
} catch {
if !Task.isCancelled {
phase = .failed(error)
}
}
}
}
/// : + ,
private static func normalize(_ s: String) -> String {
s.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: " ", with: "")
.replacingOccurrences(of: "?", with: "?")
}
private func cancelSuggestions() {
suggestTask?.cancel()
phase = hasQuestions ? .ready : .idle
}
/// question.fill textfield , question adopted
/// q ; coveredDims, prompt
private func adopt(_ question: DiaryAssistService.Question) {
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
withAnimation(.snappy(duration: 0.18)) {
questions[idx].adopted = true
}
}
let toAppend = question.fill.isEmpty ? question.q : question.fill
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
content = toAppend
} else if content.hasSuffix("\n") {
content += toAppend
} else {
content += "\n" + toAppend
}
}
private func submit() {
guard canSubmit else { return }
let entry = DiaryEntry(
@@ -100,3 +484,7 @@ struct DiaryQuickSheet: View {
dismiss()
}
}
#Preview {
DiaryQuickSheet()
}

View File

@@ -69,17 +69,17 @@ struct HomeView: View {
}
private var todayLine: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "M 月 d 日 · EEE"
return f.string(from: Date())
let now = Date()
let day = now.formatted(.dateTime.month().day())
let weekday = now.formatted(.dateTime.weekday(.abbreviated))
return "\(day) · \(weekday)"
}
private var greetingWord: String {
switch Calendar.current.component(.hour, from: Date()) {
case 5..<12: return "早安"
case 12..<18: return "下午好"
default: return "晚上好"
case 5..<12: return String(appLoc: "早安")
case 12..<18: return String(appLoc: "下午好")
default: return String(appLoc: "晚上好")
}
}
@@ -136,7 +136,7 @@ struct HomeView: View {
Button(action: onTapArchive) {
HStack(spacing: 14) {
TjPlaceholder(label: "档案 · \(reports.count)")
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
.frame(width: 56, height: 56)
VStack(alignment: .leading, spacing: 2) {
Text("我的报告档案")

View File

@@ -21,8 +21,8 @@ enum CustomMetricNameConflict: Equatable {
var warningText: String {
switch self {
case .none: return ""
case .builtin(let n): return "\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块"
case .existingCustom(let n):return "已经有一个叫「\(n)」的自定义指标"
case .builtin(let n): return String(appLoc: "\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块")
case .existingCustom(let n):return String(appLoc: "已经有一个叫「\(n)」的自定义指标")
}
}
}
@@ -133,7 +133,7 @@ struct CustomMetricEditor: View {
private var nameSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("名称")
sectionLabel(String(appLoc: "名称"))
TextField("例如:腰围 / 步数 / 睡眠时长", text: $name)
.padding(.horizontal, 14).padding(.vertical, 12)
.background(fieldBg)
@@ -161,7 +161,7 @@ struct CustomMetricEditor: View {
private var unitSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("单位(可选)")
sectionLabel(String(appLoc: "单位(可选)"))
TextField("例如:cm / 步 / 小时", text: $unit)
.autocorrectionDisabled()
.padding(.horizontal, 14).padding(.vertical, 12)
@@ -172,16 +172,16 @@ struct CustomMetricEditor: View {
private var rangeRow: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel("参考范围(可选)")
sectionLabel(String(appLoc: "参考范围(可选)"))
Spacer()
Text("用于自动判定 正常/偏高/偏低")
.font(.system(size: 10))
.foregroundStyle(Tj.Palette.text3)
}
HStack(spacing: 12) {
rangeField(label: "下限", value: $lower, placeholder: "70")
rangeField(label: String(appLoc: "下限"), value: $lower, placeholder: "70")
Text("").foregroundStyle(Tj.Palette.text3)
rangeField(label: "上限", value: $upper, placeholder: "90")
rangeField(label: String(appLoc: "上限"), value: $upper, placeholder: "90")
}
}
}
@@ -199,7 +199,7 @@ struct CustomMetricEditor: View {
private var iconSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("图标")
sectionLabel(String(appLoc: "图标"))
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4),
spacing: 8) {
ForEach(customMetricIconChoices, id: \.self) { sf in

View File

@@ -171,7 +171,7 @@ struct IndicatorQuickSheet: View {
private var monitorGridSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel("长期监测(进趋势)")
sectionLabel(String(appLoc: "长期监测(进趋势)"))
Spacer()
if !hiddenSet.isEmpty {
hiddenCountChip
@@ -329,7 +329,7 @@ struct IndicatorQuickSheet: View {
private var labPresetSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("化验项快捷(不进趋势)")
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(labPresets) { p in
@@ -345,14 +345,14 @@ struct IndicatorQuickSheet: View {
private var bpFieldSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
sectionLabel("收缩 / 舒张")
sectionLabel(String(appLoc: "收缩 / 舒张"))
Spacer()
bpRangeHint
}
HStack(spacing: 12) {
bpField(label: "收缩压", value: $systolic, placeholder: "120")
bpField(label: String(appLoc: "收缩压"), value: $systolic, placeholder: "120")
Text("/").font(.system(size: 22, weight: .light)).foregroundStyle(Tj.Palette.text3)
bpField(label: "舒张压", value: $diastolic, placeholder: "80")
bpField(label: String(appLoc: "舒张压"), value: $diastolic, placeholder: "80")
Text("mmHg").foregroundStyle(Tj.Palette.text3)
}
bpStatusChips
@@ -396,10 +396,10 @@ struct IndicatorQuickSheet: View {
private var bpStatusChips: some View {
HStack(spacing: 8) {
if let s = computedBPStatus(.systolic) {
statusBadge("收缩 " + s.label, color: s.color)
statusBadge(String(appLoc: "收缩 ") + s.label, color: s.color)
}
if let s = computedBPStatus(.diastolic) {
statusBadge("舒张 " + s.label, color: s.color)
statusBadge(String(appLoc: "舒张 ") + s.label, color: s.color)
}
Spacer()
}
@@ -407,7 +407,7 @@ struct IndicatorQuickSheet: View {
private var nameSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("指标名")
sectionLabel(String(appLoc: "指标名"))
TextField("例如:血红蛋白", text: $name)
.textInputAutocapitalization(.never)
.padding(.horizontal, 14)
@@ -427,7 +427,7 @@ struct IndicatorQuickSheet: View {
private var valueRow: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("数值")
sectionLabel(String(appLoc: "数值"))
TextField(monitorFieldPlaceholder, text: $value)
.keyboardType(.decimalPad)
.font(.system(size: 18, weight: .semibold, design: .monospaced))
@@ -437,7 +437,7 @@ struct IndicatorQuickSheet: View {
.overlay(fieldBorder)
}
VStack(alignment: .leading, spacing: 8) {
sectionLabel("单位")
sectionLabel(String(appLoc: "单位"))
TextField("mmol/L", text: $unit)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
@@ -455,7 +455,7 @@ struct IndicatorQuickSheet: View {
private var rangeSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel("参考范围")
sectionLabel(String(appLoc: "参考范围"))
Spacer()
if let m = selectedMonitor, m != .bloodPressure {
monitorRangeHint(m)
@@ -486,11 +486,11 @@ struct IndicatorQuickSheet: View {
private var statusSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("状态")
sectionLabel(String(appLoc: "状态"))
HStack(spacing: 8) {
statusChip(.normal, label: "正常", color: Tj.Palette.leaf)
statusChip(.high, label: "偏高 ↑", color: Tj.Palette.brick)
statusChip(.low, label: "偏低 ↓", color: Tj.Palette.amber)
statusChip(.normal, label: String(appLoc: "正常"), color: Tj.Palette.leaf)
statusChip(.high, label: String(appLoc: "偏高 ↑"), color: Tj.Palette.brick)
statusChip(.low, label: String(appLoc: "偏低 ↓"), color: Tj.Palette.amber)
}
}
}
@@ -498,7 +498,7 @@ struct IndicatorQuickSheet: View {
private var autoStatusHint: some View {
let auto = computedSingleStatus
return HStack(spacing: 8) {
sectionLabel("状态(按数值自动判)")
sectionLabel(String(appLoc: "状态(按数值自动判)"))
if let s = auto {
statusBadge(s.label, color: s.color)
} else {
@@ -511,7 +511,7 @@ struct IndicatorQuickSheet: View {
private var timeSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("测量时间")
sectionLabel(String(appLoc: "测量时间"))
DatePicker("", selection: $capturedAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
@@ -520,7 +520,7 @@ struct IndicatorQuickSheet: View {
private var noteSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("备注(可选)")
sectionLabel(String(appLoc: "备注(可选)"))
TextField("例如:空腹采血", text: $note, axis: .vertical)
.lineLimit(1...3)
.padding(.horizontal, 14)
@@ -535,7 +535,7 @@ struct IndicatorQuickSheet: View {
private var reminderSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
sectionLabel("周期提醒")
sectionLabel(String(appLoc: "周期提醒"))
Spacer()
Toggle("", isOn: $reminderEnabled)
.labelsHidden()
@@ -570,13 +570,13 @@ struct IndicatorQuickSheet: View {
}
weekdayPickerRow
HStack(spacing: 8) {
quickFreqChip("每天") {
quickFreqChip(String(appLoc: "每天")) {
reminderWeekdays = Set(1...7)
}
quickFreqChip("工作日") {
quickFreqChip(String(appLoc: "工作日")) {
reminderWeekdays = Set([2, 3, 4, 5, 6])
}
quickFreqChip("周末") {
quickFreqChip(String(appLoc: "周末")) {
reminderWeekdays = Set([1, 7])
}
}
@@ -600,15 +600,23 @@ struct IndicatorQuickSheet: View {
}
private var reminderFrequencyLabel: String {
if reminderWeekdays.count == 7 { return "每天" }
if reminderWeekdays.isEmpty { return "未选" }
let names = ["", "", "", "", "", "", ""]
if reminderWeekdays.count == 7 { return String(appLoc: "每天") }
if reminderWeekdays.isEmpty { return String(appLoc: "未选") }
let names = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""),
]
let sorted = reminderWeekdays.sorted()
return "每周 " + sorted.map { names[$0 - 1] }.joined()
return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined()
}
private var weekdayPickerRow: some View {
let names = ["", "", "", "", "", "", ""]
let names = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""),
]
let weekdayValues = [2, 3, 4, 5, 6, 7, 1] // (Apple Calendar )
return HStack(spacing: 6) {
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
@@ -1074,9 +1082,9 @@ struct IndicatorQuickSheet: View {
private extension IndicatorStatus {
var label: String {
switch self {
case .normal: return "正常"
case .high: return "偏高 ↑"
case .low: return "偏低 ↓"
case .normal: return String(appLoc: "正常")
case .high: return String(appLoc: "偏高 ↑")
case .low: return String(appLoc: "偏低 ↓")
}
}

View File

@@ -2,6 +2,7 @@ import SwiftUI
/// · 使
/// , Service / AIRuntime, DesignSystem token
/// App Store :/
struct AboutView: View {
/// Bundle ,
private var versionText: String {
@@ -19,45 +20,53 @@ struct AboutView: View {
VStack(spacing: 16) {
header
section(icon: "sparkles", title: "这是什么") {
section(icon: "sparkles", title: String(appLoc: "这是什么")) {
paragraph(
"康康是一款以本地优先为设计原则的个人健康影像档案工具。" +
"你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;" +
"设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。"
String(appLoc: "康康是一款以本地优先为设计原则的个人健康影像档案工具。") +
String(appLoc: "你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;") +
String(appLoc: "设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。")
)
}
section(icon: "checklist", title: "主要功能") {
bullet("拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档")
bullet("通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明")
bullet("长期趋势:关注的指标可生成折线图和简要解读")
bullet("本地问答:基于你自己的档案问答,引用可点击回链到原记录")
bullet("隐私优先:健康数据不上传、无需注册账号")
section(icon: "checklist", title: String(appLoc: "主要功能")) {
bullet(String(appLoc: "拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档"))
bullet(String(appLoc: "通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明"))
bullet(String(appLoc: "长期趋势:关注的指标可生成折线图和简要解读"))
bullet(String(appLoc: "本地问答:基于你自己的档案问答,引用可点击回链到原记录"))
bullet(String(appLoc: "隐私优先:健康数据不上传、无需注册账号"))
}
section(icon: "lock.shield", title: "隐私保护") {
bullet("AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据")
bullet("原图与数据库采用系统级文件加密,随设备锁屏受到保护。")
bullet("支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份")
bullet("可选开启 Face ID 启动锁,进一步保护隐私。")
section(icon: "iphone", title: String(appLoc: "设备要求"), tint: Tj.Palette.leaf) {
bullet(String(appLoc: "系统:iOS 17 或更新版本"))
bullet(String(appLoc: "本地 AI 功能(拍照识别、解读、问答)需要约 8GB 内存,") +
String(appLoc: "推荐 iPhone 15 Pro / Pro Max 及之后发布的机型(含 iPhone 16 系列)"))
bullet(String(appLoc: "在内存较小的旧机型上,App 仍可用于手动记录、归档与查看,") +
String(appLoc: "但本地 AI 相关功能可能无法运行。"))
}
section(icon: "exclamationmark.triangle", title: "使用注意", tint: Tj.Palette.amber) {
bullet("本地 AI 模型体积较大(约 3GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;" +
"模型未就绪时 App 仍可使用,AI 功能会提示前往下载")
bullet("AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对," +
"并以原始报告 / 化验单为准")
bullet("AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。")
bullet("数据保存在本设备:卸载 App 或删除数据后可能无法恢复,重要资料请自行留存原件。")
section(icon: "lock.shield", title: String(appLoc: "隐私保护")) {
bullet(String(appLoc: "AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据。"))
bullet(String(appLoc: "原图与数据库采用系统级文件加密,随设备锁屏受到保护"))
bullet(String(appLoc: "支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份。"))
bullet(String(appLoc: "可选开启 Face ID 启动锁,进一步保护隐私"))
}
section(icon: "hand.raised", title: "免责声明", tint: Tj.Palette.brick) {
bullet("康康是一款健康信息记录与参考工具,并非医疗器械,不提供医疗诊断、用药或剂量建议、急诊判断等医疗服务。")
bullet("App 内所有 AI 生成的解读、趋势分析与问答内容仅供信息参考," +
"不构成医疗建议,也不能替代执业医师、药师或其他专业人员的面诊、检查与意见。")
bullet("任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。")
bullet("如出现身体不适或紧急情况,请及时就医或拨打当地急救电话,请勿依赖本 App 进行判断")
bullet("在适用法律允许的范围内,因使用本 App 或依赖其中内容所产生的后果,由使用者自行承担")
section(icon: "exclamationmark.triangle", title: String(appLoc: "使用注意"), tint: Tj.Palette.amber) {
bullet(String(appLoc: "本地 AI 模型体积较大(约 4GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;") +
String(appLoc: "模型未就绪时 App 仍可使用,AI 功能会提示前往下载。"))
bullet(String(appLoc: "AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对,") +
String(appLoc: "并以原始报告 / 化验单为准。"))
bullet(String(appLoc: "AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考"))
bullet(String(appLoc: "数据保存在本设备:卸载 App 或删除数据后可能无法恢复,重要资料请自行留存原件"))
}
section(icon: "hand.raised", title: String(appLoc: "免责声明"), tint: Tj.Palette.brick) {
bullet(String(appLoc: "康康是一款健康信息记录与参考工具,并非医疗器械,不提供医疗诊断、用药或剂量建议、急诊判断等医疗服务。"))
bullet(String(appLoc: "App 内所有 AI 生成的解读、趋势分析与问答内容仅供信息参考,") +
String(appLoc: "不构成医疗建议,也不能替代执业医师、药师或其他专业人员的面诊、检查与意见。"))
bullet(String(appLoc: "任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。"))
bullet(String(appLoc: "如出现身体不适或紧急情况,请及时就医或拨打当地急救电话,请勿依赖本 App 进行判断。"))
bullet(String(appLoc: "在适用法律允许的范围内,因使用本 App 或依赖其中内容所产生的后果,由使用者自行承担。"))
}
Text("康康 · 本地优先的健康档案 · \(versionText)")
@@ -65,6 +74,12 @@ struct AboutView: View {
.foregroundStyle(Tj.Palette.text3)
.padding(.top, 4)
Text("本 App 仅供健康信息记录与参考,不能替代专业医疗意见。")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 32)
}
.padding(.horizontal, 16)

View File

@@ -72,7 +72,7 @@ struct CustomMetricsListView: View {
private var emptyState: some View {
VStack(spacing: 14) {
Spacer(minLength: 40)
TjPlaceholder(label: "还没有自定义指标")
TjPlaceholder(label: String(appLoc: "还没有自定义指标"))
.frame(width: 220, height: 130)
Text("右上角 + 新建一个")
.font(.system(size: 12))
@@ -118,7 +118,7 @@ struct CustomMetricsListView: View {
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
Text(count == 0 ? "未使用" : "\(count)")
Text(count == 0 ? String(appLoc: "未使用") : String(appLoc: "\(count)"))
.font(.system(size: 11, weight: count > 0 ? .semibold : .regular))
.foregroundStyle(count > 0 ? Tj.Palette.ink : Tj.Palette.text3)
Image(systemName: "chevron.right")

View File

@@ -0,0 +1,65 @@
import SwiftUI
/// · ( App ,)
struct LanguageSettingsView: View {
@State private var lang = LanguageManager.shared
var body: some View {
ScrollView {
VStack(spacing: 10) {
ForEach(AppLanguage.allCases) { option in
row(option)
}
Text("切换后整个 App 立即生效,无需重启。")
.font(.system(size: 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: AppLanguage) -> some View {
let selected = lang.current == option
return Button {
// .id ,
lang.set(option)
} label: {
HStack(spacing: 12) {
ZStack {
Circle().fill(selected ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "character.bubble")
.font(.system(size: 16))
.foregroundStyle(selected ? Tj.Palette.ink : Tj.Palette.text2)
}
.frame(width: 40, height: 40)
Text(option.displayName)
.font(.system(size: 15, weight: selected ? .semibold : .regular))
.foregroundStyle(Tj.Palette.text)
Spacer()
if selected {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
}
.padding(14)
.tjCard()
}
.buttonStyle(.plain)
}
}
#Preview {
NavigationStack { LanguageSettingsView() }
}

View File

@@ -4,13 +4,15 @@ import SwiftData
struct MeView: View {
@Environment(\.modelContext) private var ctx
@Query private var profiles: [UserProfile]
@Query private var reminders: [MetricReminder]
@Query private var customMetrics: [CustomMonitorMetric]
@State private var downloadService = ModelDownloadService.shared
@State private var appLock = AppLock.shared
@State private var lang = LanguageManager.shared
// key AppLock.enabledKey
@AppStorage("faceIDLockEnabled") private var lockEnabled = false
private var profile: UserProfile? { profiles.first }
private var enabledReminderCount: Int { reminders.filter(\.enabled).count }
/// Bundle ,
private var appVersionText: String {
@@ -23,16 +25,14 @@ struct MeView: View {
ScrollView {
VStack(spacing: 12) {
profileCard
remindersCard
customMetricsCard
modelManagementCard
settingsCard(title: "Face ID 启动锁",
detail: "关闭",
icon: "faceid")
languageCard
faceIDCard
NavigationLink {
AboutView()
} label: {
settingsCard(title: "关于",
settingsCard(title: String(appLoc: "关于"),
detail: appVersionText,
icon: "info.circle")
}
@@ -49,6 +49,7 @@ struct MeView: View {
_ = UserProfileStore.loadOrCreate(in: ctx)
}
downloadService.refreshStates()
appLock.refreshAvailability()
}
}
}
@@ -89,46 +90,6 @@ struct MeView: View {
.buttonStyle(.plain)
}
private var remindersCard: some View {
NavigationLink {
RemindersListView()
} label: {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(enabledReminderCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "bell.fill")
.font(.system(size: 18))
.foregroundStyle(enabledReminderCount > 0 ? Tj.Palette.ink : Tj.Palette.text2)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("记录提醒")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(reminderLine)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.tjCard()
}
.buttonStyle(.plain)
}
private var reminderLine: String {
if reminders.isEmpty { return "尚未设置" }
if enabledReminderCount == 0 { return "全部已关闭(\(reminders.count) 条)" }
return "\(enabledReminderCount) 项启用"
}
private var customMetricsCard: some View {
NavigationLink {
CustomMetricsListView()
@@ -164,25 +125,84 @@ struct MeView: View {
}
private var customMetricsLine: String {
if customMetrics.isEmpty { return "添加你自己的长期监测项" }
return "\(customMetrics.count)"
if customMetrics.isEmpty { return String(appLoc: "添加你自己的长期监测项") }
return String(appLoc: "\(customMetrics.count)")
}
private var modelManagementCard: some View {
NavigationLink {
ModelManagementView()
} label: {
settingsCard(title: "模型管理", detail: modelDetail, icon: "cpu")
settingsCard(title: String(appLoc: "模型管理"), detail: modelDetail, icon: "cpu")
}
.buttonStyle(.plain)
}
private var modelDetail: String {
let states = downloadService.states
if ModelKind.allCases.allSatisfy({ states[$0]?.phase == .ready }) { return "已就绪" }
if downloadService.isAnyDownloading { return "下载中…" }
if ModelKind.allCases.allSatisfy({ states[$0]?.phase == .ready }) { return String(appLoc: "已就绪") }
if downloadService.isAnyDownloading { return String(appLoc: "下载中…") }
let readyCount = ModelKind.allCases.filter { states[$0]?.phase == .ready }.count
return readyCount == 0 ? "未下载" : "\(readyCount)/\(ModelKind.allCases.count) 就绪"
return readyCount == 0 ? String(appLoc: "未下载") : String(appLoc: "\(readyCount)/\(ModelKind.allCases.count) 就绪")
}
private var languageCard: some View {
NavigationLink {
LanguageSettingsView()
} label: {
settingsCard(title: String(appLoc: "语言"),
detail: lang.current.displayName,
icon: "character.bubble")
}
.buttonStyle(.plain)
}
// MARK: - Face ID ( Toggle )
private var faceIDCard: some View {
HStack(spacing: 12) {
ZStack {
Circle().fill(lockEnabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "faceid")
.font(.system(size: 18))
.foregroundStyle(lockEnabled ? Tj.Palette.ink : Tj.Palette.text2)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("Face ID 启动锁")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Tj.Palette.text)
Text(faceIDLine)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Toggle("", isOn: faceIDBinding)
.labelsHidden()
.disabled(!appLock.biometryAvailable)
}
.padding(14)
.tjCard()
}
private var faceIDLine: String {
if !appLock.biometryAvailable { return String(appLoc: "本设备未设置 Face ID 或密码") }
return lockEnabled ? String(appLoc: "已开启 · \(appLock.biometryLabel)") : String(appLoc: "关闭")
}
/// , enabled();
private var faceIDBinding: Binding<Bool> {
Binding(
get: { lockEnabled },
set: { newValue in
if newValue {
Task { await appLock.enableWithAuth() }
} else {
appLock.disable()
}
}
)
}
private func settingsCard(title: String, detail: String, icon: String) -> some View {
@@ -212,7 +232,7 @@ struct MeView: View {
private var profileLine: String {
guard let p = profile, p.hasAnyBasics else {
return "点这里完善你的资料"
return String(appLoc: "点这里完善你的资料")
}
return p.summaryLine
}

View File

@@ -132,11 +132,11 @@ struct ModelManagementView: View {
private func statusBadge(_ phase: DownloadPhase) -> some View {
switch phase {
case .idle: return TjBadge(text: "待下载", style: .neutral)
case .downloading: return TjBadge(text: "下载中", style: .amber)
case .verifying: return TjBadge(text: "校验中", style: .amber)
case .ready: return TjBadge(text: "已就绪", style: .leaf)
case .failed: return TjBadge(text: "失败 · 重试", style: .brick)
case .idle: return TjBadge(text: String(appLoc: "待下载"), style: .neutral)
case .downloading: return TjBadge(text: String(appLoc: "下载中"), style: .amber)
case .verifying: return TjBadge(text: String(appLoc: "校验中"), style: .amber)
case .ready: return TjBadge(text: String(appLoc: "已就绪"), style: .leaf)
case .failed: return TjBadge(text: String(appLoc: "失败 · 重试"), style: .brick)
}
}
@@ -199,13 +199,14 @@ struct ModelManagementView: View {
let name = folder.lastPathComponent
guard let kind = ModelKind.allCases.first(where: { $0.rawValue == name }) else {
importError = "请选择名为 Qwen3-1.7B-4bit 或 Qwen2.5-VL-3B-Instruct-4bit 的文件夹"
let names = ModelKind.allCases.map(\.rawValue).joined(separator: "")
importError = String(appLoc: "请选择名为 \(names) 的文件夹")
return
}
try service.importModel(kind, from: folder)
importError = nil
} catch {
importError = "导入失败:\(error.localizedDescription)"
importError = String(appLoc: "导入失败:\(error.localizedDescription)")
}
}
@@ -217,8 +218,8 @@ struct ModelManagementView: View {
private func subtitle(_ kind: ModelKind) -> String {
switch kind {
case .llm: return "文本解读 · 趋势 / 问答"
case .vl: return "拍照识别报告 → 结构化指标"
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答")
case .vl: return String(appLoc: "拍照识别报告 → 结构化指标")
}
}

View File

@@ -12,11 +12,11 @@ struct ModelSelfTestView: View {
var label: String {
switch self {
case .idle: return "未开始"
case .loading: return "加载模型…"
case .running: return "推理中…"
case .done: return "完成 ✓"
case .failed(let m): return "失败:\(m)"
case .idle: return String(appLoc: "未开始")
case .loading: return String(appLoc: "加载模型…")
case .running: return String(appLoc: "推理中…")
case .done: return String(appLoc: "完成 ✓")
case .failed(let m): return String(appLoc: "失败:\(m)")
}
}
}

View File

@@ -3,9 +3,14 @@ import SwiftData
struct RemindersListView: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
private var reminders: [MetricReminder]
/// sheet ();
/// push ( ), false
var presentedAsSheet = false
@State private var editingId: String?
var body: some View {
@@ -33,6 +38,13 @@ struct RemindersListView: View {
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("记录提醒")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if presentedAsSheet {
ToolbarItem(placement: .topBarTrailing) {
Button("完成") { dismiss() }
}
}
}
}
private var header: some View {
@@ -50,7 +62,7 @@ struct RemindersListView: View {
private var emptyState: some View {
VStack(spacing: 12) {
Spacer(minLength: 40)
TjPlaceholder(label: "还没有记录提醒\n去「+ 指标记录」录入时打开")
TjPlaceholder(label: String(appLoc: "还没有记录提醒\n去「+ 指标记录」录入时打开"))
.frame(width: 240, height: 140)
Spacer()
}
@@ -182,7 +194,11 @@ private struct ReminderRow: View {
}
private var weekdayRow: some View {
let names = ["", "", "", "", "", "", ""]
let names = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""),
]
let weekdayValues = [2, 3, 4, 5, 6, 7, 1]
return HStack(spacing: 6) {
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in

View File

@@ -19,12 +19,12 @@ enum MonitorMetric: String, CaseIterable, Identifiable {
var displayName: String {
switch self {
case .bloodPressure: return "血压"
case .fastingGlucose: return "空腹血糖"
case .postprandialGlucose: return "餐后血糖"
case .temperature: return "体温"
case .heartRate: return "心率"
case .spo2: return "血氧"
case .bloodPressure: return String(appLoc: "血压")
case .fastingGlucose: return String(appLoc: "空腹血糖")
case .postprandialGlucose: return String(appLoc: "餐后血糖")
case .temperature: return String(appLoc: "体温")
case .heartRate: return String(appLoc: "心率")
case .spo2: return String(appLoc: "血氧")
}
}
@@ -45,43 +45,43 @@ enum MonitorMetric: String, CaseIterable, Identifiable {
case .bloodPressure:
return [
Field(seriesKey: "bp.systolic",
label: "收缩压",
label: String(appLoc: "收缩压"),
unit: "mmHg",
placeholder: "120",
baseRange: 90...140),
Field(seriesKey: "bp.diastolic",
label: "舒张压",
label: String(appLoc: "舒张压"),
unit: "mmHg",
placeholder: "80",
baseRange: 60...90),
]
case .fastingGlucose:
return [Field(seriesKey: "glucose.fasting",
label: "空腹血糖",
label: String(appLoc: "空腹血糖"),
unit: "mmol/L",
placeholder: "5.0",
baseRange: 3.9...6.1)]
case .postprandialGlucose:
return [Field(seriesKey: "glucose.postprandial",
label: "餐后 2h",
label: String(appLoc: "餐后 2h"),
unit: "mmol/L",
placeholder: "6.5",
baseRange: 0...7.8)]
case .temperature:
return [Field(seriesKey: "temperature",
label: "体温",
label: String(appLoc: "体温"),
unit: "°C",
placeholder: "36.5",
baseRange: 36.0...37.2)]
case .heartRate:
return [Field(seriesKey: "heart_rate",
label: "心率",
label: String(appLoc: "心率"),
unit: "bpm",
placeholder: "72",
baseRange: 60...100)]
case .spo2:
return [Field(seriesKey: "spo2",
label: "血氧",
label: String(appLoc: "血氧"),
unit: "%",
placeholder: "98",
baseRange: 95...100)]
@@ -101,7 +101,7 @@ extension MonitorMetric {
/// IndicatorRecordSheet 90-140 mmHg
func rangeText(_ range: ClosedRange<Double>?) -> String {
guard let r = range else { return "无参考范围" }
guard let r = range else { return String(appLoc: "无参考范围") }
let lower = format(r.lowerBound)
let upper = format(r.upperBound)
// baseRange 0...7.8,<7.8

View File

@@ -17,28 +17,47 @@ struct ProfileEditView: View {
}
}
/// `@Bindable` SwiftData @Model `$profile.xxx`
///
///
/// ( Row ):
/// SwiftData `@Model` Observation,
/// `body`,(/,
/// `@State` ) `body`,
/// 126 `Text(year)`
///
/// :
/// - `ProfileEditForm.body` `profile.*` `@State`,
/// ,
/// - Row / Section ,Observation
/// - `@State` Section ,
/// - .wheel , 126 ,
/// UIPickerView ,
private struct ProfileEditForm: View {
@Environment(\.modelContext) private var ctx
@Bindable var profile: UserProfile
@State private var newAllergy = ""
@State private var newFamilyEntry = ""
@State private var newMedication = ""
@State private var newCustomCondition = ""
private static let chronicPresets = [
"高血压", "糖尿病", "冠心病", "高血脂",
"甲状腺疾病", "哮喘", "慢性肾病", "抑郁/焦虑",
]
var body: some View {
Form {
basicsSection
chronicSection
allergySection
familySection
medicationSection
Section {
BirthYearRow(profile: profile)
SexRow(profile: profile)
HeightRow(profile: profile)
WeightRow(profile: profile)
BloodTypeRow(profile: profile)
} header: {
Text("基本")
} footer: {
BMIFooter(profile: profile)
}
ChronicSection(profile: profile)
StringListSection(title: String(appLoc: "过敏史"), placeholder: String(appLoc: "如:青霉素"),
items: $profile.allergies)
StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"),
items: $profile.familyHistory)
StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"),
items: $profile.currentMedications)
}
.navigationTitle("个人资料")
.navigationBarTitleDisplayMode(.inline)
@@ -49,48 +68,75 @@ private struct ProfileEditForm: View {
try? ctx.save()
}
}
}
// MARK: -
// MARK: - :(,)
private var basicsSection: some View {
Section {
birthYearPicker
sexPicker
heightRow
weightRow
bloodTypePicker
} header: {
Text("基本")
} footer: {
if let bmi = profile.bmi {
Text("BMI: \(String(format: "%.1f", bmi)) \(bmiLabel(bmi))")
.font(.system(size: 11))
}
}
/// : `.wheel` , 126
private struct BirthYearRow: View {
@Bindable var profile: UserProfile
@State private var expanded = false
private var currentYear: Int {
Calendar.current.component(.year, from: .now)
}
private func bmiLabel(_ bmi: Double) -> String {
switch bmi {
case ..<18.5: return "(偏瘦)"
case ..<24: return "(正常)"
case ..<28: return "(超重)"
default: return "(肥胖)"
}
/// birthYear / expanded ,;
/// `years` (body )
private var years: [Int] {
Array((1900...currentYear).reversed())
}
private var birthYearPicker: some View {
Picker("出生年份", selection: Binding(
private var selectedLabel: String {
if let y = profile.birthYear {
let age = currentYear - y
return age >= 0 ? "\(y)(\(age)\(String(appLoc: "")))" : String(y)
}
return String(appLoc: "未设置")
}
private var yearBinding: Binding<Int> {
Binding(
get: { profile.birthYear ?? 0 },
set: { profile.birthYear = $0 == 0 ? nil : $0 }
)) {
Text("未设置").tag(0)
ForEach((1900...currentYear).reversed(), id: \.self) { year in
Text(String(year)).tag(year)
}
}
)
}
private var sexPicker: some View {
var body: some View {
Button {
withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
} label: {
HStack {
Text("出生年份").foregroundStyle(Tj.Palette.text)
Spacer()
Text(selectedLabel)
.foregroundStyle(profile.birthYear == nil ? Tj.Palette.text3 : Tj.Palette.text2)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.rotationEffect(.degrees(expanded ? 90 : 0))
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if expanded {
Picker("出生年份", selection: yearBinding) {
Text("未设置").tag(0)
ForEach(years, id: \.self) { year in
Text(String(year)).tag(year)
}
}
.pickerStyle(.wheel)
.frame(maxHeight: 140)
}
}
}
private struct SexRow: View {
@Bindable var profile: UserProfile
var body: some View {
Picker("性别", selection: Binding(
get: { profile.sex },
set: { profile.sex = $0 }
@@ -101,8 +147,15 @@ private struct ProfileEditForm: View {
}
.pickerStyle(.segmented)
}
}
private var heightRow: some View {
/// :, 80pt
/// ,,
private struct HeightRow: View {
@Bindable var profile: UserProfile
@FocusState private var focused: Bool
var body: some View {
HStack {
Text("身高")
Spacer()
@@ -110,11 +163,19 @@ private struct ProfileEditForm: View {
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.frame(width: 80)
.focused($focused)
Text("cm").foregroundStyle(Tj.Palette.text3)
}
.contentShape(Rectangle())
.onTapGesture { focused = true }
}
}
private var weightRow: some View {
private struct WeightRow: View {
@Bindable var profile: UserProfile
@FocusState private var focused: Bool
var body: some View {
HStack {
Text("体重")
Spacer()
@@ -122,11 +183,18 @@ private struct ProfileEditForm: View {
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 80)
.focused($focused)
Text("kg").foregroundStyle(Tj.Palette.text3)
}
.contentShape(Rectangle())
.onTapGesture { focused = true }
}
}
private var bloodTypePicker: some View {
private struct BloodTypeRow: View {
@Bindable var profile: UserProfile
var body: some View {
Picker("血型", selection: $profile.bloodTypeRaw) {
Text("不知道").tag("")
Text("A 型").tag("A")
@@ -135,19 +203,51 @@ private struct ProfileEditForm: View {
Text("O 型").tag("O")
}
}
}
// MARK: -
/// BMI : heightCM + weightKG,
private struct BMIFooter: View {
@Bindable var profile: UserProfile
private var chronicSection: some View {
var body: some View {
if let bmi = profile.bmi {
Text("BMI: \(String(format: "%.1f", bmi)) \(label(bmi))")
.font(.system(size: 11))
}
}
private func label(_ bmi: Double) -> String {
switch bmi {
case ..<18.5: return String(appLoc: "(偏瘦)")
case ..<24: return String(appLoc: "(正常)")
case ..<28: return String(appLoc: "(超重)")
default: return String(appLoc: "(肥胖)")
}
}
}
// MARK: -
private struct ChronicSection: View {
@Bindable var profile: UserProfile
@State private var newCustomCondition = ""
/// :,( static/let )
private var presets: [String] {
[String(appLoc: "高血压"), String(appLoc: "糖尿病"), String(appLoc: "冠心病"), String(appLoc: "高血脂"),
String(appLoc: "甲状腺疾病"), String(appLoc: "哮喘"), String(appLoc: "慢性肾病"), String(appLoc: "抑郁/焦虑")]
}
var body: some View {
Section {
FlexibleChipGrid {
ForEach(Self.chronicPresets, id: \.self) { name in
ForEach(presets, id: \.self) { name in
chip(label: name,
selected: profile.chronicConditions.contains(name)) {
toggleCondition(name)
toggle(name)
}
}
ForEach(profile.chronicConditions.filter { !Self.chronicPresets.contains($0) },
ForEach(profile.chronicConditions.filter { !presets.contains($0) },
id: \.self) { name in
chip(label: name, selected: true) {
profile.chronicConditions.removeAll { $0 == name }
@@ -171,56 +271,14 @@ private struct ProfileEditForm: View {
}
}
// MARK: - / /
private var allergySection: some View {
listSection(title: "过敏史", placeholder: "如:青霉素",
items: $profile.allergies, newInput: $newAllergy)
}
private var familySection: some View {
listSection(title: "家族史", placeholder: "如:母亲 高血压",
items: $profile.familyHistory, newInput: $newFamilyEntry)
}
private var medicationSection: some View {
listSection(title: "当前用药", placeholder: "如:缬沙坦 80mg qd",
items: $profile.currentMedications, newInput: $newMedication)
}
private func listSection(title: String, placeholder: String,
items: Binding<[String]>,
newInput: Binding<String>) -> some View {
Section(title) {
ForEach(items.wrappedValue, id: \.self) { item in
HStack {
Text(item)
Spacer()
Button(role: .destructive) {
items.wrappedValue.removeAll { $0 == item }
} label: {
Image(systemName: "minus.circle")
.foregroundStyle(Tj.Palette.brick)
}
.buttonStyle(.borderless)
}
}
HStack {
TextField(placeholder, text: newInput)
Button("") {
let trimmed = newInput.wrappedValue.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty,
!items.wrappedValue.contains(trimmed) else { return }
items.wrappedValue.append(trimmed)
newInput.wrappedValue = ""
}
.disabled(newInput.wrappedValue.trimmingCharacters(in: .whitespaces).isEmpty)
}
private func toggle(_ name: String) {
if profile.chronicConditions.contains(name) {
profile.chronicConditions.removeAll { $0 == name }
} else {
profile.chronicConditions.append(name)
}
}
// MARK: - helpers
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
@@ -233,21 +291,47 @@ private struct ProfileEditForm: View {
}
.buttonStyle(.plain)
}
}
private func toggleCondition(_ name: String) {
if profile.chronicConditions.contains(name) {
profile.chronicConditions.removeAll { $0 == name }
} else {
profile.chronicConditions.append(name)
// MARK: - / / ( @State,)
private struct StringListSection: View {
let title: String
let placeholder: String
@Binding var items: [String]
@State private var newInput = ""
var body: some View {
Section(title) {
ForEach(items, id: \.self) { item in
HStack {
Text(item)
Spacer()
Button(role: .destructive) {
items.removeAll { $0 == item }
} label: {
Image(systemName: "minus.circle")
.foregroundStyle(Tj.Palette.brick)
}
.buttonStyle(.borderless)
}
}
HStack {
TextField(placeholder, text: $newInput)
Button("") {
let trimmed = newInput.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, !items.contains(trimmed) else { return }
items.append(trimmed)
newInput = ""
}
.disabled(newInput.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
private var currentYear: Int {
Calendar.current.component(.year, from: .now)
}
}
/// chip SwiftUI Wrap, Layout
// MARK: - chip (SwiftUI Wrap, Layout )
struct FlexibleChipGrid<Content: View>: View {
@ViewBuilder let content: () -> Content

View File

@@ -85,11 +85,11 @@ struct A2ConfirmView: View {
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
TjBadge(text: "偏高", style: .brick)
TjBadge(text: String(appLoc: "偏高"), style: .brick)
}
HStack(spacing: 12) {
FieldBox(label: "数值") {
FieldBox(label: String(appLoc: "数值")) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("3.84")
.font(.system(size: 30, weight: .semibold))
@@ -99,7 +99,7 @@ struct A2ConfirmView: View {
.foregroundStyle(Tj.Palette.text3)
}
}
FieldBox(label: "参考范围") {
FieldBox(label: String(appLoc: "参考范围")) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("< 3.40")
.font(.system(size: 14, design: .monospaced))

View File

@@ -14,9 +14,9 @@ struct A3BatchView: View {
var onBack: () -> Void
let items: [A3BatchItem] = [
.init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high),
.init(name: "甘油三酯 TG", value: "1.78", unit: "mmol/L", range: "< 1.70", status: .high),
.init(name: "空腹血糖 GLU", value: "5.4", unit: "mmol/L", range: "3.96.1", status: .normal),
.init(name: String(appLoc: "低密度脂蛋白胆固醇"), value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high),
.init(name: String(appLoc: "甘油三酯 TG"), value: "1.78", unit: "mmol/L", range: "< 1.70", status: .high),
.init(name: String(appLoc: "空腹血糖 GLU"), value: "5.4", unit: "mmol/L", range: "3.96.1", status: .normal),
]
var body: some View {
@@ -114,7 +114,7 @@ private struct BatchRow: View {
Text(item.value)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(item.status == .high ? Tj.Palette.brick : Tj.Palette.text)
TjBadge(text: item.status == .high ? "偏高" : "正常",
TjBadge(text: item.status == .high ? String(appLoc: "偏高") : String(appLoc: "正常"),
style: item.status == .high ? .brick : .leaf)
}
}

View File

@@ -1,25 +1,30 @@
import SwiftUI
enum RecordKind: String, Identifiable, CaseIterable {
case quick, indicator, archive, diary, symptom
case quick, indicator, archive, diary, symptom, reminder
var id: String { rawValue }
/// RecordSheet () enum ,
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .quick, .archive]
var title: String {
switch self {
case .quick: return "异常项快拍"
case .indicator: return "指标记录"
case .archive: return "关键报告归档"
case .diary: return "文字日记"
case .symptom: return "症状开始"
case .quick: return String(appLoc: "异常项快拍")
case .indicator: return String(appLoc: "记录指标")
case .archive: return String(appLoc: "体检报告归档")
case .diary: return String(appLoc: "健康日记")
case .symptom: return String(appLoc: "记录症状")
case .reminder: return String(appLoc: "开启一个提醒")
}
}
var subtitle: String {
switch self {
case .quick: return "拍一张化验单,VL 自动识别"
case .indicator: return "手动填一项指标(免拍照)"
case .archive: return "完整保存整份报告(可多页)"
case .diary: return "记录心情、用药、其他"
case .symptom: return "开始一个持续症状,结束时再点结束"
case .quick: return String(appLoc: "拍一张化验单,VL 自动识别")
case .indicator: return String(appLoc: "手动填一项指标(免拍照)")
case .archive: return String(appLoc: "完整保存整份报告(可多页)")
case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助")
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
}
}
var icon: String {
@@ -27,8 +32,9 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .quick: return "camera.fill"
case .indicator: return "number.square.fill"
case .archive: return "doc.fill"
case .diary: return "pencil"
case .diary: return "heart.text.square"
case .symptom: return "waveform.path.ecg"
case .reminder: return "bell.badge"
}
}
var accent: Color {
@@ -38,6 +44,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .archive: return Tj.Palette.ink
case .diary: return Tj.Palette.leaf
case .symptom: return Tj.Palette.amber
case .reminder: return Tj.Palette.leaf
}
}
}
@@ -64,8 +71,10 @@ struct RecordSheet: View {
}
.padding(.bottom, 14)
VStack(spacing: 10) {
ForEach(RecordKind.allCases) { kind in
// ScrollView :6 detent ,
ScrollView {
VStack(spacing: 10) {
ForEach(RecordKind.displayOrder) { kind in
Button {
onPick(kind)
} label: {
@@ -97,8 +106,10 @@ struct RecordSheet: View {
}
.buttonStyle(.plain)
}
}
.padding(.bottom, 22)
}
.padding(.bottom, 22)
.scrollIndicators(.hidden)
}
.padding(.horizontal, 18)
.background(

View File

@@ -1,10 +1,11 @@
import SwiftUI
import SwiftData
private let symptomPresets: [String] = [
"头痛", "咳嗽", "腹痛", "发烧",
"恶心", "失眠", "疲劳", "关节痛"
]
/// :,( static/let )
private func symptomPresets() -> [String] {
[String(appLoc: "头痛"), String(appLoc: "咳嗽"), String(appLoc: "腹痛"), String(appLoc: "发烧"),
String(appLoc: "恶心"), String(appLoc: "失眠"), String(appLoc: "疲劳"), String(appLoc: "关节痛")]
}
struct SymptomStartSheet: View {
@Environment(\.modelContext) private var ctx
@@ -77,10 +78,10 @@ struct SymptomStartSheet: View {
private var presetSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("常见症状")
sectionLabel(String(appLoc: "常见症状"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(symptomPresets, id: \.self) { item in
ForEach(symptomPresets(), id: \.self) { item in
chip(item, selected: name == item) {
name = item
customName = ""
@@ -93,7 +94,7 @@ struct SymptomStartSheet: View {
private var customSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("或者自己写")
sectionLabel(String(appLoc: "或者自己写"))
TextField("例如:眼皮跳", text: $customName)
.textInputAutocapitalization(.never)
.padding(.horizontal, 14)
@@ -116,7 +117,7 @@ struct SymptomStartSheet: View {
private var timeSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("开始时间")
sectionLabel(String(appLoc: "开始时间"))
DatePicker("", selection: $startedAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
@@ -126,7 +127,7 @@ struct SymptomStartSheet: View {
private var severitySection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel("强度")
sectionLabel(String(appLoc: "强度"))
Spacer()
Text("\(Int(severity)) / 5")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
@@ -144,7 +145,7 @@ struct SymptomStartSheet: View {
private var noteSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("备注(可选)")
sectionLabel(String(appLoc: "备注(可选)"))
TextField("位置、可能诱因…", text: $note, axis: .vertical)
.lineLimit(2...4)
.padding(.horizontal, 14)

View File

@@ -9,11 +9,11 @@ nonisolated enum DateSection: Hashable {
var label: String {
switch self {
case .today: return "今天"
case .yesterday: return "昨天"
case .thisWeek: return "本周"
case .thisMonth: return "本月"
case .year(let y): return "\(y)"
case .today: return String(appLoc: "今天")
case .yesterday: return String(appLoc: "昨天")
case .thisWeek: return String(appLoc: "本周")
case .thisMonth: return String(appLoc: "本月")
case .year(let y): return String(appLoc: "\(y)")
}
}
@@ -68,10 +68,10 @@ func formatDuration(_ interval: TimeInterval) -> String {
let hours = (totalMinutes % (60 * 24)) / 60
let minutes = totalMinutes % 60
if days > 0 && hours > 0 { return "\(days)\(hours) 小时" }
if days > 0 { return "\(days)" }
if hours > 0 && minutes > 0 { return "\(hours) 小时 \(minutes)" }
if hours > 0 { return "\(hours) 小时" }
if minutes > 0 { return "\(minutes) 分钟" }
return "刚刚"
if days > 0 && hours > 0 { return String(appLoc: "\(days)\(hours) 小时") }
if days > 0 { return String(appLoc: "\(days)") }
if hours > 0 && minutes > 0 { return String(appLoc: "\(hours) 小时 \(minutes)") }
if hours > 0 { return String(appLoc: "\(hours) 小时") }
if minutes > 0 { return String(appLoc: "\(minutes) 分钟") }
return String(appLoc: "刚刚")
}

View File

@@ -8,10 +8,10 @@ enum TimelineKind: String, CaseIterable, Identifiable {
var label: String {
switch self {
case .indicator: return "指标"
case .report: return "报告"
case .symptom: return "症状"
case .diary: return "日记"
case .indicator: return String(appLoc: "指标")
case .report: return String(appLoc: "报告")
case .symptom: return String(appLoc: "症状")
case .diary: return String(appLoc: "日记")
}
}
@@ -90,8 +90,8 @@ struct TimelineEntry: Identifiable, Hashable {
id: "bp-\(sys.persistentModelID)-\(dia.persistentModelID)",
kind: .indicator,
date: sys.capturedAt,
title: "血压",
subtitle: "长期监测",
title: String(appLoc: "血压"),
subtitle: String(appLoc: "长期监测"),
trailing: "\(sys.value)/\(dia.value) mmHg" + (abnormal ? "" : ""),
trailingIsAlert: abnormal,
isOngoing: false
@@ -105,8 +105,8 @@ struct TimelineEntry: Identifiable, Hashable {
kind: .report,
date: r.reportDate,
title: r.title,
subtitle: "\(r.type.label) · 共 \(r.pageCount)",
trailing: abnormal > 0 ? "\(abnormal) 项偏高" : nil,
subtitle: "\(r.type.label) · " + String(appLoc: "\(r.pageCount)"),
trailing: abnormal > 0 ? String(appLoc: "\(abnormal) 项偏高") : nil,
trailingIsAlert: abnormal > 0,
isOngoing: false
)
@@ -118,7 +118,7 @@ struct TimelineEntry: Identifiable, Hashable {
kind: .diary,
date: d.createdAt,
title: d.content.firstLine(),
subtitle: "文字日记",
subtitle: String(appLoc: "文字日记"),
trailing: nil,
trailingIsAlert: false,
isOngoing: false
@@ -131,11 +131,11 @@ struct TimelineEntry: Identifiable, Hashable {
let subtitle: String
let trailing: String?
if ongoing {
subtitle = "症状 · 持续中"
trailing = "持续 \(formatDuration(s.duration))"
subtitle = String(appLoc: "症状 · 持续中")
trailing = String(appLoc: "持续 \(formatDuration(s.duration))")
} else {
subtitle = "症状 · 已结束"
trailing = "持续 \(formatDuration(s.duration))"
subtitle = String(appLoc: "症状 · 已结束")
trailing = String(appLoc: "持续 \(formatDuration(s.duration))")
}
return TimelineEntry(
id: "symptom-\(s.persistentModelID)",
@@ -151,9 +151,9 @@ struct TimelineEntry: Identifiable, Hashable {
private static func typeSubtitle(for i: Indicator) -> String {
if let report = i.report {
return "指标 · \(report.title)"
return String(appLoc: "指标 · \(report.title)")
}
return "异常项快拍"
return String(appLoc: "异常项快拍")
}
private static func indicatorValue(_ i: Indicator) -> String {
@@ -175,6 +175,6 @@ private extension String {
let s = String(line)
return s.count > 40 ? String(s.prefix(40)) + "" : s
}
return trimmed.isEmpty ? "(空日记)" : trimmed
return trimmed.isEmpty ? String(appLoc: "(空日记)") : trimmed
}
}

View File

@@ -56,18 +56,12 @@ extension Date {
return self.formatted(date: .omitted, time: .shortened)
}
if cal.isDateInYesterday(self) {
return "昨天 " + self.formatted(date: .omitted, time: .shortened)
return String(appLoc: "昨天") + " " + self.formatted(date: .omitted, time: .shortened)
}
let now = Date.now
if cal.isDate(self, equalTo: now, toGranularity: .year) {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "M 月 d 日"
return f.string(from: self)
return self.formatted(.dateTime.month().day())
}
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "yyyy 年 M 月 d 日"
return f.string(from: self)
return self.formatted(.dateTime.year().month().day())
}
}

View File

@@ -3,16 +3,31 @@ import SwiftUI
struct CalendarMonthGrid: View {
let monthAnchor: Date
let data: CalendarData
let selectedDate: Date?
let onTapDay: (Date) -> Void
init(monthAnchor: Date,
data: CalendarData,
selectedDate: Date? = nil,
onTapDay: @escaping (Date) -> Void) {
self.monthAnchor = monthAnchor
self.data = data
self.selectedDate = selectedDate
self.onTapDay = onTapDay
}
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.firstWeekday = 2 //
c.locale = Locale(identifier: "zh_CN")
c.locale = Locale.current
return c
}()
private let weekdayLabels = ["", "", "", "", "", "", ""]
private let weekdayLabels = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: "")
]
private let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)
private var days: [DayCell] {
@@ -64,6 +79,9 @@ struct CalendarMonthGrid: View {
ranges: data.ranges(touching: cell.date, calendar: calendar),
marks: data.marks(for: cell.date, calendar: calendar),
isToday: calendar.isDateInToday(cell.date),
isSelected: selectedDate.map {
calendar.isDate(cell.date, inSameDayAs: $0)
} ?? false,
calendar: calendar
)
.onTapGesture { onTapDay(cell.date) }
@@ -84,6 +102,7 @@ private struct DayCellView: View {
let ranges: [SymptomRange]
let marks: DayMarks
let isToday: Bool
let isSelected: Bool
let calendar: Calendar
private var dayNumber: Int {
@@ -92,14 +111,20 @@ private struct DayCellView: View {
var body: some View {
ZStack(alignment: .top) {
// :
// :selected > today
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(isToday ? Tj.Palette.sand2 : Color.clear)
.fill(backgroundFill)
//
if isSelected {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.strokeBorder(Tj.Palette.brick, lineWidth: 1.5)
}
VStack(spacing: 2) {
Text("\(dayNumber)")
.font(.system(size: 13,
weight: isToday ? .bold : .regular,
weight: (isToday || isSelected) ? .bold : .regular,
design: .default))
.foregroundStyle(textColor)
.padding(.top, 4)
@@ -145,10 +170,17 @@ private struct DayCellView: View {
private var textColor: Color {
if !cell.inCurrentMonth { return Tj.Palette.text3.opacity(0.5) }
if isSelected { return Tj.Palette.brick }
if isToday { return Tj.Palette.ink }
return Tj.Palette.text
}
private var backgroundFill: Color {
if isSelected { return Tj.Palette.brickSoft.opacity(0.5) }
if isToday { return Tj.Palette.sand2 }
return .clear
}
private func symptomBar(_ range: SymptomRange) -> some View {
let pos = range.position(cell.date, calendar: calendar)
let leadingRadius: CGFloat = (pos == .start || pos == .single) ? 3 : 0

View File

@@ -8,7 +8,7 @@ struct CalendarYearGrid: View {
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.firstWeekday = 2
c.locale = Locale(identifier: "zh_CN")
c.locale = Locale.current
return c
}()
@@ -42,10 +42,7 @@ private struct MiniMonth: View {
let calendar: Calendar
private var monthLabel: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "M 月"
return f.string(from: anchor)
anchor.formatted(.dateTime.month())
}
private var days: [Date] {

View File

@@ -6,38 +6,37 @@ struct SelectedDay: Identifiable, Hashable {
var id: TimeInterval { date.timeIntervalSince1970 }
}
struct DayDetailSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
// MARK: - DayDetailContent( inline sheet)
/// sheet , TrendsView inline 使, sheet
struct DayDetailContent: View {
let date: Date
let indicators: [Indicator]
let reports: [Report]
let diaries: [DiaryEntry]
let symptoms: [Symptom]
/// header(inline header,sheet DayDetailSheet )
var showHeader: Bool = true
@State private var endingSymptom: Symptom?
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.locale = Locale(identifier: "zh_CN")
c.locale = Locale.current
return c
}()
// MARK: -
// MARK:
private var dayIndicators: [Indicator] {
indicators.filter { calendar.isDate($0.capturedAt, inSameDayAs: date) }
}
private var dayReports: [Report] {
reports.filter { calendar.isDate($0.reportDate, inSameDayAs: date) }
}
private var dayDiaries: [DiaryEntry] {
diaries.filter { calendar.isDate($0.createdAt, inSameDayAs: date) }
}
private var daySymptoms: [(symptom: Symptom, state: SymptomDayState)] {
symptoms.compactMap { s in
let start = calendar.startOfDay(for: s.startedAt)
@@ -52,90 +51,54 @@ struct DayDetailSheet: View {
return (s, state)
}
}
private var totalCount: Int {
dayIndicators.count + dayReports.count + dayDiaries.count + daySymptoms.count
}
// MARK: - body
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
header
.padding(.horizontal, 20)
.padding(.bottom, 12)
VStack(alignment: .leading, spacing: 14) {
if showHeader { header }
if totalCount == 0 {
emptyState
} else {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
if !daySymptoms.isEmpty {
section("症状", count: daySymptoms.count) {
VStack(spacing: 8) {
ForEach(daySymptoms, id: \.symptom.id) { item in
symptomRow(item.symptom, state: item.state)
}
}
}
}
if !dayIndicators.isEmpty {
section("指标", count: dayIndicators.count) {
VStack(spacing: 8) {
ForEach(dayIndicators) { i in
indicatorRow(i)
}
}
}
}
if !dayReports.isEmpty {
section("报告", count: dayReports.count) {
VStack(spacing: 8) {
ForEach(dayReports) { r in
reportRow(r)
}
}
}
}
if !dayDiaries.isEmpty {
section("日记", count: dayDiaries.count) {
VStack(spacing: 8) {
ForEach(dayDiaries) { d in
diaryRow(d)
}
}
if !daySymptoms.isEmpty {
section(String(appLoc: "症状"), count: daySymptoms.count) {
VStack(spacing: 8) {
ForEach(daySymptoms, id: \.symptom.id) { item in
symptomRow(item.symptom, state: item.state)
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
if !dayIndicators.isEmpty {
section(String(appLoc: "指标"), count: dayIndicators.count) {
VStack(spacing: 8) {
ForEach(dayIndicators) { i in indicatorRow(i) }
}
}
}
if !dayReports.isEmpty {
section(String(appLoc: "报告"), count: dayReports.count) {
VStack(spacing: 8) {
ForEach(dayReports) { r in reportRow(r) }
}
}
}
if !dayDiaries.isEmpty {
section(String(appLoc: "日记"), count: dayDiaries.count) {
VStack(spacing: 8) {
ForEach(dayDiaries) { d in diaryRow(d) }
}
}
}
}
}
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.sheet(item: $endingSymptom) { sym in
SymptomEndSheet(symptom: sym)
}
}
// MARK: - header
// MARK: header
private var header: some View {
HStack(alignment: .firstTextBaseline) {
@@ -145,7 +108,7 @@ struct DayDetailSheet: View {
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
Text(dayLabel)
.font(.tjTitle(28))
.font(.tjTitle(22))
.foregroundStyle(Tj.Palette.text)
}
Spacer()
@@ -158,27 +121,18 @@ struct DayDetailSheet: View {
}
private var dateLine: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "yyyy 年"
return f.string(from: date) + " · " + weekdayLabel
date.formatted(.dateTime.year()) + " · " + weekdayLabel
}
private var dayLabel: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "M 月 d 日"
return f.string(from: date)
date.formatted(.dateTime.month().day())
}
private var weekdayLabel: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "EEEE"
return f.string(from: date)
date.formatted(.dateTime.weekday(.wide))
}
// MARK: - section
// MARK: section helper
private func section<Content: View>(_ title: String,
count: Int,
@@ -198,27 +152,30 @@ struct DayDetailSheet: View {
}
}
// MARK: - rows
// MARK: rows
private func symptomRow(_ s: Symptom, state: SymptomDayState) -> some View {
HStack(spacing: 12) {
Capsule()
.fill(severityColor(s.severity))
.frame(width: 4, height: 36)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(s.name)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
stateBadge(state, isOngoing: s.isOngoing)
Text(state.badge)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(state.badgeFg)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(state.badgeBg))
}
Text("\(state.subtitle) · 持续 \(formatDuration(s.duration))")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 6)
if s.isOngoing {
Button {
endingSymptom = s
@@ -237,15 +194,6 @@ struct DayDetailSheet: View {
.tjCard(bordered: true)
}
private func stateBadge(_ state: SymptomDayState, isOngoing: Bool) -> some View {
Text(state.badge)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(state.badgeFg)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(state.badgeBg))
}
private func indicatorRow(_ i: Indicator) -> some View {
HStack(spacing: 12) {
ZStack {
@@ -256,7 +204,6 @@ struct DayDetailSheet: View {
.foregroundStyle(indicatorAccent(i))
}
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text(i.name)
.font(.system(size: 14, weight: .medium))
@@ -269,7 +216,6 @@ struct DayDetailSheet: View {
}
}
Spacer(minLength: 6)
Text("\(i.value) \(i.unit)\(arrow(i))")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundStyle(i.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
@@ -291,7 +237,6 @@ struct DayDetailSheet: View {
.foregroundStyle(Tj.Palette.ink2)
}
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text(r.title)
.font(.system(size: 14, weight: .medium))
@@ -331,23 +276,19 @@ struct DayDetailSheet: View {
.tjCard(bordered: true)
}
// MARK: - empty
private var emptyState: some View {
VStack(spacing: 12) {
Spacer(minLength: 16)
TjPlaceholder(label: "这一天还没有记录")
.frame(width: 220, height: 120)
VStack(spacing: 8) {
TjPlaceholder(label: String(appLoc: "这一天还没有记录"))
.frame(height: 90)
.frame(maxWidth: 240)
Text("点底部 + 号可以补一条")
.font(.system(size: 12))
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
}
// MARK: - utils
private func severityColor(_ value: Int) -> Color {
switch value {
case 1, 2: return Tj.Palette.leaf
@@ -369,22 +310,65 @@ struct DayDetailSheet: View {
}
}
// MARK: - Sheet wrapper(; TrendsView inline,)
struct DayDetailSheet: View {
let date: Date
let indicators: [Indicator]
let reports: [Report]
let diaries: [DiaryEntry]
let symptoms: [Symptom]
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
ScrollView(showsIndicators: false) {
DayDetailContent(
date: date,
indicators: indicators,
reports: reports,
diaries: diaries,
symptoms: symptoms,
showHeader: true
)
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
}
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
}
}
// MARK: - SymptomDayState
enum SymptomDayState {
case startedToday, ongoing, endedToday
var subtitle: String {
switch self {
case .startedToday: return "今天开始"
case .ongoing: return "进行中"
case .endedToday: return "今天结束"
case .startedToday: return String(appLoc: "今天开始")
case .ongoing: return String(appLoc: "进行中")
case .endedToday: return String(appLoc: "今天结束")
}
}
var badge: String {
switch self {
case .startedToday: return "开始"
case .ongoing: return "持续"
case .endedToday: return "结束"
case .startedToday: return String(appLoc: "开始")
case .ongoing: return String(appLoc: "持续")
case .endedToday: return String(appLoc: "结束")
}
}

View File

@@ -123,7 +123,7 @@ extension SeriesBucket {
let sysLine = SeriesLine(
id: "bp.systolic",
seriesKey: "bp.systolic",
label: "收缩",
label: String(appLoc: "收缩"),
color: Tj.Palette.brick,
points: sysItems.compactMap { point(from: $0) },
referenceRange: m.effectiveRange(for: sysField, profile: profile)
@@ -131,7 +131,7 @@ extension SeriesBucket {
let diaLine = SeriesLine(
id: "bp.diastolic",
seriesKey: "bp.diastolic",
label: "舒张",
label: String(appLoc: "舒张"),
color: Tj.Palette.leaf,
points: diaItems.compactMap { point(from: $0) },
referenceRange: m.effectiveRange(for: diaField, profile: profile)
@@ -144,7 +144,7 @@ extension SeriesBucket {
return SeriesBucket(
id: "bp",
title: "血压",
title: String(appLoc: "血压"),
unit: "mmHg",
lines: [sysLine, diaLine],
latestDate: latest

View File

@@ -165,10 +165,10 @@ struct SeriesChartCard: View {
let days = Calendar.current.dateComponents([.day],
from: dom.lowerBound,
to: dom.upperBound).day ?? 0
if days <= 0 { return "今天" }
if days < 30 { return "\(days)" }
if days < 365 { return "\(days / 30) 个月" }
return "\(days / 365)"
if days <= 0 { return String(appLoc: "今天") }
if days < 30 { return String(appLoc: "\(days)") }
if days < 365 { return String(appLoc: "\(days / 30) 个月") }
return String(appLoc: "\(days / 365)")
}
private func formatValue(_ v: Double) -> String {

View File

@@ -6,8 +6,8 @@ enum CalendarMode: String, CaseIterable, Identifiable {
var id: String { rawValue }
var label: String {
switch self {
case .month: return ""
case .year: return ""
case .month: return String(appLoc: "")
case .year: return String(appLoc: "")
}
}
}
@@ -31,7 +31,8 @@ struct TrendsView: View {
@State private var mode: CalendarMode = .month
@State private var anchor: Date = .now
@State private var selectedDay: SelectedDay?
/// , inline
@State private var selectedDate: Date = .now
private var profile: UserProfile? { profiles.first }
@@ -44,7 +45,7 @@ struct TrendsView: View {
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.firstWeekday = 2
c.locale = Locale(identifier: "zh_CN")
c.locale = Locale.current
return c
}()
@@ -66,6 +67,9 @@ struct TrendsView: View {
anchorBar
calendarBody
legend
if mode == .month {
dayDetailInline
}
seriesSection
}
.padding(.horizontal, 20)
@@ -73,15 +77,31 @@ struct TrendsView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
.sheet(item: $selectedDay) { sel in
DayDetailSheet(
date: sel.date,
}
/// inline (symptoms / indicators / reports / diaries)
private var dayDetailInline: some View {
VStack(alignment: .leading, spacing: 0) {
DayDetailContent(
date: selectedDate,
indicators: indicators,
reports: reports,
diaries: diaries,
symptoms: symptoms
symptoms: symptoms,
showHeader: true
)
.padding(14)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
.animation(.snappy(duration: 0.2), value: selectedDate)
}
private var header: some View {
@@ -91,7 +111,10 @@ struct TrendsView: View {
.foregroundStyle(Tj.Palette.text)
Spacer()
Button {
anchor = .now
withAnimation(.snappy(duration: 0.2)) {
anchor = .now
selectedDate = .now
}
} label: {
Text("回到今天")
.font(.system(size: 12))
@@ -164,18 +187,20 @@ struct TrendsView: View {
}
private var anchorTitle: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = mode == .month ? "yyyy 年 M 月" : "yyyy 年"
return f.string(from: anchor)
let style: Date.FormatStyle = mode == .month
? .dateTime.year().month()
: .dateTime.year()
return anchor.formatted(style)
}
@ViewBuilder
private var calendarBody: some View {
switch mode {
case .month:
CalendarMonthGrid(monthAnchor: anchor, data: data) { day in
selectedDay = SelectedDay(date: day)
CalendarMonthGrid(monthAnchor: anchor, data: data, selectedDate: selectedDate) { day in
withAnimation(.snappy(duration: 0.2)) {
selectedDate = day
}
}
.padding(14)
.background(
@@ -231,10 +256,10 @@ struct TrendsView: View {
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
HStack(spacing: 14) {
legendItem(color: Tj.Palette.brick, label: "指标异常")
legendItem(color: Tj.Palette.amber, label: "症状持续中")
legendItem(color: Tj.Palette.ink2, label: "报告归档")
legendItem(color: Tj.Palette.leaf, label: "正常")
legendItem(color: Tj.Palette.brick, label: String(appLoc: "指标异常"))
legendItem(color: Tj.Palette.amber, label: String(appLoc: "症状持续中"))
legendItem(color: Tj.Palette.ink2, label: String(appLoc: "报告归档"))
legendItem(color: Tj.Palette.leaf, label: String(appLoc: "正常"))
}
}
.padding(.top, 4)
@@ -268,6 +293,14 @@ struct TrendsView: View {
if let next = calendar.date(byAdding: component, value: delta, to: anchor) {
withAnimation(.snappy) {
anchor = next
// selection :() 1
if mode == .month {
if calendar.isDate(next, equalTo: .now, toGranularity: .month) {
selectedDate = .now
} else if let first = calendar.dateInterval(of: .month, for: next)?.start {
selectedDate = first
}
}
}
}
}

11594
康康/Localizable.xcstrings Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
import Foundation
import SwiftData
/// = HealthExport
///
/// Indicator/Report `[String]` ( SwiftData
/// ),,
///
/// @Model(Indicator/ChatTurn ):
/// default, `init`
@Model
final class HealthExport {
var prompt: String
var content: String
var createdAt: Date
// (§3.3 RAG ,W3 )
var referencedIndicatorIDs: [String]
var referencedReportIDs: [String]
var referencedSymptomIDs: [String]
var referencedDiaryIDs: [String]
// ,,
var inferredTimeFromDate: Date?
var inferredTimeToDate: Date?
var inferredIntent: String?
// demo
/// tag, "Qwen3-1.7B-4bit"
var modelTag: String
/// tok/s, demo #6 Live Activity
var decodeRate: Double
init(prompt: String = "",
content: String = "",
createdAt: Date = .now,
referencedIndicatorIDs: [String] = [],
referencedReportIDs: [String] = [],
referencedSymptomIDs: [String] = [],
referencedDiaryIDs: [String] = [],
inferredTimeFromDate: Date? = nil,
inferredTimeToDate: Date? = nil,
inferredIntent: String? = nil,
modelTag: String = "Qwen3-1.7B-4bit",
decodeRate: Double = 0) {
self.prompt = prompt
self.content = content
self.createdAt = createdAt
self.referencedIndicatorIDs = referencedIndicatorIDs
self.referencedReportIDs = referencedReportIDs
self.referencedSymptomIDs = referencedSymptomIDs
self.referencedDiaryIDs = referencedDiaryIDs
self.inferredTimeFromDate = inferredTimeFromDate
self.inferredTimeToDate = inferredTimeToDate
self.inferredIntent = inferredIntent
self.modelTag = modelTag
self.decodeRate = decodeRate
}
}
extension HealthExport {
/// / strip prompt ( 30 + ...)
var promptPreview: String {
let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.count <= 30 { return trimmed }
return trimmed.prefix(30) + ""
}
}

View File

@@ -10,11 +10,11 @@ enum ReportType: String, Codable, CaseIterable {
var label: String {
switch self {
case .checkup: return "体检报告"
case .lab: return "化验单"
case .imaging: return "影像报告"
case .prescription: return "处方"
case .other: return "其他"
case .checkup: return String(appLoc: "体检报告")
case .lab: return String(appLoc: "化验单")
case .imaging: return String(appLoc: "影像报告")
case .prescription: return String(appLoc: "处方")
case .other: return String(appLoc: "其他")
}
}
}
@@ -250,12 +250,12 @@ final class MetricReminder {
var isEveryDay: Bool { Set(weekdays) == Set(1...7) }
var frequencyLabel: String {
if !enabled { return "已关闭" }
if isEveryDay { return "每天" }
if weekdays.isEmpty { return "未选日" }
let names = ["", "", "", "", "", "", ""]
if !enabled { return String(appLoc: "已关闭") }
if isEveryDay { return String(appLoc: "每天") }
if weekdays.isEmpty { return String(appLoc: "未选日") }
let names = [String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: "")]
let sorted = weekdays.sorted()
return "每周 " + sorted.map { names[$0 - 1] }.joined()
return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined()
}
var timeLabel: String {

View File

@@ -57,9 +57,9 @@ extension UserProfile {
var label: String {
switch self {
case .male: return ""
case .female: return ""
case .undisclosed: return "不愿透露"
case .male: return String(appLoc: "")
case .female: return String(appLoc: "")
case .undisclosed: return String(appLoc: "不愿透露")
}
}
}
@@ -78,7 +78,7 @@ extension UserProfile {
/// ProfileCard :"38 · · 175cm · 68kg · A"
var summaryLine: String {
var parts: [String] = []
if let age { parts.append("\(age)") }
if let age { parts.append(String(appLoc: "\(age)")) }
if sex != .undisclosed { parts.append(sex.label) }
if let h = heightCM { parts.append("\(h)cm") }
if let w = weightKG {
@@ -87,7 +87,7 @@ extension UserProfile {
: String(format: "%.1fkg", w)
parts.append(s)
}
if !bloodTypeRaw.isEmpty { parts.append("\(bloodTypeRaw)") }
if !bloodTypeRaw.isEmpty { parts.append(String(appLoc: "\(bloodTypeRaw)")) }
return parts.joined(separator: " · ")
}

View File

@@ -4,10 +4,10 @@ enum TjTab: String, Hashable, CaseIterable {
case home, records, trend, me
var label: String {
switch self {
case .home: return "主页"
case .records: return "记录"
case .trend: return "趋势"
case .me: return "我的"
case .home: return String(appLoc: "主页")
case .records: return String(appLoc: "记录")
case .trend: return String(appLoc: "趋势")
case .me: return String(appLoc: "我的")
}
}
var icon: String {
@@ -18,6 +18,15 @@ enum TjTab: String, Hashable, CaseIterable {
case .me: return "person.circle"
}
}
/// , push
var index: Int {
switch self {
case .home: return 0
case .records: return 1
case .trend: return 2
case .me: return 3
}
}
}
enum ActiveFlow: Identifiable {
@@ -27,26 +36,39 @@ enum ActiveFlow: Identifiable {
struct RootView: View {
@State private var tab: TjTab = .home
/// push : tab trailing , leading
@State private var pushEdge: Edge = .trailing
@State private var showRecordSheet = false
@State private var activeFlow: ActiveFlow?
@State private var showSymptomStart = false
@State private var showDiary = false
@State private var showIndicator = false
@State private var showReminders = false
/// tab : pushEdge, tab
/// tab ,
private func select(_ newTab: TjTab) {
guard newTab != tab else { return }
pushEdge = newTab.index > tab.index ? .trailing : .leading
withAnimation(.easeInOut(duration: 0.26)) { tab = newTab }
}
var body: some View {
VStack(spacing: 0) {
Group {
switch tab {
case .home: HomeView(onTapArchive: { tab = .records })
case .home: HomeView(onTapArchive: { select(.records) })
case .records: ArchiveListView()
case .trend: TrendsView()
case .me: MeView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.id(tab)
.transition(.push(from: pushEdge))
TabBar(active: tab,
onTap: { tab = $0 },
onTap: { select($0) },
onTapRecord: { showRecordSheet = true })
}
.background(Tj.Palette.sand.ignoresSafeArea())
@@ -60,6 +82,7 @@ struct RootView: View {
case .symptom: showSymptomStart = true
case .diary: showDiary = true
case .indicator: showIndicator = true
case .reminder: showReminders = true
}
}
}
@@ -73,6 +96,10 @@ struct RootView: View {
.sheet(isPresented: $showIndicator) {
IndicatorQuickSheet()
}
.sheet(isPresented: $showReminders) {
// NavigationStack ;sheet
NavigationStack { RemindersListView(presentedAsSheet: true) }
}
#if os(iOS)
.fullScreenCover(item: $activeFlow) { flow in
switch flow {
@@ -100,6 +127,8 @@ private struct TabBar: View {
let onTap: (TjTab) -> Void
let onTapRecord: () -> Void
@Namespace private var indicatorNS
private let cornerRadius: CGFloat = 22
private let slotHeight: CGFloat = 34
@@ -115,6 +144,7 @@ private struct TabBar: View {
.padding(.top, 10)
.padding(.bottom, 6)
.background(barBackground)
.animation(.spring(response: 0.35, dampingFraction: 0.75), value: active)
}
private var barBackground: some View {
@@ -143,6 +173,7 @@ private struct TabBar: View {
Capsule()
.fill(Tj.Palette.sand2)
.frame(width: 44, height: slotHeight - 6)
.matchedGeometryEffect(id: "tabIndicator", in: indicatorNS)
}
Image(systemName: t.icon)
.font(.system(size: 18, weight: isActive ? .semibold : .regular))
@@ -188,7 +219,7 @@ private struct TabBar: View {
.buttonStyle(TabPressStyle())
}
}
//
private struct TabPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label

View File

@@ -0,0 +1,158 @@
import Foundation
import LocalAuthentication
import SwiftUI
import Observation
/// Face ID ()
///
/// `docs/superpowers/specs/2026-05-30-faceid-app-lock-design.md`
/// 线(CLAUDE.md §10.2): `LocalAuthentication`,
///
/// `ModelDownloadService.shared` :`@MainActor @Observable`
/// UI(`AppLockContainer` / `MeView` / `LockScreenView`) observable ,
/// `handleAppear` / `handleScenePhase` / `authenticate`
@MainActor
@Observable
final class AppLock {
static let shared = AppLock()
///
static let gracePeriod: TimeInterval = 60
/// key, `MeView` `@AppStorage`
static let enabledKey = "faceIDLockEnabled"
// MARK: - Observable
/// ()
private(set) var isLocked = false
/// / ()
private(set) var showsPrivacyCover = false
/// ( false)
private(set) var biometryAvailable = false
/// / :"Face ID" / "Touch ID" / ""
private(set) var biometryLabel = String(appLoc: "密码")
// MARK: -
/// UserDefaults( MeView @AppStorage key)
/// observable UI @AppStorage key
var enabled: Bool {
get { UserDefaults.standard.bool(forKey: Self.enabledKey) }
set { UserDefaults.standard.set(newValue, forKey: Self.enabledKey) }
}
private var lastBackgroundedAt: Date?
private var didColdLaunchLock = false
private var isAuthenticating = false
private init() {
refreshAvailability()
}
// MARK: -
/// /
func refreshAvailability() {
let ctx = LAContext()
var error: NSError?
// .deviceOwnerAuthentication: true( + )
biometryAvailable = ctx.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
// biometryType canEvaluatePolicy
switch ctx.biometryType {
case .faceID: biometryLabel = "Face ID"
case .touchID: biometryLabel = "Touch ID"
default: biometryLabel = String(appLoc: "密码")
}
}
// MARK: - ( AppLockContainer )
/// :
func handleAppear() {
refreshAvailability()
guard enabled, !didColdLaunchLock else { return }
didColdLaunchLock = true
isLocked = true
Task { await authenticate() }
}
/// scenePhase
func handleScenePhase(_ phase: ScenePhase) {
switch phase {
case .inactive:
// / :()
showsPrivacyCover = enabled && !isLocked
case .background:
lastBackgroundedAt = Date()
showsPrivacyCover = enabled
case .active:
showsPrivacyCover = false
if enabled, !isLocked,
let since = lastBackgroundedAt,
Date().timeIntervalSince(since) > Self.gracePeriod {
isLocked = true
}
if isLocked { Task { await authenticate() } }
lastBackgroundedAt = nil
@unknown default:
break
}
}
// MARK: -
/// ;/ `isAuthenticating` ,
/// onAppear
func authenticate() async {
guard isLocked, !isAuthenticating else { return }
isAuthenticating = true
defer { isAuthenticating = false }
let ctx = LAContext()
ctx.localizedFallbackTitle = String(appLoc: "输入密码")
do {
let ok = try await ctx.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: String(appLoc: "解锁康康,查看你的健康档案")
)
if ok { isLocked = false }
} catch {
// /:, UI
}
}
// MARK: - (MeView )
/// :( + ), `enabled`
///
@discardableResult
func enableWithAuth() async -> Bool {
let ctx = LAContext()
ctx.localizedFallbackTitle = String(appLoc: "输入密码")
do {
let ok = try await ctx.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: String(appLoc: "验证你本人,开启 Face ID 启动锁")
)
if ok {
enabled = true
return true
}
} catch {
// /:
}
return false
}
/// :( App )
func disable() {
enabled = false
}
}

View File

@@ -0,0 +1,31 @@
import SwiftUI
/// `RootView` : scenePhase, /
/// RootView (线 §10.7 Tab )
///
/// (KangkangApp):`AppLockContainer { RootView() }`
struct AppLockContainer<Content: View>: View {
@ViewBuilder var content: () -> Content
@Environment(\.scenePhase) private var scenePhase
@State private var appLock = AppLock.shared
var body: some View {
content()
.overlay {
if appLock.isLocked {
LockScreenView()
.transition(.opacity)
} else if appLock.showsPrivacyCover {
// :,
PrivacyCoverView()
}
}
// ;
.animation(.easeInOut(duration: 0.2), value: appLock.isLocked)
.onAppear { appLock.handleAppear() }
.onChange(of: scenePhase) { _, newPhase in
appLock.handleScenePhase(newPhase)
}
}
}

View File

@@ -0,0 +1,94 @@
import SwiftUI
/// :,onAppear ;/,
struct LockScreenView: View {
@State private var appLock = AppLock.shared
/// /
private var glyph: String {
switch appLock.biometryLabel {
case "Face ID": return "faceid"
case "Touch ID": return "touchid"
default: return "lock.fill"
}
}
var body: some View {
ZStack {
Tj.Palette.sand.ignoresSafeArea()
VStack(spacing: 18) {
Spacer()
ZStack {
Circle()
.fill(Tj.Palette.paper)
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
Image(systemName: "lock.fill")
.font(.system(size: 34))
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 92, height: 92)
.shadow(color: Tj.Palette.ink.opacity(0.06), radius: 12, y: 4)
VStack(spacing: 6) {
Text("康康 已锁定")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("你的健康档案已加密保护")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Button {
Task { await appLock.authenticate() }
} label: {
Label("\(appLock.biometryLabel) 解锁", systemImage: glyph)
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton(height: 52, fontSize: 16))
.padding(.horizontal, 40)
.padding(.bottom, 48)
}
}
.onAppear {
Task { await appLock.authenticate() }
}
}
}
/// : / ,
/// ,
struct PrivacyCoverView: View {
var body: some View {
ZStack {
Tj.Palette.sand.ignoresSafeArea()
VStack(spacing: 14) {
ZStack {
Circle()
.fill(Tj.Palette.paper)
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
Image(systemName: "heart.text.square.fill")
.font(.system(size: 30))
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 80, height: 80)
Text("康康")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
}
}
}
}
#Preview("锁屏") {
LockScreenView()
}
#Preview("隐私遮罩") {
PrivacyCoverView()
}

View File

@@ -1,5 +1,6 @@
import Foundation
import UIKit
import SwiftData
/// VL (, SwiftData )
/// Indicator/Report prompt schema
@@ -40,16 +41,14 @@ struct ParsedReport: Sendable {
/// CaptureService UI (退 vs )
enum CaptureError: Error, LocalizedError {
case modelNotReady
case writeAssetFailed
case inferenceFailed(String)
case parseFailed(String)
var errorDescription: String? {
switch self {
case .modelNotReady: return "VL 模型尚未就绪"
case .writeAssetFailed: return "图片保存失败"
case .inferenceFailed(let m): return "识别失败:\(m)"
case .parseFailed(let m): return "结构化失败:\(m)"
case .modelNotReady: return String(appLoc: "VL 模型尚未就绪")
case .inferenceFailed(let m): return String(appLoc: "识别失败:\(m)")
case .parseFailed(let m): return String(appLoc: "结构化失败:\(m)")
}
}
}
@@ -60,38 +59,36 @@ actor CaptureService {
static let shared = CaptureService()
private init() {}
/// + VL + ParsedReport
/// , CaptureError;UI
/// - Returns: (ParsedReport, [FileVault.SavedAsset]) ,
/// SavedAsset Asset @Model
func analyze(images: [UIImage]) async throws
-> (parsed: ParsedReport, assets: [FileVault.SavedAsset]) {
/// Vault Asset VL, ParsedReport
/// :
/// - UnifiedCaptureFlow (UI ,/ assets )
/// -
/// - C2(W5)
/// SwiftData (MainActor), `Report.applyReanalyzed(_:in:)`
/// @Model , Sendable actor
func reanalyze(assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
try await runVL(on: assets)
}
// 1. Vault()
let assets: [FileVault.SavedAsset]
/// VL + JSON assets Vault
private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
do {
assets = try images.map { try FileVault.shared.writeJPEG($0) }
try await AIRuntime.shared.prepareVL()
} catch {
throw CaptureError.writeAssetFailed
throw CaptureError.modelNotReady
}
// 2. VL
try await AIRuntime.shared.prepareVL()
let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
let raw: String
do {
raw = try await AIRuntime.shared.analyzeReport(
imageURLs: urls,
prompt: VLPrompts.reportExtraction
prompt: VLPrompts.reportExtraction()
)
} catch {
throw CaptureError.inferenceFailed("\(error)")
}
// 3. JSON (: / )
do {
let parsed = try CaptureService.parseReportJSON(raw, pageCount: assets.count)
return (parsed, assets)
return try CaptureService.parseReportJSON(raw, pageCount: assets.count)
} catch let CaptureError.parseFailed(msg) {
throw CaptureError.parseFailed(msg)
} catch {
@@ -136,7 +133,7 @@ actor CaptureService {
}
return ParsedReport(
title: title.isEmpty ? "拍摄识别" : title,
title: title.isEmpty ? String(appLoc: "拍摄识别") : title,
typeRaw: typeRaw,
reportDate: reportDate,
institution: institution,
@@ -216,3 +213,53 @@ actor CaptureService {
return .init(name: name, value: value, unit: unit, range: range, status: status)
}
}
// MARK: - Report CaptureService (MainActor )
//
// CaptureService actor, Report(@Model Sendable)
// C2UI :
// ```
// let assets = report.savedAssets
// let parsed = try await CaptureService.shared.reanalyze(assets: assets)
// report.applyReanalyzed(parsed, in: ctx)
// ```
extension Report {
/// Asset SavedAsset, CaptureService.reanalyze
var savedAssets: [FileVault.SavedAsset] {
assets.map { .init(relativePath: $0.relativePath, bytes: $0.bytes) }
}
/// VL Report
/// - indicators:,(cascade delete )
/// - summary / institution:,
/// MainActor / SwiftData
@MainActor
func applyReanalyzed(_ parsed: ParsedReport, in ctx: ModelContext) {
if !parsed.summary.isEmpty {
self.summary = parsed.summary
}
if !parsed.institution.isEmpty {
self.institution = parsed.institution
}
// indicators (cascade )
for old in indicators {
ctx.delete(old)
}
indicators.removeAll()
// indicators
for p in parsed.indicators {
let i = Indicator(
name: p.name,
value: p.value,
unit: p.unit,
range: p.range,
status: p.status,
capturedAt: reportDate,
report: self
)
ctx.insert(i)
}
try? ctx.save()
}
}

View File

@@ -0,0 +1,101 @@
import Foundation
/// AI : LLM 3-4
///
/// HealthExportService ,(< 400 token),
/// await
///
/// :DiaryQuickSheet
@MainActor
struct DiaryAssistService {
static let shared = DiaryAssistService()
private init() {}
/// fill ,
/// `dim` ( `DiaryAssistPrompts.dimensions`),
/// `adopted` UI ;`round` UI append ,
struct Question: Identifiable, Hashable {
let id: UUID
let q: String
let fill: String
let dim: String
var adopted: Bool
var round: Int
init(id: UUID = UUID(),
q: String,
fill: String,
dim: String = "",
adopted: Bool = false,
round: Int = 0) {
self.id = id
self.q = q
self.fill = fill
self.dim = dim
self.adopted = adopted
self.round = round
}
}
enum AssistError: Error, LocalizedError {
case modelNotReady
case empty
case parseFailed(String)
var errorDescription: String? {
switch self {
case .modelNotReady: return String(appLoc: "AI 模型尚未准备好")
case .empty: return String(appLoc: "AI 没有给出建议,请稍后重试")
case .parseFailed(let m): return String(appLoc: "结果解析失败:\(m)")
}
}
}
/// 3-4
/// - coveredDimensions: ,( question.dim),
/// prompt
/// : AIRuntime actor , Capture / Export GPU
func suggest(content: String,
coveredDimensions: [String] = []) async throws -> (questions: [Question], decodeRate: Double) {
do {
try await AIRuntime.shared.prepare()
} catch {
throw AssistError.modelNotReady
}
let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions)
var collected = ""
var lastRate: Double = 0
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400)
for try await chunk in stream {
collected += chunk.text
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
}
// 1. <think>...</think>( HealthExportService )
let stripped = HealthExportService.stripThinkBlocks(collected)
// 2. JSON( CaptureService.extractJSONObject)
let jsonStr = CaptureService.extractJSONObject(from: stripped)
guard let data = jsonStr.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
let dict = obj as? [String: Any] else {
throw AssistError.parseFailed("非 JSON 输出")
}
guard let rawQuestions = dict["questions"] as? [[String: Any]] else {
throw AssistError.parseFailed("缺少 questions 字段")
}
let questions = rawQuestions.compactMap { d -> Question? in
guard let q = (d["q"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else {
return nil
}
let fill = (d["fill"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let dim = (d["dim"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return Question(q: q, fill: fill, dim: dim)
}
guard !questions.isEmpty else { throw AssistError.empty }
return (Array(questions.prefix(4)), lastRate)
}
}

View File

@@ -0,0 +1,460 @@
import Foundation
import SwiftData
///
///
/// ( spec §6):
/// prepare extractingIntent retrieving generating completed
///
/// 线:
/// - UI AI(§3.1)
/// - LLM `AIRuntime.shared` actor , CaptureService (§3.1)
/// - JSON 30 + ,(§3.2 / spec §9)
/// - (§10)
@MainActor
struct HealthExportService {
static let shared = HealthExportService()
private init() {}
// MARK: - Public types
enum Phase: String, Sendable {
case extractingIntent
case retrieving
case generating
case completed
var label: String {
switch self {
case .extractingIntent: return String(appLoc: "理解意图")
case .retrieving: return String(appLoc: "检索数据")
case .generating: return String(appLoc: "撰写报告")
case .completed: return String(appLoc: "已完成")
}
}
}
enum Event {
case phaseChanged(Phase)
case token(TokenChunk)
case completed(persistentID: PersistentIdentifier)
// .failed stream throw, Event
}
enum ServiceError: Error, LocalizedError {
case modelNotReady
case generationFailed(String)
case cancelled
var errorDescription: String? {
switch self {
case .modelNotReady: return String(appLoc: "AI 模型尚未准备好,请先到「我的 · 模型管理」下载。")
case .generationFailed(let m): return String(appLoc: "生成失败:\(m)")
case .cancelled: return String(appLoc: "已取消")
}
}
}
// MARK: - Entry point
/// ;UI sheet stream Service
/// MainActor
func export(prompt: String,
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
}
// Phase 1:
continuation.yield(.phaseChanged(.extractingIntent))
let intent = await Self.extractIntent(userPrompt: prompt)
try Task.checkCancellation()
// Phase 2:
continuation.yield(.phaseChanged(.retrieving))
let snapshot = Self.retrieve(intent: intent, ctx: modelContext)
try Task.checkCancellation()
// Phase 3:
continuation.yield(.phaseChanged(.generating))
let dataJSON = Self.serializeData(snapshot: snapshot)
let genPrompt = HealthExportPrompts.reportGeneration(
userPrompt: prompt,
intentLabelCN: intent.labelCN,
dataJSON: dataJSON
)
// <think>...</think>
// Prompt Qwen3 `/no_think`, thinking
// + chunk + diff yield:
// - thinking ,UI generated
// - </think> ,
var rawAccum = ""
var generated = ""
var lastRate: Double = 0
let stream = await AIRuntime.shared.generate(
prompt: genPrompt,
maxTokens: 1024
)
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 {
// :() UI 退,
// generated = clean yield(退)
generated = clean
}
}
guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ServiceError.generationFailed("模型未输出任何内容")
}
// Phase 4:
let export = HealthExport(
prompt: prompt,
content: generated,
referencedIndicatorIDs: snapshot.indicators.map { Self.idString($0.persistentModelID) },
referencedReportIDs: snapshot.reports.map { Self.idString($0.persistentModelID) },
referencedSymptomIDs: snapshot.symptoms.map { Self.idString($0.persistentModelID) },
referencedDiaryIDs: snapshot.diaries.map { Self.idString($0.persistentModelID) },
inferredTimeFromDate: snapshot.fromDate,
inferredTimeToDate: snapshot.toDate,
inferredIntent: intent.intent,
decodeRate: lastRate
)
modelContext.insert(export)
do { try modelContext.save() } catch {
// UI ;(W6 telemetry)
print("[HealthExportService] save failed: \(error)")
}
continuation.yield(.phaseChanged(.completed))
continuation.yield(.completed(persistentID: export.persistentModelID))
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: ServiceError.cancelled)
} catch let e as ServiceError {
continuation.finish(throwing: e)
} catch {
continuation.finish(throwing: ServiceError.generationFailed("\(error)"))
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
// MARK: - Phase 1: intent extraction
struct Intent: Sendable {
var timeRangeDays: Int
var keywords: [String]
var symptomKeywords: [String]
var intent: String
var labelCN: String
/// : 30 +
static let fallback = Intent(
timeRangeDays: 30,
keywords: [],
symptomKeywords: [],
intent: "general_review",
labelCN: "近期健康摘要"
)
}
/// LLM JSON, `Intent.fallback`
///
private static func extractIntent(userPrompt: String) async -> Intent {
let prompt = HealthExportPrompts.intentExtraction(userPrompt: userPrompt)
var collected = ""
do {
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 200)
for try await chunk in stream {
collected += chunk.text
}
} catch {
return .fallback
}
return parseIntent(collected) ?? .fallback
}
/// JSON: `{}`,
/// (internal)
static func parseIntent(_ raw: String) -> Intent? {
let jsonString = CaptureService.extractJSONObject(from: raw)
guard let data = jsonString.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
let dict = obj as? [String: Any] else {
return nil
}
let days = clampDays(dict["time_range_days"])
let keywords = stringArray(dict["keywords"])
let symptomKeywords = stringArray(dict["symptom_keywords"])
let intent = (dict["intent"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "general_review"
let labelCN = (dict["intent_label_cn"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "近期健康摘要"
return Intent(
timeRangeDays: days,
keywords: keywords,
symptomKeywords: symptomKeywords,
intent: intent.isEmpty ? "general_review" : intent,
labelCN: labelCN.isEmpty ? "近期健康摘要" : labelCN
)
}
private static func clampDays(_ raw: Any?) -> Int {
if let n = raw as? Int { return max(1, min(365, n)) }
if let n = raw as? Double { return max(1, min(365, Int(n))) }
if let s = raw as? String, let n = Int(s) { return max(1, min(365, n)) }
return 30
}
private static func stringArray(_ raw: Any?) -> [String] {
guard let arr = raw as? [Any] else { return [] }
return arr.compactMap { ($0 as? String)?.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
// MARK: - Phase 2: retrieve
struct Snapshot {
var fromDate: Date
var toDate: Date
var indicators: [Indicator]
var symptoms: [Symptom]
var reports: [Report]
var diaries: [DiaryEntry]
var profile: UserProfile
}
/// SwiftData @MainActor
private static func retrieve(intent: Intent, ctx: ModelContext) -> Snapshot {
let toDate = Date()
let fromDate = Calendar.current.date(
byAdding: .day, value: -intent.timeRangeDays, to: toDate
) ?? toDate.addingTimeInterval(-30 * 86400)
// Indicators( + )
let indDesc = FetchDescriptor<Indicator>(
predicate: #Predicate { $0.capturedAt >= fromDate && $0.capturedAt <= toDate },
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
)
var indicators = (try? ctx.fetch(indDesc)) ?? []
if !intent.keywords.isEmpty {
let filtered = indicators.filter { ind in
intent.keywords.contains { kw in
ind.name.localizedCaseInsensitiveContains(kw)
}
}
// ,()
let abnormal = indicators.filter { $0.status != .normal }
let combined = (filtered + abnormal).reduce(into: [Indicator]()) { acc, x in
if !acc.contains(where: { $0.persistentModelID == x.persistentModelID }) {
acc.append(x)
}
}
indicators = combined.isEmpty ? indicators : combined
}
indicators = Array(indicators.prefix(20))
// Symptoms()
let symptomDesc = FetchDescriptor<Symptom>(
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
)
let allSymptoms = (try? ctx.fetch(symptomDesc)) ?? []
let symptoms = Array(
allSymptoms.filter { sym in
let overlapsStart = sym.startedAt <= toDate
let overlapsEnd = (sym.endedAt ?? Date.distantFuture) >= fromDate
return overlapsStart && overlapsEnd
}.prefix(10)
)
// Reports()
let reportDesc = FetchDescriptor<Report>(
predicate: #Predicate { $0.reportDate >= fromDate && $0.reportDate <= toDate },
sortBy: [SortDescriptor(\.reportDate, order: .reverse)]
)
let reports = Array(((try? ctx.fetch(reportDesc)) ?? []).prefix(8))
// Diary(: symptom_keyword , prompt)
let diaries: [DiaryEntry]
if intent.symptomKeywords.isEmpty {
diaries = []
} else {
let diaryDesc = FetchDescriptor<DiaryEntry>(
predicate: #Predicate { $0.createdAt >= fromDate && $0.createdAt <= toDate },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
let all = (try? ctx.fetch(diaryDesc)) ?? []
diaries = Array(
all.filter { d in
intent.symptomKeywords.contains { kw in
d.content.localizedCaseInsensitiveContains(kw)
}
}.prefix(5)
)
}
// Profile()
let profile = UserProfileStore.loadOrCreate(in: ctx)
return Snapshot(
fromDate: fromDate,
toDate: toDate,
indicators: indicators,
symptoms: symptoms,
reports: reports,
diaries: diaries,
profile: profile
)
}
// MARK: - Phase 3: serialize data for prompt
/// Snapshot LLM JSON
/// Codable prompt key,
static func serializeData(snapshot: Snapshot) -> String {
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX")
df.dateFormat = "yyyy-MM-dd"
let profile = snapshot.profile
var root: [String: Any] = [:]
// profile
var profDict: [String: Any] = [:]
if let age = profile.age { profDict["age"] = age }
let sexLabel = profile.sex.label
if profile.sex != .undisclosed { profDict["sex"] = sexLabel }
if let h = profile.heightCM { profDict["height_cm"] = h }
if let w = profile.weightKG {
profDict["weight_kg"] = w.truncatingRemainder(dividingBy: 1) == 0
? Int(w) : Double(round(w * 10) / 10)
}
if !profile.bloodTypeRaw.isEmpty { profDict["blood_type"] = profile.bloodTypeRaw }
if !profile.allergies.isEmpty { profDict["allergies"] = profile.allergies }
if !profile.chronicConditions.isEmpty { profDict["chronic"] = profile.chronicConditions }
if !profile.familyHistory.isEmpty { profDict["family_history"] = profile.familyHistory }
if !profile.currentMedications.isEmpty { profDict["current_meds"] = profile.currentMedications }
root["profile"] = profDict
// symptoms
root["symptoms"] = snapshot.symptoms.map { s -> [String: Any] in
var d: [String: Any] = [
"name": s.name,
"started": df.string(from: s.startedAt),
"severity": s.severity,
"ongoing": s.isOngoing
]
if let ended = s.endedAt { d["ended"] = df.string(from: ended) }
if let note = s.note, !note.isEmpty { d["note"] = note }
return d
}
// indicators
root["indicators"] = snapshot.indicators.map { i -> [String: Any] in
[
"name": i.name,
"value": i.value,
"unit": i.unit,
"range": i.range,
"status": i.status.rawValue,
"date": df.string(from: i.capturedAt)
]
}
// reports
root["reports"] = snapshot.reports.map { r -> [String: Any] in
var d: [String: Any] = [
"title": r.title,
"type": r.type.label,
"date": df.string(from: r.reportDate)
]
if let inst = r.institution, !inst.isEmpty { d["institution"] = inst }
if let sum = r.summary, !sum.isEmpty { d["summary"] = sum }
return d
}
// diaries
root["diaries"] = snapshot.diaries.map { d -> [String: Any] in
let excerpt = String(d.content.prefix(80))
return [
"date": df.string(from: d.createdAt),
"excerpt": excerpt
]
}
// LLM
root["time_window"] = [
"from": df.string(from: snapshot.fromDate),
"to": df.string(from: snapshot.toDate)
]
guard let data = try? JSONSerialization.data(
withJSONObject: root,
options: [.prettyPrinted, .sortedKeys]
),
let str = String(data: data, encoding: .utf8) else {
return "{}"
}
return str
}
// MARK: - Helpers
/// SwiftData persistentModelID
/// W3 ,()
private static func idString(_ id: PersistentIdentifier) -> String {
String(describing: id)
}
// MARK: - <think>
/// ,
/// + + diff yield,:
/// - `<think>...</think>` ( think )
/// - `<think>...`() ,
/// - Qwen3 `</think>` thinking
/// - trim, `## `
static func stripThinkBlocks(_ raw: String) -> String {
var s = raw
// 1. <think>...</think>( think )
while let openR = s.range(of: "<think>"),
let closeR = s.range(of: "</think>", range: openR.upperBound..<s.endIndex) {
s.removeSubrange(openR.lowerBound..<closeR.upperBound)
}
// 2. :,
if let openR = s.range(of: "<think>") {
s = String(s[..<openR.lowerBound])
}
// 3. (Qwen3 ):
if let closeR = s.range(of: "</think>") {
s = String(s[closeR.upperBound...])
}
// 4. trim
while let first = s.first, first.isWhitespace {
s.removeFirst()
}
return s
}
}

View File

@@ -132,7 +132,7 @@ final class ModelDownloadService {
states[kind] = DownloadState(phase: .ready, receivedBytes: total,
totalBytes: total, bytesPerSecond: 0)
} else {
states[kind] = DownloadState(phase: .failed(message ?? "下载失败"),
states[kind] = DownloadState(phase: .failed(message ?? String(appLoc: "下载失败")),
receivedBytes: completedBytes(for: kind),
totalBytes: total, bytesPerSecond: 0)
}

View File

@@ -55,8 +55,8 @@ enum ReminderService {
let center = UNUserNotificationCenter.current()
let content = UNMutableNotificationContent()
content.title = "该测\(reminder.displayName)"
content.body = "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"
content.title = String(appLoc: "该测\(reminder.displayName)")
content.body = String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次")
content.sound = .default
content.threadIdentifier = "kangkang.reminder.\(reminder.metricId)"

View File

@@ -8,8 +8,8 @@ struct ModelManifestTests {
#expect(ModelManifest.files(for: .llm).count == 9)
}
@Test func vlHasElevenFunctionalFiles() {
#expect(ModelManifest.files(for: .vl).count == 11)
@Test func vlHasFourteenFunctionalFiles() {
#expect(ModelManifest.files(for: .vl).count == 14)
}
@Test func llmTotalBytesMatchesManifest() {
@@ -17,7 +17,7 @@ struct ModelManifestTests {
}
@Test func vlTotalBytesMatchesManifest() {
#expect(ModelManifest.totalBytes(for: .vl) == 3_089_710_883)
#expect(ModelManifest.totalBytes(for: .vl) == 3_109_729_929)
}
@Test func excludesReadmeAndGitattributes() {