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>
@@ -84,7 +84,7 @@ VL prompt 必须:
|
|||||||
## 4. 模型分发
|
## 4. 模型分发
|
||||||
|
|
||||||
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条
|
||||||
- 总体积 ~3GB,WiFi 提示必须有
|
- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有
|
||||||
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载"
|
||||||
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用)
|
||||||
|
|
||||||
|
|||||||
130
docs/superpowers/specs/2026-05-30-faceid-app-lock-design.md
Normal 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
@@ -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
@@ -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
|
||||||
@@ -199,11 +199,14 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "康康" */;
|
buildConfigurationList = 5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "康康" */;
|
||||||
developmentRegion = en;
|
developmentRegion = "zh-Hans";
|
||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
|
"zh-Hans",
|
||||||
|
ja,
|
||||||
|
ko,
|
||||||
);
|
);
|
||||||
mainGroup = 5E463CF02FC403BB0089145B;
|
mainGroup = 5E463CF02FC403BB0089145B;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
@@ -413,6 +416,10 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
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=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -458,6 +465,10 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
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=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ enum AIRuntimeError: Error, LocalizedError {
|
|||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .notReady: return "AI 模型尚未准备好"
|
case .notReady: return String(appLoc: "AI 模型尚未准备好")
|
||||||
case .modelLoadFailed(let m): return "模型加载失败:\(m)"
|
case .modelLoadFailed(let m): return String(appLoc: "模型加载失败:\(m)")
|
||||||
case .inferenceFailed(let m): return "推理失败:\(m)"
|
case .inferenceFailed(let m): return String(appLoc: "推理失败:\(m)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ enum DownloadError: Error, LocalizedError {
|
|||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .badStatus(let code):
|
case .badStatus(let code):
|
||||||
return "下载失败(HTTP \(code))"
|
return String(appLoc: "下载失败(HTTP \(code))")
|
||||||
case .sizeMismatch(let expected, let got):
|
case .sizeMismatch(let expected, let got):
|
||||||
return "文件大小校验失败(预期 \(expected),实际 \(got))"
|
return String(appLoc: "文件大小校验失败(预期 \(expected),实际 \(got))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,18 +30,27 @@ enum ModelManifest {
|
|||||||
ModelFile(path: "added_tokens.json", bytes: 707),
|
ModelFile(path: "added_tokens.json", bytes: 707),
|
||||||
]
|
]
|
||||||
case .vl:
|
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 [
|
return [
|
||||||
ModelFile(path: "config.json", bytes: 1_659),
|
ModelFile(path: "config.json", bytes: 7_137),
|
||||||
ModelFile(path: "model.safetensors", bytes: 3_073_720_461),
|
ModelFile(path: "model.safetensors", bytes: 3_093_767_283),
|
||||||
ModelFile(path: "model.safetensors.index.json", bytes: 108_307),
|
ModelFile(path: "model.safetensors.index.json", bytes: 64_742),
|
||||||
ModelFile(path: "tokenizer.json", bytes: 11_421_896),
|
ModelFile(path: "tokenizer.json", bytes: 11_422_654),
|
||||||
ModelFile(path: "tokenizer_config.json", bytes: 7_256),
|
ModelFile(path: "tokenizer_config.json", bytes: 5_445),
|
||||||
ModelFile(path: "vocab.json", bytes: 2_776_833),
|
ModelFile(path: "vocab.json", bytes: 2_776_833),
|
||||||
ModelFile(path: "merges.txt", bytes: 1_671_853),
|
ModelFile(path: "merges.txt", bytes: 1_671_853),
|
||||||
ModelFile(path: "special_tokens_map.json", bytes: 613),
|
ModelFile(path: "special_tokens_map.json", bytes: 613),
|
||||||
ModelFile(path: "added_tokens.json", bytes: 605),
|
ModelFile(path: "added_tokens.json", bytes: 707),
|
||||||
ModelFile(path: "chat_template.json", bytes: 1_050),
|
ModelFile(path: "generation_config.json", bytes: 269),
|
||||||
ModelFile(path: "preprocessor_config.json", bytes: 350),
|
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),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import Foundation
|
|||||||
nonisolated enum ModelKind: String, CaseIterable {
|
nonisolated enum ModelKind: String, CaseIterable {
|
||||||
/// 与 HuggingFace mlx-community 仓库名一一对应,也是沙盒 Models/ 下的子目录名。
|
/// 与 HuggingFace mlx-community 仓库名一一对应,也是沙盒 Models/ 下的子目录名。
|
||||||
case llm = "Qwen3-1.7B-4bit"
|
case llm = "Qwen3-1.7B-4bit"
|
||||||
case vl = "Qwen2.5-VL-3B-Instruct-4bit"
|
case vl = "Qwen3-VL-4B-Instruct-4bit"
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .llm: return "Qwen3-1.7B"
|
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? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .missingConfig:
|
case .missingConfig:
|
||||||
return "所选文件夹缺少 config.json,不是有效的模型目录"
|
return String(appLoc: "所选文件夹缺少 config.json,不是有效的模型目录")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
76
康康/AI/Prompts/DiaryAssistPrompts.swift
Normal 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
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
91
康康/AI/Prompts/HealthExportPrompts.swift
Normal 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
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// VL 模型(Qwen2.5-VL)用于体检 / 化验单识别的 prompt 模板。
|
/// VL 模型(Qwen3-VL)用于体检 / 化验单识别的 prompt 模板。
|
||||||
/// 输出契约:严格 JSON,无任何解释文字、markdown 围栏或前后缀。
|
/// 输出契约:严格 JSON,无任何解释文字、markdown 围栏或前后缀。
|
||||||
/// 解析失败 → CaptureService 回退到手动录入(§3.2 失败回退红线)。
|
/// 解析失败 → CaptureService 回退到手动录入(§3.2 失败回退红线)。
|
||||||
enum VLPrompts {
|
enum VLPrompts {
|
||||||
@@ -27,9 +27,21 @@ enum VLPrompts {
|
|||||||
/// ```
|
/// ```
|
||||||
/// `kind` 字段省略 —— UI 由 indicators 数量决定走 A2(单项)或 B3(多项)。
|
/// `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 围栏、不要任何前后缀文字。
|
你是一个医学体检报告识别助手。请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||||
|
|
||||||
|
今天的日期是 {{TODAY}}。
|
||||||
|
|
||||||
JSON schema(严格):
|
JSON schema(严格):
|
||||||
{
|
{
|
||||||
"title": string,
|
"title": string,
|
||||||
@@ -52,7 +64,8 @@ JSON schema(严格):
|
|||||||
规则:
|
规则:
|
||||||
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"。
|
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"。
|
||||||
- range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象。
|
- range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象。
|
||||||
- 无法识别的字段填空字符串(institution / summary)或合理默认值(report_date 用今天)。
|
- 无法识别的字段填空字符串(institution / summary)。
|
||||||
|
- report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。
|
||||||
- 不要发明指标。看不清的整行跳过。
|
- 不要发明指标。看不清的整行跳过。
|
||||||
- 化验单一般 type = "lab",体检套餐 = "checkup"。
|
- 化验单一般 type = "lab",体检套餐 = "checkup"。
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import MLX
|
|||||||
import MLXVLM
|
import MLXVLM
|
||||||
import MLXLMCommon
|
import MLXLMCommon
|
||||||
|
|
||||||
/// 封装 MLX VL 模型(Qwen2.5-VL)的图像 → 文本推理。
|
/// 封装 MLX VL 模型(Qwen3-VL)的图像 → 文本推理。
|
||||||
/// 与 LLMSession 同款 actor 隔离,串行化由上游 AIRuntime 统一保证。
|
/// 与 LLMSession 同款 actor 隔离,串行化由上游 AIRuntime 统一保证。
|
||||||
actor VLSession {
|
actor VLSession {
|
||||||
let container: ModelContainer
|
let container: ModelContainer
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import SwiftData
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct KangkangApp: App {
|
struct KangkangApp: App {
|
||||||
|
@State private var lang = LanguageManager.shared
|
||||||
|
|
||||||
var sharedModelContainer: ModelContainer = {
|
var sharedModelContainer: ModelContainer = {
|
||||||
let schema = Schema([
|
let schema = Schema([
|
||||||
Indicator.self,
|
Indicator.self,
|
||||||
@@ -39,7 +41,11 @@ struct KangkangApp: App {
|
|||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView()
|
AppLockContainer {
|
||||||
|
RootView()
|
||||||
|
.environment(\.locale, lang.locale)
|
||||||
|
.id(lang.current) // 语言切换 → 整树重建,即时生效
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.modelContainer(sharedModelContainer)
|
.modelContainer(sharedModelContainer)
|
||||||
}
|
}
|
||||||
|
|||||||
127
康康/App/Localization.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 540 B |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 540 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 807 KiB |
@@ -14,8 +14,13 @@ struct ArchiveListView: View {
|
|||||||
@Query(sort: \Symptom.startedAt, order: .reverse)
|
@Query(sort: \Symptom.startedAt, order: .reverse)
|
||||||
private var symptoms: [Symptom]
|
private var symptoms: [Symptom]
|
||||||
|
|
||||||
|
@Query(sort: \HealthExport.createdAt, order: .reverse)
|
||||||
|
private var exports: [HealthExport]
|
||||||
|
|
||||||
@State private var filter: TimelineKind? = nil
|
@State private var filter: TimelineKind? = nil
|
||||||
@State private var endingSymptom: Symptom?
|
@State private var endingSymptom: Symptom?
|
||||||
|
@State private var showExportSheet = false
|
||||||
|
@State private var showExportList = false
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private var allEntries: [TimelineEntry] {
|
private var allEntries: [TimelineEntry] {
|
||||||
@@ -35,6 +40,15 @@ struct ArchiveListView: View {
|
|||||||
private var totalCount: Int { allEntries.count }
|
private var totalCount: Int { allEntries.count }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
content
|
||||||
|
.navigationDestination(isPresented: $showExportList) {
|
||||||
|
HealthExportListView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var content: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
header
|
header
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -71,6 +85,9 @@ struct ArchiveListView: View {
|
|||||||
.sheet(item: $endingSymptom) { sym in
|
.sheet(item: $endingSymptom) { sym in
|
||||||
SymptomEndSheet(symptom: sym)
|
SymptomEndSheet(symptom: sym)
|
||||||
}
|
}
|
||||||
|
.fullScreenCover(isPresented: $showExportSheet) {
|
||||||
|
HealthExportSheet()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -93,17 +110,44 @@ struct ArchiveListView: View {
|
|||||||
Text("记录")
|
Text("记录")
|
||||||
.font(.tjTitle(26))
|
.font(.tjTitle(26))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text(totalCount == 0 ? "" : "\(totalCount) 条")
|
Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount) 条"))
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
showExportSheet = true
|
||||||
|
} label: {
|
||||||
|
Label("生成新导出", systemImage: "doc.text.below.ecg")
|
||||||
|
}
|
||||||
|
if !exports.isEmpty {
|
||||||
|
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 {
|
private var filterChips: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
chip(label: "全部", selected: filter == nil) { filter = nil }
|
chip(label: String(appLoc: "全部"), selected: filter == nil) { filter = nil }
|
||||||
ForEach(TimelineKind.allCases) { kind in
|
ForEach(TimelineKind.allCases) { kind in
|
||||||
chip(label: kind.label, selected: filter == kind) {
|
chip(label: kind.label, selected: filter == kind) {
|
||||||
filter = filter == kind ? nil : kind
|
filter = filter == kind ? nil : kind
|
||||||
@@ -152,9 +196,9 @@ struct ArchiveListView: View {
|
|||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
Spacer()
|
Spacer()
|
||||||
TjPlaceholder(label: "还没有任何记录\n点底部 + 号开始")
|
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
|
||||||
.frame(width: 240, height: 140)
|
.frame(width: 240, height: 140)
|
||||||
Text(filter == nil ? "记录会按时间归类显示" : "这个类别下没有记录")
|
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -166,6 +210,8 @@ struct ArchiveListView: View {
|
|||||||
#Preview {
|
#Preview {
|
||||||
ArchiveListView()
|
ArchiveListView()
|
||||||
.modelContainer(for: [
|
.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)
|
], inMemory: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ struct B1GuideView: View {
|
|||||||
.padding(.bottom, 26)
|
.padding(.bottom, 26)
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
OptCard(title: "单张报告", sub: "一张图,几秒搞定", hint: "化验单 · 处方", badge: nil, action: onSingle)
|
OptCard(title: String(appLoc: "单张报告"), sub: String(appLoc: "一张图,几秒搞定"), hint: String(appLoc: "化验单 · 处方"), badge: nil, action: onSingle)
|
||||||
OptCard(title: "多页报告", sub: "像扫描文档一样翻页拍摄", hint: "体检报告 · 影像报告", badge: "推荐", action: onMulti)
|
OptCard(title: String(appLoc: "多页报告"), sub: String(appLoc: "像扫描文档一样翻页拍摄"), hint: String(appLoc: "体检报告 · 影像报告"), badge: String(appLoc: "推荐"), action: onMulti)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 18)
|
Spacer(minLength: 18)
|
||||||
|
|||||||
@@ -63,16 +63,16 @@ struct B2ScanView: View {
|
|||||||
|
|
||||||
private var reportRows: [(String, String, String)] {
|
private var reportRows: [(String, String, String)] {
|
||||||
[
|
[
|
||||||
("总胆固醇", "5.42", "3.10–5.18"),
|
(String(appLoc: "总胆固醇"), "5.42", "3.10–5.18"),
|
||||||
("甘油三酯", "1.78", "0.45–1.70"),
|
(String(appLoc: "甘油三酯"), "1.78", "0.45–1.70"),
|
||||||
("低密度脂蛋白", "3.84↑", "<3.40"),
|
(String(appLoc: "低密度脂蛋白"), "3.84↑", "<3.40"),
|
||||||
("高密度脂蛋白", "1.21", ">1.04"),
|
(String(appLoc: "高密度脂蛋白"), "1.21", ">1.04"),
|
||||||
("载脂蛋白 A1", "1.42", "1.00–1.60"),
|
(String(appLoc: "载脂蛋白 A1"), "1.42", "1.00–1.60"),
|
||||||
("载脂蛋白 B", "1.04", "0.55–1.05"),
|
(String(appLoc: "载脂蛋白 B"), "1.04", "0.55–1.05"),
|
||||||
("谷丙转氨酶", "28", "9–50"),
|
(String(appLoc: "谷丙转氨酶"), "28", "9–50"),
|
||||||
("谷草转氨酶", "24", "15–40"),
|
(String(appLoc: "谷草转氨酶"), "24", "15–40"),
|
||||||
("空腹血糖", "5.4", "3.9–6.1"),
|
(String(appLoc: "空腹血糖"), "5.4", "3.9–6.1"),
|
||||||
("糖化血红蛋白", "5.7", "4.0–6.0"),
|
(String(appLoc: "糖化血红蛋白"), "5.7", "4.0–6.0"),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ struct B3MetaView: View {
|
|||||||
var onBack: () -> Void
|
var onBack: () -> Void
|
||||||
|
|
||||||
@State private var selectedType = 0
|
@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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ struct B4ProgressView: View {
|
|||||||
@State private var elapsed: Double = 0.2
|
@State private var elapsed: Double = 0.2
|
||||||
|
|
||||||
private let lineLabels = [
|
private let lineLabels = [
|
||||||
"正在本地识别第 1 / 3 页…",
|
String(appLoc: "正在本地识别第 1 / 3 页…"),
|
||||||
"正在本地识别第 2 / 3 页…",
|
String(appLoc: "正在本地识别第 2 / 3 页…"),
|
||||||
"正在本地识别第 3 / 3 页…",
|
String(appLoc: "正在本地识别第 3 / 3 页…"),
|
||||||
"提取指标 · 共 28 项",
|
String(appLoc: "提取指标 · 共 28 项"),
|
||||||
"生成整体摘要…",
|
String(appLoc: "生成整体摘要…"),
|
||||||
]
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -127,7 +127,7 @@ struct B4ProgressView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var speedBadge: some 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))
|
.font(.system(size: 10, design: .monospaced))
|
||||||
.tracking(0.6)
|
.tracking(0.6)
|
||||||
.foregroundStyle(Color.white.opacity(0.75))
|
.foregroundStyle(Color.white.opacity(0.75))
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ struct B5ResultView: View {
|
|||||||
@State private var normalsExpanded = false
|
@State private var normalsExpanded = false
|
||||||
|
|
||||||
let abnormal: [B5IndicatorData] = [
|
let abnormal: [B5IndicatorData] = [
|
||||||
.init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high,
|
.init(name: String(appLoc: "低密度脂蛋白胆固醇"), value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high,
|
||||||
note: "超过参考上限 0.44。建议关注饮食结构,3 个月内复查。"),
|
note: String(appLoc: "超过参考上限 0.44。建议关注饮食结构,3 个月内复查。")),
|
||||||
.init(name: "甘油三酯 TG", value: "1.78", unit: "mmol/L", range: "0.45–1.70", status: .high, note: nil),
|
.init(name: String(appLoc: "甘油三酯 TG"), value: "1.78", unit: "mmol/L", range: "0.45–1.70", status: .high, note: nil),
|
||||||
.init(name: "尿酸 UA", value: "428", unit: "μmol/L", range: "150–420", status: .high, note: nil),
|
.init(name: String(appLoc: "尿酸 UA"), value: "428", unit: "μmol/L", range: "150–420", status: .high, note: nil),
|
||||||
.init(name: "维生素 D", value: "18", unit: "ng/mL", range: "30–100", status: .low, note: nil),
|
.init(name: String(appLoc: "维生素 D"), value: "18", unit: "ng/mL", range: "30–100", status: .low, note: nil),
|
||||||
]
|
]
|
||||||
let normalCount = 24
|
let normalCount = 24
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ struct B5ResultView: View {
|
|||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
reportMeta.padding(.bottom, 16)
|
reportMeta.padding(.bottom, 16)
|
||||||
summaryCard.padding(.bottom, 18)
|
summaryCard.padding(.bottom, 18)
|
||||||
SectionLabel("异常项", count: abnormal.count, accent: .brick)
|
SectionLabel(String(appLoc: "异常项"), count: abnormal.count, accent: .brick)
|
||||||
.padding(.bottom, 10)
|
.padding(.bottom, 10)
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
ForEach(Array(abnormal.enumerated()), id: \.offset) { idx, it in
|
ForEach(Array(abnormal.enumerated()), id: \.offset) { idx, it in
|
||||||
@@ -44,7 +44,7 @@ struct B5ResultView: View {
|
|||||||
}
|
}
|
||||||
.padding(.bottom, 18)
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
SectionLabel("正常项", count: normalCount, accent: .leaf)
|
SectionLabel(String(appLoc: "正常项"), count: normalCount, accent: .leaf)
|
||||||
.padding(.bottom, 10)
|
.padding(.bottom, 10)
|
||||||
normalCollapsed
|
normalCollapsed
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ struct B5ResultView: View {
|
|||||||
private var reportMeta: some View {
|
private var reportMeta: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
TjBadge(text: "体检报告", style: .ink)
|
TjBadge(text: String(appLoc: "体检报告"), style: .ink)
|
||||||
Text("3 页")
|
Text("3 页")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
@@ -130,10 +130,10 @@ struct B5ResultView: View {
|
|||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
|
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
Stat(n: "28", label: "总项")
|
Stat(n: "28", label: String(appLoc: "总项"))
|
||||||
Stat(n: "3", label: "偏高", tone: .brick)
|
Stat(n: "3", label: String(appLoc: "偏高"), tone: .brick)
|
||||||
Stat(n: "1", label: "偏低", tone: .amber)
|
Stat(n: "1", label: String(appLoc: "偏低"), tone: .amber)
|
||||||
Stat(n: "24", label: "正常", tone: .leaf)
|
Stat(n: "24", label: String(appLoc: "正常"), tone: .leaf)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 14)
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
@@ -253,9 +253,9 @@ private struct IndicatorRow: View {
|
|||||||
}
|
}
|
||||||
var statusWord: String {
|
var statusWord: String {
|
||||||
switch item.status {
|
switch item.status {
|
||||||
case .high: return "偏高"
|
case .high: return String(appLoc: "偏高")
|
||||||
case .low: return "偏低"
|
case .low: return String(appLoc: "偏低")
|
||||||
case .normal: return "正常"
|
case .normal: return String(appLoc: "正常")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var valueColor: Color {
|
var valueColor: Color {
|
||||||
|
|||||||
189
康康/Features/Archive/HealthExportDetailView.swift
Normal 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)
|
||||||
|
}
|
||||||
137
康康/Features/Archive/HealthExportListView.swift
Normal 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)
|
||||||
|
}
|
||||||
548
康康/Features/Archive/HealthExportSheet.swift
Normal 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)
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ struct CaptureReviewForm: View {
|
|||||||
let warning: String?
|
let warning: String?
|
||||||
let onSave: (ParsedReport) -> Void
|
let onSave: (ParsedReport) -> Void
|
||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
|
/// 「重新识别」回调。assets 为空(写图失败)时传 nil,banner 上不显示该按钮。
|
||||||
|
var onReanalyze: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -36,10 +38,22 @@ struct CaptureReviewForm: View {
|
|||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
Text(text)
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.font(.system(size: 12))
|
Text(text)
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
.font(.system(size: 12))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.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)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
@@ -53,7 +67,7 @@ struct CaptureReviewForm: View {
|
|||||||
|
|
||||||
private var pageThumbnails: some View {
|
private var pageThumbnails: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("已保存 \(assets.count) 页(端侧加密)")
|
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
|
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
|
||||||
@@ -78,13 +92,13 @@ struct CaptureReviewForm: View {
|
|||||||
|
|
||||||
private var metaSection: some View {
|
private var metaSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
sectionLabel("基本信息")
|
sectionLabel(String(appLoc: "基本信息"))
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
labeledField("标题") {
|
labeledField(String(appLoc: "标题")) {
|
||||||
TextField("如:春季年度体检", text: $parsed.title)
|
TextField("如:春季年度体检", text: $parsed.title)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
}
|
}
|
||||||
labeledField("类型") {
|
labeledField(String(appLoc: "类型")) {
|
||||||
Picker("", selection: $parsed.typeRaw) {
|
Picker("", selection: $parsed.typeRaw) {
|
||||||
ForEach(ReportType.allCases, id: \.rawValue) { t in
|
ForEach(ReportType.allCases, id: \.rawValue) { t in
|
||||||
Text(t.label).tag(t.rawValue)
|
Text(t.label).tag(t.rawValue)
|
||||||
@@ -92,18 +106,18 @@ struct CaptureReviewForm: View {
|
|||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
}
|
}
|
||||||
labeledField("报告日期") {
|
labeledField(String(appLoc: "报告日期")) {
|
||||||
DatePicker("", selection: $parsed.reportDate,
|
DatePicker("", selection: $parsed.reportDate,
|
||||||
in: ...Date.now,
|
in: ...Date.now,
|
||||||
displayedComponents: .date)
|
displayedComponents: .date)
|
||||||
.datePickerStyle(.compact)
|
.datePickerStyle(.compact)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.environment(\.locale, Locale(identifier: "zh_CN"))
|
.environment(\.locale, Locale.current)
|
||||||
}
|
}
|
||||||
labeledField("机构(可选)") {
|
labeledField(String(appLoc: "机构(可选)")) {
|
||||||
TextField("如:协和医院", text: $parsed.institution)
|
TextField("如:协和医院", text: $parsed.institution)
|
||||||
}
|
}
|
||||||
labeledField("摘要(可选)") {
|
labeledField(String(appLoc: "摘要(可选)")) {
|
||||||
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
|
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
|
||||||
.lineLimit(1...3)
|
.lineLimit(1...3)
|
||||||
}
|
}
|
||||||
@@ -128,7 +142,7 @@ struct CaptureReviewForm: View {
|
|||||||
private var indicatorSection: some View {
|
private var indicatorSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
sectionLabel("指标(\(parsed.indicators.count) 项)")
|
sectionLabel(String(appLoc: "指标(\(parsed.indicators.count) 项)"))
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
parsed.indicators.append(
|
parsed.indicators.append(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
/// 拍报告 → VL 识别 → 编辑 → 保存(图 + 结构化文本)
|
/// 拍报告 → VL 识别 → 编辑 → 保存(图 + 结构化文本)
|
||||||
/// 一条统一流程,替代原 A1-A3 / B1-B5 两套 mockup。
|
/// 一条统一流程,替代原 A1-A3 / B1-B5 两套 mockup。
|
||||||
@@ -16,11 +17,17 @@ struct UnifiedCaptureFlow: View {
|
|||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
let onClose: () -> Void
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
@AppStorage("hasSeenCaptureTip") private var hasSeenCaptureTip: Bool = false
|
||||||
@State private var phase: Phase = .idle
|
@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 {
|
enum Phase {
|
||||||
case idle
|
case idle
|
||||||
case analyzing(images: [UIImage])
|
case analyzing(images: [UIImage], assets: [FileVault.SavedAsset]?)
|
||||||
case editing(parsed: ParsedReport,
|
case editing(parsed: ParsedReport,
|
||||||
assets: [FileVault.SavedAsset],
|
assets: [FileVault.SavedAsset],
|
||||||
warning: String?)
|
warning: String?)
|
||||||
@@ -32,20 +39,30 @@ struct UnifiedCaptureFlow: View {
|
|||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
Button("取消") { onClose() }
|
Button("取消") { cancelAll() }
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(phaseTitle)
|
.navigationTitle(phaseTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
if !hasSeenCaptureTip { showTip = true }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showTip) {
|
||||||
|
CaptureTipSheet(onDismiss: {
|
||||||
|
hasSeenCaptureTip = true
|
||||||
|
showTip = false
|
||||||
|
})
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var phaseTitle: String {
|
private var phaseTitle: String {
|
||||||
switch phase {
|
switch phase {
|
||||||
case .idle: return "拍摄报告"
|
case .idle: return String(appLoc: "拍摄报告")
|
||||||
case .analyzing: return "本地识别中…"
|
case .analyzing: return String(appLoc: "本地识别中…")
|
||||||
case .editing: return "核对识别结果"
|
case .editing: return String(appLoc: "核对识别结果")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,21 +71,57 @@ struct UnifiedCaptureFlow: View {
|
|||||||
switch phase {
|
switch phase {
|
||||||
case .idle:
|
case .idle:
|
||||||
captureEntry
|
captureEntry
|
||||||
case .analyzing(let images):
|
case .analyzing(let images, _):
|
||||||
AnalyzingView(images: images)
|
AnalyzingView(
|
||||||
|
images: images,
|
||||||
|
timeoutSeconds: analyzeTimeoutSeconds,
|
||||||
|
onCancel: {
|
||||||
|
analyzeTask?.cancel()
|
||||||
|
analyzeTask = nil
|
||||||
|
phase = .idle
|
||||||
|
}
|
||||||
|
)
|
||||||
case .editing(let parsed, let assets, let warning):
|
case .editing(let parsed, let assets, let warning):
|
||||||
CaptureReviewForm(
|
CaptureReviewForm(
|
||||||
parsed: parsed,
|
parsed: parsed,
|
||||||
assets: assets,
|
assets: assets,
|
||||||
warning: warning,
|
warning: warning,
|
||||||
onSave: { final in saveAll(parsed: final, assets: assets) },
|
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: - 入口:相机 / 相册
|
// MARK: - 入口:相机 / 相册
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
private var captureEntry: some View {
|
private var captureEntry: some View {
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
PhotoPickerSheet(
|
PhotoPickerSheet(
|
||||||
@@ -95,54 +148,124 @@ struct UnifiedCaptureFlow: View {
|
|||||||
|
|
||||||
private func startAnalyze(images: [UIImage]) {
|
private func startAnalyze(images: [UIImage]) {
|
||||||
guard !images.isEmpty else { onClose(); return }
|
guard !images.isEmpty else { onClose(); return }
|
||||||
phase = .analyzing(images: images)
|
analyzeTask?.cancel()
|
||||||
Task {
|
phase = .analyzing(images: images, assets: nil)
|
||||||
do {
|
let timeout = analyzeTimeoutSeconds
|
||||||
let result = try await CaptureService.shared.analyze(images: images)
|
analyzeTask = Task {
|
||||||
await MainActor.run {
|
// Step 1: 先把图写进 Vault。
|
||||||
phase = .editing(
|
// 在 UI 这一层写,而不是塞进 CaptureService.analyze —— 这样取消/失败回退时,
|
||||||
parsed: result.parsed,
|
// assets 已经在 phase 里,cancelAll 能清理孤儿,editingFallback 也不必再补写。
|
||||||
assets: result.assets,
|
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
|
||||||
warning: result.parsed.isEmpty
|
// 极端情况:用户在写图过程中按了「取消」,View 已 dismiss、cancelAll 看到的
|
||||||
? "识别没有读出指标,请手动补充"
|
// phase 还是 .analyzing(_, nil),清不到这批刚写完的图 — 这里手动收尾。
|
||||||
: nil
|
if Task.isCancelled {
|
||||||
)
|
for a in assets { try? FileVault.shared.remove(relativePath: a.relativePath) }
|
||||||
}
|
return
|
||||||
} catch let CaptureError.parseFailed(msg) {
|
}
|
||||||
// 解析失败:仍然展示编辑表单,只是 indicators 为空,assets 已保存
|
guard !assets.isEmpty else {
|
||||||
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 {
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
phase = .editing(
|
phase = .editing(
|
||||||
parsed: .empty(),
|
parsed: .empty(),
|
||||||
assets: [],
|
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 {
|
} 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 {
|
/// 「重新识别」:复用已存 assets,不再写图,只重跑 VL。
|
||||||
// 即便 VL 失败,图片应当已经写入了 Vault(在 CaptureService.analyze 第 1 步)。
|
private func reanalyze(assets: [FileVault.SavedAsset]) {
|
||||||
// 但若是 writeAsset 之前的失败(modelNotReady / inferenceFailed),
|
analyzeTask?.cancel()
|
||||||
// 这里再补一次写,保证图不丢。
|
// 这里没有原始 UIImage,AnalyzingView 显示首张缩略图即可
|
||||||
var assets: [FileVault.SavedAsset] = []
|
let thumbnails: [UIImage] = assets.compactMap {
|
||||||
for img in images {
|
try? FileVault.shared.loadImage(relativePath: $0.relativePath)
|
||||||
if let a = try? FileVault.shared.writeJPEG(img) { assets.append(a) }
|
|
||||||
}
|
}
|
||||||
|
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 {
|
await MainActor.run {
|
||||||
phase = .editing(
|
phase = .editing(parsed: .empty(), assets: assets, warning: msg)
|
||||||
parsed: .empty(),
|
|
||||||
assets: assets,
|
|
||||||
warning: msg
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +274,7 @@ struct UnifiedCaptureFlow: View {
|
|||||||
private func saveAll(parsed final: ParsedReport,
|
private func saveAll(parsed final: ParsedReport,
|
||||||
assets: [FileVault.SavedAsset]) {
|
assets: [FileVault.SavedAsset]) {
|
||||||
let report = Report(
|
let report = Report(
|
||||||
title: final.title.isEmpty ? "拍摄识别" : final.title,
|
title: final.title.isEmpty ? String(appLoc: "拍摄识别") : final.title,
|
||||||
type: ReportType(rawValue: final.typeRaw) ?? .other,
|
type: ReportType(rawValue: final.typeRaw) ?? .other,
|
||||||
reportDate: final.reportDate,
|
reportDate: final.reportDate,
|
||||||
institution: final.institution.isEmpty ? nil : final.institution,
|
institution: final.institution.isEmpty ? nil : final.institution,
|
||||||
@@ -190,6 +313,11 @@ struct UnifiedCaptureFlow: View {
|
|||||||
|
|
||||||
private struct AnalyzingView: View {
|
private struct AnalyzingView: View {
|
||||||
let images: [UIImage]
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
@@ -216,13 +344,72 @@ private struct AnalyzingView: View {
|
|||||||
Text("本地识别中")
|
Text("本地识别中")
|
||||||
.font(.tjH2())
|
.font(.tjH2())
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Text("\(images.count) 页 · 100% 本地推理")
|
Text("\(images.count) 页 · 100% 本地推理 · 已用 \(elapsed)s")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.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()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
/// 「健康记录」录入 sheet。
|
||||||
|
/// 主体仍是 DiaryEntry @Model;UI/文案改为面向健康记录,并加 AI 辅助区:
|
||||||
|
/// 让 Qwen3 从医生问诊角度提 3-4 个追问,用户可一键将「补充模板」追加到输入框。
|
||||||
|
/// 支持多轮——每轮把已问过的 q 传给 LLM 要求别重复;已采纳的 row 灰色 + ✓ 标记。
|
||||||
struct DiaryQuickSheet: View {
|
struct DiaryQuickSheet: View {
|
||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -8,9 +12,35 @@ struct DiaryQuickSheet: View {
|
|||||||
@State private var content: String = ""
|
@State private var content: String = ""
|
||||||
@State private var createdAt: Date = .now
|
@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
|
!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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -21,44 +51,70 @@ struct DiaryQuickSheet: View {
|
|||||||
.padding(.bottom, 14)
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("写日记")
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
.font(.tjH2())
|
Text("健康记录")
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.font(.tjH2())
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("本机保存")
|
Text("本机保存")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
ScrollViewReader { proxy in
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
ScrollView(showsIndicators: false) {
|
||||||
sectionLabel("内容")
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
TextField("今天怎么样?", text: $content, axis: .vertical)
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.lineLimit(4...10)
|
sectionLabel(String(appLoc: "内容"))
|
||||||
.padding(.horizontal, 14)
|
TextField("今天身体怎么样?吃了什么药、有什么感觉?",
|
||||||
.padding(.vertical, 12)
|
text: $content, axis: .vertical)
|
||||||
.background(
|
.lineLimit(3...8)
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
.focused($contentFocused)
|
||||||
.fill(Tj.Palette.paper)
|
.padding(.horizontal, 14)
|
||||||
)
|
.padding(.vertical, 12)
|
||||||
.overlay(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
.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)
|
||||||
}
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
.onChange(of: questions.count) { old, new in
|
||||||
sectionLabel("时间")
|
guard new > old else { return }
|
||||||
DatePicker("", selection: $createdAt, in: ...Date.now)
|
// 滚到新一轮的 round divider(让用户先看到「第 N 轮」的标签,
|
||||||
.datePickerStyle(.compact)
|
// 再依次看到这一轮的 questions)
|
||||||
.labelsHidden()
|
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) {
|
HStack(spacing: 12) {
|
||||||
Button("取消") { dismiss() }
|
Button("取消") { dismiss() }
|
||||||
@@ -76,12 +132,258 @@ struct DiaryQuickSheet: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||||
.ignoresSafeArea(edges: .bottom)
|
.ignoresSafeArea(edges: .bottom)
|
||||||
)
|
)
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.medium, .large], selection: $detent)
|
||||||
.presentationDragIndicator(.hidden)
|
.presentationDragIndicator(.hidden)
|
||||||
.presentationBackground(Tj.Palette.sand)
|
.presentationBackground(Tj.Palette.sand)
|
||||||
.presentationCornerRadius(Tj.Radius.xl)
|
.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 {
|
private func sectionLabel(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
@@ -89,6 +391,88 @@ struct DiaryQuickSheet: View {
|
|||||||
.foregroundStyle(Tj.Palette.text2)
|
.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() {
|
private func submit() {
|
||||||
guard canSubmit else { return }
|
guard canSubmit else { return }
|
||||||
let entry = DiaryEntry(
|
let entry = DiaryEntry(
|
||||||
@@ -100,3 +484,7 @@ struct DiaryQuickSheet: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
DiaryQuickSheet()
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,17 +69,17 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var todayLine: String {
|
private var todayLine: String {
|
||||||
let f = DateFormatter()
|
let now = Date()
|
||||||
f.locale = Locale(identifier: "zh_CN")
|
let day = now.formatted(.dateTime.month().day())
|
||||||
f.dateFormat = "M 月 d 日 · EEE"
|
let weekday = now.formatted(.dateTime.weekday(.abbreviated))
|
||||||
return f.string(from: Date())
|
return "\(day) · \(weekday)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var greetingWord: String {
|
private var greetingWord: String {
|
||||||
switch Calendar.current.component(.hour, from: Date()) {
|
switch Calendar.current.component(.hour, from: Date()) {
|
||||||
case 5..<12: return "早安"
|
case 5..<12: return String(appLoc: "早安")
|
||||||
case 12..<18: return "下午好"
|
case 12..<18: return String(appLoc: "下午好")
|
||||||
default: return "晚上好"
|
default: return String(appLoc: "晚上好")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ struct HomeView: View {
|
|||||||
|
|
||||||
Button(action: onTapArchive) {
|
Button(action: onTapArchive) {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
TjPlaceholder(label: "档案 · \(reports.count)")
|
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56, height: 56)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("我的报告档案")
|
Text("我的报告档案")
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ enum CustomMetricNameConflict: Equatable {
|
|||||||
var warningText: String {
|
var warningText: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .none: return ""
|
case .none: return ""
|
||||||
case .builtin(let n): return "「\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块"
|
case .builtin(let n): return String(appLoc: "「\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块")
|
||||||
case .existingCustom(let n):return "已经有一个叫「\(n)」的自定义指标"
|
case .existingCustom(let n):return String(appLoc: "已经有一个叫「\(n)」的自定义指标")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ struct CustomMetricEditor: View {
|
|||||||
|
|
||||||
private var nameSection: some View {
|
private var nameSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("名称")
|
sectionLabel(String(appLoc: "名称"))
|
||||||
TextField("例如:腰围 / 步数 / 睡眠时长", text: $name)
|
TextField("例如:腰围 / 步数 / 睡眠时长", text: $name)
|
||||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||||
.background(fieldBg)
|
.background(fieldBg)
|
||||||
@@ -161,7 +161,7 @@ struct CustomMetricEditor: View {
|
|||||||
|
|
||||||
private var unitSection: some View {
|
private var unitSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("单位(可选)")
|
sectionLabel(String(appLoc: "单位(可选)"))
|
||||||
TextField("例如:cm / 步 / 小时", text: $unit)
|
TextField("例如:cm / 步 / 小时", text: $unit)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||||
@@ -172,16 +172,16 @@ struct CustomMetricEditor: View {
|
|||||||
private var rangeRow: some View {
|
private var rangeRow: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
sectionLabel("参考范围(可选)")
|
sectionLabel(String(appLoc: "参考范围(可选)"))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("用于自动判定 正常/偏高/偏低")
|
Text("用于自动判定 正常/偏高/偏低")
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
rangeField(label: "下限", value: $lower, placeholder: "70")
|
rangeField(label: String(appLoc: "下限"), value: $lower, placeholder: "70")
|
||||||
Text("—").foregroundStyle(Tj.Palette.text3)
|
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 {
|
private var iconSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("图标")
|
sectionLabel(String(appLoc: "图标"))
|
||||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4),
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4),
|
||||||
spacing: 8) {
|
spacing: 8) {
|
||||||
ForEach(customMetricIconChoices, id: \.self) { sf in
|
ForEach(customMetricIconChoices, id: \.self) { sf in
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
private var monitorGridSection: some View {
|
private var monitorGridSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
sectionLabel("长期监测(进趋势)")
|
sectionLabel(String(appLoc: "长期监测(进趋势)"))
|
||||||
Spacer()
|
Spacer()
|
||||||
if !hiddenSet.isEmpty {
|
if !hiddenSet.isEmpty {
|
||||||
hiddenCountChip
|
hiddenCountChip
|
||||||
@@ -329,7 +329,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private var labPresetSection: some View {
|
private var labPresetSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("化验项快捷(不进趋势)")
|
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(labPresets) { p in
|
ForEach(labPresets) { p in
|
||||||
@@ -345,14 +345,14 @@ struct IndicatorQuickSheet: View {
|
|||||||
private var bpFieldSection: some View {
|
private var bpFieldSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
sectionLabel("收缩 / 舒张")
|
sectionLabel(String(appLoc: "收缩 / 舒张"))
|
||||||
Spacer()
|
Spacer()
|
||||||
bpRangeHint
|
bpRangeHint
|
||||||
}
|
}
|
||||||
HStack(spacing: 12) {
|
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)
|
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)
|
Text("mmHg").foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
bpStatusChips
|
bpStatusChips
|
||||||
@@ -396,10 +396,10 @@ struct IndicatorQuickSheet: View {
|
|||||||
private var bpStatusChips: some View {
|
private var bpStatusChips: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if let s = computedBPStatus(.systolic) {
|
if let s = computedBPStatus(.systolic) {
|
||||||
statusBadge("收缩 " + s.label, color: s.color)
|
statusBadge(String(appLoc: "收缩 ") + s.label, color: s.color)
|
||||||
}
|
}
|
||||||
if let s = computedBPStatus(.diastolic) {
|
if let s = computedBPStatus(.diastolic) {
|
||||||
statusBadge("舒张 " + s.label, color: s.color)
|
statusBadge(String(appLoc: "舒张 ") + s.label, color: s.color)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -407,7 +407,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private var nameSection: some View {
|
private var nameSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("指标名")
|
sectionLabel(String(appLoc: "指标名"))
|
||||||
TextField("例如:血红蛋白", text: $name)
|
TextField("例如:血红蛋白", text: $name)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
@@ -427,7 +427,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
private var valueRow: some View {
|
private var valueRow: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("数值")
|
sectionLabel(String(appLoc: "数值"))
|
||||||
TextField(monitorFieldPlaceholder, text: $value)
|
TextField(monitorFieldPlaceholder, text: $value)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
.font(.system(size: 18, weight: .semibold, design: .monospaced))
|
.font(.system(size: 18, weight: .semibold, design: .monospaced))
|
||||||
@@ -437,7 +437,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
.overlay(fieldBorder)
|
.overlay(fieldBorder)
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("单位")
|
sectionLabel(String(appLoc: "单位"))
|
||||||
TextField("mmol/L", text: $unit)
|
TextField("mmol/L", text: $unit)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
@@ -455,7 +455,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
private var rangeSection: some View {
|
private var rangeSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
sectionLabel("参考范围")
|
sectionLabel(String(appLoc: "参考范围"))
|
||||||
Spacer()
|
Spacer()
|
||||||
if let m = selectedMonitor, m != .bloodPressure {
|
if let m = selectedMonitor, m != .bloodPressure {
|
||||||
monitorRangeHint(m)
|
monitorRangeHint(m)
|
||||||
@@ -486,11 +486,11 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private var statusSection: some View {
|
private var statusSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("状态")
|
sectionLabel(String(appLoc: "状态"))
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
statusChip(.normal, label: "正常", color: Tj.Palette.leaf)
|
statusChip(.normal, label: String(appLoc: "正常"), color: Tj.Palette.leaf)
|
||||||
statusChip(.high, label: "偏高 ↑", color: Tj.Palette.brick)
|
statusChip(.high, label: String(appLoc: "偏高 ↑"), color: Tj.Palette.brick)
|
||||||
statusChip(.low, label: "偏低 ↓", color: Tj.Palette.amber)
|
statusChip(.low, label: String(appLoc: "偏低 ↓"), color: Tj.Palette.amber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,7 +498,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
private var autoStatusHint: some View {
|
private var autoStatusHint: some View {
|
||||||
let auto = computedSingleStatus
|
let auto = computedSingleStatus
|
||||||
return HStack(spacing: 8) {
|
return HStack(spacing: 8) {
|
||||||
sectionLabel("状态(按数值自动判)")
|
sectionLabel(String(appLoc: "状态(按数值自动判)"))
|
||||||
if let s = auto {
|
if let s = auto {
|
||||||
statusBadge(s.label, color: s.color)
|
statusBadge(s.label, color: s.color)
|
||||||
} else {
|
} else {
|
||||||
@@ -511,7 +511,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private var timeSection: some View {
|
private var timeSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("测量时间")
|
sectionLabel(String(appLoc: "测量时间"))
|
||||||
DatePicker("", selection: $capturedAt, in: ...Date.now)
|
DatePicker("", selection: $capturedAt, in: ...Date.now)
|
||||||
.datePickerStyle(.compact)
|
.datePickerStyle(.compact)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
@@ -520,7 +520,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
|
|
||||||
private var noteSection: some View {
|
private var noteSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("备注(可选)")
|
sectionLabel(String(appLoc: "备注(可选)"))
|
||||||
TextField("例如:空腹采血", text: $note, axis: .vertical)
|
TextField("例如:空腹采血", text: $note, axis: .vertical)
|
||||||
.lineLimit(1...3)
|
.lineLimit(1...3)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
@@ -535,7 +535,7 @@ struct IndicatorQuickSheet: View {
|
|||||||
private var reminderSection: some View {
|
private var reminderSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
sectionLabel("周期提醒")
|
sectionLabel(String(appLoc: "周期提醒"))
|
||||||
Spacer()
|
Spacer()
|
||||||
Toggle("", isOn: $reminderEnabled)
|
Toggle("", isOn: $reminderEnabled)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
@@ -570,13 +570,13 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
weekdayPickerRow
|
weekdayPickerRow
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
quickFreqChip("每天") {
|
quickFreqChip(String(appLoc: "每天")) {
|
||||||
reminderWeekdays = Set(1...7)
|
reminderWeekdays = Set(1...7)
|
||||||
}
|
}
|
||||||
quickFreqChip("工作日") {
|
quickFreqChip(String(appLoc: "工作日")) {
|
||||||
reminderWeekdays = Set([2, 3, 4, 5, 6])
|
reminderWeekdays = Set([2, 3, 4, 5, 6])
|
||||||
}
|
}
|
||||||
quickFreqChip("周末") {
|
quickFreqChip(String(appLoc: "周末")) {
|
||||||
reminderWeekdays = Set([1, 7])
|
reminderWeekdays = Set([1, 7])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -600,15 +600,23 @@ struct IndicatorQuickSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var reminderFrequencyLabel: String {
|
private var reminderFrequencyLabel: String {
|
||||||
if reminderWeekdays.count == 7 { return "每天" }
|
if reminderWeekdays.count == 7 { return String(appLoc: "每天") }
|
||||||
if reminderWeekdays.isEmpty { return "未选" }
|
if reminderWeekdays.isEmpty { return String(appLoc: "未选") }
|
||||||
let names = ["日", "一", "二", "三", "四", "五", "六"]
|
let names = [
|
||||||
|
String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"),
|
||||||
|
String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"),
|
||||||
|
String(appLoc: "六"),
|
||||||
|
]
|
||||||
let sorted = reminderWeekdays.sorted()
|
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 {
|
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 编号)
|
let weekdayValues = [2, 3, 4, 5, 6, 7, 1] // 周一到周日(Apple Calendar 编号)
|
||||||
return HStack(spacing: 6) {
|
return HStack(spacing: 6) {
|
||||||
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
|
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
|
||||||
@@ -1074,9 +1082,9 @@ struct IndicatorQuickSheet: View {
|
|||||||
private extension IndicatorStatus {
|
private extension IndicatorStatus {
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .normal: return "正常"
|
case .normal: return String(appLoc: "正常")
|
||||||
case .high: return "偏高 ↑"
|
case .high: return String(appLoc: "偏高 ↑")
|
||||||
case .low: return "偏低 ↓"
|
case .low: return String(appLoc: "偏低 ↓")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
/// 「我的 · 关于」——本软件基本介绍、使用注意与免责声明。
|
/// 「我的 · 关于」——本软件基本介绍、使用注意与免责声明。
|
||||||
/// 纯静态阅读页,不调任何 Service / AIRuntime,复用现有 DesignSystem token。
|
/// 纯静态阅读页,不调任何 Service / AIRuntime,复用现有 DesignSystem token。
|
||||||
|
/// 文案按 App Store 上架合规口径撰写:避免绝对化用语、精确区分本地/联网行为、强化医疗免责。
|
||||||
struct AboutView: View {
|
struct AboutView: View {
|
||||||
/// 真实读取 Bundle 版本号,避免硬编码与实际发版脱节。
|
/// 真实读取 Bundle 版本号,避免硬编码与实际发版脱节。
|
||||||
private var versionText: String {
|
private var versionText: String {
|
||||||
@@ -19,45 +20,53 @@ struct AboutView: View {
|
|||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
header
|
header
|
||||||
|
|
||||||
section(icon: "sparkles", title: "这是什么") {
|
section(icon: "sparkles", title: String(appLoc: "这是什么")) {
|
||||||
paragraph(
|
paragraph(
|
||||||
"康康是一款以本地优先为设计原则的个人健康影像档案工具。" +
|
String(appLoc: "康康是一款以本地优先为设计原则的个人健康影像档案工具。") +
|
||||||
"你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;" +
|
String(appLoc: "你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;") +
|
||||||
"设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。"
|
String(appLoc: "设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
section(icon: "checklist", title: "主要功能") {
|
section(icon: "checklist", title: String(appLoc: "主要功能")) {
|
||||||
bullet("拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档")
|
bullet(String(appLoc: "拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档"))
|
||||||
bullet("通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明")
|
bullet(String(appLoc: "通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明"))
|
||||||
bullet("长期趋势:关注的指标可生成折线图和简要解读")
|
bullet(String(appLoc: "长期趋势:关注的指标可生成折线图和简要解读"))
|
||||||
bullet("本地问答:基于你自己的档案问答,引用可点击回链到原记录")
|
bullet(String(appLoc: "本地问答:基于你自己的档案问答,引用可点击回链到原记录"))
|
||||||
bullet("隐私优先:健康数据不上传、无需注册账号")
|
bullet(String(appLoc: "隐私优先:健康数据不上传、无需注册账号"))
|
||||||
}
|
}
|
||||||
|
|
||||||
section(icon: "lock.shield", title: "隐私保护") {
|
section(icon: "iphone", title: String(appLoc: "设备要求"), tint: Tj.Palette.leaf) {
|
||||||
bullet("AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据。")
|
bullet(String(appLoc: "系统:iOS 17 或更新版本。"))
|
||||||
bullet("原图与数据库采用系统级文件加密,随设备锁屏受到保护。")
|
bullet(String(appLoc: "本地 AI 功能(拍照识别、解读、问答)需要约 8GB 内存,") +
|
||||||
bullet("支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份。")
|
String(appLoc: "推荐 iPhone 15 Pro / Pro Max 及之后发布的机型(含 iPhone 16 系列)。"))
|
||||||
bullet("可选开启 Face ID 启动锁,进一步保护隐私。")
|
bullet(String(appLoc: "在内存较小的旧机型上,App 仍可用于手动记录、归档与查看,") +
|
||||||
|
String(appLoc: "但本地 AI 相关功能可能无法运行。"))
|
||||||
}
|
}
|
||||||
|
|
||||||
section(icon: "exclamationmark.triangle", title: "使用注意", tint: Tj.Palette.amber) {
|
section(icon: "lock.shield", title: String(appLoc: "隐私保护")) {
|
||||||
bullet("本地 AI 模型体积较大(约 3GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;" +
|
bullet(String(appLoc: "AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据。"))
|
||||||
"模型未就绪时 App 仍可使用,AI 功能会提示前往下载。")
|
bullet(String(appLoc: "原图与数据库采用系统级文件加密,随设备锁屏受到保护。"))
|
||||||
bullet("AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对," +
|
bullet(String(appLoc: "支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份。"))
|
||||||
"并以原始报告 / 化验单为准。")
|
bullet(String(appLoc: "可选开启 Face ID 启动锁,进一步保护隐私。"))
|
||||||
bullet("AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。")
|
|
||||||
bullet("数据保存在本设备:卸载 App 或删除数据后可能无法恢复,重要资料请自行留存原件。")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
section(icon: "hand.raised", title: "免责声明", tint: Tj.Palette.brick) {
|
section(icon: "exclamationmark.triangle", title: String(appLoc: "使用注意"), tint: Tj.Palette.amber) {
|
||||||
bullet("康康是一款健康信息记录与参考工具,并非医疗器械,不提供医疗诊断、用药或剂量建议、急诊判断等医疗服务。")
|
bullet(String(appLoc: "本地 AI 模型体积较大(约 4GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;") +
|
||||||
bullet("App 内所有 AI 生成的解读、趋势分析与问答内容仅供信息参考," +
|
String(appLoc: "模型未就绪时 App 仍可使用,AI 功能会提示前往下载。"))
|
||||||
"不构成医疗建议,也不能替代执业医师、药师或其他专业人员的面诊、检查与意见。")
|
bullet(String(appLoc: "AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对,") +
|
||||||
bullet("任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。")
|
String(appLoc: "并以原始报告 / 化验单为准。"))
|
||||||
bullet("如出现身体不适或紧急情况,请及时就医或拨打当地急救电话,请勿依赖本 App 进行判断。")
|
bullet(String(appLoc: "AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。"))
|
||||||
bullet("在适用法律允许的范围内,因使用本 App 或依赖其中内容所产生的后果,由使用者自行承担。")
|
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)")
|
Text("康康 · 本地优先的健康档案 · \(versionText)")
|
||||||
@@ -65,6 +74,12 @@ struct AboutView: View {
|
|||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
Text("本 App 仅供健康信息记录与参考,不能替代专业医疗意见。")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
Spacer(minLength: 32)
|
Spacer(minLength: 32)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ struct CustomMetricsListView: View {
|
|||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
Spacer(minLength: 40)
|
Spacer(minLength: 40)
|
||||||
TjPlaceholder(label: "还没有自定义指标")
|
TjPlaceholder(label: String(appLoc: "还没有自定义指标"))
|
||||||
.frame(width: 220, height: 130)
|
.frame(width: 220, height: 130)
|
||||||
Text("右上角 + 新建一个")
|
Text("右上角 + 新建一个")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
@@ -118,7 +118,7 @@ struct CustomMetricsListView: View {
|
|||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
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))
|
.font(.system(size: 11, weight: count > 0 ? .semibold : .regular))
|
||||||
.foregroundStyle(count > 0 ? Tj.Palette.ink : Tj.Palette.text3)
|
.foregroundStyle(count > 0 ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
|
|||||||
65
康康/Features/Me/LanguageSettingsView.swift
Normal 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() }
|
||||||
|
}
|
||||||
@@ -4,13 +4,15 @@ import SwiftData
|
|||||||
struct MeView: View {
|
struct MeView: View {
|
||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
@Query private var profiles: [UserProfile]
|
@Query private var profiles: [UserProfile]
|
||||||
@Query private var reminders: [MetricReminder]
|
|
||||||
@Query private var customMetrics: [CustomMonitorMetric]
|
@Query private var customMetrics: [CustomMonitorMetric]
|
||||||
|
|
||||||
@State private var downloadService = ModelDownloadService.shared
|
@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 profile: UserProfile? { profiles.first }
|
||||||
private var enabledReminderCount: Int { reminders.filter(\.enabled).count }
|
|
||||||
|
|
||||||
/// 真实读取 Bundle 版本号,与「关于」页保持一致。
|
/// 真实读取 Bundle 版本号,与「关于」页保持一致。
|
||||||
private var appVersionText: String {
|
private var appVersionText: String {
|
||||||
@@ -23,16 +25,14 @@ struct MeView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
profileCard
|
profileCard
|
||||||
remindersCard
|
|
||||||
customMetricsCard
|
customMetricsCard
|
||||||
modelManagementCard
|
modelManagementCard
|
||||||
settingsCard(title: "Face ID 启动锁",
|
languageCard
|
||||||
detail: "关闭",
|
faceIDCard
|
||||||
icon: "faceid")
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
AboutView()
|
AboutView()
|
||||||
} label: {
|
} label: {
|
||||||
settingsCard(title: "关于",
|
settingsCard(title: String(appLoc: "关于"),
|
||||||
detail: appVersionText,
|
detail: appVersionText,
|
||||||
icon: "info.circle")
|
icon: "info.circle")
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,7 @@ struct MeView: View {
|
|||||||
_ = UserProfileStore.loadOrCreate(in: ctx)
|
_ = UserProfileStore.loadOrCreate(in: ctx)
|
||||||
}
|
}
|
||||||
downloadService.refreshStates()
|
downloadService.refreshStates()
|
||||||
|
appLock.refreshAvailability()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,46 +90,6 @@ struct MeView: View {
|
|||||||
.buttonStyle(.plain)
|
.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 {
|
private var customMetricsCard: some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
CustomMetricsListView()
|
CustomMetricsListView()
|
||||||
@@ -164,25 +125,84 @@ struct MeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var customMetricsLine: String {
|
private var customMetricsLine: String {
|
||||||
if customMetrics.isEmpty { return "添加你自己的长期监测项" }
|
if customMetrics.isEmpty { return String(appLoc: "添加你自己的长期监测项") }
|
||||||
return "\(customMetrics.count) 项"
|
return String(appLoc: "\(customMetrics.count) 项")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var modelManagementCard: some View {
|
private var modelManagementCard: some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ModelManagementView()
|
ModelManagementView()
|
||||||
} label: {
|
} label: {
|
||||||
settingsCard(title: "模型管理", detail: modelDetail, icon: "cpu")
|
settingsCard(title: String(appLoc: "模型管理"), detail: modelDetail, icon: "cpu")
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var modelDetail: String {
|
private var modelDetail: String {
|
||||||
let states = downloadService.states
|
let states = downloadService.states
|
||||||
if ModelKind.allCases.allSatisfy({ states[$0]?.phase == .ready }) { return "已就绪" }
|
if ModelKind.allCases.allSatisfy({ states[$0]?.phase == .ready }) { return String(appLoc: "已就绪") }
|
||||||
if downloadService.isAnyDownloading { return "下载中…" }
|
if downloadService.isAnyDownloading { return String(appLoc: "下载中…") }
|
||||||
let readyCount = ModelKind.allCases.filter { states[$0]?.phase == .ready }.count
|
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 {
|
private func settingsCard(title: String, detail: String, icon: String) -> some View {
|
||||||
@@ -212,7 +232,7 @@ struct MeView: View {
|
|||||||
|
|
||||||
private var profileLine: String {
|
private var profileLine: String {
|
||||||
guard let p = profile, p.hasAnyBasics else {
|
guard let p = profile, p.hasAnyBasics else {
|
||||||
return "点这里完善你的资料"
|
return String(appLoc: "点这里完善你的资料")
|
||||||
}
|
}
|
||||||
return p.summaryLine
|
return p.summaryLine
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,11 +132,11 @@ struct ModelManagementView: View {
|
|||||||
|
|
||||||
private func statusBadge(_ phase: DownloadPhase) -> some View {
|
private func statusBadge(_ phase: DownloadPhase) -> some View {
|
||||||
switch phase {
|
switch phase {
|
||||||
case .idle: return TjBadge(text: "待下载", style: .neutral)
|
case .idle: return TjBadge(text: String(appLoc: "待下载"), style: .neutral)
|
||||||
case .downloading: return TjBadge(text: "下载中", style: .amber)
|
case .downloading: return TjBadge(text: String(appLoc: "下载中"), style: .amber)
|
||||||
case .verifying: return TjBadge(text: "校验中", style: .amber)
|
case .verifying: return TjBadge(text: String(appLoc: "校验中"), style: .amber)
|
||||||
case .ready: return TjBadge(text: "已就绪", style: .leaf)
|
case .ready: return TjBadge(text: String(appLoc: "已就绪"), style: .leaf)
|
||||||
case .failed: return TjBadge(text: "失败 · 重试", style: .brick)
|
case .failed: return TjBadge(text: String(appLoc: "失败 · 重试"), style: .brick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,13 +199,14 @@ struct ModelManagementView: View {
|
|||||||
|
|
||||||
let name = folder.lastPathComponent
|
let name = folder.lastPathComponent
|
||||||
guard let kind = ModelKind.allCases.first(where: { $0.rawValue == name }) else {
|
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
|
return
|
||||||
}
|
}
|
||||||
try service.importModel(kind, from: folder)
|
try service.importModel(kind, from: folder)
|
||||||
importError = nil
|
importError = nil
|
||||||
} catch {
|
} catch {
|
||||||
importError = "导入失败:\(error.localizedDescription)"
|
importError = String(appLoc: "导入失败:\(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,8 +218,8 @@ struct ModelManagementView: View {
|
|||||||
|
|
||||||
private func subtitle(_ kind: ModelKind) -> String {
|
private func subtitle(_ kind: ModelKind) -> String {
|
||||||
switch kind {
|
switch kind {
|
||||||
case .llm: return "文本解读 · 趋势 / 问答"
|
case .llm: return String(appLoc: "文本解读 · 趋势 / 问答")
|
||||||
case .vl: return "拍照识别报告 → 结构化指标"
|
case .vl: return String(appLoc: "拍照识别报告 → 结构化指标")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ struct ModelSelfTestView: View {
|
|||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .idle: return "未开始"
|
case .idle: return String(appLoc: "未开始")
|
||||||
case .loading: return "加载模型…"
|
case .loading: return String(appLoc: "加载模型…")
|
||||||
case .running: return "推理中…"
|
case .running: return String(appLoc: "推理中…")
|
||||||
case .done: return "完成 ✓"
|
case .done: return String(appLoc: "完成 ✓")
|
||||||
case .failed(let m): return "失败:\(m)"
|
case .failed(let m): return String(appLoc: "失败:\(m)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ import SwiftData
|
|||||||
|
|
||||||
struct RemindersListView: View {
|
struct RemindersListView: View {
|
||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
|
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
|
||||||
private var reminders: [MetricReminder]
|
private var reminders: [MetricReminder]
|
||||||
|
|
||||||
|
/// 以 sheet 形态呈现(从「新建」入口进入)时补一个「完成」按钮关闭;
|
||||||
|
/// push 形态(我的 → 记录提醒)有系统返回,默认 false。
|
||||||
|
var presentedAsSheet = false
|
||||||
|
|
||||||
@State private var editingId: String?
|
@State private var editingId: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -33,6 +38,13 @@ struct RemindersListView: View {
|
|||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
.navigationTitle("记录提醒")
|
.navigationTitle("记录提醒")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
if presentedAsSheet {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("完成") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
@@ -50,7 +62,7 @@ struct RemindersListView: View {
|
|||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Spacer(minLength: 40)
|
Spacer(minLength: 40)
|
||||||
TjPlaceholder(label: "还没有记录提醒\n去「+ 指标记录」录入时打开")
|
TjPlaceholder(label: String(appLoc: "还没有记录提醒\n去「+ 指标记录」录入时打开"))
|
||||||
.frame(width: 240, height: 140)
|
.frame(width: 240, height: 140)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -182,7 +194,11 @@ private struct ReminderRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var weekdayRow: some 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]
|
let weekdayValues = [2, 3, 4, 5, 6, 7, 1]
|
||||||
return HStack(spacing: 6) {
|
return HStack(spacing: 6) {
|
||||||
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
|
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ enum MonitorMetric: String, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .bloodPressure: return "血压"
|
case .bloodPressure: return String(appLoc: "血压")
|
||||||
case .fastingGlucose: return "空腹血糖"
|
case .fastingGlucose: return String(appLoc: "空腹血糖")
|
||||||
case .postprandialGlucose: return "餐后血糖"
|
case .postprandialGlucose: return String(appLoc: "餐后血糖")
|
||||||
case .temperature: return "体温"
|
case .temperature: return String(appLoc: "体温")
|
||||||
case .heartRate: return "心率"
|
case .heartRate: return String(appLoc: "心率")
|
||||||
case .spo2: return "血氧"
|
case .spo2: return String(appLoc: "血氧")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,43 +45,43 @@ enum MonitorMetric: String, CaseIterable, Identifiable {
|
|||||||
case .bloodPressure:
|
case .bloodPressure:
|
||||||
return [
|
return [
|
||||||
Field(seriesKey: "bp.systolic",
|
Field(seriesKey: "bp.systolic",
|
||||||
label: "收缩压",
|
label: String(appLoc: "收缩压"),
|
||||||
unit: "mmHg",
|
unit: "mmHg",
|
||||||
placeholder: "120",
|
placeholder: "120",
|
||||||
baseRange: 90...140),
|
baseRange: 90...140),
|
||||||
Field(seriesKey: "bp.diastolic",
|
Field(seriesKey: "bp.diastolic",
|
||||||
label: "舒张压",
|
label: String(appLoc: "舒张压"),
|
||||||
unit: "mmHg",
|
unit: "mmHg",
|
||||||
placeholder: "80",
|
placeholder: "80",
|
||||||
baseRange: 60...90),
|
baseRange: 60...90),
|
||||||
]
|
]
|
||||||
case .fastingGlucose:
|
case .fastingGlucose:
|
||||||
return [Field(seriesKey: "glucose.fasting",
|
return [Field(seriesKey: "glucose.fasting",
|
||||||
label: "空腹血糖",
|
label: String(appLoc: "空腹血糖"),
|
||||||
unit: "mmol/L",
|
unit: "mmol/L",
|
||||||
placeholder: "5.0",
|
placeholder: "5.0",
|
||||||
baseRange: 3.9...6.1)]
|
baseRange: 3.9...6.1)]
|
||||||
case .postprandialGlucose:
|
case .postprandialGlucose:
|
||||||
return [Field(seriesKey: "glucose.postprandial",
|
return [Field(seriesKey: "glucose.postprandial",
|
||||||
label: "餐后 2h",
|
label: String(appLoc: "餐后 2h"),
|
||||||
unit: "mmol/L",
|
unit: "mmol/L",
|
||||||
placeholder: "6.5",
|
placeholder: "6.5",
|
||||||
baseRange: 0...7.8)]
|
baseRange: 0...7.8)]
|
||||||
case .temperature:
|
case .temperature:
|
||||||
return [Field(seriesKey: "temperature",
|
return [Field(seriesKey: "temperature",
|
||||||
label: "体温",
|
label: String(appLoc: "体温"),
|
||||||
unit: "°C",
|
unit: "°C",
|
||||||
placeholder: "36.5",
|
placeholder: "36.5",
|
||||||
baseRange: 36.0...37.2)]
|
baseRange: 36.0...37.2)]
|
||||||
case .heartRate:
|
case .heartRate:
|
||||||
return [Field(seriesKey: "heart_rate",
|
return [Field(seriesKey: "heart_rate",
|
||||||
label: "心率",
|
label: String(appLoc: "心率"),
|
||||||
unit: "bpm",
|
unit: "bpm",
|
||||||
placeholder: "72",
|
placeholder: "72",
|
||||||
baseRange: 60...100)]
|
baseRange: 60...100)]
|
||||||
case .spo2:
|
case .spo2:
|
||||||
return [Field(seriesKey: "spo2",
|
return [Field(seriesKey: "spo2",
|
||||||
label: "血氧",
|
label: String(appLoc: "血氧"),
|
||||||
unit: "%",
|
unit: "%",
|
||||||
placeholder: "98",
|
placeholder: "98",
|
||||||
baseRange: 95...100)]
|
baseRange: 95...100)]
|
||||||
@@ -101,7 +101,7 @@ extension MonitorMetric {
|
|||||||
|
|
||||||
/// 给 IndicatorRecordSheet 显示在数值旁的「90-140 mmHg」字样。
|
/// 给 IndicatorRecordSheet 显示在数值旁的「90-140 mmHg」字样。
|
||||||
func rangeText(_ range: ClosedRange<Double>?) -> String {
|
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 lower = format(r.lowerBound)
|
||||||
let upper = format(r.upperBound)
|
let upper = format(r.upperBound)
|
||||||
// 餐后血糖 baseRange 是 0...7.8,显示成「<7.8」
|
// 餐后血糖 baseRange 是 0...7.8,显示成「<7.8」
|
||||||
|
|||||||
@@ -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 {
|
private struct ProfileEditForm: View {
|
||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
@Bindable var profile: UserProfile
|
@Bindable var profile: UserProfile
|
||||||
|
|
||||||
@State private var newAllergy = ""
|
|
||||||
@State private var newFamilyEntry = ""
|
|
||||||
@State private var newMedication = ""
|
|
||||||
@State private var newCustomCondition = ""
|
|
||||||
|
|
||||||
private static let chronicPresets = [
|
|
||||||
"高血压", "糖尿病", "冠心病", "高血脂",
|
|
||||||
"甲状腺疾病", "哮喘", "慢性肾病", "抑郁/焦虑",
|
|
||||||
]
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
basicsSection
|
Section {
|
||||||
chronicSection
|
BirthYearRow(profile: profile)
|
||||||
allergySection
|
SexRow(profile: profile)
|
||||||
familySection
|
HeightRow(profile: profile)
|
||||||
medicationSection
|
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("个人资料")
|
.navigationTitle("个人资料")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -49,48 +68,75 @@ private struct ProfileEditForm: View {
|
|||||||
try? ctx.save()
|
try? ctx.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 基本
|
// MARK: - 基本:逐行子视图(各自只读一个字段,失效互不牵连)
|
||||||
|
|
||||||
private var basicsSection: some View {
|
/// 出生年份:点击行展开 `.wheel` 滚轮,折叠时只是一行文字 —— 不构建 126 项列表。
|
||||||
Section {
|
private struct BirthYearRow: View {
|
||||||
birthYearPicker
|
@Bindable var profile: UserProfile
|
||||||
sexPicker
|
@State private var expanded = false
|
||||||
heightRow
|
|
||||||
weightRow
|
private var currentYear: Int {
|
||||||
bloodTypePicker
|
Calendar.current.component(.year, from: .now)
|
||||||
} header: {
|
|
||||||
Text("基本")
|
|
||||||
} footer: {
|
|
||||||
if let bmi = profile.bmi {
|
|
||||||
Text("BMI: \(String(format: "%.1f", bmi)) \(bmiLabel(bmi))")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func bmiLabel(_ bmi: Double) -> String {
|
/// 年份倒序数组。本行仅在 birthYear / expanded 变化时重算,与其他字段编辑解耦;
|
||||||
switch bmi {
|
/// 且 `years` 只在滚轮展开(body 实际读它)时才被遍历构建。
|
||||||
case ..<18.5: return "(偏瘦)"
|
private var years: [Int] {
|
||||||
case ..<24: return "(正常)"
|
Array((1900...currentYear).reversed())
|
||||||
case ..<28: return "(超重)"
|
|
||||||
default: return "(肥胖)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var birthYearPicker: some View {
|
private var selectedLabel: String {
|
||||||
Picker("出生年份", selection: Binding(
|
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 },
|
get: { profile.birthYear ?? 0 },
|
||||||
set: { profile.birthYear = $0 == 0 ? nil : $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(
|
Picker("性别", selection: Binding(
|
||||||
get: { profile.sex },
|
get: { profile.sex },
|
||||||
set: { profile.sex = $0 }
|
set: { profile.sex = $0 }
|
||||||
@@ -101,8 +147,15 @@ private struct ProfileEditForm: View {
|
|||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.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 {
|
HStack {
|
||||||
Text("身高")
|
Text("身高")
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -110,11 +163,19 @@ private struct ProfileEditForm: View {
|
|||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
.multilineTextAlignment(.trailing)
|
.multilineTextAlignment(.trailing)
|
||||||
.frame(width: 80)
|
.frame(width: 80)
|
||||||
|
.focused($focused)
|
||||||
Text("cm").foregroundStyle(Tj.Palette.text3)
|
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 {
|
HStack {
|
||||||
Text("体重")
|
Text("体重")
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -122,11 +183,18 @@ private struct ProfileEditForm: View {
|
|||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
.multilineTextAlignment(.trailing)
|
.multilineTextAlignment(.trailing)
|
||||||
.frame(width: 80)
|
.frame(width: 80)
|
||||||
|
.focused($focused)
|
||||||
Text("kg").foregroundStyle(Tj.Palette.text3)
|
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) {
|
Picker("血型", selection: $profile.bloodTypeRaw) {
|
||||||
Text("不知道").tag("")
|
Text("不知道").tag("")
|
||||||
Text("A 型").tag("A")
|
Text("A 型").tag("A")
|
||||||
@@ -135,19 +203,51 @@ private struct ProfileEditForm: View {
|
|||||||
Text("O 型").tag("O")
|
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 {
|
Section {
|
||||||
FlexibleChipGrid {
|
FlexibleChipGrid {
|
||||||
ForEach(Self.chronicPresets, id: \.self) { name in
|
ForEach(presets, id: \.self) { name in
|
||||||
chip(label: name,
|
chip(label: name,
|
||||||
selected: profile.chronicConditions.contains(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
|
id: \.self) { name in
|
||||||
chip(label: name, selected: true) {
|
chip(label: name, selected: true) {
|
||||||
profile.chronicConditions.removeAll { $0 == name }
|
profile.chronicConditions.removeAll { $0 == name }
|
||||||
@@ -171,56 +271,14 @@ private struct ProfileEditForm: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 过敏 / 家族史 / 用药
|
private func toggle(_ name: String) {
|
||||||
|
if profile.chronicConditions.contains(name) {
|
||||||
private var allergySection: some View {
|
profile.chronicConditions.removeAll { $0 == name }
|
||||||
listSection(title: "过敏史", placeholder: "如:青霉素",
|
} else {
|
||||||
items: $profile.allergies, newInput: $newAllergy)
|
profile.chronicConditions.append(name)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - helpers
|
|
||||||
|
|
||||||
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Text(label)
|
Text(label)
|
||||||
@@ -233,21 +291,47 @@ private struct ProfileEditForm: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func toggleCondition(_ name: String) {
|
// MARK: - 过敏 / 家族史 / 用药(每节自带 @State,敲字只重算本节)
|
||||||
if profile.chronicConditions.contains(name) {
|
|
||||||
profile.chronicConditions.removeAll { $0 == name }
|
private struct StringListSection: View {
|
||||||
} else {
|
let title: String
|
||||||
profile.chronicConditions.append(name)
|
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 {
|
struct FlexibleChipGrid<Content: View>: View {
|
||||||
@ViewBuilder let content: () -> Content
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
|
|||||||
@@ -85,11 +85,11 @@ struct A2ConfirmView: View {
|
|||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
TjBadge(text: "偏高", style: .brick)
|
TjBadge(text: String(appLoc: "偏高"), style: .brick)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
FieldBox(label: "数值") {
|
FieldBox(label: String(appLoc: "数值")) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
Text("3.84")
|
Text("3.84")
|
||||||
.font(.system(size: 30, weight: .semibold))
|
.font(.system(size: 30, weight: .semibold))
|
||||||
@@ -99,7 +99,7 @@ struct A2ConfirmView: View {
|
|||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FieldBox(label: "参考范围") {
|
FieldBox(label: String(appLoc: "参考范围")) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
Text("< 3.40")
|
Text("< 3.40")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ struct A3BatchView: View {
|
|||||||
var onBack: () -> Void
|
var onBack: () -> Void
|
||||||
|
|
||||||
let items: [A3BatchItem] = [
|
let items: [A3BatchItem] = [
|
||||||
.init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high),
|
.init(name: String(appLoc: "低密度脂蛋白胆固醇"), 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: String(appLoc: "甘油三酯 TG"), value: "1.78", unit: "mmol/L", range: "< 1.70", status: .high),
|
||||||
.init(name: "空腹血糖 GLU", value: "5.4", unit: "mmol/L", range: "3.9–6.1", status: .normal),
|
.init(name: String(appLoc: "空腹血糖 GLU"), value: "5.4", unit: "mmol/L", range: "3.9–6.1", status: .normal),
|
||||||
]
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -114,7 +114,7 @@ private struct BatchRow: View {
|
|||||||
Text(item.value)
|
Text(item.value)
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.foregroundStyle(item.status == .high ? Tj.Palette.brick : Tj.Palette.text)
|
.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)
|
style: item.status == .high ? .brick : .leaf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,30 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum RecordKind: String, Identifiable, CaseIterable {
|
enum RecordKind: String, Identifiable, CaseIterable {
|
||||||
case quick, indicator, archive, diary, symptom
|
case quick, indicator, archive, diary, symptom, reminder
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
/// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。
|
||||||
|
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .quick, .archive]
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .quick: return "异常项快拍"
|
case .quick: return String(appLoc: "异常项快拍")
|
||||||
case .indicator: return "指标记录"
|
case .indicator: return String(appLoc: "记录指标")
|
||||||
case .archive: return "关键报告归档"
|
case .archive: return String(appLoc: "体检报告归档")
|
||||||
case .diary: return "文字日记"
|
case .diary: return String(appLoc: "健康日记")
|
||||||
case .symptom: return "症状开始"
|
case .symptom: return String(appLoc: "记录症状")
|
||||||
|
case .reminder: return String(appLoc: "开启一个提醒")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var subtitle: String {
|
var subtitle: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .quick: return "拍一张化验单,VL 自动识别"
|
case .quick: return String(appLoc: "拍一张化验单,VL 自动识别")
|
||||||
case .indicator: return "手动填一项指标(免拍照)"
|
case .indicator: return String(appLoc: "手动填一项指标(免拍照)")
|
||||||
case .archive: return "完整保存整份报告(可多页)"
|
case .archive: return String(appLoc: "完整保存整份报告(可多页)")
|
||||||
case .diary: return "记录心情、用药、其他"
|
case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助")
|
||||||
case .symptom: return "开始一个持续症状,结束时再点结束"
|
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
|
||||||
|
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var icon: String {
|
var icon: String {
|
||||||
@@ -27,8 +32,9 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
|||||||
case .quick: return "camera.fill"
|
case .quick: return "camera.fill"
|
||||||
case .indicator: return "number.square.fill"
|
case .indicator: return "number.square.fill"
|
||||||
case .archive: return "doc.fill"
|
case .archive: return "doc.fill"
|
||||||
case .diary: return "pencil"
|
case .diary: return "heart.text.square"
|
||||||
case .symptom: return "waveform.path.ecg"
|
case .symptom: return "waveform.path.ecg"
|
||||||
|
case .reminder: return "bell.badge"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var accent: Color {
|
var accent: Color {
|
||||||
@@ -38,6 +44,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
|||||||
case .archive: return Tj.Palette.ink
|
case .archive: return Tj.Palette.ink
|
||||||
case .diary: return Tj.Palette.leaf
|
case .diary: return Tj.Palette.leaf
|
||||||
case .symptom: return Tj.Palette.amber
|
case .symptom: return Tj.Palette.amber
|
||||||
|
case .reminder: return Tj.Palette.leaf
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,8 +71,10 @@ struct RecordSheet: View {
|
|||||||
}
|
}
|
||||||
.padding(.bottom, 14)
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
VStack(spacing: 10) {
|
// ScrollView 包裹:6 个入口在小屏固定 detent 下可能溢出,滚动确保都能触达。
|
||||||
ForEach(RecordKind.allCases) { kind in
|
ScrollView {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(RecordKind.displayOrder) { kind in
|
||||||
Button {
|
Button {
|
||||||
onPick(kind)
|
onPick(kind)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -97,8 +106,10 @@ struct RecordSheet: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 22)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 22)
|
.scrollIndicators(.hidden)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 18)
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
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 {
|
struct SymptomStartSheet: View {
|
||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
@@ -77,10 +78,10 @@ struct SymptomStartSheet: View {
|
|||||||
|
|
||||||
private var presetSection: some View {
|
private var presetSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("常见症状")
|
sectionLabel(String(appLoc: "常见症状"))
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(symptomPresets, id: \.self) { item in
|
ForEach(symptomPresets(), id: \.self) { item in
|
||||||
chip(item, selected: name == item) {
|
chip(item, selected: name == item) {
|
||||||
name = item
|
name = item
|
||||||
customName = ""
|
customName = ""
|
||||||
@@ -93,7 +94,7 @@ struct SymptomStartSheet: View {
|
|||||||
|
|
||||||
private var customSection: some View {
|
private var customSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("或者自己写")
|
sectionLabel(String(appLoc: "或者自己写"))
|
||||||
TextField("例如:眼皮跳", text: $customName)
|
TextField("例如:眼皮跳", text: $customName)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
@@ -116,7 +117,7 @@ struct SymptomStartSheet: View {
|
|||||||
|
|
||||||
private var timeSection: some View {
|
private var timeSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("开始时间")
|
sectionLabel(String(appLoc: "开始时间"))
|
||||||
DatePicker("", selection: $startedAt, in: ...Date.now)
|
DatePicker("", selection: $startedAt, in: ...Date.now)
|
||||||
.datePickerStyle(.compact)
|
.datePickerStyle(.compact)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
@@ -126,7 +127,7 @@ struct SymptomStartSheet: View {
|
|||||||
private var severitySection: some View {
|
private var severitySection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
sectionLabel("强度")
|
sectionLabel(String(appLoc: "强度"))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(Int(severity)) / 5")
|
Text("\(Int(severity)) / 5")
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||||
@@ -144,7 +145,7 @@ struct SymptomStartSheet: View {
|
|||||||
|
|
||||||
private var noteSection: some View {
|
private var noteSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionLabel("备注(可选)")
|
sectionLabel(String(appLoc: "备注(可选)"))
|
||||||
TextField("位置、可能诱因…", text: $note, axis: .vertical)
|
TextField("位置、可能诱因…", text: $note, axis: .vertical)
|
||||||
.lineLimit(2...4)
|
.lineLimit(2...4)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ nonisolated enum DateSection: Hashable {
|
|||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .today: return "今天"
|
case .today: return String(appLoc: "今天")
|
||||||
case .yesterday: return "昨天"
|
case .yesterday: return String(appLoc: "昨天")
|
||||||
case .thisWeek: return "本周"
|
case .thisWeek: return String(appLoc: "本周")
|
||||||
case .thisMonth: return "本月"
|
case .thisMonth: return String(appLoc: "本月")
|
||||||
case .year(let y): return "\(y) 年"
|
case .year(let y): return String(appLoc: "\(y) 年")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,10 +68,10 @@ func formatDuration(_ interval: TimeInterval) -> String {
|
|||||||
let hours = (totalMinutes % (60 * 24)) / 60
|
let hours = (totalMinutes % (60 * 24)) / 60
|
||||||
let minutes = totalMinutes % 60
|
let minutes = totalMinutes % 60
|
||||||
|
|
||||||
if days > 0 && hours > 0 { return "\(days) 天 \(hours) 小时" }
|
if days > 0 && hours > 0 { return String(appLoc: "\(days) 天 \(hours) 小时") }
|
||||||
if days > 0 { return "\(days) 天" }
|
if days > 0 { return String(appLoc: "\(days) 天") }
|
||||||
if hours > 0 && minutes > 0 { return "\(hours) 小时 \(minutes) 分" }
|
if hours > 0 && minutes > 0 { return String(appLoc: "\(hours) 小时 \(minutes) 分") }
|
||||||
if hours > 0 { return "\(hours) 小时" }
|
if hours > 0 { return String(appLoc: "\(hours) 小时") }
|
||||||
if minutes > 0 { return "\(minutes) 分钟" }
|
if minutes > 0 { return String(appLoc: "\(minutes) 分钟") }
|
||||||
return "刚刚"
|
return String(appLoc: "刚刚")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ enum TimelineKind: String, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .indicator: return "指标"
|
case .indicator: return String(appLoc: "指标")
|
||||||
case .report: return "报告"
|
case .report: return String(appLoc: "报告")
|
||||||
case .symptom: return "症状"
|
case .symptom: return String(appLoc: "症状")
|
||||||
case .diary: return "日记"
|
case .diary: return String(appLoc: "日记")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +90,8 @@ struct TimelineEntry: Identifiable, Hashable {
|
|||||||
id: "bp-\(sys.persistentModelID)-\(dia.persistentModelID)",
|
id: "bp-\(sys.persistentModelID)-\(dia.persistentModelID)",
|
||||||
kind: .indicator,
|
kind: .indicator,
|
||||||
date: sys.capturedAt,
|
date: sys.capturedAt,
|
||||||
title: "血压",
|
title: String(appLoc: "血压"),
|
||||||
subtitle: "长期监测",
|
subtitle: String(appLoc: "长期监测"),
|
||||||
trailing: "\(sys.value)/\(dia.value) mmHg" + (abnormal ? " ↑" : ""),
|
trailing: "\(sys.value)/\(dia.value) mmHg" + (abnormal ? " ↑" : ""),
|
||||||
trailingIsAlert: abnormal,
|
trailingIsAlert: abnormal,
|
||||||
isOngoing: false
|
isOngoing: false
|
||||||
@@ -105,8 +105,8 @@ struct TimelineEntry: Identifiable, Hashable {
|
|||||||
kind: .report,
|
kind: .report,
|
||||||
date: r.reportDate,
|
date: r.reportDate,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
subtitle: "\(r.type.label) · 共 \(r.pageCount) 页",
|
subtitle: "\(r.type.label) · " + String(appLoc: "共 \(r.pageCount) 页"),
|
||||||
trailing: abnormal > 0 ? "\(abnormal) 项偏高" : nil,
|
trailing: abnormal > 0 ? String(appLoc: "\(abnormal) 项偏高") : nil,
|
||||||
trailingIsAlert: abnormal > 0,
|
trailingIsAlert: abnormal > 0,
|
||||||
isOngoing: false
|
isOngoing: false
|
||||||
)
|
)
|
||||||
@@ -118,7 +118,7 @@ struct TimelineEntry: Identifiable, Hashable {
|
|||||||
kind: .diary,
|
kind: .diary,
|
||||||
date: d.createdAt,
|
date: d.createdAt,
|
||||||
title: d.content.firstLine(),
|
title: d.content.firstLine(),
|
||||||
subtitle: "文字日记",
|
subtitle: String(appLoc: "文字日记"),
|
||||||
trailing: nil,
|
trailing: nil,
|
||||||
trailingIsAlert: false,
|
trailingIsAlert: false,
|
||||||
isOngoing: false
|
isOngoing: false
|
||||||
@@ -131,11 +131,11 @@ struct TimelineEntry: Identifiable, Hashable {
|
|||||||
let subtitle: String
|
let subtitle: String
|
||||||
let trailing: String?
|
let trailing: String?
|
||||||
if ongoing {
|
if ongoing {
|
||||||
subtitle = "症状 · 持续中"
|
subtitle = String(appLoc: "症状 · 持续中")
|
||||||
trailing = "持续 \(formatDuration(s.duration))"
|
trailing = String(appLoc: "持续 \(formatDuration(s.duration))")
|
||||||
} else {
|
} else {
|
||||||
subtitle = "症状 · 已结束"
|
subtitle = String(appLoc: "症状 · 已结束")
|
||||||
trailing = "持续 \(formatDuration(s.duration))"
|
trailing = String(appLoc: "持续 \(formatDuration(s.duration))")
|
||||||
}
|
}
|
||||||
return TimelineEntry(
|
return TimelineEntry(
|
||||||
id: "symptom-\(s.persistentModelID)",
|
id: "symptom-\(s.persistentModelID)",
|
||||||
@@ -151,9 +151,9 @@ struct TimelineEntry: Identifiable, Hashable {
|
|||||||
|
|
||||||
private static func typeSubtitle(for i: Indicator) -> String {
|
private static func typeSubtitle(for i: Indicator) -> String {
|
||||||
if let report = i.report {
|
if let report = i.report {
|
||||||
return "指标 · \(report.title)"
|
return String(appLoc: "指标 · \(report.title)")
|
||||||
}
|
}
|
||||||
return "异常项快拍"
|
return String(appLoc: "异常项快拍")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func indicatorValue(_ i: Indicator) -> String {
|
private static func indicatorValue(_ i: Indicator) -> String {
|
||||||
@@ -175,6 +175,6 @@ private extension String {
|
|||||||
let s = String(line)
|
let s = String(line)
|
||||||
return s.count > 40 ? String(s.prefix(40)) + "…" : s
|
return s.count > 40 ? String(s.prefix(40)) + "…" : s
|
||||||
}
|
}
|
||||||
return trimmed.isEmpty ? "(空日记)" : trimmed
|
return trimmed.isEmpty ? String(appLoc: "(空日记)") : trimmed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,18 +56,12 @@ extension Date {
|
|||||||
return self.formatted(date: .omitted, time: .shortened)
|
return self.formatted(date: .omitted, time: .shortened)
|
||||||
}
|
}
|
||||||
if cal.isDateInYesterday(self) {
|
if cal.isDateInYesterday(self) {
|
||||||
return "昨天 " + self.formatted(date: .omitted, time: .shortened)
|
return String(appLoc: "昨天") + " " + self.formatted(date: .omitted, time: .shortened)
|
||||||
}
|
}
|
||||||
let now = Date.now
|
let now = Date.now
|
||||||
if cal.isDate(self, equalTo: now, toGranularity: .year) {
|
if cal.isDate(self, equalTo: now, toGranularity: .year) {
|
||||||
let f = DateFormatter()
|
return self.formatted(.dateTime.month().day())
|
||||||
f.locale = Locale(identifier: "zh_CN")
|
|
||||||
f.dateFormat = "M 月 d 日"
|
|
||||||
return f.string(from: self)
|
|
||||||
}
|
}
|
||||||
let f = DateFormatter()
|
return self.formatted(.dateTime.year().month().day())
|
||||||
f.locale = Locale(identifier: "zh_CN")
|
|
||||||
f.dateFormat = "yyyy 年 M 月 d 日"
|
|
||||||
return f.string(from: self)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,31 @@ import SwiftUI
|
|||||||
struct CalendarMonthGrid: View {
|
struct CalendarMonthGrid: View {
|
||||||
let monthAnchor: Date
|
let monthAnchor: Date
|
||||||
let data: CalendarData
|
let data: CalendarData
|
||||||
|
let selectedDate: Date?
|
||||||
let onTapDay: (Date) -> Void
|
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 = {
|
private let calendar: Calendar = {
|
||||||
var c = Calendar(identifier: .gregorian)
|
var c = Calendar(identifier: .gregorian)
|
||||||
c.firstWeekday = 2 // 周一开始
|
c.firstWeekday = 2 // 周一开始
|
||||||
c.locale = Locale(identifier: "zh_CN")
|
c.locale = Locale.current
|
||||||
return c
|
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 let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)
|
||||||
|
|
||||||
private var days: [DayCell] {
|
private var days: [DayCell] {
|
||||||
@@ -64,6 +79,9 @@ struct CalendarMonthGrid: View {
|
|||||||
ranges: data.ranges(touching: cell.date, calendar: calendar),
|
ranges: data.ranges(touching: cell.date, calendar: calendar),
|
||||||
marks: data.marks(for: cell.date, calendar: calendar),
|
marks: data.marks(for: cell.date, calendar: calendar),
|
||||||
isToday: calendar.isDateInToday(cell.date),
|
isToday: calendar.isDateInToday(cell.date),
|
||||||
|
isSelected: selectedDate.map {
|
||||||
|
calendar.isDate(cell.date, inSameDayAs: $0)
|
||||||
|
} ?? false,
|
||||||
calendar: calendar
|
calendar: calendar
|
||||||
)
|
)
|
||||||
.onTapGesture { onTapDay(cell.date) }
|
.onTapGesture { onTapDay(cell.date) }
|
||||||
@@ -84,6 +102,7 @@ private struct DayCellView: View {
|
|||||||
let ranges: [SymptomRange]
|
let ranges: [SymptomRange]
|
||||||
let marks: DayMarks
|
let marks: DayMarks
|
||||||
let isToday: Bool
|
let isToday: Bool
|
||||||
|
let isSelected: Bool
|
||||||
let calendar: Calendar
|
let calendar: Calendar
|
||||||
|
|
||||||
private var dayNumber: Int {
|
private var dayNumber: Int {
|
||||||
@@ -92,14 +111,20 @@ private struct DayCellView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
// 背景:今天高亮
|
// 背景层:selected > today
|
||||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
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) {
|
VStack(spacing: 2) {
|
||||||
Text("\(dayNumber)")
|
Text("\(dayNumber)")
|
||||||
.font(.system(size: 13,
|
.font(.system(size: 13,
|
||||||
weight: isToday ? .bold : .regular,
|
weight: (isToday || isSelected) ? .bold : .regular,
|
||||||
design: .default))
|
design: .default))
|
||||||
.foregroundStyle(textColor)
|
.foregroundStyle(textColor)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
@@ -145,10 +170,17 @@ private struct DayCellView: View {
|
|||||||
|
|
||||||
private var textColor: Color {
|
private var textColor: Color {
|
||||||
if !cell.inCurrentMonth { return Tj.Palette.text3.opacity(0.5) }
|
if !cell.inCurrentMonth { return Tj.Palette.text3.opacity(0.5) }
|
||||||
|
if isSelected { return Tj.Palette.brick }
|
||||||
if isToday { return Tj.Palette.ink }
|
if isToday { return Tj.Palette.ink }
|
||||||
return Tj.Palette.text
|
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 {
|
private func symptomBar(_ range: SymptomRange) -> some View {
|
||||||
let pos = range.position(cell.date, calendar: calendar)
|
let pos = range.position(cell.date, calendar: calendar)
|
||||||
let leadingRadius: CGFloat = (pos == .start || pos == .single) ? 3 : 0
|
let leadingRadius: CGFloat = (pos == .start || pos == .single) ? 3 : 0
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ struct CalendarYearGrid: View {
|
|||||||
private let calendar: Calendar = {
|
private let calendar: Calendar = {
|
||||||
var c = Calendar(identifier: .gregorian)
|
var c = Calendar(identifier: .gregorian)
|
||||||
c.firstWeekday = 2
|
c.firstWeekday = 2
|
||||||
c.locale = Locale(identifier: "zh_CN")
|
c.locale = Locale.current
|
||||||
return c
|
return c
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -42,10 +42,7 @@ private struct MiniMonth: View {
|
|||||||
let calendar: Calendar
|
let calendar: Calendar
|
||||||
|
|
||||||
private var monthLabel: String {
|
private var monthLabel: String {
|
||||||
let f = DateFormatter()
|
anchor.formatted(.dateTime.month())
|
||||||
f.locale = Locale(identifier: "zh_CN")
|
|
||||||
f.dateFormat = "M 月"
|
|
||||||
return f.string(from: anchor)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var days: [Date] {
|
private var days: [Date] {
|
||||||
|
|||||||
@@ -6,38 +6,37 @@ struct SelectedDay: Identifiable, Hashable {
|
|||||||
var id: TimeInterval { date.timeIntervalSince1970 }
|
var id: TimeInterval { date.timeIntervalSince1970 }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DayDetailSheet: View {
|
// MARK: - DayDetailContent(可 inline 或入 sheet)
|
||||||
@Environment(\.modelContext) private var ctx
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
|
/// 选中日详情的核心渲染。无 sheet 外壳,可同时被 TrendsView inline 使用,也能被 sheet 包。
|
||||||
|
struct DayDetailContent: View {
|
||||||
let date: Date
|
let date: Date
|
||||||
let indicators: [Indicator]
|
let indicators: [Indicator]
|
||||||
let reports: [Report]
|
let reports: [Report]
|
||||||
let diaries: [DiaryEntry]
|
let diaries: [DiaryEntry]
|
||||||
let symptoms: [Symptom]
|
let symptoms: [Symptom]
|
||||||
|
/// 是否显示日期 header(inline 时通常自带 header,sheet 模式让 DayDetailSheet 自己画)
|
||||||
|
var showHeader: Bool = true
|
||||||
|
|
||||||
@State private var endingSymptom: Symptom?
|
@State private var endingSymptom: Symptom?
|
||||||
|
|
||||||
private let calendar: Calendar = {
|
private let calendar: Calendar = {
|
||||||
var c = Calendar(identifier: .gregorian)
|
var c = Calendar(identifier: .gregorian)
|
||||||
c.locale = Locale(identifier: "zh_CN")
|
c.locale = Locale.current
|
||||||
return c
|
return c
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: - 当日数据筛选
|
// MARK: 当日筛选
|
||||||
|
|
||||||
private var dayIndicators: [Indicator] {
|
private var dayIndicators: [Indicator] {
|
||||||
indicators.filter { calendar.isDate($0.capturedAt, inSameDayAs: date) }
|
indicators.filter { calendar.isDate($0.capturedAt, inSameDayAs: date) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dayReports: [Report] {
|
private var dayReports: [Report] {
|
||||||
reports.filter { calendar.isDate($0.reportDate, inSameDayAs: date) }
|
reports.filter { calendar.isDate($0.reportDate, inSameDayAs: date) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dayDiaries: [DiaryEntry] {
|
private var dayDiaries: [DiaryEntry] {
|
||||||
diaries.filter { calendar.isDate($0.createdAt, inSameDayAs: date) }
|
diaries.filter { calendar.isDate($0.createdAt, inSameDayAs: date) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var daySymptoms: [(symptom: Symptom, state: SymptomDayState)] {
|
private var daySymptoms: [(symptom: Symptom, state: SymptomDayState)] {
|
||||||
symptoms.compactMap { s in
|
symptoms.compactMap { s in
|
||||||
let start = calendar.startOfDay(for: s.startedAt)
|
let start = calendar.startOfDay(for: s.startedAt)
|
||||||
@@ -52,90 +51,54 @@ struct DayDetailSheet: View {
|
|||||||
return (s, state)
|
return (s, state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var totalCount: Int {
|
private var totalCount: Int {
|
||||||
dayIndicators.count + dayReports.count + dayDiaries.count + daySymptoms.count
|
dayIndicators.count + dayReports.count + dayDiaries.count + daySymptoms.count
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - body
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
Capsule()
|
if showHeader { header }
|
||||||
.fill(Tj.Palette.line)
|
|
||||||
.frame(width: 40, height: 4)
|
|
||||||
.padding(.top, 10)
|
|
||||||
.padding(.bottom, 14)
|
|
||||||
|
|
||||||
header
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.bottom, 12)
|
|
||||||
|
|
||||||
if totalCount == 0 {
|
if totalCount == 0 {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
ScrollView(showsIndicators: false) {
|
if !daySymptoms.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
section(String(appLoc: "症状"), count: daySymptoms.count) {
|
||||||
if !daySymptoms.isEmpty {
|
VStack(spacing: 8) {
|
||||||
section("症状", count: daySymptoms.count) {
|
ForEach(daySymptoms, id: \.symptom.id) { item in
|
||||||
VStack(spacing: 8) {
|
symptomRow(item.symptom, state: item.state)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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
|
.sheet(item: $endingSymptom) { sym in
|
||||||
SymptomEndSheet(symptom: sym)
|
SymptomEndSheet(symptom: sym)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - header
|
// MARK: header
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
@@ -145,7 +108,7 @@ struct DayDetailSheet: View {
|
|||||||
.tracking(0.5)
|
.tracking(0.5)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Text(dayLabel)
|
Text(dayLabel)
|
||||||
.font(.tjTitle(28))
|
.font(.tjTitle(22))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -158,27 +121,18 @@ struct DayDetailSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var dateLine: String {
|
private var dateLine: String {
|
||||||
let f = DateFormatter()
|
date.formatted(.dateTime.year()) + " · " + weekdayLabel
|
||||||
f.locale = Locale(identifier: "zh_CN")
|
|
||||||
f.dateFormat = "yyyy 年"
|
|
||||||
return f.string(from: date) + " · " + weekdayLabel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dayLabel: String {
|
private var dayLabel: String {
|
||||||
let f = DateFormatter()
|
date.formatted(.dateTime.month().day())
|
||||||
f.locale = Locale(identifier: "zh_CN")
|
|
||||||
f.dateFormat = "M 月 d 日"
|
|
||||||
return f.string(from: date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var weekdayLabel: String {
|
private var weekdayLabel: String {
|
||||||
let f = DateFormatter()
|
date.formatted(.dateTime.weekday(.wide))
|
||||||
f.locale = Locale(identifier: "zh_CN")
|
|
||||||
f.dateFormat = "EEEE"
|
|
||||||
return f.string(from: date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - section
|
// MARK: section helper
|
||||||
|
|
||||||
private func section<Content: View>(_ title: String,
|
private func section<Content: View>(_ title: String,
|
||||||
count: Int,
|
count: Int,
|
||||||
@@ -198,27 +152,30 @@ struct DayDetailSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - rows
|
// MARK: rows
|
||||||
|
|
||||||
private func symptomRow(_ s: Symptom, state: SymptomDayState) -> some View {
|
private func symptomRow(_ s: Symptom, state: SymptomDayState) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(severityColor(s.severity))
|
.fill(severityColor(s.severity))
|
||||||
.frame(width: 4, height: 36)
|
.frame(width: 4, height: 36)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(s.name)
|
Text(s.name)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.system(size: 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.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))")
|
Text("\(state.subtitle) · 持续 \(formatDuration(s.duration))")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 6)
|
Spacer(minLength: 6)
|
||||||
|
|
||||||
if s.isOngoing {
|
if s.isOngoing {
|
||||||
Button {
|
Button {
|
||||||
endingSymptom = s
|
endingSymptom = s
|
||||||
@@ -237,15 +194,6 @@ struct DayDetailSheet: View {
|
|||||||
.tjCard(bordered: true)
|
.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 {
|
private func indicatorRow(_ i: Indicator) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -256,7 +204,6 @@ struct DayDetailSheet: View {
|
|||||||
.foregroundStyle(indicatorAccent(i))
|
.foregroundStyle(indicatorAccent(i))
|
||||||
}
|
}
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(i.name)
|
Text(i.name)
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.system(size: 14, weight: .medium))
|
||||||
@@ -269,7 +216,6 @@ struct DayDetailSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 6)
|
Spacer(minLength: 6)
|
||||||
|
|
||||||
Text("\(i.value) \(i.unit)\(arrow(i))")
|
Text("\(i.value) \(i.unit)\(arrow(i))")
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||||
.foregroundStyle(i.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
|
.foregroundStyle(i.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
|
||||||
@@ -291,7 +237,6 @@ struct DayDetailSheet: View {
|
|||||||
.foregroundStyle(Tj.Palette.ink2)
|
.foregroundStyle(Tj.Palette.ink2)
|
||||||
}
|
}
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(r.title)
|
Text(r.title)
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.system(size: 14, weight: .medium))
|
||||||
@@ -331,23 +276,19 @@ struct DayDetailSheet: View {
|
|||||||
.tjCard(bordered: true)
|
.tjCard(bordered: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - empty
|
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 8) {
|
||||||
Spacer(minLength: 16)
|
TjPlaceholder(label: String(appLoc: "这一天还没有记录"))
|
||||||
TjPlaceholder(label: "这一天还没有记录")
|
.frame(height: 90)
|
||||||
.frame(width: 220, height: 120)
|
.frame(maxWidth: 240)
|
||||||
Text("点底部 + 号可以补一条")
|
Text("点底部 + 号可以补一条")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - utils
|
|
||||||
|
|
||||||
private func severityColor(_ value: Int) -> Color {
|
private func severityColor(_ value: Int) -> Color {
|
||||||
switch value {
|
switch value {
|
||||||
case 1, 2: return Tj.Palette.leaf
|
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 {
|
enum SymptomDayState {
|
||||||
case startedToday, ongoing, endedToday
|
case startedToday, ongoing, endedToday
|
||||||
|
|
||||||
var subtitle: String {
|
var subtitle: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .startedToday: return "今天开始"
|
case .startedToday: return String(appLoc: "今天开始")
|
||||||
case .ongoing: return "进行中"
|
case .ongoing: return String(appLoc: "进行中")
|
||||||
case .endedToday: return "今天结束"
|
case .endedToday: return String(appLoc: "今天结束")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var badge: String {
|
var badge: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .startedToday: return "开始"
|
case .startedToday: return String(appLoc: "开始")
|
||||||
case .ongoing: return "持续"
|
case .ongoing: return String(appLoc: "持续")
|
||||||
case .endedToday: return "结束"
|
case .endedToday: return String(appLoc: "结束")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ extension SeriesBucket {
|
|||||||
let sysLine = SeriesLine(
|
let sysLine = SeriesLine(
|
||||||
id: "bp.systolic",
|
id: "bp.systolic",
|
||||||
seriesKey: "bp.systolic",
|
seriesKey: "bp.systolic",
|
||||||
label: "收缩",
|
label: String(appLoc: "收缩"),
|
||||||
color: Tj.Palette.brick,
|
color: Tj.Palette.brick,
|
||||||
points: sysItems.compactMap { point(from: $0) },
|
points: sysItems.compactMap { point(from: $0) },
|
||||||
referenceRange: m.effectiveRange(for: sysField, profile: profile)
|
referenceRange: m.effectiveRange(for: sysField, profile: profile)
|
||||||
@@ -131,7 +131,7 @@ extension SeriesBucket {
|
|||||||
let diaLine = SeriesLine(
|
let diaLine = SeriesLine(
|
||||||
id: "bp.diastolic",
|
id: "bp.diastolic",
|
||||||
seriesKey: "bp.diastolic",
|
seriesKey: "bp.diastolic",
|
||||||
label: "舒张",
|
label: String(appLoc: "舒张"),
|
||||||
color: Tj.Palette.leaf,
|
color: Tj.Palette.leaf,
|
||||||
points: diaItems.compactMap { point(from: $0) },
|
points: diaItems.compactMap { point(from: $0) },
|
||||||
referenceRange: m.effectiveRange(for: diaField, profile: profile)
|
referenceRange: m.effectiveRange(for: diaField, profile: profile)
|
||||||
@@ -144,7 +144,7 @@ extension SeriesBucket {
|
|||||||
|
|
||||||
return SeriesBucket(
|
return SeriesBucket(
|
||||||
id: "bp",
|
id: "bp",
|
||||||
title: "血压",
|
title: String(appLoc: "血压"),
|
||||||
unit: "mmHg",
|
unit: "mmHg",
|
||||||
lines: [sysLine, diaLine],
|
lines: [sysLine, diaLine],
|
||||||
latestDate: latest
|
latestDate: latest
|
||||||
|
|||||||
@@ -165,10 +165,10 @@ struct SeriesChartCard: View {
|
|||||||
let days = Calendar.current.dateComponents([.day],
|
let days = Calendar.current.dateComponents([.day],
|
||||||
from: dom.lowerBound,
|
from: dom.lowerBound,
|
||||||
to: dom.upperBound).day ?? 0
|
to: dom.upperBound).day ?? 0
|
||||||
if days <= 0 { return "今天" }
|
if days <= 0 { return String(appLoc: "今天") }
|
||||||
if days < 30 { return "\(days) 天" }
|
if days < 30 { return String(appLoc: "\(days) 天") }
|
||||||
if days < 365 { return "\(days / 30) 个月" }
|
if days < 365 { return String(appLoc: "\(days / 30) 个月") }
|
||||||
return "\(days / 365) 年"
|
return String(appLoc: "\(days / 365) 年")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatValue(_ v: Double) -> String {
|
private func formatValue(_ v: Double) -> String {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ enum CalendarMode: String, CaseIterable, Identifiable {
|
|||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .month: return "月"
|
case .month: return String(appLoc: "月")
|
||||||
case .year: return "年"
|
case .year: return String(appLoc: "年")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,8 @@ struct TrendsView: View {
|
|||||||
|
|
||||||
@State private var mode: CalendarMode = .month
|
@State private var mode: CalendarMode = .month
|
||||||
@State private var anchor: Date = .now
|
@State private var anchor: Date = .now
|
||||||
@State private var selectedDay: SelectedDay?
|
/// 选中的当天 — 默认选今天,日历下方 inline 显示该日详情
|
||||||
|
@State private var selectedDate: Date = .now
|
||||||
|
|
||||||
private var profile: UserProfile? { profiles.first }
|
private var profile: UserProfile? { profiles.first }
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ struct TrendsView: View {
|
|||||||
private let calendar: Calendar = {
|
private let calendar: Calendar = {
|
||||||
var c = Calendar(identifier: .gregorian)
|
var c = Calendar(identifier: .gregorian)
|
||||||
c.firstWeekday = 2
|
c.firstWeekday = 2
|
||||||
c.locale = Locale(identifier: "zh_CN")
|
c.locale = Locale.current
|
||||||
return c
|
return c
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -66,6 +67,9 @@ struct TrendsView: View {
|
|||||||
anchorBar
|
anchorBar
|
||||||
calendarBody
|
calendarBody
|
||||||
legend
|
legend
|
||||||
|
if mode == .month {
|
||||||
|
dayDetailInline
|
||||||
|
}
|
||||||
seriesSection
|
seriesSection
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -73,15 +77,31 @@ struct TrendsView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
.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,
|
indicators: indicators,
|
||||||
reports: reports,
|
reports: reports,
|
||||||
diaries: diaries,
|
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 {
|
private var header: some View {
|
||||||
@@ -91,7 +111,10 @@ struct TrendsView: View {
|
|||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
anchor = .now
|
withAnimation(.snappy(duration: 0.2)) {
|
||||||
|
anchor = .now
|
||||||
|
selectedDate = .now
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text("回到今天")
|
Text("回到今天")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
@@ -164,18 +187,20 @@ struct TrendsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var anchorTitle: String {
|
private var anchorTitle: String {
|
||||||
let f = DateFormatter()
|
let style: Date.FormatStyle = mode == .month
|
||||||
f.locale = Locale(identifier: "zh_CN")
|
? .dateTime.year().month()
|
||||||
f.dateFormat = mode == .month ? "yyyy 年 M 月" : "yyyy 年"
|
: .dateTime.year()
|
||||||
return f.string(from: anchor)
|
return anchor.formatted(style)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var calendarBody: some View {
|
private var calendarBody: some View {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .month:
|
case .month:
|
||||||
CalendarMonthGrid(monthAnchor: anchor, data: data) { day in
|
CalendarMonthGrid(monthAnchor: anchor, data: data, selectedDate: selectedDate) { day in
|
||||||
selectedDay = SelectedDay(date: day)
|
withAnimation(.snappy(duration: 0.2)) {
|
||||||
|
selectedDate = day
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.background(
|
.background(
|
||||||
@@ -231,10 +256,10 @@ struct TrendsView: View {
|
|||||||
.tracking(0.5)
|
.tracking(0.5)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
legendItem(color: Tj.Palette.brick, label: "指标异常")
|
legendItem(color: Tj.Palette.brick, label: String(appLoc: "指标异常"))
|
||||||
legendItem(color: Tj.Palette.amber, label: "症状持续中")
|
legendItem(color: Tj.Palette.amber, label: String(appLoc: "症状持续中"))
|
||||||
legendItem(color: Tj.Palette.ink2, label: "报告归档")
|
legendItem(color: Tj.Palette.ink2, label: String(appLoc: "报告归档"))
|
||||||
legendItem(color: Tj.Palette.leaf, label: "正常")
|
legendItem(color: Tj.Palette.leaf, label: String(appLoc: "正常"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
@@ -268,6 +293,14 @@ struct TrendsView: View {
|
|||||||
if let next = calendar.date(byAdding: component, value: delta, to: anchor) {
|
if let next = calendar.date(byAdding: component, value: delta, to: anchor) {
|
||||||
withAnimation(.snappy) {
|
withAnimation(.snappy) {
|
||||||
anchor = next
|
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
68
康康/Models/HealthExport.swift
Normal 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) + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,11 @@ enum ReportType: String, Codable, CaseIterable {
|
|||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .checkup: return "体检报告"
|
case .checkup: return String(appLoc: "体检报告")
|
||||||
case .lab: return "化验单"
|
case .lab: return String(appLoc: "化验单")
|
||||||
case .imaging: return "影像报告"
|
case .imaging: return String(appLoc: "影像报告")
|
||||||
case .prescription: return "处方"
|
case .prescription: return String(appLoc: "处方")
|
||||||
case .other: return "其他"
|
case .other: return String(appLoc: "其他")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,12 +250,12 @@ final class MetricReminder {
|
|||||||
var isEveryDay: Bool { Set(weekdays) == Set(1...7) }
|
var isEveryDay: Bool { Set(weekdays) == Set(1...7) }
|
||||||
|
|
||||||
var frequencyLabel: String {
|
var frequencyLabel: String {
|
||||||
if !enabled { return "已关闭" }
|
if !enabled { return String(appLoc: "已关闭") }
|
||||||
if isEveryDay { return "每天" }
|
if isEveryDay { return String(appLoc: "每天") }
|
||||||
if weekdays.isEmpty { return "未选日" }
|
if weekdays.isEmpty { return String(appLoc: "未选日") }
|
||||||
let names = ["日", "一", "二", "三", "四", "五", "六"]
|
let names = [String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六")]
|
||||||
let sorted = weekdays.sorted()
|
let sorted = weekdays.sorted()
|
||||||
return "每周 " + sorted.map { names[$0 - 1] }.joined()
|
return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined()
|
||||||
}
|
}
|
||||||
|
|
||||||
var timeLabel: String {
|
var timeLabel: String {
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ extension UserProfile {
|
|||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .male: return "男"
|
case .male: return String(appLoc: "男")
|
||||||
case .female: return "女"
|
case .female: return String(appLoc: "女")
|
||||||
case .undisclosed: return "不愿透露"
|
case .undisclosed: return String(appLoc: "不愿透露")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ extension UserProfile {
|
|||||||
/// 给 ProfileCard 一行预览:"38岁 · 男 · 175cm · 68kg · A型"
|
/// 给 ProfileCard 一行预览:"38岁 · 男 · 175cm · 68kg · A型"
|
||||||
var summaryLine: String {
|
var summaryLine: String {
|
||||||
var parts: [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 sex != .undisclosed { parts.append(sex.label) }
|
||||||
if let h = heightCM { parts.append("\(h)cm") }
|
if let h = heightCM { parts.append("\(h)cm") }
|
||||||
if let w = weightKG {
|
if let w = weightKG {
|
||||||
@@ -87,7 +87,7 @@ extension UserProfile {
|
|||||||
: String(format: "%.1fkg", w)
|
: String(format: "%.1fkg", w)
|
||||||
parts.append(s)
|
parts.append(s)
|
||||||
}
|
}
|
||||||
if !bloodTypeRaw.isEmpty { parts.append("\(bloodTypeRaw)型") }
|
if !bloodTypeRaw.isEmpty { parts.append(String(appLoc: "\(bloodTypeRaw)型")) }
|
||||||
return parts.joined(separator: " · ")
|
return parts.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ enum TjTab: String, Hashable, CaseIterable {
|
|||||||
case home, records, trend, me
|
case home, records, trend, me
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .home: return "主页"
|
case .home: return String(appLoc: "主页")
|
||||||
case .records: return "记录"
|
case .records: return String(appLoc: "记录")
|
||||||
case .trend: return "趋势"
|
case .trend: return String(appLoc: "趋势")
|
||||||
case .me: return "我的"
|
case .me: return String(appLoc: "我的")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var icon: String {
|
var icon: String {
|
||||||
@@ -18,6 +18,15 @@ enum TjTab: String, Hashable, CaseIterable {
|
|||||||
case .me: return "person.circle"
|
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 {
|
enum ActiveFlow: Identifiable {
|
||||||
@@ -27,26 +36,39 @@ enum ActiveFlow: Identifiable {
|
|||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@State private var tab: TjTab = .home
|
@State private var tab: TjTab = .home
|
||||||
|
/// 页面 push 过渡的来向:切到右侧 tab 时从 trailing 推入,切到左侧时从 leading 推入。
|
||||||
|
@State private var pushEdge: Edge = .trailing
|
||||||
@State private var showRecordSheet = false
|
@State private var showRecordSheet = false
|
||||||
@State private var activeFlow: ActiveFlow?
|
@State private var activeFlow: ActiveFlow?
|
||||||
@State private var showSymptomStart = false
|
@State private var showSymptomStart = false
|
||||||
@State private var showDiary = false
|
@State private var showDiary = false
|
||||||
@State private var showIndicator = 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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Group {
|
Group {
|
||||||
switch tab {
|
switch tab {
|
||||||
case .home: HomeView(onTapArchive: { tab = .records })
|
case .home: HomeView(onTapArchive: { select(.records) })
|
||||||
case .records: ArchiveListView()
|
case .records: ArchiveListView()
|
||||||
case .trend: TrendsView()
|
case .trend: TrendsView()
|
||||||
case .me: MeView()
|
case .me: MeView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.id(tab)
|
||||||
|
.transition(.push(from: pushEdge))
|
||||||
|
|
||||||
TabBar(active: tab,
|
TabBar(active: tab,
|
||||||
onTap: { tab = $0 },
|
onTap: { select($0) },
|
||||||
onTapRecord: { showRecordSheet = true })
|
onTapRecord: { showRecordSheet = true })
|
||||||
}
|
}
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
@@ -60,6 +82,7 @@ struct RootView: View {
|
|||||||
case .symptom: showSymptomStart = true
|
case .symptom: showSymptomStart = true
|
||||||
case .diary: showDiary = true
|
case .diary: showDiary = true
|
||||||
case .indicator: showIndicator = true
|
case .indicator: showIndicator = true
|
||||||
|
case .reminder: showReminders = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,6 +96,10 @@ struct RootView: View {
|
|||||||
.sheet(isPresented: $showIndicator) {
|
.sheet(isPresented: $showIndicator) {
|
||||||
IndicatorQuickSheet()
|
IndicatorQuickSheet()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showReminders) {
|
||||||
|
// 列表页依赖外层 NavigationStack 提供标题栏;sheet 形态补「完成」按钮。
|
||||||
|
NavigationStack { RemindersListView(presentedAsSheet: true) }
|
||||||
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.fullScreenCover(item: $activeFlow) { flow in
|
.fullScreenCover(item: $activeFlow) { flow in
|
||||||
switch flow {
|
switch flow {
|
||||||
@@ -100,6 +127,8 @@ private struct TabBar: View {
|
|||||||
let onTap: (TjTab) -> Void
|
let onTap: (TjTab) -> Void
|
||||||
let onTapRecord: () -> Void
|
let onTapRecord: () -> Void
|
||||||
|
|
||||||
|
@Namespace private var indicatorNS
|
||||||
|
|
||||||
private let cornerRadius: CGFloat = 22
|
private let cornerRadius: CGFloat = 22
|
||||||
private let slotHeight: CGFloat = 34
|
private let slotHeight: CGFloat = 34
|
||||||
|
|
||||||
@@ -115,6 +144,7 @@ private struct TabBar: View {
|
|||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
.background(barBackground)
|
.background(barBackground)
|
||||||
|
.animation(.spring(response: 0.35, dampingFraction: 0.75), value: active)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var barBackground: some View {
|
private var barBackground: some View {
|
||||||
@@ -143,6 +173,7 @@ private struct TabBar: View {
|
|||||||
Capsule()
|
Capsule()
|
||||||
.fill(Tj.Palette.sand2)
|
.fill(Tj.Palette.sand2)
|
||||||
.frame(width: 44, height: slotHeight - 6)
|
.frame(width: 44, height: slotHeight - 6)
|
||||||
|
.matchedGeometryEffect(id: "tabIndicator", in: indicatorNS)
|
||||||
}
|
}
|
||||||
Image(systemName: t.icon)
|
Image(systemName: t.icon)
|
||||||
.font(.system(size: 18, weight: isActive ? .semibold : .regular))
|
.font(.system(size: 18, weight: isActive ? .semibold : .regular))
|
||||||
@@ -188,7 +219,7 @@ private struct TabBar: View {
|
|||||||
.buttonStyle(TabPressStyle())
|
.buttonStyle(TabPressStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 你好
|
||||||
private struct TabPressStyle: ButtonStyle {
|
private struct TabPressStyle: ButtonStyle {
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
|
|||||||
158
康康/Security/AppLock.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
31
康康/Security/AppLockContainer.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
康康/Security/LockScreenView.swift
Normal 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()
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
/// VL 解析结果(已结构化,可直接喂 SwiftData 模型构造)。
|
/// VL 解析结果(已结构化,可直接喂 SwiftData 模型构造)。
|
||||||
/// 与 Indicator/Report 字段近似但解耦 —— 这样 prompt schema 调整不污染数据层。
|
/// 与 Indicator/Report 字段近似但解耦 —— 这样 prompt schema 调整不污染数据层。
|
||||||
@@ -40,16 +41,14 @@ struct ParsedReport: Sendable {
|
|||||||
/// CaptureService 错误 — UI 决定怎么呈现(回退表单 vs 重试)。
|
/// CaptureService 错误 — UI 决定怎么呈现(回退表单 vs 重试)。
|
||||||
enum CaptureError: Error, LocalizedError {
|
enum CaptureError: Error, LocalizedError {
|
||||||
case modelNotReady
|
case modelNotReady
|
||||||
case writeAssetFailed
|
|
||||||
case inferenceFailed(String)
|
case inferenceFailed(String)
|
||||||
case parseFailed(String)
|
case parseFailed(String)
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .modelNotReady: return "VL 模型尚未就绪"
|
case .modelNotReady: return String(appLoc: "VL 模型尚未就绪")
|
||||||
case .writeAssetFailed: return "图片保存失败"
|
case .inferenceFailed(let m): return String(appLoc: "识别失败:\(m)")
|
||||||
case .inferenceFailed(let m): return "识别失败:\(m)"
|
case .parseFailed(let m): return String(appLoc: "结构化失败:\(m)")
|
||||||
case .parseFailed(let m): return "结构化失败:\(m)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,38 +59,36 @@ actor CaptureService {
|
|||||||
static let shared = CaptureService()
|
static let shared = CaptureService()
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
/// 写图 + VL 推理 + 解析 → ParsedReport。
|
/// 对已写入 Vault 的 Asset 跑 VL,返回结构化 ParsedReport。
|
||||||
/// 任何阶段失败,都抛 CaptureError;UI 接住后切到「手动录入」表单。
|
/// 用于:
|
||||||
/// - Returns: (ParsedReport, [FileVault.SavedAsset]) 元组,
|
/// - UnifiedCaptureFlow 的初次识别(UI 先写图、再调本方法,失败/取消都能保留 assets 走手动录入)
|
||||||
/// SavedAsset 列表用于后续构造 Asset @Model。
|
/// - 录入表单顶部的「重新识别」按钮
|
||||||
func analyze(images: [UIImage]) async throws
|
/// - C2「重新解读」(W5)
|
||||||
-> (parsed: ParsedReport, assets: [FileVault.SavedAsset]) {
|
/// SwiftData 写回由调用方(MainActor)负责,见 `Report.applyReanalyzed(_:in:)`。
|
||||||
|
/// 不直接接 @Model 类型,避免把非 Sendable 引用抛过 actor 边界。
|
||||||
|
func reanalyze(assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
|
||||||
|
try await runVL(on: assets)
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 写图到 Vault(全程加密目录)
|
/// VL 推理 + JSON 解析的纯阶段。assets 必须已写入 Vault。
|
||||||
let assets: [FileVault.SavedAsset]
|
private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
|
||||||
do {
|
do {
|
||||||
assets = try images.map { try FileVault.shared.writeJPEG($0) }
|
try await AIRuntime.shared.prepareVL()
|
||||||
} catch {
|
} 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 urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
|
||||||
let raw: String
|
let raw: String
|
||||||
do {
|
do {
|
||||||
raw = try await AIRuntime.shared.analyzeReport(
|
raw = try await AIRuntime.shared.analyzeReport(
|
||||||
imageURLs: urls,
|
imageURLs: urls,
|
||||||
prompt: VLPrompts.reportExtraction
|
prompt: VLPrompts.reportExtraction()
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
throw CaptureError.inferenceFailed("\(error)")
|
throw CaptureError.inferenceFailed("\(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. JSON 解析(带容错:可能包含围栏 / 前后文字)
|
|
||||||
do {
|
do {
|
||||||
let parsed = try CaptureService.parseReportJSON(raw, pageCount: assets.count)
|
return try CaptureService.parseReportJSON(raw, pageCount: assets.count)
|
||||||
return (parsed, assets)
|
|
||||||
} catch let CaptureError.parseFailed(msg) {
|
} catch let CaptureError.parseFailed(msg) {
|
||||||
throw CaptureError.parseFailed(msg)
|
throw CaptureError.parseFailed(msg)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -136,7 +133,7 @@ actor CaptureService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ParsedReport(
|
return ParsedReport(
|
||||||
title: title.isEmpty ? "拍摄识别" : title,
|
title: title.isEmpty ? String(appLoc: "拍摄识别") : title,
|
||||||
typeRaw: typeRaw,
|
typeRaw: typeRaw,
|
||||||
reportDate: reportDate,
|
reportDate: reportDate,
|
||||||
institution: institution,
|
institution: institution,
|
||||||
@@ -216,3 +213,53 @@ actor CaptureService {
|
|||||||
return .init(name: name, value: value, unit: unit, range: range, status: status)
|
return .init(name: name, value: value, unit: unit, range: range, status: status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Report ↔ CaptureService 桥接(MainActor 侧)
|
||||||
|
//
|
||||||
|
// CaptureService 是 actor,不能直接收 Report(@Model 非 Sendable)。
|
||||||
|
// C2「重新解读」UI 走这条路径:
|
||||||
|
// ```
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
101
康康/Services/DiaryAssistService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
460
康康/Services/HealthExportService.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,7 +132,7 @@ final class ModelDownloadService {
|
|||||||
states[kind] = DownloadState(phase: .ready, receivedBytes: total,
|
states[kind] = DownloadState(phase: .ready, receivedBytes: total,
|
||||||
totalBytes: total, bytesPerSecond: 0)
|
totalBytes: total, bytesPerSecond: 0)
|
||||||
} else {
|
} else {
|
||||||
states[kind] = DownloadState(phase: .failed(message ?? "下载失败"),
|
states[kind] = DownloadState(phase: .failed(message ?? String(appLoc: "下载失败")),
|
||||||
receivedBytes: completedBytes(for: kind),
|
receivedBytes: completedBytes(for: kind),
|
||||||
totalBytes: total, bytesPerSecond: 0)
|
totalBytes: total, bytesPerSecond: 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ enum ReminderService {
|
|||||||
|
|
||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = "该测\(reminder.displayName)了"
|
content.title = String(appLoc: "该测\(reminder.displayName)了")
|
||||||
content.body = "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"
|
content.body = String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次")
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
content.threadIdentifier = "kangkang.reminder.\(reminder.metricId)"
|
content.threadIdentifier = "kangkang.reminder.\(reminder.metricId)"
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ struct ModelManifestTests {
|
|||||||
#expect(ModelManifest.files(for: .llm).count == 9)
|
#expect(ModelManifest.files(for: .llm).count == 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func vlHasElevenFunctionalFiles() {
|
@Test func vlHasFourteenFunctionalFiles() {
|
||||||
#expect(ModelManifest.files(for: .vl).count == 11)
|
#expect(ModelManifest.files(for: .vl).count == 14)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func llmTotalBytesMatchesManifest() {
|
@Test func llmTotalBytesMatchesManifest() {
|
||||||
@@ -17,7 +17,7 @@ struct ModelManifestTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func vlTotalBytesMatchesManifest() {
|
@Test func vlTotalBytesMatchesManifest() {
|
||||||
#expect(ModelManifest.totalBytes(for: .vl) == 3_089_710_883)
|
#expect(ModelManifest.totalBytes(for: .vl) == 3_109_729_929)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func excludesReadmeAndGitattributes() {
|
@Test func excludesReadmeAndGitattributes() {
|
||||||
|
|||||||