diff --git a/CLAUDE.md b/CLAUDE.md index ffd2b73..05de976 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ VL prompt 必须: ## 4. 模型分发 - 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条 -- 总体积 ~3GB,WiFi 提示必须有 +- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有 - App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载" - `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用) diff --git a/docs/superpowers/specs/2026-05-30-faceid-app-lock-design.md b/docs/superpowers/specs/2026-05-30-faceid-app-lock-design.md new file mode 100644 index 0000000..55b58cc --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-faceid-app-lock-design.md @@ -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 │ + │ @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 可模拟。 diff --git a/scripts/fetch-qwen3vl.sh b/scripts/fetch-qwen3vl.sh new file mode 100755 index 0000000..a89c013 --- /dev/null +++ b/scripts/fetch-qwen3vl.sh @@ -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" diff --git a/scripts/upload-qwen3vl.sh b/scripts/upload-qwen3vl.sh new file mode 100644 index 0000000..7d18a2f --- /dev/null +++ b/scripts/upload-qwen3vl.sh @@ -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/ +# → 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 diff --git a/康康.xcodeproj/project.pbxproj b/康康.xcodeproj/project.pbxproj index a28849a..50cbe91 100644 --- a/康康.xcodeproj/project.pbxproj +++ b/康康.xcodeproj/project.pbxproj @@ -199,11 +199,14 @@ }; }; buildConfigurationList = 5E463CF42FC403BB0089145B /* Build configuration list for PBXProject "康康" */; - developmentRegion = en; + developmentRegion = "zh-Hans"; hasScannedForEncodings = 0; knownRegions = ( en, Base, + "zh-Hans", + ja, + ko, ); mainGroup = 5E463CF02FC403BB0089145B; minimizedProjectReferenceProxies = 1; @@ -413,6 +416,10 @@ ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。"; + INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -458,6 +465,10 @@ ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。"; + INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "康康需要读取你已有的体检/化验报告照片用于本地识别,不会上传。"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; diff --git a/康康/AI/AIRuntime.swift b/康康/AI/AIRuntime.swift index 42cf6a9..3ec5392 100644 --- a/康康/AI/AIRuntime.swift +++ b/康康/AI/AIRuntime.swift @@ -7,9 +7,9 @@ enum AIRuntimeError: Error, LocalizedError { var errorDescription: String? { switch self { - case .notReady: return "AI 模型尚未准备好" - case .modelLoadFailed(let m): return "模型加载失败:\(m)" - case .inferenceFailed(let m): return "推理失败:\(m)" + case .notReady: return String(appLoc: "AI 模型尚未准备好") + case .modelLoadFailed(let m): return String(appLoc: "模型加载失败:\(m)") + case .inferenceFailed(let m): return String(appLoc: "推理失败:\(m)") } } } diff --git a/康康/AI/FileDownloader.swift b/康康/AI/FileDownloader.swift index 673a215..cc1e4e7 100644 --- a/康康/AI/FileDownloader.swift +++ b/康康/AI/FileDownloader.swift @@ -7,9 +7,9 @@ enum DownloadError: Error, LocalizedError { var errorDescription: String? { switch self { case .badStatus(let code): - return "下载失败(HTTP \(code))" + return String(appLoc: "下载失败(HTTP \(code))") case .sizeMismatch(let expected, let got): - return "文件大小校验失败(预期 \(expected),实际 \(got))" + return String(appLoc: "文件大小校验失败(预期 \(expected),实际 \(got))") } } } diff --git a/康康/AI/ModelManifest.swift b/康康/AI/ModelManifest.swift index fc4413c..b651856 100644 --- a/康康/AI/ModelManifest.swift +++ b/康康/AI/ModelManifest.swift @@ -30,18 +30,27 @@ enum ModelManifest { ModelFile(path: "added_tokens.json", bytes: 707), ] case .vl: + // Qwen3-VL-4B-Instruct-4bit:字节数取自 mlx-community 仓库实际 blob 大小 + // (HF API blobs=true,2026-05 核对),用于总进度计算与下载后大小校验。 + // 策略:完整镜像仓库的全部运行文件(仅排除 README.md / .gitattributes), + // 与标准 mlx-vlm 加载环境保持一致,避免漏文件导致 VLMModelFactory 加载失败。 + // 同时带两份 chat_template(.json 旧约定 + .jinja 新约定)与 video 预处理配置, + // 以兼容不同版本 swift-transformers / Qwen3VLProcessor 的读取路径。 return [ - ModelFile(path: "config.json", bytes: 1_659), - ModelFile(path: "model.safetensors", bytes: 3_073_720_461), - ModelFile(path: "model.safetensors.index.json", bytes: 108_307), - ModelFile(path: "tokenizer.json", bytes: 11_421_896), - ModelFile(path: "tokenizer_config.json", bytes: 7_256), + ModelFile(path: "config.json", bytes: 7_137), + ModelFile(path: "model.safetensors", bytes: 3_093_767_283), + ModelFile(path: "model.safetensors.index.json", bytes: 64_742), + ModelFile(path: "tokenizer.json", bytes: 11_422_654), + ModelFile(path: "tokenizer_config.json", bytes: 5_445), ModelFile(path: "vocab.json", bytes: 2_776_833), ModelFile(path: "merges.txt", bytes: 1_671_853), ModelFile(path: "special_tokens_map.json", bytes: 613), - ModelFile(path: "added_tokens.json", bytes: 605), - ModelFile(path: "chat_template.json", bytes: 1_050), - ModelFile(path: "preprocessor_config.json", bytes: 350), + ModelFile(path: "added_tokens.json", bytes: 707), + ModelFile(path: "generation_config.json", bytes: 269), + ModelFile(path: "chat_template.json", bytes: 5_502), + ModelFile(path: "chat_template.jinja", bytes: 5_292), + ModelFile(path: "preprocessor_config.json", bytes: 782), + ModelFile(path: "video_preprocessor_config.json", bytes: 817), ] } } diff --git a/康康/AI/ModelStore.swift b/康康/AI/ModelStore.swift index c76a2f4..dcfa9c0 100644 --- a/康康/AI/ModelStore.swift +++ b/康康/AI/ModelStore.swift @@ -3,12 +3,12 @@ import Foundation nonisolated enum ModelKind: String, CaseIterable { /// 与 HuggingFace mlx-community 仓库名一一对应,也是沙盒 Models/ 下的子目录名。 case llm = "Qwen3-1.7B-4bit" - case vl = "Qwen2.5-VL-3B-Instruct-4bit" + case vl = "Qwen3-VL-4B-Instruct-4bit" var displayName: String { switch self { case .llm: return "Qwen3-1.7B" - case .vl: return "Qwen2.5-VL-3B" + case .vl: return "Qwen3-VL-4B" } } @@ -132,7 +132,7 @@ enum ModelStoreError: Error, LocalizedError { var errorDescription: String? { switch self { case .missingConfig: - return "所选文件夹缺少 config.json,不是有效的模型目录" + return String(appLoc: "所选文件夹缺少 config.json,不是有效的模型目录") } } } diff --git a/康康/AI/Prompts/DiaryAssistPrompts.swift b/康康/AI/Prompts/DiaryAssistPrompts.swift new file mode 100644 index 0000000..5f73f2d --- /dev/null +++ b/康康/AI/Prompts/DiaryAssistPrompts.swift @@ -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 围栏、不要 标签。结构: + {"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 + """ + } +} diff --git a/康康/AI/Prompts/HealthExportPrompts.swift b/康康/AI/Prompts/HealthExportPrompts.swift new file mode 100644 index 0000000..67b3bab --- /dev/null +++ b/康康/AI/Prompts/HealthExportPrompts.swift @@ -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(直接输出,不要思考过程,不要 标签): + /no_think + """ + } +} diff --git a/康康/AI/Prompts/VLPrompts.swift b/康康/AI/Prompts/VLPrompts.swift index cc18dc9..59bdb25 100644 --- a/康康/AI/Prompts/VLPrompts.swift +++ b/康康/AI/Prompts/VLPrompts.swift @@ -1,6 +1,6 @@ import Foundation -/// VL 模型(Qwen2.5-VL)用于体检 / 化验单识别的 prompt 模板。 +/// VL 模型(Qwen3-VL)用于体检 / 化验单识别的 prompt 模板。 /// 输出契约:严格 JSON,无任何解释文字、markdown 围栏或前后缀。 /// 解析失败 → CaptureService 回退到手动录入(§3.2 失败回退红线)。 enum VLPrompts { @@ -27,9 +27,21 @@ enum VLPrompts { /// ``` /// `kind` 字段省略 —— UI 由 indicators 数量决定走 A2(单项)或 B3(多项)。 - static let reportExtraction: String = #""" + /// VL 模型不知"今天"是哪天,且 few-shot 示例里写死了日期, + /// 必须把当天日期显式注入 prompt,模型在无报告日期时才会用对正确的回退值。 + static func reportExtraction(today: Date = .now) -> String { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + let todayStr = f.string(from: today) + return reportExtractionTemplate.replacingOccurrences(of: "{{TODAY}}", with: todayStr) + } + + private static let reportExtractionTemplate: String = #""" 你是一个医学体检报告识别助手。请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。 +今天的日期是 {{TODAY}}。 + JSON schema(严格): { "title": string, @@ -52,7 +64,8 @@ JSON schema(严格): 规则: - status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"。 - range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象。 -- 无法识别的字段填空字符串(institution / summary)或合理默认值(report_date 用今天)。 +- 无法识别的字段填空字符串(institution / summary)。 +- report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。 - 不要发明指标。看不清的整行跳过。 - 化验单一般 type = "lab",体检套餐 = "checkup"。 diff --git a/康康/AI/VLSession.swift b/康康/AI/VLSession.swift index db16184..20666bf 100644 --- a/康康/AI/VLSession.swift +++ b/康康/AI/VLSession.swift @@ -3,7 +3,7 @@ import MLX import MLXVLM import MLXLMCommon -/// 封装 MLX VL 模型(Qwen2.5-VL)的图像 → 文本推理。 +/// 封装 MLX VL 模型(Qwen3-VL)的图像 → 文本推理。 /// 与 LLMSession 同款 actor 隔离,串行化由上游 AIRuntime 统一保证。 actor VLSession { let container: ModelContainer diff --git a/康康/App/KangkangApp.swift b/康康/App/KangkangApp.swift index 892bc85..f744b37 100644 --- a/康康/App/KangkangApp.swift +++ b/康康/App/KangkangApp.swift @@ -3,6 +3,8 @@ import SwiftData @main struct KangkangApp: App { + @State private var lang = LanguageManager.shared + var sharedModelContainer: ModelContainer = { let schema = Schema([ Indicator.self, @@ -39,7 +41,11 @@ struct KangkangApp: App { var body: some Scene { WindowGroup { - RootView() + AppLockContainer { + RootView() + .environment(\.locale, lang.locale) + .id(lang.current) // 语言切换 → 整树重建,即时生效 + } } .modelContainer(sharedModelContainer) } diff --git a/康康/App/Localization.swift b/康康/App/Localization.swift new file mode 100644 index 0000000..8394768 --- /dev/null +++ b/康康/App/Localization.swift @@ -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) + } +} diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-1024.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-1024.png new file mode 100644 index 0000000..204381b Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-1024.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-128.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-128.png new file mode 100644 index 0000000..71eb47a Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-128.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-16.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-16.png new file mode 100644 index 0000000..f0117a1 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-16.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-256.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-256.png new file mode 100644 index 0000000..735739e Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-256.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-32.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-32.png new file mode 100644 index 0000000..4fe3f43 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-32.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-512.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-512.png new file mode 100644 index 0000000..7427677 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-512.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-64.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-64.png new file mode 100644 index 0000000..2fc2975 Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-64.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-dark-1024.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-dark-1024.png new file mode 100644 index 0000000..204381b Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-dark-1024.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-tinted-1024.png b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-tinted-1024.png new file mode 100644 index 0000000..204381b Binary files /dev/null and b/康康/Assets.xcassets/AppIcon.appiconset/.backup/app-icon-kangkang-tinted-1024.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-1024.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-1024.png index 204381b..bacaf6e 100644 Binary files a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-1024.png and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-1024.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-128.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-128.png index 71eb47a..e76e570 100644 Binary files a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-128.png and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-128.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-16.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-16.png index f0117a1..7aa13ca 100644 Binary files a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-16.png and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-16.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-256.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-256.png index 735739e..958318d 100644 Binary files a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-256.png and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-256.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-32.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-32.png index 4fe3f43..15af40f 100644 Binary files a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-32.png and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-32.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-512.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-512.png index 7427677..3c51660 100644 Binary files a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-512.png and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-512.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-64.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-64.png index 2fc2975..d082773 100644 Binary files a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-64.png and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-64.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-dark-1024.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-dark-1024.png index 204381b..bacaf6e 100644 Binary files a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-dark-1024.png and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-dark-1024.png differ diff --git a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-tinted-1024.png b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-tinted-1024.png index 204381b..bacaf6e 100644 Binary files a/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-tinted-1024.png and b/康康/Assets.xcassets/AppIcon.appiconset/app-icon-kangkang-tinted-1024.png differ diff --git a/康康/Features/Archive/ArchiveListView.swift b/康康/Features/Archive/ArchiveListView.swift index 93e62bc..e9fd786 100644 --- a/康康/Features/Archive/ArchiveListView.swift +++ b/康康/Features/Archive/ArchiveListView.swift @@ -14,8 +14,13 @@ struct ArchiveListView: View { @Query(sort: \Symptom.startedAt, order: .reverse) private var symptoms: [Symptom] + @Query(sort: \HealthExport.createdAt, order: .reverse) + private var exports: [HealthExport] + @State private var filter: TimelineKind? = nil @State private var endingSymptom: Symptom? + @State private var showExportSheet = false + @State private var showExportList = false @MainActor private var allEntries: [TimelineEntry] { @@ -35,6 +40,15 @@ struct ArchiveListView: View { private var totalCount: Int { allEntries.count } var body: some View { + NavigationStack { + content + .navigationDestination(isPresented: $showExportList) { + HealthExportListView() + } + } + } + + private var content: some View { VStack(alignment: .leading, spacing: 0) { header .padding(.horizontal, 20) @@ -71,6 +85,9 @@ struct ArchiveListView: View { .sheet(item: $endingSymptom) { sym in SymptomEndSheet(symptom: sym) } + .fullScreenCover(isPresented: $showExportSheet) { + HealthExportSheet() + } } @ViewBuilder @@ -93,17 +110,44 @@ struct ArchiveListView: View { Text("记录") .font(.tjTitle(26)) .foregroundStyle(Tj.Palette.text) - Text(totalCount == 0 ? "" : "\(totalCount) 条") + Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount) 条")) .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) Spacer() + Menu { + Button { + showExportSheet = true + } label: { + Label("生成新导出", systemImage: "doc.text.below.ecg") + } + if !exports.isEmpty { + Button { + showExportList = true + } label: { + Label("我的导出 · \(exports.count) 份", systemImage: "clock.arrow.circlepath") + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: "doc.text.below.ecg") + .font(.system(size: 12, weight: .semibold)) + Text("导出") + .font(.system(size: 13, weight: .semibold)) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + } + .foregroundStyle(Tj.Palette.paper) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Capsule().fill(Tj.Palette.ink)) + } } } private var filterChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - chip(label: "全部", selected: filter == nil) { filter = nil } + chip(label: String(appLoc: "全部"), selected: filter == nil) { filter = nil } ForEach(TimelineKind.allCases) { kind in chip(label: kind.label, selected: filter == kind) { filter = filter == kind ? nil : kind @@ -152,9 +196,9 @@ struct ArchiveListView: View { private var emptyState: some View { VStack(spacing: 14) { Spacer() - TjPlaceholder(label: "还没有任何记录\n点底部 + 号开始") + TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始")) .frame(width: 240, height: 140) - Text(filter == nil ? "记录会按时间归类显示" : "这个类别下没有记录") + Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录")) .font(.system(size: 13)) .foregroundStyle(Tj.Palette.text3) Spacer() @@ -166,6 +210,8 @@ struct ArchiveListView: View { #Preview { ArchiveListView() .modelContainer(for: [ - Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self + Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self, + HealthExport.self, ChatTurn.self, UserProfile.self, + MetricReminder.self, CustomMonitorMetric.self ], inMemory: true) } diff --git a/康康/Features/Archive/B1GuideView.swift b/康康/Features/Archive/B1GuideView.swift index d4251d7..3a57764 100644 --- a/康康/Features/Archive/B1GuideView.swift +++ b/康康/Features/Archive/B1GuideView.swift @@ -48,8 +48,8 @@ struct B1GuideView: View { .padding(.bottom, 26) VStack(spacing: 12) { - OptCard(title: "单张报告", sub: "一张图,几秒搞定", hint: "化验单 · 处方", badge: nil, action: onSingle) - OptCard(title: "多页报告", sub: "像扫描文档一样翻页拍摄", hint: "体检报告 · 影像报告", badge: "推荐", action: onMulti) + OptCard(title: String(appLoc: "单张报告"), sub: String(appLoc: "一张图,几秒搞定"), hint: String(appLoc: "化验单 · 处方"), badge: nil, action: onSingle) + OptCard(title: String(appLoc: "多页报告"), sub: String(appLoc: "像扫描文档一样翻页拍摄"), hint: String(appLoc: "体检报告 · 影像报告"), badge: String(appLoc: "推荐"), action: onMulti) } Spacer(minLength: 18) diff --git a/康康/Features/Archive/B2ScanView.swift b/康康/Features/Archive/B2ScanView.swift index 6bc478c..7cfff94 100644 --- a/康康/Features/Archive/B2ScanView.swift +++ b/康康/Features/Archive/B2ScanView.swift @@ -63,16 +63,16 @@ struct B2ScanView: View { private var reportRows: [(String, String, String)] { [ - ("总胆固醇", "5.42", "3.10–5.18"), - ("甘油三酯", "1.78", "0.45–1.70"), - ("低密度脂蛋白", "3.84↑", "<3.40"), - ("高密度脂蛋白", "1.21", ">1.04"), - ("载脂蛋白 A1", "1.42", "1.00–1.60"), - ("载脂蛋白 B", "1.04", "0.55–1.05"), - ("谷丙转氨酶", "28", "9–50"), - ("谷草转氨酶", "24", "15–40"), - ("空腹血糖", "5.4", "3.9–6.1"), - ("糖化血红蛋白", "5.7", "4.0–6.0"), + (String(appLoc: "总胆固醇"), "5.42", "3.10–5.18"), + (String(appLoc: "甘油三酯"), "1.78", "0.45–1.70"), + (String(appLoc: "低密度脂蛋白"), "3.84↑", "<3.40"), + (String(appLoc: "高密度脂蛋白"), "1.21", ">1.04"), + (String(appLoc: "载脂蛋白 A1"), "1.42", "1.00–1.60"), + (String(appLoc: "载脂蛋白 B"), "1.04", "0.55–1.05"), + (String(appLoc: "谷丙转氨酶"), "28", "9–50"), + (String(appLoc: "谷草转氨酶"), "24", "15–40"), + (String(appLoc: "空腹血糖"), "5.4", "3.9–6.1"), + (String(appLoc: "糖化血红蛋白"), "5.7", "4.0–6.0"), ] } diff --git a/康康/Features/Archive/B3MetaView.swift b/康康/Features/Archive/B3MetaView.swift index 86de467..e2b0677 100644 --- a/康康/Features/Archive/B3MetaView.swift +++ b/康康/Features/Archive/B3MetaView.swift @@ -5,7 +5,13 @@ struct B3MetaView: View { var onBack: () -> Void @State private var selectedType = 0 - private let types = ["体检报告", "化验单", "影像报告", "处方", "其他"] + private let types = [ + String(appLoc: "体检报告"), + String(appLoc: "化验单"), + String(appLoc: "影像报告"), + String(appLoc: "处方"), + String(appLoc: "其他"), + ] var body: some View { VStack(spacing: 0) { diff --git a/康康/Features/Archive/B4ProgressView.swift b/康康/Features/Archive/B4ProgressView.swift index df4ab3f..cb16616 100644 --- a/康康/Features/Archive/B4ProgressView.swift +++ b/康康/Features/Archive/B4ProgressView.swift @@ -10,11 +10,11 @@ struct B4ProgressView: View { @State private var elapsed: Double = 0.2 private let lineLabels = [ - "正在本地识别第 1 / 3 页…", - "正在本地识别第 2 / 3 页…", - "正在本地识别第 3 / 3 页…", - "提取指标 · 共 28 项", - "生成整体摘要…", + String(appLoc: "正在本地识别第 1 / 3 页…"), + String(appLoc: "正在本地识别第 2 / 3 页…"), + String(appLoc: "正在本地识别第 3 / 3 页…"), + String(appLoc: "提取指标 · 共 28 项"), + String(appLoc: "生成整体摘要…"), ] var body: some View { @@ -127,7 +127,7 @@ struct B4ProgressView: View { } private var speedBadge: some View { - Text(String(format: "已处理 %.1fs · 比云端快 4.2×", elapsed)) + Text(String(format: String(appLoc: "已处理 %.1fs · 比云端快 4.2×"), elapsed)) .font(.system(size: 10, design: .monospaced)) .tracking(0.6) .foregroundStyle(Color.white.opacity(0.75)) diff --git a/康康/Features/Archive/B5ResultView.swift b/康康/Features/Archive/B5ResultView.swift index 83a7ecd..69a1b87 100644 --- a/康康/Features/Archive/B5ResultView.swift +++ b/康康/Features/Archive/B5ResultView.swift @@ -17,11 +17,11 @@ struct B5ResultView: View { @State private var normalsExpanded = false let abnormal: [B5IndicatorData] = [ - .init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high, - note: "超过参考上限 0.44。建议关注饮食结构,3 个月内复查。"), - .init(name: "甘油三酯 TG", value: "1.78", unit: "mmol/L", range: "0.45–1.70", status: .high, note: nil), - .init(name: "尿酸 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: "低密度脂蛋白胆固醇"), value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high, + note: String(appLoc: "超过参考上限 0.44。建议关注饮食结构,3 个月内复查。")), + .init(name: String(appLoc: "甘油三酯 TG"), value: "1.78", unit: "mmol/L", range: "0.45–1.70", status: .high, note: nil), + .init(name: String(appLoc: "尿酸 UA"), value: "428", unit: "μmol/L", range: "150–420", status: .high, note: nil), + .init(name: String(appLoc: "维生素 D"), value: "18", unit: "ng/mL", range: "30–100", status: .low, note: nil), ] let normalCount = 24 @@ -33,7 +33,7 @@ struct B5ResultView: View { VStack(alignment: .leading, spacing: 0) { reportMeta.padding(.bottom, 16) summaryCard.padding(.bottom, 18) - SectionLabel("异常项", count: abnormal.count, accent: .brick) + SectionLabel(String(appLoc: "异常项"), count: abnormal.count, accent: .brick) .padding(.bottom, 10) VStack(spacing: 8) { ForEach(Array(abnormal.enumerated()), id: \.offset) { idx, it in @@ -44,7 +44,7 @@ struct B5ResultView: View { } .padding(.bottom, 18) - SectionLabel("正常项", count: normalCount, accent: .leaf) + SectionLabel(String(appLoc: "正常项"), count: normalCount, accent: .leaf) .padding(.bottom, 10) normalCollapsed } @@ -97,7 +97,7 @@ struct B5ResultView: View { private var reportMeta: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { - TjBadge(text: "体检报告", style: .ink) + TjBadge(text: String(appLoc: "体检报告"), style: .ink) Text("3 页") .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text3) @@ -130,10 +130,10 @@ struct B5ResultView: View { .padding(.bottom, 12) HStack(spacing: 14) { - Stat(n: "28", label: "总项") - Stat(n: "3", label: "偏高", tone: .brick) - Stat(n: "1", label: "偏低", tone: .amber) - Stat(n: "24", label: "正常", tone: .leaf) + Stat(n: "28", label: String(appLoc: "总项")) + Stat(n: "3", label: String(appLoc: "偏高"), tone: .brick) + Stat(n: "1", label: String(appLoc: "偏低"), tone: .amber) + Stat(n: "24", label: String(appLoc: "正常"), tone: .leaf) } .padding(.bottom, 14) @@ -253,9 +253,9 @@ private struct IndicatorRow: View { } var statusWord: String { switch item.status { - case .high: return "偏高" - case .low: return "偏低" - case .normal: return "正常" + case .high: return String(appLoc: "偏高") + case .low: return String(appLoc: "偏低") + case .normal: return String(appLoc: "正常") } } var valueColor: Color { diff --git a/康康/Features/Archive/HealthExportDetailView.swift b/康康/Features/Archive/HealthExportDetailView.swift new file mode 100644 index 0000000..e98a9a0 --- /dev/null +++ b/康康/Features/Archive/HealthExportDetailView.swift @@ -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) +} diff --git a/康康/Features/Archive/HealthExportListView.swift b/康康/Features/Archive/HealthExportListView.swift new file mode 100644 index 0000000..b32c485 --- /dev/null +++ b/康康/Features/Archive/HealthExportListView.swift @@ -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) +} diff --git a/康康/Features/Archive/HealthExportSheet.swift b/康康/Features/Archive/HealthExportSheet.swift new file mode 100644 index 0000000..7396462 --- /dev/null +++ b/康康/Features/Archive/HealthExportSheet.swift @@ -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? + @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) +} diff --git a/康康/Features/Capture/CaptureReviewForm.swift b/康康/Features/Capture/CaptureReviewForm.swift index d8139d2..cc7ce4e 100644 --- a/康康/Features/Capture/CaptureReviewForm.swift +++ b/康康/Features/Capture/CaptureReviewForm.swift @@ -10,6 +10,8 @@ struct CaptureReviewForm: View { let warning: String? let onSave: (ParsedReport) -> Void let onCancel: () -> Void + /// 「重新识别」回调。assets 为空(写图失败)时传 nil,banner 上不显示该按钮。 + var onReanalyze: (() -> Void)? = nil var body: some View { ScrollView { @@ -36,10 +38,22 @@ struct CaptureReviewForm: View { HStack(alignment: .top, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(Tj.Palette.amber) - Text(text) - .font(.system(size: 12)) - .foregroundStyle(Tj.Palette.text2) - .fixedSize(horizontal: false, vertical: true) + VStack(alignment: .leading, spacing: 8) { + Text(text) + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text2) + .fixedSize(horizontal: false, vertical: true) + if let onReanalyze { + Button { + onReanalyze() + } label: { + Label("重新识别", systemImage: "arrow.clockwise") + .font(.system(size: 12, weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(Tj.Palette.ink) + } + } Spacer(minLength: 0) } .padding(12) @@ -53,7 +67,7 @@ struct CaptureReviewForm: View { private var pageThumbnails: some View { VStack(alignment: .leading, spacing: 8) { - sectionLabel("已保存 \(assets.count) 页(端侧加密)") + sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)")) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in @@ -78,13 +92,13 @@ struct CaptureReviewForm: View { private var metaSection: some View { VStack(alignment: .leading, spacing: 12) { - sectionLabel("基本信息") + sectionLabel(String(appLoc: "基本信息")) VStack(spacing: 10) { - labeledField("标题") { + labeledField(String(appLoc: "标题")) { TextField("如:春季年度体检", text: $parsed.title) .textFieldStyle(.plain) } - labeledField("类型") { + labeledField(String(appLoc: "类型")) { Picker("", selection: $parsed.typeRaw) { ForEach(ReportType.allCases, id: \.rawValue) { t in Text(t.label).tag(t.rawValue) @@ -92,18 +106,18 @@ struct CaptureReviewForm: View { } .pickerStyle(.segmented) } - labeledField("报告日期") { + labeledField(String(appLoc: "报告日期")) { DatePicker("", selection: $parsed.reportDate, in: ...Date.now, displayedComponents: .date) .datePickerStyle(.compact) .labelsHidden() - .environment(\.locale, Locale(identifier: "zh_CN")) + .environment(\.locale, Locale.current) } - labeledField("机构(可选)") { + labeledField(String(appLoc: "机构(可选)")) { TextField("如:协和医院", text: $parsed.institution) } - labeledField("摘要(可选)") { + labeledField(String(appLoc: "摘要(可选)")) { TextField("一句话总结", text: $parsed.summary, axis: .vertical) .lineLimit(1...3) } @@ -128,7 +142,7 @@ struct CaptureReviewForm: View { private var indicatorSection: some View { VStack(alignment: .leading, spacing: 10) { HStack { - sectionLabel("指标(\(parsed.indicators.count) 项)") + sectionLabel(String(appLoc: "指标(\(parsed.indicators.count) 项)")) Spacer() Button { parsed.indicators.append( diff --git a/康康/Features/Capture/UnifiedCaptureFlow.swift b/康康/Features/Capture/UnifiedCaptureFlow.swift index aa1b4fa..480ed5f 100644 --- a/康康/Features/Capture/UnifiedCaptureFlow.swift +++ b/康康/Features/Capture/UnifiedCaptureFlow.swift @@ -1,6 +1,7 @@ import SwiftUI import SwiftData import UIKit +import Combine /// 拍报告 → VL 识别 → 编辑 → 保存(图 + 结构化文本) /// 一条统一流程,替代原 A1-A3 / B1-B5 两套 mockup。 @@ -16,11 +17,17 @@ struct UnifiedCaptureFlow: View { @Environment(\.modelContext) private var ctx let onClose: () -> Void + @AppStorage("hasSeenCaptureTip") private var hasSeenCaptureTip: Bool = false @State private var phase: Phase = .idle + @State private var analyzeTask: Task? = nil + @State private var showTip: Bool = false + + /// VL 单次推理超时(防止卡死);超时后 cancel 子任务,UI 走手动录入回退。 + private let analyzeTimeoutSeconds: Int = 30 enum Phase { case idle - case analyzing(images: [UIImage]) + case analyzing(images: [UIImage], assets: [FileVault.SavedAsset]?) case editing(parsed: ParsedReport, assets: [FileVault.SavedAsset], warning: String?) @@ -32,20 +39,30 @@ struct UnifiedCaptureFlow: View { .background(Tj.Palette.sand.ignoresSafeArea()) .toolbar { ToolbarItem(placement: .topBarLeading) { - Button("取消") { onClose() } + Button("取消") { cancelAll() } .foregroundStyle(Tj.Palette.text) } } .navigationTitle(phaseTitle) .navigationBarTitleDisplayMode(.inline) } + .onAppear { + if !hasSeenCaptureTip { showTip = true } + } + .sheet(isPresented: $showTip) { + CaptureTipSheet(onDismiss: { + hasSeenCaptureTip = true + showTip = false + }) + .presentationDetents([.medium]) + } } private var phaseTitle: String { switch phase { - case .idle: return "拍摄报告" - case .analyzing: return "本地识别中…" - case .editing: return "核对识别结果" + case .idle: return String(appLoc: "拍摄报告") + case .analyzing: return String(appLoc: "本地识别中…") + case .editing: return String(appLoc: "核对识别结果") } } @@ -54,21 +71,57 @@ struct UnifiedCaptureFlow: View { switch phase { case .idle: captureEntry - case .analyzing(let images): - AnalyzingView(images: images) + case .analyzing(let images, _): + AnalyzingView( + images: images, + timeoutSeconds: analyzeTimeoutSeconds, + onCancel: { + analyzeTask?.cancel() + analyzeTask = nil + phase = .idle + } + ) case .editing(let parsed, let assets, let warning): CaptureReviewForm( parsed: parsed, assets: assets, warning: warning, onSave: { final in saveAll(parsed: final, assets: assets) }, - onCancel: onClose + onCancel: cancelAll, + onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) } ) } } + // MARK: - 取消统一入口 + + /// 取消推理 + 清理未保存到 SwiftData 的 Vault 孤儿图片,再关闭 sheet。 + /// 工具栏「取消」与编辑表单底部「取消(图片不保留)」都走这里, + /// 保证「图片不保留」的隐私承诺(§6)真的成立,且 Vault 不被孤儿图片堆爆。 + /// 仅清理 .analyzing/.editing 阶段的 assets;.idle 时还没写图,无需清理。 + private func cancelAll() { + analyzeTask?.cancel() + analyzeTask = nil + switch phase { + case .idle: + break + case .analyzing(_, let maybeAssets): + if let assets = maybeAssets { removeOrphans(assets) } + case .editing(_, let assets, _): + removeOrphans(assets) + } + onClose() + } + + private func removeOrphans(_ assets: [FileVault.SavedAsset]) { + for a in assets { + try? FileVault.shared.remove(relativePath: a.relativePath) + } + } + // MARK: - 入口:相机 / 相册 + @ViewBuilder private var captureEntry: some View { #if targetEnvironment(simulator) PhotoPickerSheet( @@ -95,54 +148,124 @@ struct UnifiedCaptureFlow: View { private func startAnalyze(images: [UIImage]) { guard !images.isEmpty else { onClose(); return } - phase = .analyzing(images: images) - Task { - do { - let result = try await CaptureService.shared.analyze(images: images) - await MainActor.run { - phase = .editing( - parsed: result.parsed, - assets: result.assets, - warning: result.parsed.isEmpty - ? "识别没有读出指标,请手动补充" - : nil - ) - } - } catch let CaptureError.parseFailed(msg) { - // 解析失败:仍然展示编辑表单,只是 indicators 为空,assets 已保存 - await fallbackToManual(images: images, msg: "VL 输出无法解析:\(msg)") - } catch let CaptureError.inferenceFailed(msg) { - await fallbackToManual(images: images, msg: "推理失败:\(msg)") - } catch let CaptureError.modelNotReady { - await fallbackToManual(images: images, msg: "VL 模型未就绪,先手动录入") - } catch CaptureError.writeAssetFailed { + analyzeTask?.cancel() + phase = .analyzing(images: images, assets: nil) + let timeout = analyzeTimeoutSeconds + analyzeTask = Task { + // Step 1: 先把图写进 Vault。 + // 在 UI 这一层写,而不是塞进 CaptureService.analyze —— 这样取消/失败回退时, + // assets 已经在 phase 里,cancelAll 能清理孤儿,editingFallback 也不必再补写。 + let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) } + // 极端情况:用户在写图过程中按了「取消」,View 已 dismiss、cancelAll 看到的 + // phase 还是 .analyzing(_, nil),清不到这批刚写完的图 — 这里手动收尾。 + if Task.isCancelled { + for a in assets { try? FileVault.shared.remove(relativePath: a.relativePath) } + return + } + guard !assets.isEmpty else { await MainActor.run { phase = .editing( parsed: .empty(), assets: [], - warning: "图片保存失败,手动录入并保留文本" + warning: String(appLoc: "图片保存失败,手动录入并保留文本") ) } + return + } + // 把 assets 暴露给 phase,使工具栏「取消」也能找到孤儿清理。 + await MainActor.run { + if case .analyzing(let imgs, _) = phase { + phase = .analyzing(images: imgs, assets: assets) + } + } + + // Step 2: VL 推理(timeout 哨兵到点 cancel 父任务,VLSession 在下一个 token break)。 + let watchdog = Task { + try? await Task.sleep(for: .seconds(timeout)) + analyzeTask?.cancel() + } + defer { watchdog.cancel() } + + do { + let parsed = try await CaptureService.shared.reanalyze(assets: assets) + if Task.isCancelled { + await editingFallback(assets: assets, + msg: String(appLoc: "识别超时(>\(timeout)s),先手动录入")) + return + } + await MainActor.run { + phase = .editing( + parsed: parsed, + assets: assets, + warning: parsed.isEmpty ? String(appLoc: "识别没有读出指标,请手动补充") : nil + ) + } + } catch let CaptureError.parseFailed(msg) { + await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)")) + } catch let CaptureError.inferenceFailed(msg) { + await editingFallback(assets: assets, + msg: Task.isCancelled + ? String(appLoc: "识别超时(>\(timeout)s),先手动录入") + : String(appLoc: "推理失败:\(msg)")) + } catch CaptureError.modelNotReady { + await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪,先手动录入")) } catch { - await fallbackToManual(images: images, msg: "未知错误:\(error.localizedDescription)") + await editingFallback(assets: assets, + msg: String(appLoc: "未知错误:\(error.localizedDescription)")) } } } - private func fallbackToManual(images: [UIImage], msg: String) async { - // 即便 VL 失败,图片应当已经写入了 Vault(在 CaptureService.analyze 第 1 步)。 - // 但若是 writeAsset 之前的失败(modelNotReady / inferenceFailed), - // 这里再补一次写,保证图不丢。 - var assets: [FileVault.SavedAsset] = [] - for img in images { - if let a = try? FileVault.shared.writeJPEG(img) { assets.append(a) } + /// 「重新识别」:复用已存 assets,不再写图,只重跑 VL。 + private func reanalyze(assets: [FileVault.SavedAsset]) { + analyzeTask?.cancel() + // 这里没有原始 UIImage,AnalyzingView 显示首张缩略图即可 + let thumbnails: [UIImage] = assets.compactMap { + try? FileVault.shared.loadImage(relativePath: $0.relativePath) } + phase = .analyzing(images: thumbnails, assets: assets) + let timeout = analyzeTimeoutSeconds + analyzeTask = Task { + let watchdog = Task { + try? await Task.sleep(for: .seconds(timeout)) + analyzeTask?.cancel() + } + defer { watchdog.cancel() } + + do { + let parsed = try await CaptureService.shared.reanalyze(assets: assets) + if Task.isCancelled { + await editingFallback(assets: assets, + msg: String(appLoc: "识别超时(>\(timeout)s),保留旧编辑")) + return + } + await MainActor.run { + phase = .editing( + parsed: parsed, + assets: assets, + warning: parsed.isEmpty ? String(appLoc: "重新识别没有读出新指标") : nil + ) + } + } catch CaptureError.modelNotReady { + await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪")) + } catch let CaptureError.parseFailed(msg) { + await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)")) + } catch let CaptureError.inferenceFailed(msg) { + await editingFallback(assets: assets, + msg: Task.isCancelled + ? String(appLoc: "识别超时(>\(timeout)s)") + : String(appLoc: "推理失败:\(msg)")) + } catch { + await editingFallback(assets: assets, + msg: String(appLoc: "未知错误:\(error.localizedDescription)")) + } + } + } + + /// reanalyze 失败时回到 editing,保留 assets 但清空 parsed。 + private func editingFallback(assets: [FileVault.SavedAsset], msg: String) async { await MainActor.run { - phase = .editing( - parsed: .empty(), - assets: assets, - warning: msg - ) + phase = .editing(parsed: .empty(), assets: assets, warning: msg) } } @@ -151,7 +274,7 @@ struct UnifiedCaptureFlow: View { private func saveAll(parsed final: ParsedReport, assets: [FileVault.SavedAsset]) { let report = Report( - title: final.title.isEmpty ? "拍摄识别" : final.title, + title: final.title.isEmpty ? String(appLoc: "拍摄识别") : final.title, type: ReportType(rawValue: final.typeRaw) ?? .other, reportDate: final.reportDate, institution: final.institution.isEmpty ? nil : final.institution, @@ -190,6 +313,11 @@ struct UnifiedCaptureFlow: View { private struct AnalyzingView: View { let images: [UIImage] + let timeoutSeconds: Int + let onCancel: () -> Void + + @State private var elapsed: Int = 0 + private let tick = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { VStack(spacing: 20) { @@ -216,13 +344,72 @@ private struct AnalyzingView: View { Text("本地识别中") .font(.tjH2()) .foregroundStyle(Tj.Palette.text) - Text("\(images.count) 页 · 100% 本地推理") + Text("\(images.count) 页 · 100% 本地推理 · 已用 \(elapsed)s") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) + if elapsed >= timeoutSeconds - 5 { + Text("快超时了,>\(timeoutSeconds)s 会自动转为手动录入") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.amber) + } } + Button("取消识别 · 改为手动录入", action: onCancel) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Tj.Palette.text3) + .padding(.top, 4) Spacer() } .padding(.horizontal, 20) .frame(maxWidth: .infinity, maxHeight: .infinity) + .onReceive(tick) { _ in elapsed += 1 } + } +} + +// MARK: - 一次性使用提示 + +private struct CaptureTipSheet: View { + let onDismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + Image(systemName: "doc.viewfinder") + .font(.system(size: 28)) + .foregroundStyle(Tj.Palette.ink) + Text("拍报告的小贴士") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + } + VStack(alignment: .leading, spacing: 12) { + tip(String(appLoc: "纸张铺平,避免反光、阴影")) + tip(String(appLoc: "整页入框,避免裁切到指标")) + tip(String(appLoc: "多页报告可连拍,系统自动透视校正")) + tip(String(appLoc: "识别全程在本地,图片不会上传")) + } + Spacer() + Button { + onDismiss() + } label: { + Text("我知道了,开始拍") + .frame(maxWidth: .infinity) + } + .buttonStyle(TjPrimaryButton()) + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Tj.Palette.sand.ignoresSafeArea()) + } + + private func tip(_ text: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Tj.Palette.leaf) + .padding(.top, 2) + Text(text) + .font(.tjSerifBody()) + .foregroundStyle(Tj.Palette.text2) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } } } diff --git a/康康/Features/Diary/DiaryQuickSheet.swift b/康康/Features/Diary/DiaryQuickSheet.swift index bb07eec..ed7f3f3 100644 --- a/康康/Features/Diary/DiaryQuickSheet.swift +++ b/康康/Features/Diary/DiaryQuickSheet.swift @@ -1,6 +1,10 @@ import SwiftUI import SwiftData +/// 「健康记录」录入 sheet。 +/// 主体仍是 DiaryEntry @Model;UI/文案改为面向健康记录,并加 AI 辅助区: +/// 让 Qwen3 从医生问诊角度提 3-4 个追问,用户可一键将「补充模板」追加到输入框。 +/// 支持多轮——每轮把已问过的 q 传给 LLM 要求别重复;已采纳的 row 灰色 + ✓ 标记。 struct DiaryQuickSheet: View { @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss @@ -8,9 +12,35 @@ struct DiaryQuickSheet: View { @State private var content: String = "" @State private var createdAt: Date = .now - private var canSubmit: Bool { + /// AI 辅助状态 + enum AssistPhase { + case idle // 从未生成 + case loading // 正在 LLM 调用 + case ready // 有结果显示,等待下一轮 / 采纳 / 重试 + case failed(Error) // 最近一次失败 + } + @State private var phase: AssistPhase = .idle + @State private var questions: [DiaryAssistService.Question] = [] + @State private var lastRate: Double = 0 + @State private var currentRound: Int = 0 + /// 累积已覆盖的问诊维度(question.dim),回传下一轮 prompt 用于按维度去重。 + @State private var coveredDims: Set = [] + @State private var suggestTask: Task? + /// sheet detent。默认 large,确保建议面板有足够展示空间。 + /// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。 + @State private var detent: PresentationDetent = .large + @FocusState private var contentFocused: Bool + + private var hasContent: Bool { !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + private var hasQuestions: Bool { !questions.isEmpty } + private var isLoading: Bool { + if case .loading = phase { return true } + return false + } + private var canRequestSuggest: Bool { hasContent && !isLoading } + private var canSubmit: Bool { hasContent } var body: some View { VStack(spacing: 0) { @@ -21,44 +51,70 @@ struct DiaryQuickSheet: View { .padding(.bottom, 14) HStack { - Text("写日记") - .font(.tjH2()) - .foregroundStyle(Tj.Palette.text) + VStack(alignment: .leading, spacing: 2) { + Text("健康记录") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } Spacer() Text("本机保存") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) } .padding(.horizontal, 20) - .padding(.bottom, 16) + .padding(.bottom, 14) - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 8) { - sectionLabel("内容") - TextField("今天怎么样?", text: $content, axis: .vertical) - .lineLimit(4...10) - .padding(.horizontal, 14) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) - .fill(Tj.Palette.paper) - ) - .overlay( - RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) - .strokeBorder(Tj.Palette.line, lineWidth: 1) - ) + ScrollViewReader { proxy in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + sectionLabel(String(appLoc: "内容")) + TextField("今天身体怎么样?吃了什么药、有什么感觉?", + text: $content, axis: .vertical) + .lineLimit(3...8) + .focused($contentFocused) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: 1) + ) + } + + assistSection + + VStack(alignment: .leading, spacing: 8) { + sectionLabel(String(appLoc: "时间")) + DatePicker("", selection: $createdAt, in: ...Date.now) + .datePickerStyle(.compact) + .labelsHidden() + } + // 底部锚点,新一轮 question 进来后自动滚到这里 + Color.clear.frame(height: 1).id("assist-bottom") + } + .padding(.horizontal, 20) + .padding(.bottom, 6) } - - VStack(alignment: .leading, spacing: 8) { - sectionLabel("时间") - DatePicker("", selection: $createdAt, in: ...Date.now) - .datePickerStyle(.compact) - .labelsHidden() + .scrollDismissesKeyboard(.interactively) + .onChange(of: questions.count) { old, new in + guard new > old else { return } + // 滚到新一轮的 round divider(让用户先看到「第 N 轮」的标签, + // 再依次看到这一轮的 questions) + let roundId = "round-\(questions[old].round)" + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + withAnimation(.easeOut(duration: 0.25)) { + proxy.scrollTo(roundId, anchor: .top) + } + } } } - .padding(.horizontal, 20) - - Spacer(minLength: 12) HStack(spacing: 12) { Button("取消") { dismiss() } @@ -76,12 +132,258 @@ struct DiaryQuickSheet: View { .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous)) .ignoresSafeArea(edges: .bottom) ) - .presentationDetents([.medium, .large]) + .presentationDetents([.medium, .large], selection: $detent) .presentationDragIndicator(.hidden) .presentationBackground(Tj.Palette.sand) .presentationCornerRadius(Tj.Radius.xl) + .onDisappear { suggestTask?.cancel() } } + // MARK: - AI 辅助区 + + @ViewBuilder + private var assistSection: some View { + VStack(alignment: .leading, spacing: 10) { + // section header + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Tj.Palette.brick) + sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺")) + Spacer() + if hasQuestions { + Text("\(questions.count) 个建议") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + } + if lastRate > 0 { + Text(String(format: "%.1f tok/s", lastRate)) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(Tj.Palette.leaf) + } + } + + // 累积的 questions 列表(多轮,带轮次分隔) + if hasQuestions { + VStack(spacing: 8) { + ForEach(Array(questions.enumerated()), id: \.element.id) { idx, q in + if idx == 0 || questions[idx - 1].round != q.round { + roundDivider(round: q.round, + count: questions.filter { $0.round == q.round }.count) + .id("round-\(q.round)") + } + questionRow(index: roundLocalIndex(at: idx), question: q) + } + } + } + + // 底部主操作按钮(状态机驱动) + phaseFooter + } + } + + @ViewBuilder + private var phaseFooter: some View { + switch phase { + case .idle: + assistPrimaryButton( + icon: "sparkles", + label: canRequestSuggest + ? String(appLoc: "让 AI 帮我想想还能记什么") + : String(appLoc: "先写几个字,AI 来帮忙补充"), + enabled: canRequestSuggest, + action: requestSuggestions + ) + + case .loading: + HStack(spacing: 10) { + ProgressView().controlSize(.small) + Text("AI 思考中… 本地推理,通常 5-10 秒") + .font(.system(size: 13)) + .foregroundStyle(Tj.Palette.text2) + Spacer() + Button("取消") { cancelSuggestions() } + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(.vertical, 11) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + ) + + case .ready: + assistPrimaryButton( + icon: "arrow.clockwise", + label: canRequestSuggest + ? String(appLoc: "再问一轮 · 让 AI 从新角度追问") + : String(appLoc: "更新一下原文,再让 AI 继续追问"), + enabled: canRequestSuggest, + action: requestSuggestions + ) + + case .failed(let err): + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Tj.Palette.brick) + Text(err.localizedDescription) + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text) + Spacer() + } + Button { requestSuggestions() } label: { + Text("重试") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.ink) + } + .buttonStyle(.plain) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.brickSoft.opacity(0.5)) + ) + } + } + + private func assistPrimaryButton(icon: String, + label: String, + enabled: Bool, + action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: icon) + Text(label) + } + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3) + .frame(maxWidth: .infinity) + .padding(.vertical, 11) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder( + enabled ? Tj.Palette.ink : Tj.Palette.line, + style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3]) + ) + ) + } + .buttonStyle(.plain) + .disabled(!enabled) + } + + /// 给定整张 questions list 里 idx 位置的 question,返回它在自己 round 内的序号(1-based)。 + private func roundLocalIndex(at idx: Int) -> Int { + let target = questions[idx].round + var count = 0 + for i in 0...idx where questions[i].round == target { + count += 1 + } + return count + } + + /// 第 N 轮的分隔条 —— 让用户清楚下一轮 LLM 看到的是更新过的最新文本。 + private func roundDivider(round: Int, count: Int) -> some View { + HStack(spacing: 8) { + HStack(spacing: 6) { + Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Tj.Palette.brick) + Text(round == 1 + ? String(appLoc: "第 1 轮 · \(count) 条") + : String(appLoc: "第 \(round) 轮 · 基于你刚才更新的文本 · \(count) 条")) + .font(.system(size: 11, weight: .semibold)) + .tracking(0.3) + .foregroundStyle(Tj.Palette.text2) + } + Rectangle() + .fill(Tj.Palette.line) + .frame(height: 1) + .mask( + HStack(spacing: 3) { + ForEach(0..<60, id: \.self) { _ in + Rectangle().frame(width: 3, height: 1) + } + } + ) + } + .padding(.top, round == 1 ? 0 : 6) + } + + private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View { + let adopted = question.adopted + return VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 8) { + Text("\(index).") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick) + Text(question.q) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text) + .strikethrough(adopted, color: Tj.Palette.text3) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 4) + + if adopted { + HStack(spacing: 4) { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .bold)) + Text("已采纳") + .font(.system(size: 11, weight: .semibold)) + } + .foregroundStyle(Tj.Palette.leaf) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Capsule().fill(Tj.Palette.leafSoft)) + } else { + Button { adopt(question) } label: { + HStack(spacing: 4) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 12)) + Text("采纳") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundStyle(Tj.Palette.paper) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Capsule().fill(Tj.Palette.ink)) + } + .buttonStyle(.plain) + } + } + if !question.fill.isEmpty && !adopted { + HStack(alignment: .top, spacing: 4) { + Text("将追加:") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + Text(question.fill) + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text2) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.leading, 22) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(adopted ? Tj.Palette.sand2 : Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + ) + } + + // MARK: - Actions + private func sectionLabel(_ text: String) -> some View { Text(text) .font(.system(size: 12, weight: .semibold)) @@ -89,6 +391,88 @@ struct DiaryQuickSheet: View { .foregroundStyle(Tj.Palette.text2) } + /// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM, + /// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。 + private func requestSuggestions() { + suggestTask?.cancel() + let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines) + let covered = Array(coveredDims) + // 1. 主动收起键盘 —— 否则建议面板被键盘吃掉一半 + contentFocused = false + // 2. 确保 sheet 在 large(用户可能下拉到 medium 又触发 AI) + if detent != .large { + withAnimation(.snappy(duration: 0.25)) { + detent = .large + } + } + phase = .loading + suggestTask = Task { @MainActor in + do { + let result = try await DiaryAssistService.shared.suggest( + content: snapshotContent, + coveredDimensions: covered + ) + if Task.isCancelled { return } + // 客户端字面兜底(防 LLM 不听话);跨轮去重主要靠 prompt 的维度排除。 + let existing = Set(questions.map { Self.normalize($0.q) }) + let nextRound = currentRound + 1 + let fresh = result.questions + .filter { !existing.contains(Self.normalize($0.q)) } + .map { q -> DiaryAssistService.Question in + var stamped = q + stamped.round = nextRound + return stamped + } + withAnimation(.snappy(duration: 0.2)) { + questions.append(contentsOf: fresh) + for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) } + lastRate = result.decodeRate + currentRound = nextRound + phase = .ready + } + } catch is CancellationError { + if !Task.isCancelled { + phase = hasQuestions ? .ready : .idle + } + } catch { + if !Task.isCancelled { + phase = .failed(error) + } + } + } + } + + /// 简单归一化:去空白 + 折叠成统一形式,用于客户端去重比对。 + private static func normalize(_ s: String) -> String { + s.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "?", with: "?") + } + + private func cancelSuggestions() { + suggestTask?.cancel() + phase = hasQuestions ? .ready : .idle + } + + /// 把 question.fill 追加到 textfield 末尾,并把该 question 标记为 adopted。 + /// 已采纳的 q 不会从列表里消失;其维度已在生成时计入 coveredDims,下一轮 prompt 会避开。 + private func adopt(_ question: DiaryAssistService.Question) { + if let idx = questions.firstIndex(where: { $0.id == question.id }) { + withAnimation(.snappy(duration: 0.18)) { + questions[idx].adopted = true + } + } + let toAppend = question.fill.isEmpty ? question.q : question.fill + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + content = toAppend + } else if content.hasSuffix("\n") { + content += toAppend + } else { + content += "\n" + toAppend + } + } + private func submit() { guard canSubmit else { return } let entry = DiaryEntry( @@ -100,3 +484,7 @@ struct DiaryQuickSheet: View { dismiss() } } + +#Preview { + DiaryQuickSheet() +} diff --git a/康康/Features/Home/HomeView.swift b/康康/Features/Home/HomeView.swift index 5507afc..c2d53ea 100644 --- a/康康/Features/Home/HomeView.swift +++ b/康康/Features/Home/HomeView.swift @@ -69,17 +69,17 @@ struct HomeView: View { } private var todayLine: String { - let f = DateFormatter() - f.locale = Locale(identifier: "zh_CN") - f.dateFormat = "M 月 d 日 · EEE" - return f.string(from: Date()) + let now = Date() + let day = now.formatted(.dateTime.month().day()) + let weekday = now.formatted(.dateTime.weekday(.abbreviated)) + return "\(day) · \(weekday)" } private var greetingWord: String { switch Calendar.current.component(.hour, from: Date()) { - case 5..<12: return "早安" - case 12..<18: return "下午好" - default: return "晚上好" + case 5..<12: return String(appLoc: "早安") + case 12..<18: return String(appLoc: "下午好") + default: return String(appLoc: "晚上好") } } @@ -136,7 +136,7 @@ struct HomeView: View { Button(action: onTapArchive) { HStack(spacing: 14) { - TjPlaceholder(label: "档案 · \(reports.count)") + TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)")) .frame(width: 56, height: 56) VStack(alignment: .leading, spacing: 2) { Text("我的报告档案") diff --git a/康康/Features/Indicator/CustomMetricEditor.swift b/康康/Features/Indicator/CustomMetricEditor.swift index 59ff812..7522b6d 100644 --- a/康康/Features/Indicator/CustomMetricEditor.swift +++ b/康康/Features/Indicator/CustomMetricEditor.swift @@ -21,8 +21,8 @@ enum CustomMetricNameConflict: Equatable { var warningText: String { switch self { case .none: return "" - case .builtin(let n): return "「\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块" - case .existingCustom(let n):return "已经有一个叫「\(n)」的自定义指标" + case .builtin(let n): return String(appLoc: "「\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块") + case .existingCustom(let n):return String(appLoc: "已经有一个叫「\(n)」的自定义指标") } } } @@ -133,7 +133,7 @@ struct CustomMetricEditor: View { private var nameSection: some View { VStack(alignment: .leading, spacing: 8) { - sectionLabel("名称") + sectionLabel(String(appLoc: "名称")) TextField("例如:腰围 / 步数 / 睡眠时长", text: $name) .padding(.horizontal, 14).padding(.vertical, 12) .background(fieldBg) @@ -161,7 +161,7 @@ struct CustomMetricEditor: View { private var unitSection: some View { VStack(alignment: .leading, spacing: 8) { - sectionLabel("单位(可选)") + sectionLabel(String(appLoc: "单位(可选)")) TextField("例如:cm / 步 / 小时", text: $unit) .autocorrectionDisabled() .padding(.horizontal, 14).padding(.vertical, 12) @@ -172,16 +172,16 @@ struct CustomMetricEditor: View { private var rangeRow: some View { VStack(alignment: .leading, spacing: 8) { HStack { - sectionLabel("参考范围(可选)") + sectionLabel(String(appLoc: "参考范围(可选)")) Spacer() Text("用于自动判定 正常/偏高/偏低") .font(.system(size: 10)) .foregroundStyle(Tj.Palette.text3) } HStack(spacing: 12) { - rangeField(label: "下限", value: $lower, placeholder: "70") + rangeField(label: String(appLoc: "下限"), value: $lower, placeholder: "70") Text("—").foregroundStyle(Tj.Palette.text3) - rangeField(label: "上限", value: $upper, placeholder: "90") + rangeField(label: String(appLoc: "上限"), value: $upper, placeholder: "90") } } } @@ -199,7 +199,7 @@ struct CustomMetricEditor: View { private var iconSection: some View { VStack(alignment: .leading, spacing: 8) { - sectionLabel("图标") + sectionLabel(String(appLoc: "图标")) LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), spacing: 8) { ForEach(customMetricIconChoices, id: \.self) { sf in diff --git a/康康/Features/Indicator/IndicatorQuickSheet.swift b/康康/Features/Indicator/IndicatorQuickSheet.swift index 2a7988e..2a04661 100644 --- a/康康/Features/Indicator/IndicatorQuickSheet.swift +++ b/康康/Features/Indicator/IndicatorQuickSheet.swift @@ -171,7 +171,7 @@ struct IndicatorQuickSheet: View { private var monitorGridSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { - sectionLabel("长期监测(进趋势)") + sectionLabel(String(appLoc: "长期监测(进趋势)")) Spacer() if !hiddenSet.isEmpty { hiddenCountChip @@ -329,7 +329,7 @@ struct IndicatorQuickSheet: View { private var labPresetSection: some View { VStack(alignment: .leading, spacing: 8) { - sectionLabel("化验项快捷(不进趋势)") + sectionLabel(String(appLoc: "化验项快捷(不进趋势)")) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(labPresets) { p in @@ -345,14 +345,14 @@ struct IndicatorQuickSheet: View { private var bpFieldSection: some View { VStack(alignment: .leading, spacing: 12) { HStack { - sectionLabel("收缩 / 舒张") + sectionLabel(String(appLoc: "收缩 / 舒张")) Spacer() bpRangeHint } HStack(spacing: 12) { - bpField(label: "收缩压", value: $systolic, placeholder: "120") + bpField(label: String(appLoc: "收缩压"), value: $systolic, placeholder: "120") Text("/").font(.system(size: 22, weight: .light)).foregroundStyle(Tj.Palette.text3) - bpField(label: "舒张压", value: $diastolic, placeholder: "80") + bpField(label: String(appLoc: "舒张压"), value: $diastolic, placeholder: "80") Text("mmHg").foregroundStyle(Tj.Palette.text3) } bpStatusChips @@ -396,10 +396,10 @@ struct IndicatorQuickSheet: View { private var bpStatusChips: some View { HStack(spacing: 8) { if let s = computedBPStatus(.systolic) { - statusBadge("收缩 " + s.label, color: s.color) + statusBadge(String(appLoc: "收缩 ") + s.label, color: s.color) } if let s = computedBPStatus(.diastolic) { - statusBadge("舒张 " + s.label, color: s.color) + statusBadge(String(appLoc: "舒张 ") + s.label, color: s.color) } Spacer() } @@ -407,7 +407,7 @@ struct IndicatorQuickSheet: View { private var nameSection: some View { VStack(alignment: .leading, spacing: 8) { - sectionLabel("指标名") + sectionLabel(String(appLoc: "指标名")) TextField("例如:血红蛋白", text: $name) .textInputAutocapitalization(.never) .padding(.horizontal, 14) @@ -427,7 +427,7 @@ struct IndicatorQuickSheet: View { private var valueRow: some View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 8) { - sectionLabel("数值") + sectionLabel(String(appLoc: "数值")) TextField(monitorFieldPlaceholder, text: $value) .keyboardType(.decimalPad) .font(.system(size: 18, weight: .semibold, design: .monospaced)) @@ -437,7 +437,7 @@ struct IndicatorQuickSheet: View { .overlay(fieldBorder) } VStack(alignment: .leading, spacing: 8) { - sectionLabel("单位") + sectionLabel(String(appLoc: "单位")) TextField("mmol/L", text: $unit) .textInputAutocapitalization(.never) .autocorrectionDisabled() @@ -455,7 +455,7 @@ struct IndicatorQuickSheet: View { private var rangeSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { - sectionLabel("参考范围") + sectionLabel(String(appLoc: "参考范围")) Spacer() if let m = selectedMonitor, m != .bloodPressure { monitorRangeHint(m) @@ -486,11 +486,11 @@ struct IndicatorQuickSheet: View { private var statusSection: some View { VStack(alignment: .leading, spacing: 8) { - sectionLabel("状态") + sectionLabel(String(appLoc: "状态")) HStack(spacing: 8) { - statusChip(.normal, label: "正常", color: Tj.Palette.leaf) - statusChip(.high, label: "偏高 ↑", color: Tj.Palette.brick) - statusChip(.low, label: "偏低 ↓", color: Tj.Palette.amber) + statusChip(.normal, label: String(appLoc: "正常"), color: Tj.Palette.leaf) + statusChip(.high, label: String(appLoc: "偏高 ↑"), color: Tj.Palette.brick) + statusChip(.low, label: String(appLoc: "偏低 ↓"), color: Tj.Palette.amber) } } } @@ -498,7 +498,7 @@ struct IndicatorQuickSheet: View { private var autoStatusHint: some View { let auto = computedSingleStatus return HStack(spacing: 8) { - sectionLabel("状态(按数值自动判)") + sectionLabel(String(appLoc: "状态(按数值自动判)")) if let s = auto { statusBadge(s.label, color: s.color) } else { @@ -511,7 +511,7 @@ struct IndicatorQuickSheet: View { private var timeSection: some View { VStack(alignment: .leading, spacing: 8) { - sectionLabel("测量时间") + sectionLabel(String(appLoc: "测量时间")) DatePicker("", selection: $capturedAt, in: ...Date.now) .datePickerStyle(.compact) .labelsHidden() @@ -520,7 +520,7 @@ struct IndicatorQuickSheet: View { private var noteSection: some View { VStack(alignment: .leading, spacing: 8) { - sectionLabel("备注(可选)") + sectionLabel(String(appLoc: "备注(可选)")) TextField("例如:空腹采血", text: $note, axis: .vertical) .lineLimit(1...3) .padding(.horizontal, 14) @@ -535,7 +535,7 @@ struct IndicatorQuickSheet: View { private var reminderSection: some View { VStack(alignment: .leading, spacing: 10) { HStack { - sectionLabel("周期提醒") + sectionLabel(String(appLoc: "周期提醒")) Spacer() Toggle("", isOn: $reminderEnabled) .labelsHidden() @@ -570,13 +570,13 @@ struct IndicatorQuickSheet: View { } weekdayPickerRow HStack(spacing: 8) { - quickFreqChip("每天") { + quickFreqChip(String(appLoc: "每天")) { reminderWeekdays = Set(1...7) } - quickFreqChip("工作日") { + quickFreqChip(String(appLoc: "工作日")) { reminderWeekdays = Set([2, 3, 4, 5, 6]) } - quickFreqChip("周末") { + quickFreqChip(String(appLoc: "周末")) { reminderWeekdays = Set([1, 7]) } } @@ -600,15 +600,23 @@ struct IndicatorQuickSheet: View { } private var reminderFrequencyLabel: String { - if reminderWeekdays.count == 7 { return "每天" } - if reminderWeekdays.isEmpty { return "未选" } - let names = ["日", "一", "二", "三", "四", "五", "六"] + if reminderWeekdays.count == 7 { return String(appLoc: "每天") } + if reminderWeekdays.isEmpty { return String(appLoc: "未选") } + let names = [ + String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), + String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), + String(appLoc: "六"), + ] let sorted = reminderWeekdays.sorted() - return "每周 " + sorted.map { names[$0 - 1] }.joined() + return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined() } private var weekdayPickerRow: some View { - let names = ["一", "二", "三", "四", "五", "六", "日"] + let names = [ + String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), + String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"), + String(appLoc: "日"), + ] let weekdayValues = [2, 3, 4, 5, 6, 7, 1] // 周一到周日(Apple Calendar 编号) return HStack(spacing: 6) { ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in @@ -1074,9 +1082,9 @@ struct IndicatorQuickSheet: View { private extension IndicatorStatus { var label: String { switch self { - case .normal: return "正常" - case .high: return "偏高 ↑" - case .low: return "偏低 ↓" + case .normal: return String(appLoc: "正常") + case .high: return String(appLoc: "偏高 ↑") + case .low: return String(appLoc: "偏低 ↓") } } diff --git a/康康/Features/Me/AboutView.swift b/康康/Features/Me/AboutView.swift index 755c7d9..c7bc41f 100644 --- a/康康/Features/Me/AboutView.swift +++ b/康康/Features/Me/AboutView.swift @@ -2,6 +2,7 @@ import SwiftUI /// 「我的 · 关于」——本软件基本介绍、使用注意与免责声明。 /// 纯静态阅读页,不调任何 Service / AIRuntime,复用现有 DesignSystem token。 +/// 文案按 App Store 上架合规口径撰写:避免绝对化用语、精确区分本地/联网行为、强化医疗免责。 struct AboutView: View { /// 真实读取 Bundle 版本号,避免硬编码与实际发版脱节。 private var versionText: String { @@ -19,45 +20,53 @@ struct AboutView: View { VStack(spacing: 16) { header - section(icon: "sparkles", title: "这是什么") { + section(icon: "sparkles", title: String(appLoc: "这是什么")) { paragraph( - "康康是一款以本地优先为设计原则的个人健康影像档案工具。" + - "你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;" + - "设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。" + String(appLoc: "康康是一款以本地优先为设计原则的个人健康影像档案工具。") + + String(appLoc: "你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;") + + String(appLoc: "设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。") ) } - section(icon: "checklist", title: "主要功能") { - bullet("拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档") - bullet("通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明") - bullet("长期趋势:关注的指标可生成折线图和简要解读") - bullet("本地问答:基于你自己的档案问答,引用可点击回链到原记录") - bullet("隐私优先:健康数据不上传、无需注册账号") + section(icon: "checklist", title: String(appLoc: "主要功能")) { + bullet(String(appLoc: "拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档")) + bullet(String(appLoc: "通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明")) + bullet(String(appLoc: "长期趋势:关注的指标可生成折线图和简要解读")) + bullet(String(appLoc: "本地问答:基于你自己的档案问答,引用可点击回链到原记录")) + bullet(String(appLoc: "隐私优先:健康数据不上传、无需注册账号")) } - section(icon: "lock.shield", title: "隐私保护") { - bullet("AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据。") - bullet("原图与数据库采用系统级文件加密,随设备锁屏受到保护。") - bullet("支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份。") - bullet("可选开启 Face ID 启动锁,进一步保护隐私。") + section(icon: "iphone", title: String(appLoc: "设备要求"), tint: Tj.Palette.leaf) { + bullet(String(appLoc: "系统:iOS 17 或更新版本。")) + bullet(String(appLoc: "本地 AI 功能(拍照识别、解读、问答)需要约 8GB 内存,") + + String(appLoc: "推荐 iPhone 15 Pro / Pro Max 及之后发布的机型(含 iPhone 16 系列)。")) + bullet(String(appLoc: "在内存较小的旧机型上,App 仍可用于手动记录、归档与查看,") + + String(appLoc: "但本地 AI 相关功能可能无法运行。")) } - section(icon: "exclamationmark.triangle", title: "使用注意", tint: Tj.Palette.amber) { - bullet("本地 AI 模型体积较大(约 3GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;" + - "模型未就绪时 App 仍可使用,AI 功能会提示前往下载。") - bullet("AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对," + - "并以原始报告 / 化验单为准。") - bullet("AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。") - bullet("数据保存在本设备:卸载 App 或删除数据后可能无法恢复,重要资料请自行留存原件。") + section(icon: "lock.shield", title: String(appLoc: "隐私保护")) { + bullet(String(appLoc: "AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据。")) + bullet(String(appLoc: "原图与数据库采用系统级文件加密,随设备锁屏受到保护。")) + bullet(String(appLoc: "支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份。")) + bullet(String(appLoc: "可选开启 Face ID 启动锁,进一步保护隐私。")) } - section(icon: "hand.raised", title: "免责声明", tint: Tj.Palette.brick) { - bullet("康康是一款健康信息记录与参考工具,并非医疗器械,不提供医疗诊断、用药或剂量建议、急诊判断等医疗服务。") - bullet("App 内所有 AI 生成的解读、趋势分析与问答内容仅供信息参考," + - "不构成医疗建议,也不能替代执业医师、药师或其他专业人员的面诊、检查与意见。") - bullet("任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。") - bullet("如出现身体不适或紧急情况,请及时就医或拨打当地急救电话,请勿依赖本 App 进行判断。") - bullet("在适用法律允许的范围内,因使用本 App 或依赖其中内容所产生的后果,由使用者自行承担。") + section(icon: "exclamationmark.triangle", title: String(appLoc: "使用注意"), tint: Tj.Palette.amber) { + bullet(String(appLoc: "本地 AI 模型体积较大(约 4GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;") + + String(appLoc: "模型未就绪时 App 仍可使用,AI 功能会提示前往下载。")) + bullet(String(appLoc: "AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对,") + + String(appLoc: "并以原始报告 / 化验单为准。")) + bullet(String(appLoc: "AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。")) + bullet(String(appLoc: "数据保存在本设备:卸载 App 或删除数据后可能无法恢复,重要资料请自行留存原件。")) + } + + section(icon: "hand.raised", title: String(appLoc: "免责声明"), tint: Tj.Palette.brick) { + bullet(String(appLoc: "康康是一款健康信息记录与参考工具,并非医疗器械,不提供医疗诊断、用药或剂量建议、急诊判断等医疗服务。")) + bullet(String(appLoc: "App 内所有 AI 生成的解读、趋势分析与问答内容仅供信息参考,") + + String(appLoc: "不构成医疗建议,也不能替代执业医师、药师或其他专业人员的面诊、检查与意见。")) + bullet(String(appLoc: "任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。")) + bullet(String(appLoc: "如出现身体不适或紧急情况,请及时就医或拨打当地急救电话,请勿依赖本 App 进行判断。")) + bullet(String(appLoc: "在适用法律允许的范围内,因使用本 App 或依赖其中内容所产生的后果,由使用者自行承担。")) } Text("康康 · 本地优先的健康档案 · \(versionText)") @@ -65,6 +74,12 @@ struct AboutView: View { .foregroundStyle(Tj.Palette.text3) .padding(.top, 4) + Text("本 App 仅供健康信息记录与参考,不能替代专业医疗意见。") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 32) } .padding(.horizontal, 16) diff --git a/康康/Features/Me/CustomMetricsListView.swift b/康康/Features/Me/CustomMetricsListView.swift index 7363102..3549e2e 100644 --- a/康康/Features/Me/CustomMetricsListView.swift +++ b/康康/Features/Me/CustomMetricsListView.swift @@ -72,7 +72,7 @@ struct CustomMetricsListView: View { private var emptyState: some View { VStack(spacing: 14) { Spacer(minLength: 40) - TjPlaceholder(label: "还没有自定义指标") + TjPlaceholder(label: String(appLoc: "还没有自定义指标")) .frame(width: 220, height: 130) Text("右上角 + 新建一个") .font(.system(size: 12)) @@ -118,7 +118,7 @@ struct CustomMetricsListView: View { Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 2) { - Text(count == 0 ? "未使用" : "用 \(count) 次") + Text(count == 0 ? String(appLoc: "未使用") : String(appLoc: "用 \(count) 次")) .font(.system(size: 11, weight: count > 0 ? .semibold : .regular)) .foregroundStyle(count > 0 ? Tj.Palette.ink : Tj.Palette.text3) Image(systemName: "chevron.right") diff --git a/康康/Features/Me/LanguageSettingsView.swift b/康康/Features/Me/LanguageSettingsView.swift new file mode 100644 index 0000000..f95db1c --- /dev/null +++ b/康康/Features/Me/LanguageSettingsView.swift @@ -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() } +} diff --git a/康康/Features/Me/MeView.swift b/康康/Features/Me/MeView.swift index 8987d42..d3f8661 100644 --- a/康康/Features/Me/MeView.swift +++ b/康康/Features/Me/MeView.swift @@ -4,13 +4,15 @@ import SwiftData struct MeView: View { @Environment(\.modelContext) private var ctx @Query private var profiles: [UserProfile] - @Query private var reminders: [MetricReminder] @Query private var customMetrics: [CustomMonitorMetric] @State private var downloadService = ModelDownloadService.shared + @State private var appLock = AppLock.shared + @State private var lang = LanguageManager.shared + // key 必须与 AppLock.enabledKey 一致。 + @AppStorage("faceIDLockEnabled") private var lockEnabled = false private var profile: UserProfile? { profiles.first } - private var enabledReminderCount: Int { reminders.filter(\.enabled).count } /// 真实读取 Bundle 版本号,与「关于」页保持一致。 private var appVersionText: String { @@ -23,16 +25,14 @@ struct MeView: View { ScrollView { VStack(spacing: 12) { profileCard - remindersCard customMetricsCard modelManagementCard - settingsCard(title: "Face ID 启动锁", - detail: "关闭", - icon: "faceid") + languageCard + faceIDCard NavigationLink { AboutView() } label: { - settingsCard(title: "关于", + settingsCard(title: String(appLoc: "关于"), detail: appVersionText, icon: "info.circle") } @@ -49,6 +49,7 @@ struct MeView: View { _ = UserProfileStore.loadOrCreate(in: ctx) } downloadService.refreshStates() + appLock.refreshAvailability() } } } @@ -89,46 +90,6 @@ struct MeView: View { .buttonStyle(.plain) } - private var remindersCard: some View { - NavigationLink { - RemindersListView() - } label: { - HStack(spacing: 12) { - ZStack { - Circle() - .fill(enabledReminderCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2) - Image(systemName: "bell.fill") - .font(.system(size: 18)) - .foregroundStyle(enabledReminderCount > 0 ? Tj.Palette.ink : Tj.Palette.text2) - } - .frame(width: 44, height: 44) - - VStack(alignment: .leading, spacing: 2) { - Text("记录提醒") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Tj.Palette.text) - Text(reminderLine) - .font(.system(size: 12)) - .foregroundStyle(Tj.Palette.text3) - .lineLimit(1) - } - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(Tj.Palette.text3) - } - .padding(14) - .tjCard() - } - .buttonStyle(.plain) - } - - private var reminderLine: String { - if reminders.isEmpty { return "尚未设置" } - if enabledReminderCount == 0 { return "全部已关闭(\(reminders.count) 条)" } - return "\(enabledReminderCount) 项启用" - } - private var customMetricsCard: some View { NavigationLink { CustomMetricsListView() @@ -164,25 +125,84 @@ struct MeView: View { } private var customMetricsLine: String { - if customMetrics.isEmpty { return "添加你自己的长期监测项" } - return "\(customMetrics.count) 项" + if customMetrics.isEmpty { return String(appLoc: "添加你自己的长期监测项") } + return String(appLoc: "\(customMetrics.count) 项") } private var modelManagementCard: some View { NavigationLink { ModelManagementView() } label: { - settingsCard(title: "模型管理", detail: modelDetail, icon: "cpu") + settingsCard(title: String(appLoc: "模型管理"), detail: modelDetail, icon: "cpu") } .buttonStyle(.plain) } private var modelDetail: String { let states = downloadService.states - if ModelKind.allCases.allSatisfy({ states[$0]?.phase == .ready }) { return "已就绪" } - if downloadService.isAnyDownloading { return "下载中…" } + if ModelKind.allCases.allSatisfy({ states[$0]?.phase == .ready }) { return String(appLoc: "已就绪") } + if downloadService.isAnyDownloading { return String(appLoc: "下载中…") } let readyCount = ModelKind.allCases.filter { states[$0]?.phase == .ready }.count - return readyCount == 0 ? "未下载" : "\(readyCount)/\(ModelKind.allCases.count) 就绪" + return readyCount == 0 ? String(appLoc: "未下载") : String(appLoc: "\(readyCount)/\(ModelKind.allCases.count) 就绪") + } + + private var languageCard: some View { + NavigationLink { + LanguageSettingsView() + } label: { + settingsCard(title: String(appLoc: "语言"), + detail: lang.current.displayName, + icon: "character.bubble") + } + .buttonStyle(.plain) + } + + // MARK: - Face ID 启动锁(可交互 Toggle 卡) + + private var faceIDCard: some View { + HStack(spacing: 12) { + ZStack { + Circle().fill(lockEnabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2) + Image(systemName: "faceid") + .font(.system(size: 18)) + .foregroundStyle(lockEnabled ? Tj.Palette.ink : Tj.Palette.text2) + } + .frame(width: 44, height: 44) + + VStack(alignment: .leading, spacing: 2) { + Text("Face ID 启动锁") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Tj.Palette.text) + Text(faceIDLine) + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer() + Toggle("", isOn: faceIDBinding) + .labelsHidden() + .disabled(!appLock.biometryAvailable) + } + .padding(14) + .tjCard() + } + + private var faceIDLine: String { + if !appLock.biometryAvailable { return String(appLoc: "本设备未设置 Face ID 或密码") } + return lockEnabled ? String(appLoc: "已开启 · \(appLock.biometryLabel)") : String(appLoc: "关闭") + } + + /// 打开 → 先认证一次,成功才置 enabled(失败则开关弹回);关闭 → 直接关。 + private var faceIDBinding: Binding { + Binding( + get: { lockEnabled }, + set: { newValue in + if newValue { + Task { await appLock.enableWithAuth() } + } else { + appLock.disable() + } + } + ) } private func settingsCard(title: String, detail: String, icon: String) -> some View { @@ -212,7 +232,7 @@ struct MeView: View { private var profileLine: String { guard let p = profile, p.hasAnyBasics else { - return "点这里完善你的资料" + return String(appLoc: "点这里完善你的资料") } return p.summaryLine } diff --git a/康康/Features/Me/ModelManagementView.swift b/康康/Features/Me/ModelManagementView.swift index d04df44..2a33f4e 100644 --- a/康康/Features/Me/ModelManagementView.swift +++ b/康康/Features/Me/ModelManagementView.swift @@ -132,11 +132,11 @@ struct ModelManagementView: View { private func statusBadge(_ phase: DownloadPhase) -> some View { switch phase { - case .idle: return TjBadge(text: "待下载", style: .neutral) - case .downloading: return TjBadge(text: "下载中", style: .amber) - case .verifying: return TjBadge(text: "校验中", style: .amber) - case .ready: return TjBadge(text: "已就绪", style: .leaf) - case .failed: return TjBadge(text: "失败 · 重试", style: .brick) + case .idle: return TjBadge(text: String(appLoc: "待下载"), style: .neutral) + case .downloading: return TjBadge(text: String(appLoc: "下载中"), style: .amber) + case .verifying: return TjBadge(text: String(appLoc: "校验中"), style: .amber) + case .ready: return TjBadge(text: String(appLoc: "已就绪"), style: .leaf) + case .failed: return TjBadge(text: String(appLoc: "失败 · 重试"), style: .brick) } } @@ -199,13 +199,14 @@ struct ModelManagementView: View { let name = folder.lastPathComponent guard let kind = ModelKind.allCases.first(where: { $0.rawValue == name }) else { - importError = "请选择名为 Qwen3-1.7B-4bit 或 Qwen2.5-VL-3B-Instruct-4bit 的文件夹" + let names = ModelKind.allCases.map(\.rawValue).joined(separator: " 或 ") + importError = String(appLoc: "请选择名为 \(names) 的文件夹") return } try service.importModel(kind, from: folder) importError = nil } catch { - importError = "导入失败:\(error.localizedDescription)" + importError = String(appLoc: "导入失败:\(error.localizedDescription)") } } @@ -217,8 +218,8 @@ struct ModelManagementView: View { private func subtitle(_ kind: ModelKind) -> String { switch kind { - case .llm: return "文本解读 · 趋势 / 问答" - case .vl: return "拍照识别报告 → 结构化指标" + case .llm: return String(appLoc: "文本解读 · 趋势 / 问答") + case .vl: return String(appLoc: "拍照识别报告 → 结构化指标") } } diff --git a/康康/Features/Me/ModelSelfTestView.swift b/康康/Features/Me/ModelSelfTestView.swift index 3fa71d0..24368ca 100644 --- a/康康/Features/Me/ModelSelfTestView.swift +++ b/康康/Features/Me/ModelSelfTestView.swift @@ -12,11 +12,11 @@ struct ModelSelfTestView: View { var label: String { switch self { - case .idle: return "未开始" - case .loading: return "加载模型…" - case .running: return "推理中…" - case .done: return "完成 ✓" - case .failed(let m): return "失败:\(m)" + case .idle: return String(appLoc: "未开始") + case .loading: return String(appLoc: "加载模型…") + case .running: return String(appLoc: "推理中…") + case .done: return String(appLoc: "完成 ✓") + case .failed(let m): return String(appLoc: "失败:\(m)") } } } diff --git a/康康/Features/Me/RemindersListView.swift b/康康/Features/Me/RemindersListView.swift index 195585b..1fa9a2a 100644 --- a/康康/Features/Me/RemindersListView.swift +++ b/康康/Features/Me/RemindersListView.swift @@ -3,9 +3,14 @@ import SwiftData struct RemindersListView: View { @Environment(\.modelContext) private var ctx + @Environment(\.dismiss) private var dismiss @Query(sort: \MetricReminder.updatedAt, order: .reverse) private var reminders: [MetricReminder] + /// 以 sheet 形态呈现(从「新建」入口进入)时补一个「完成」按钮关闭; + /// push 形态(我的 → 记录提醒)有系统返回,默认 false。 + var presentedAsSheet = false + @State private var editingId: String? var body: some View { @@ -33,6 +38,13 @@ struct RemindersListView: View { .background(Tj.Palette.sand.ignoresSafeArea()) .navigationTitle("记录提醒") .navigationBarTitleDisplayMode(.inline) + .toolbar { + if presentedAsSheet { + ToolbarItem(placement: .topBarTrailing) { + Button("完成") { dismiss() } + } + } + } } private var header: some View { @@ -50,7 +62,7 @@ struct RemindersListView: View { private var emptyState: some View { VStack(spacing: 12) { Spacer(minLength: 40) - TjPlaceholder(label: "还没有记录提醒\n去「+ 指标记录」录入时打开") + TjPlaceholder(label: String(appLoc: "还没有记录提醒\n去「+ 指标记录」录入时打开")) .frame(width: 240, height: 140) Spacer() } @@ -182,7 +194,11 @@ private struct ReminderRow: View { } private var weekdayRow: some View { - let names = ["一", "二", "三", "四", "五", "六", "日"] + let names = [ + String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), + String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"), + String(appLoc: "日"), + ] let weekdayValues = [2, 3, 4, 5, 6, 7, 1] return HStack(spacing: 6) { ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in diff --git a/康康/Features/Monitor/MonitorMetric.swift b/康康/Features/Monitor/MonitorMetric.swift index ca770af..f48d14a 100644 --- a/康康/Features/Monitor/MonitorMetric.swift +++ b/康康/Features/Monitor/MonitorMetric.swift @@ -19,12 +19,12 @@ enum MonitorMetric: String, CaseIterable, Identifiable { var displayName: String { switch self { - case .bloodPressure: return "血压" - case .fastingGlucose: return "空腹血糖" - case .postprandialGlucose: return "餐后血糖" - case .temperature: return "体温" - case .heartRate: return "心率" - case .spo2: return "血氧" + case .bloodPressure: return String(appLoc: "血压") + case .fastingGlucose: return String(appLoc: "空腹血糖") + case .postprandialGlucose: return String(appLoc: "餐后血糖") + case .temperature: return String(appLoc: "体温") + case .heartRate: return String(appLoc: "心率") + case .spo2: return String(appLoc: "血氧") } } @@ -45,43 +45,43 @@ enum MonitorMetric: String, CaseIterable, Identifiable { case .bloodPressure: return [ Field(seriesKey: "bp.systolic", - label: "收缩压", + label: String(appLoc: "收缩压"), unit: "mmHg", placeholder: "120", baseRange: 90...140), Field(seriesKey: "bp.diastolic", - label: "舒张压", + label: String(appLoc: "舒张压"), unit: "mmHg", placeholder: "80", baseRange: 60...90), ] case .fastingGlucose: return [Field(seriesKey: "glucose.fasting", - label: "空腹血糖", + label: String(appLoc: "空腹血糖"), unit: "mmol/L", placeholder: "5.0", baseRange: 3.9...6.1)] case .postprandialGlucose: return [Field(seriesKey: "glucose.postprandial", - label: "餐后 2h", + label: String(appLoc: "餐后 2h"), unit: "mmol/L", placeholder: "6.5", baseRange: 0...7.8)] case .temperature: return [Field(seriesKey: "temperature", - label: "体温", + label: String(appLoc: "体温"), unit: "°C", placeholder: "36.5", baseRange: 36.0...37.2)] case .heartRate: return [Field(seriesKey: "heart_rate", - label: "心率", + label: String(appLoc: "心率"), unit: "bpm", placeholder: "72", baseRange: 60...100)] case .spo2: return [Field(seriesKey: "spo2", - label: "血氧", + label: String(appLoc: "血氧"), unit: "%", placeholder: "98", baseRange: 95...100)] @@ -101,7 +101,7 @@ extension MonitorMetric { /// 给 IndicatorRecordSheet 显示在数值旁的「90-140 mmHg」字样。 func rangeText(_ range: ClosedRange?) -> String { - guard let r = range else { return "无参考范围" } + guard let r = range else { return String(appLoc: "无参考范围") } let lower = format(r.lowerBound) let upper = format(r.upperBound) // 餐后血糖 baseRange 是 0...7.8,显示成「<7.8」 diff --git a/康康/Features/Profile/ProfileEditView.swift b/康康/Features/Profile/ProfileEditView.swift index e31a8d7..ff1e69a 100644 --- a/康康/Features/Profile/ProfileEditView.swift +++ b/康康/Features/Profile/ProfileEditView.swift @@ -17,28 +17,47 @@ struct ProfileEditView: View { } } -/// 实际表单。`@Bindable` 让 SwiftData @Model 的字段可以 `$profile.xxx` 双向绑定。 +/// 实际表单。 +/// +/// 性能要点(为什么拆成一堆小 Row 子视图): +/// SwiftData `@Model` 走 Observation,谁读了某个属性谁才会因它变化而失效。 +/// 早期这页把所有字段塞进一个 `body`,任何一次按键(包括「添加过敏/用药」输入框, +/// 它们的 `@State` 当时也挂在父视图上)都会重算整个 `body`,顺带把年份选择器里 +/// 126 个 `Text(year)` 全部重建一遍 → 输入卡顿。 +/// +/// 现在的写法: +/// - `ProfileEditForm.body` 不读任何 `profile.*`、不持有随打字变化的 `@State`, +/// 所以编辑过程中它整体不再重算,只是组合一批子视图。 +/// - 每个 Row / Section 子视图只读自己那一个字段,Observation 把失效范围收到单行。 +/// - 各「添加条目」输入框的 `@State` 下沉进各自的 Section 子视图,敲字只重算那一节。 +/// - 年份用「点开展开 .wheel 滚轮」,折叠时不构建 126 项,展开时由原生 +/// UIPickerView 虚拟化承载,秒开。 private struct ProfileEditForm: View { @Environment(\.modelContext) private var ctx @Bindable var profile: UserProfile - @State private var newAllergy = "" - @State private var newFamilyEntry = "" - @State private var newMedication = "" - @State private var newCustomCondition = "" - - private static let chronicPresets = [ - "高血压", "糖尿病", "冠心病", "高血脂", - "甲状腺疾病", "哮喘", "慢性肾病", "抑郁/焦虑", - ] - var body: some View { Form { - basicsSection - chronicSection - allergySection - familySection - medicationSection + Section { + BirthYearRow(profile: profile) + SexRow(profile: profile) + HeightRow(profile: profile) + WeightRow(profile: profile) + BloodTypeRow(profile: profile) + } header: { + Text("基本") + } footer: { + BMIFooter(profile: profile) + } + + ChronicSection(profile: profile) + + StringListSection(title: String(appLoc: "过敏史"), placeholder: String(appLoc: "如:青霉素"), + items: $profile.allergies) + StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"), + items: $profile.familyHistory) + StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"), + items: $profile.currentMedications) } .navigationTitle("个人资料") .navigationBarTitleDisplayMode(.inline) @@ -49,48 +68,75 @@ private struct ProfileEditForm: View { try? ctx.save() } } +} - // MARK: - 基本 +// MARK: - 基本:逐行子视图(各自只读一个字段,失效互不牵连) - private var basicsSection: some View { - Section { - birthYearPicker - sexPicker - heightRow - weightRow - bloodTypePicker - } header: { - Text("基本") - } footer: { - if let bmi = profile.bmi { - Text("BMI: \(String(format: "%.1f", bmi)) \(bmiLabel(bmi))") - .font(.system(size: 11)) - } - } +/// 出生年份:点击行展开 `.wheel` 滚轮,折叠时只是一行文字 —— 不构建 126 项列表。 +private struct BirthYearRow: View { + @Bindable var profile: UserProfile + @State private var expanded = false + + private var currentYear: Int { + Calendar.current.component(.year, from: .now) } - private func bmiLabel(_ bmi: Double) -> String { - switch bmi { - case ..<18.5: return "(偏瘦)" - case ..<24: return "(正常)" - case ..<28: return "(超重)" - default: return "(肥胖)" - } + /// 年份倒序数组。本行仅在 birthYear / expanded 变化时重算,与其他字段编辑解耦; + /// 且 `years` 只在滚轮展开(body 实际读它)时才被遍历构建。 + private var years: [Int] { + Array((1900...currentYear).reversed()) } - private var birthYearPicker: some View { - Picker("出生年份", selection: Binding( + private var selectedLabel: String { + if let y = profile.birthYear { + let age = currentYear - y + return age >= 0 ? "\(y)(\(age)\(String(appLoc: "岁")))" : String(y) + } + return String(appLoc: "未设置") + } + + private var yearBinding: Binding { + Binding( get: { profile.birthYear ?? 0 }, set: { profile.birthYear = $0 == 0 ? nil : $0 } - )) { - Text("未设置").tag(0) - ForEach((1900...currentYear).reversed(), id: \.self) { year in - Text(String(year)).tag(year) - } - } + ) } - private var sexPicker: some View { + var body: some View { + Button { + withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() } + } label: { + HStack { + Text("出生年份").foregroundStyle(Tj.Palette.text) + Spacer() + Text(selectedLabel) + .foregroundStyle(profile.birthYear == nil ? Tj.Palette.text3 : Tj.Palette.text2) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.text3) + .rotationEffect(.degrees(expanded ? 90 : 0)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if expanded { + Picker("出生年份", selection: yearBinding) { + Text("未设置").tag(0) + ForEach(years, id: \.self) { year in + Text(String(year)).tag(year) + } + } + .pickerStyle(.wheel) + .frame(maxHeight: 140) + } + } +} + +private struct SexRow: View { + @Bindable var profile: UserProfile + + var body: some View { Picker("性别", selection: Binding( get: { profile.sex }, set: { profile.sex = $0 } @@ -101,8 +147,15 @@ private struct ProfileEditForm: View { } .pickerStyle(.segmented) } +} - private var heightRow: some View { +/// 身高:数值输入逻辑不变,只把整行变成可点聚焦区 —— 原先只有右侧 80pt 的输入框 +/// 本体能点中,标签与中间空白点了不聚焦,所以显得「不灵敏」。 +private struct HeightRow: View { + @Bindable var profile: UserProfile + @FocusState private var focused: Bool + + var body: some View { HStack { Text("身高") Spacer() @@ -110,11 +163,19 @@ private struct ProfileEditForm: View { .keyboardType(.numberPad) .multilineTextAlignment(.trailing) .frame(width: 80) + .focused($focused) Text("cm").foregroundStyle(Tj.Palette.text3) } + .contentShape(Rectangle()) + .onTapGesture { focused = true } } +} - private var weightRow: some View { +private struct WeightRow: View { + @Bindable var profile: UserProfile + @FocusState private var focused: Bool + + var body: some View { HStack { Text("体重") Spacer() @@ -122,11 +183,18 @@ private struct ProfileEditForm: View { .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) .frame(width: 80) + .focused($focused) Text("kg").foregroundStyle(Tj.Palette.text3) } + .contentShape(Rectangle()) + .onTapGesture { focused = true } } +} - private var bloodTypePicker: some View { +private struct BloodTypeRow: View { + @Bindable var profile: UserProfile + + var body: some View { Picker("血型", selection: $profile.bloodTypeRaw) { Text("不知道").tag("") Text("A 型").tag("A") @@ -135,19 +203,51 @@ private struct ProfileEditForm: View { Text("O 型").tag("O") } } +} - // MARK: - 慢病 +/// BMI 页脚:只读 heightCM + weightKG,只有这两项变化时才重算。 +private struct BMIFooter: View { + @Bindable var profile: UserProfile - private var chronicSection: some View { + var body: some View { + if let bmi = profile.bmi { + Text("BMI: \(String(format: "%.1f", bmi)) \(label(bmi))") + .font(.system(size: 11)) + } + } + + private func label(_ bmi: Double) -> String { + switch bmi { + case ..<18.5: return String(appLoc: "(偏瘦)") + case ..<24: return String(appLoc: "(正常)") + case ..<28: return String(appLoc: "(超重)") + default: return String(appLoc: "(肥胖)") + } + } +} + +// MARK: - 慢病 + +private struct ChronicSection: View { + @Bindable var profile: UserProfile + @State private var newCustomCondition = "" + + /// 计算属性形式:每次按当前语言解析,语言切换即时更新(不可用 static/let 缓存)。 + private var presets: [String] { + [String(appLoc: "高血压"), String(appLoc: "糖尿病"), String(appLoc: "冠心病"), String(appLoc: "高血脂"), + String(appLoc: "甲状腺疾病"), String(appLoc: "哮喘"), String(appLoc: "慢性肾病"), String(appLoc: "抑郁/焦虑")] + } + + var body: some View { Section { FlexibleChipGrid { - ForEach(Self.chronicPresets, id: \.self) { name in + ForEach(presets, id: \.self) { name in chip(label: name, selected: profile.chronicConditions.contains(name)) { - toggleCondition(name) + toggle(name) } } - ForEach(profile.chronicConditions.filter { !Self.chronicPresets.contains($0) }, + ForEach(profile.chronicConditions.filter { !presets.contains($0) }, id: \.self) { name in chip(label: name, selected: true) { profile.chronicConditions.removeAll { $0 == name } @@ -171,56 +271,14 @@ private struct ProfileEditForm: View { } } - // MARK: - 过敏 / 家族史 / 用药 - - private var allergySection: some View { - listSection(title: "过敏史", placeholder: "如:青霉素", - items: $profile.allergies, newInput: $newAllergy) - } - - private var familySection: some View { - listSection(title: "家族史", placeholder: "如:母亲 高血压", - items: $profile.familyHistory, newInput: $newFamilyEntry) - } - - private var medicationSection: some View { - listSection(title: "当前用药", placeholder: "如:缬沙坦 80mg qd", - items: $profile.currentMedications, newInput: $newMedication) - } - - private func listSection(title: String, placeholder: String, - items: Binding<[String]>, - newInput: Binding) -> some View { - Section(title) { - ForEach(items.wrappedValue, id: \.self) { item in - HStack { - Text(item) - Spacer() - Button(role: .destructive) { - items.wrappedValue.removeAll { $0 == item } - } label: { - Image(systemName: "minus.circle") - .foregroundStyle(Tj.Palette.brick) - } - .buttonStyle(.borderless) - } - } - HStack { - TextField(placeholder, text: newInput) - Button("加") { - let trimmed = newInput.wrappedValue.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, - !items.wrappedValue.contains(trimmed) else { return } - items.wrappedValue.append(trimmed) - newInput.wrappedValue = "" - } - .disabled(newInput.wrappedValue.trimmingCharacters(in: .whitespaces).isEmpty) - } + private func toggle(_ name: String) { + if profile.chronicConditions.contains(name) { + profile.chronicConditions.removeAll { $0 == name } + } else { + profile.chronicConditions.append(name) } } - // MARK: - helpers - private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) @@ -233,21 +291,47 @@ private struct ProfileEditForm: View { } .buttonStyle(.plain) } +} - private func toggleCondition(_ name: String) { - if profile.chronicConditions.contains(name) { - profile.chronicConditions.removeAll { $0 == name } - } else { - profile.chronicConditions.append(name) +// MARK: - 过敏 / 家族史 / 用药(每节自带 @State,敲字只重算本节) + +private struct StringListSection: View { + let title: String + let placeholder: String + @Binding var items: [String] + @State private var newInput = "" + + var body: some View { + Section(title) { + ForEach(items, id: \.self) { item in + HStack { + Text(item) + Spacer() + Button(role: .destructive) { + items.removeAll { $0 == item } + } label: { + Image(systemName: "minus.circle") + .foregroundStyle(Tj.Palette.brick) + } + .buttonStyle(.borderless) + } + } + HStack { + TextField(placeholder, text: $newInput) + Button("加") { + let trimmed = newInput.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !items.contains(trimmed) else { return } + items.append(trimmed) + newInput = "" + } + .disabled(newInput.trimmingCharacters(in: .whitespaces).isEmpty) + } } } - - private var currentYear: Int { - Calendar.current.component(.year, from: .now) - } } -/// 简化版 chip 流式布局——SwiftUI 没有原生 Wrap,用 Layout 协议自实现。 +// MARK: - 流式 chip 布局(SwiftUI 无原生 Wrap,用 Layout 协议自实现) + struct FlexibleChipGrid: View { @ViewBuilder let content: () -> Content diff --git a/康康/Features/Quick/A2ConfirmView.swift b/康康/Features/Quick/A2ConfirmView.swift index 0f45648..522487a 100644 --- a/康康/Features/Quick/A2ConfirmView.swift +++ b/康康/Features/Quick/A2ConfirmView.swift @@ -85,11 +85,11 @@ struct A2ConfirmView: View { .foregroundStyle(Tj.Palette.text3) } Spacer() - TjBadge(text: "偏高", style: .brick) + TjBadge(text: String(appLoc: "偏高"), style: .brick) } HStack(spacing: 12) { - FieldBox(label: "数值") { + FieldBox(label: String(appLoc: "数值")) { HStack(alignment: .firstTextBaseline, spacing: 4) { Text("3.84") .font(.system(size: 30, weight: .semibold)) @@ -99,7 +99,7 @@ struct A2ConfirmView: View { .foregroundStyle(Tj.Palette.text3) } } - FieldBox(label: "参考范围") { + FieldBox(label: String(appLoc: "参考范围")) { HStack(alignment: .firstTextBaseline, spacing: 4) { Text("< 3.40") .font(.system(size: 14, design: .monospaced)) diff --git a/康康/Features/Quick/A3BatchView.swift b/康康/Features/Quick/A3BatchView.swift index 96503f8..fb61544 100644 --- a/康康/Features/Quick/A3BatchView.swift +++ b/康康/Features/Quick/A3BatchView.swift @@ -14,9 +14,9 @@ struct A3BatchView: View { var onBack: () -> Void let items: [A3BatchItem] = [ - .init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high), - .init(name: "甘油三酯 TG", value: "1.78", unit: "mmol/L", range: "< 1.70", status: .high), - .init(name: "空腹血糖 GLU", value: "5.4", unit: "mmol/L", range: "3.9–6.1", status: .normal), + .init(name: String(appLoc: "低密度脂蛋白胆固醇"), value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high), + .init(name: String(appLoc: "甘油三酯 TG"), value: "1.78", unit: "mmol/L", range: "< 1.70", status: .high), + .init(name: String(appLoc: "空腹血糖 GLU"), value: "5.4", unit: "mmol/L", range: "3.9–6.1", status: .normal), ] var body: some View { @@ -114,7 +114,7 @@ private struct BatchRow: View { Text(item.value) .font(.system(size: 17, weight: .semibold)) .foregroundStyle(item.status == .high ? Tj.Palette.brick : Tj.Palette.text) - TjBadge(text: item.status == .high ? "偏高" : "正常", + TjBadge(text: item.status == .high ? String(appLoc: "偏高") : String(appLoc: "正常"), style: item.status == .high ? .brick : .leaf) } } diff --git a/康康/Features/Record/RecordSheet.swift b/康康/Features/Record/RecordSheet.swift index 7c24bc9..250cf04 100644 --- a/康康/Features/Record/RecordSheet.swift +++ b/康康/Features/Record/RecordSheet.swift @@ -1,25 +1,30 @@ import SwiftUI enum RecordKind: String, Identifiable, CaseIterable { - case quick, indicator, archive, diary, symptom + case quick, indicator, archive, diary, symptom, reminder var id: String { rawValue } + /// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。 + static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .quick, .archive] + var title: String { switch self { - case .quick: return "异常项快拍" - case .indicator: return "指标记录" - case .archive: return "关键报告归档" - case .diary: return "文字日记" - case .symptom: return "症状开始" + case .quick: return String(appLoc: "异常项快拍") + case .indicator: return String(appLoc: "记录指标") + case .archive: return String(appLoc: "体检报告归档") + case .diary: return String(appLoc: "健康日记") + case .symptom: return String(appLoc: "记录症状") + case .reminder: return String(appLoc: "开启一个提醒") } } var subtitle: String { switch self { - case .quick: return "拍一张化验单,VL 自动识别" - case .indicator: return "手动填一项指标(免拍照)" - case .archive: return "完整保存整份报告(可多页)" - case .diary: return "记录心情、用药、其他" - case .symptom: return "开始一个持续症状,结束时再点结束" + case .quick: return String(appLoc: "拍一张化验单,VL 自动识别") + case .indicator: return String(appLoc: "手动填一项指标(免拍照)") + case .archive: return String(appLoc: "完整保存整份报告(可多页)") + case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助") + case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束") + case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒") } } var icon: String { @@ -27,8 +32,9 @@ enum RecordKind: String, Identifiable, CaseIterable { case .quick: return "camera.fill" case .indicator: return "number.square.fill" case .archive: return "doc.fill" - case .diary: return "pencil" + case .diary: return "heart.text.square" case .symptom: return "waveform.path.ecg" + case .reminder: return "bell.badge" } } var accent: Color { @@ -38,6 +44,7 @@ enum RecordKind: String, Identifiable, CaseIterable { case .archive: return Tj.Palette.ink case .diary: return Tj.Palette.leaf case .symptom: return Tj.Palette.amber + case .reminder: return Tj.Palette.leaf } } } @@ -64,8 +71,10 @@ struct RecordSheet: View { } .padding(.bottom, 14) - VStack(spacing: 10) { - ForEach(RecordKind.allCases) { kind in + // ScrollView 包裹:6 个入口在小屏固定 detent 下可能溢出,滚动确保都能触达。 + ScrollView { + VStack(spacing: 10) { + ForEach(RecordKind.displayOrder) { kind in Button { onPick(kind) } label: { @@ -97,8 +106,10 @@ struct RecordSheet: View { } .buttonStyle(.plain) } + } + .padding(.bottom, 22) } - .padding(.bottom, 22) + .scrollIndicators(.hidden) } .padding(.horizontal, 18) .background( diff --git a/康康/Features/Symptom/SymptomStartSheet.swift b/康康/Features/Symptom/SymptomStartSheet.swift index 4ab8733..71e0457 100644 --- a/康康/Features/Symptom/SymptomStartSheet.swift +++ b/康康/Features/Symptom/SymptomStartSheet.swift @@ -1,10 +1,11 @@ import SwiftUI import SwiftData -private let symptomPresets: [String] = [ - "头痛", "咳嗽", "腹痛", "发烧", - "恶心", "失眠", "疲劳", "关节痛" -] +/// 计算属性形式:每次取值按当前语言解析,语言切换后即时更新(不可用 static/let 缓存)。 +private func symptomPresets() -> [String] { + [String(appLoc: "头痛"), String(appLoc: "咳嗽"), String(appLoc: "腹痛"), String(appLoc: "发烧"), + String(appLoc: "恶心"), String(appLoc: "失眠"), String(appLoc: "疲劳"), String(appLoc: "关节痛")] +} struct SymptomStartSheet: View { @Environment(\.modelContext) private var ctx @@ -77,10 +78,10 @@ struct SymptomStartSheet: View { private var presetSection: some View { VStack(alignment: .leading, spacing: 8) { - sectionLabel("常见症状") + sectionLabel(String(appLoc: "常见症状")) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - ForEach(symptomPresets, id: \.self) { item in + ForEach(symptomPresets(), id: \.self) { item in chip(item, selected: name == item) { name = item customName = "" @@ -93,7 +94,7 @@ struct SymptomStartSheet: View { private var customSection: some View { VStack(alignment: .leading, spacing: 8) { - sectionLabel("或者自己写") + sectionLabel(String(appLoc: "或者自己写")) TextField("例如:眼皮跳", text: $customName) .textInputAutocapitalization(.never) .padding(.horizontal, 14) @@ -116,7 +117,7 @@ struct SymptomStartSheet: View { private var timeSection: some View { VStack(alignment: .leading, spacing: 8) { - sectionLabel("开始时间") + sectionLabel(String(appLoc: "开始时间")) DatePicker("", selection: $startedAt, in: ...Date.now) .datePickerStyle(.compact) .labelsHidden() @@ -126,7 +127,7 @@ struct SymptomStartSheet: View { private var severitySection: some View { VStack(alignment: .leading, spacing: 8) { HStack { - sectionLabel("强度") + sectionLabel(String(appLoc: "强度")) Spacer() Text("\(Int(severity)) / 5") .font(.system(size: 13, weight: .semibold, design: .monospaced)) @@ -144,7 +145,7 @@ struct SymptomStartSheet: View { private var noteSection: some View { VStack(alignment: .leading, spacing: 8) { - sectionLabel("备注(可选)") + sectionLabel(String(appLoc: "备注(可选)")) TextField("位置、可能诱因…", text: $note, axis: .vertical) .lineLimit(2...4) .padding(.horizontal, 14) diff --git a/康康/Features/Timeline/DateSection.swift b/康康/Features/Timeline/DateSection.swift index 33a4514..1517727 100644 --- a/康康/Features/Timeline/DateSection.swift +++ b/康康/Features/Timeline/DateSection.swift @@ -9,11 +9,11 @@ nonisolated enum DateSection: Hashable { var label: String { switch self { - case .today: return "今天" - case .yesterday: return "昨天" - case .thisWeek: return "本周" - case .thisMonth: return "本月" - case .year(let y): return "\(y) 年" + case .today: return String(appLoc: "今天") + case .yesterday: return String(appLoc: "昨天") + case .thisWeek: return String(appLoc: "本周") + case .thisMonth: return String(appLoc: "本月") + case .year(let y): return String(appLoc: "\(y) 年") } } @@ -68,10 +68,10 @@ func formatDuration(_ interval: TimeInterval) -> String { let hours = (totalMinutes % (60 * 24)) / 60 let minutes = totalMinutes % 60 - if days > 0 && hours > 0 { return "\(days) 天 \(hours) 小时" } - if days > 0 { return "\(days) 天" } - if hours > 0 && minutes > 0 { return "\(hours) 小时 \(minutes) 分" } - if hours > 0 { return "\(hours) 小时" } - if minutes > 0 { return "\(minutes) 分钟" } - return "刚刚" + if days > 0 && hours > 0 { return String(appLoc: "\(days) 天 \(hours) 小时") } + if days > 0 { return String(appLoc: "\(days) 天") } + if hours > 0 && minutes > 0 { return String(appLoc: "\(hours) 小时 \(minutes) 分") } + if hours > 0 { return String(appLoc: "\(hours) 小时") } + if minutes > 0 { return String(appLoc: "\(minutes) 分钟") } + return String(appLoc: "刚刚") } diff --git a/康康/Features/Timeline/TimelineEntry.swift b/康康/Features/Timeline/TimelineEntry.swift index c3b7d94..6e3eb0b 100644 --- a/康康/Features/Timeline/TimelineEntry.swift +++ b/康康/Features/Timeline/TimelineEntry.swift @@ -8,10 +8,10 @@ enum TimelineKind: String, CaseIterable, Identifiable { var label: String { switch self { - case .indicator: return "指标" - case .report: return "报告" - case .symptom: return "症状" - case .diary: return "日记" + case .indicator: return String(appLoc: "指标") + case .report: return String(appLoc: "报告") + case .symptom: return String(appLoc: "症状") + case .diary: return String(appLoc: "日记") } } @@ -90,8 +90,8 @@ struct TimelineEntry: Identifiable, Hashable { id: "bp-\(sys.persistentModelID)-\(dia.persistentModelID)", kind: .indicator, date: sys.capturedAt, - title: "血压", - subtitle: "长期监测", + title: String(appLoc: "血压"), + subtitle: String(appLoc: "长期监测"), trailing: "\(sys.value)/\(dia.value) mmHg" + (abnormal ? " ↑" : ""), trailingIsAlert: abnormal, isOngoing: false @@ -105,8 +105,8 @@ struct TimelineEntry: Identifiable, Hashable { kind: .report, date: r.reportDate, title: r.title, - subtitle: "\(r.type.label) · 共 \(r.pageCount) 页", - trailing: abnormal > 0 ? "\(abnormal) 项偏高" : nil, + subtitle: "\(r.type.label) · " + String(appLoc: "共 \(r.pageCount) 页"), + trailing: abnormal > 0 ? String(appLoc: "\(abnormal) 项偏高") : nil, trailingIsAlert: abnormal > 0, isOngoing: false ) @@ -118,7 +118,7 @@ struct TimelineEntry: Identifiable, Hashable { kind: .diary, date: d.createdAt, title: d.content.firstLine(), - subtitle: "文字日记", + subtitle: String(appLoc: "文字日记"), trailing: nil, trailingIsAlert: false, isOngoing: false @@ -131,11 +131,11 @@ struct TimelineEntry: Identifiable, Hashable { let subtitle: String let trailing: String? if ongoing { - subtitle = "症状 · 持续中" - trailing = "持续 \(formatDuration(s.duration))" + subtitle = String(appLoc: "症状 · 持续中") + trailing = String(appLoc: "持续 \(formatDuration(s.duration))") } else { - subtitle = "症状 · 已结束" - trailing = "持续 \(formatDuration(s.duration))" + subtitle = String(appLoc: "症状 · 已结束") + trailing = String(appLoc: "持续 \(formatDuration(s.duration))") } return TimelineEntry( id: "symptom-\(s.persistentModelID)", @@ -151,9 +151,9 @@ struct TimelineEntry: Identifiable, Hashable { private static func typeSubtitle(for i: Indicator) -> String { if let report = i.report { - return "指标 · \(report.title)" + return String(appLoc: "指标 · \(report.title)") } - return "异常项快拍" + return String(appLoc: "异常项快拍") } private static func indicatorValue(_ i: Indicator) -> String { @@ -175,6 +175,6 @@ private extension String { let s = String(line) return s.count > 40 ? String(s.prefix(40)) + "…" : s } - return trimmed.isEmpty ? "(空日记)" : trimmed + return trimmed.isEmpty ? String(appLoc: "(空日记)") : trimmed } } diff --git a/康康/Features/Timeline/TimelineRow.swift b/康康/Features/Timeline/TimelineRow.swift index 0533ac9..4fbd8fd 100644 --- a/康康/Features/Timeline/TimelineRow.swift +++ b/康康/Features/Timeline/TimelineRow.swift @@ -56,18 +56,12 @@ extension Date { return self.formatted(date: .omitted, time: .shortened) } if cal.isDateInYesterday(self) { - return "昨天 " + self.formatted(date: .omitted, time: .shortened) + return String(appLoc: "昨天") + " " + self.formatted(date: .omitted, time: .shortened) } let now = Date.now if cal.isDate(self, equalTo: now, toGranularity: .year) { - let f = DateFormatter() - f.locale = Locale(identifier: "zh_CN") - f.dateFormat = "M 月 d 日" - return f.string(from: self) + return self.formatted(.dateTime.month().day()) } - let f = DateFormatter() - f.locale = Locale(identifier: "zh_CN") - f.dateFormat = "yyyy 年 M 月 d 日" - return f.string(from: self) + return self.formatted(.dateTime.year().month().day()) } } diff --git a/康康/Features/Trends/CalendarMonthGrid.swift b/康康/Features/Trends/CalendarMonthGrid.swift index 71b9680..f5210d6 100644 --- a/康康/Features/Trends/CalendarMonthGrid.swift +++ b/康康/Features/Trends/CalendarMonthGrid.swift @@ -3,16 +3,31 @@ import SwiftUI struct CalendarMonthGrid: View { let monthAnchor: Date let data: CalendarData + let selectedDate: Date? let onTapDay: (Date) -> Void + init(monthAnchor: Date, + data: CalendarData, + selectedDate: Date? = nil, + onTapDay: @escaping (Date) -> Void) { + self.monthAnchor = monthAnchor + self.data = data + self.selectedDate = selectedDate + self.onTapDay = onTapDay + } + private let calendar: Calendar = { var c = Calendar(identifier: .gregorian) c.firstWeekday = 2 // 周一开始 - c.locale = Locale(identifier: "zh_CN") + c.locale = Locale.current return c }() - private let weekdayLabels = ["一", "二", "三", "四", "五", "六", "日"] + private let weekdayLabels = [ + String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), + String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"), + String(appLoc: "日") + ] private let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7) private var days: [DayCell] { @@ -64,6 +79,9 @@ struct CalendarMonthGrid: View { ranges: data.ranges(touching: cell.date, calendar: calendar), marks: data.marks(for: cell.date, calendar: calendar), isToday: calendar.isDateInToday(cell.date), + isSelected: selectedDate.map { + calendar.isDate(cell.date, inSameDayAs: $0) + } ?? false, calendar: calendar ) .onTapGesture { onTapDay(cell.date) } @@ -84,6 +102,7 @@ private struct DayCellView: View { let ranges: [SymptomRange] let marks: DayMarks let isToday: Bool + let isSelected: Bool let calendar: Calendar private var dayNumber: Int { @@ -92,14 +111,20 @@ private struct DayCellView: View { var body: some View { ZStack(alignment: .top) { - // 背景:今天高亮 + // 背景层:selected > today RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(isToday ? Tj.Palette.sand2 : Color.clear) + .fill(backgroundFill) + + // 选中描边 + if isSelected { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(Tj.Palette.brick, lineWidth: 1.5) + } VStack(spacing: 2) { Text("\(dayNumber)") .font(.system(size: 13, - weight: isToday ? .bold : .regular, + weight: (isToday || isSelected) ? .bold : .regular, design: .default)) .foregroundStyle(textColor) .padding(.top, 4) @@ -145,10 +170,17 @@ private struct DayCellView: View { private var textColor: Color { if !cell.inCurrentMonth { return Tj.Palette.text3.opacity(0.5) } + if isSelected { return Tj.Palette.brick } if isToday { return Tj.Palette.ink } return Tj.Palette.text } + private var backgroundFill: Color { + if isSelected { return Tj.Palette.brickSoft.opacity(0.5) } + if isToday { return Tj.Palette.sand2 } + return .clear + } + private func symptomBar(_ range: SymptomRange) -> some View { let pos = range.position(cell.date, calendar: calendar) let leadingRadius: CGFloat = (pos == .start || pos == .single) ? 3 : 0 diff --git a/康康/Features/Trends/CalendarYearGrid.swift b/康康/Features/Trends/CalendarYearGrid.swift index a64ec6c..97d5d95 100644 --- a/康康/Features/Trends/CalendarYearGrid.swift +++ b/康康/Features/Trends/CalendarYearGrid.swift @@ -8,7 +8,7 @@ struct CalendarYearGrid: View { private let calendar: Calendar = { var c = Calendar(identifier: .gregorian) c.firstWeekday = 2 - c.locale = Locale(identifier: "zh_CN") + c.locale = Locale.current return c }() @@ -42,10 +42,7 @@ private struct MiniMonth: View { let calendar: Calendar private var monthLabel: String { - let f = DateFormatter() - f.locale = Locale(identifier: "zh_CN") - f.dateFormat = "M 月" - return f.string(from: anchor) + anchor.formatted(.dateTime.month()) } private var days: [Date] { diff --git a/康康/Features/Trends/DayDetailSheet.swift b/康康/Features/Trends/DayDetailSheet.swift index 8b403dd..8ef2f7b 100644 --- a/康康/Features/Trends/DayDetailSheet.swift +++ b/康康/Features/Trends/DayDetailSheet.swift @@ -6,38 +6,37 @@ struct SelectedDay: Identifiable, Hashable { var id: TimeInterval { date.timeIntervalSince1970 } } -struct DayDetailSheet: View { - @Environment(\.modelContext) private var ctx - @Environment(\.dismiss) private var dismiss +// MARK: - DayDetailContent(可 inline 或入 sheet) +/// 选中日详情的核心渲染。无 sheet 外壳,可同时被 TrendsView inline 使用,也能被 sheet 包。 +struct DayDetailContent: View { let date: Date let indicators: [Indicator] let reports: [Report] let diaries: [DiaryEntry] let symptoms: [Symptom] + /// 是否显示日期 header(inline 时通常自带 header,sheet 模式让 DayDetailSheet 自己画) + var showHeader: Bool = true @State private var endingSymptom: Symptom? private let calendar: Calendar = { var c = Calendar(identifier: .gregorian) - c.locale = Locale(identifier: "zh_CN") + c.locale = Locale.current return c }() - // MARK: - 当日数据筛选 + // MARK: 当日筛选 private var dayIndicators: [Indicator] { indicators.filter { calendar.isDate($0.capturedAt, inSameDayAs: date) } } - private var dayReports: [Report] { reports.filter { calendar.isDate($0.reportDate, inSameDayAs: date) } } - private var dayDiaries: [DiaryEntry] { diaries.filter { calendar.isDate($0.createdAt, inSameDayAs: date) } } - private var daySymptoms: [(symptom: Symptom, state: SymptomDayState)] { symptoms.compactMap { s in let start = calendar.startOfDay(for: s.startedAt) @@ -52,90 +51,54 @@ struct DayDetailSheet: View { return (s, state) } } - private var totalCount: Int { dayIndicators.count + dayReports.count + dayDiaries.count + daySymptoms.count } - // MARK: - body - var body: some View { - VStack(spacing: 0) { - Capsule() - .fill(Tj.Palette.line) - .frame(width: 40, height: 4) - .padding(.top, 10) - .padding(.bottom, 14) - - header - .padding(.horizontal, 20) - .padding(.bottom, 12) - + VStack(alignment: .leading, spacing: 14) { + if showHeader { header } if totalCount == 0 { emptyState } else { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 18) { - if !daySymptoms.isEmpty { - section("症状", count: daySymptoms.count) { - VStack(spacing: 8) { - ForEach(daySymptoms, id: \.symptom.id) { item in - symptomRow(item.symptom, state: item.state) - } - } - } - } - - if !dayIndicators.isEmpty { - section("指标", count: dayIndicators.count) { - VStack(spacing: 8) { - ForEach(dayIndicators) { i in - indicatorRow(i) - } - } - } - } - - if !dayReports.isEmpty { - section("报告", count: dayReports.count) { - VStack(spacing: 8) { - ForEach(dayReports) { r in - reportRow(r) - } - } - } - } - - if !dayDiaries.isEmpty { - section("日记", count: dayDiaries.count) { - VStack(spacing: 8) { - ForEach(dayDiaries) { d in - diaryRow(d) - } - } + if !daySymptoms.isEmpty { + section(String(appLoc: "症状"), count: daySymptoms.count) { + VStack(spacing: 8) { + ForEach(daySymptoms, id: \.symptom.id) { item in + symptomRow(item.symptom, state: item.state) } } } - .padding(.horizontal, 20) - .padding(.bottom, 24) + } + if !dayIndicators.isEmpty { + section(String(appLoc: "指标"), count: dayIndicators.count) { + VStack(spacing: 8) { + ForEach(dayIndicators) { i in indicatorRow(i) } + } + } + } + if !dayReports.isEmpty { + section(String(appLoc: "报告"), count: dayReports.count) { + VStack(spacing: 8) { + ForEach(dayReports) { r in reportRow(r) } + } + } + } + if !dayDiaries.isEmpty { + section(String(appLoc: "日记"), count: dayDiaries.count) { + VStack(spacing: 8) { + ForEach(dayDiaries) { d in diaryRow(d) } + } + } } } } - .background( - Tj.Palette.sand - .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous)) - .ignoresSafeArea(edges: .bottom) - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.hidden) - .presentationBackground(Tj.Palette.sand) - .presentationCornerRadius(Tj.Radius.xl) .sheet(item: $endingSymptom) { sym in SymptomEndSheet(symptom: sym) } } - // MARK: - header + // MARK: header private var header: some View { HStack(alignment: .firstTextBaseline) { @@ -145,7 +108,7 @@ struct DayDetailSheet: View { .tracking(0.5) .foregroundStyle(Tj.Palette.text3) Text(dayLabel) - .font(.tjTitle(28)) + .font(.tjTitle(22)) .foregroundStyle(Tj.Palette.text) } Spacer() @@ -158,27 +121,18 @@ struct DayDetailSheet: View { } private var dateLine: String { - let f = DateFormatter() - f.locale = Locale(identifier: "zh_CN") - f.dateFormat = "yyyy 年" - return f.string(from: date) + " · " + weekdayLabel + date.formatted(.dateTime.year()) + " · " + weekdayLabel } private var dayLabel: String { - let f = DateFormatter() - f.locale = Locale(identifier: "zh_CN") - f.dateFormat = "M 月 d 日" - return f.string(from: date) + date.formatted(.dateTime.month().day()) } private var weekdayLabel: String { - let f = DateFormatter() - f.locale = Locale(identifier: "zh_CN") - f.dateFormat = "EEEE" - return f.string(from: date) + date.formatted(.dateTime.weekday(.wide)) } - // MARK: - section + // MARK: section helper private func section(_ title: String, count: Int, @@ -198,27 +152,30 @@ struct DayDetailSheet: View { } } - // MARK: - rows + // MARK: rows private func symptomRow(_ s: Symptom, state: SymptomDayState) -> some View { HStack(spacing: 12) { Capsule() .fill(severityColor(s.severity)) .frame(width: 4, height: 36) - VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(s.name) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) - stateBadge(state, isOngoing: s.isOngoing) + Text(state.badge) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(state.badgeFg) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(state.badgeBg)) } Text("\(state.subtitle) · 持续 \(formatDuration(s.duration))") .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text3) } Spacer(minLength: 6) - if s.isOngoing { Button { endingSymptom = s @@ -237,15 +194,6 @@ struct DayDetailSheet: View { .tjCard(bordered: true) } - private func stateBadge(_ state: SymptomDayState, isOngoing: Bool) -> some View { - Text(state.badge) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(state.badgeFg) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Capsule().fill(state.badgeBg)) - } - private func indicatorRow(_ i: Indicator) -> some View { HStack(spacing: 12) { ZStack { @@ -256,7 +204,6 @@ struct DayDetailSheet: View { .foregroundStyle(indicatorAccent(i)) } .frame(width: 32, height: 32) - VStack(alignment: .leading, spacing: 2) { Text(i.name) .font(.system(size: 14, weight: .medium)) @@ -269,7 +216,6 @@ struct DayDetailSheet: View { } } Spacer(minLength: 6) - Text("\(i.value) \(i.unit)\(arrow(i))") .font(.system(size: 13, weight: .semibold, design: .monospaced)) .foregroundStyle(i.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick) @@ -291,7 +237,6 @@ struct DayDetailSheet: View { .foregroundStyle(Tj.Palette.ink2) } .frame(width: 32, height: 32) - VStack(alignment: .leading, spacing: 2) { Text(r.title) .font(.system(size: 14, weight: .medium)) @@ -331,23 +276,19 @@ struct DayDetailSheet: View { .tjCard(bordered: true) } - // MARK: - empty - private var emptyState: some View { - VStack(spacing: 12) { - Spacer(minLength: 16) - TjPlaceholder(label: "这一天还没有记录") - .frame(width: 220, height: 120) + VStack(spacing: 8) { + TjPlaceholder(label: String(appLoc: "这一天还没有记录")) + .frame(height: 90) + .frame(maxWidth: 240) Text("点底部 + 号可以补一条") - .font(.system(size: 12)) + .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text3) - Spacer() } + .padding(.vertical, 12) .frame(maxWidth: .infinity) } - // MARK: - utils - private func severityColor(_ value: Int) -> Color { switch value { case 1, 2: return Tj.Palette.leaf @@ -369,22 +310,65 @@ struct DayDetailSheet: View { } } +// MARK: - Sheet wrapper(保留;现在 TrendsView 走 inline,但其他入口可能用) + +struct DayDetailSheet: View { + let date: Date + let indicators: [Indicator] + let reports: [Report] + let diaries: [DiaryEntry] + let symptoms: [Symptom] + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Tj.Palette.line) + .frame(width: 40, height: 4) + .padding(.top, 10) + .padding(.bottom, 14) + ScrollView(showsIndicators: false) { + DayDetailContent( + date: date, + indicators: indicators, + reports: reports, + diaries: diaries, + symptoms: symptoms, + showHeader: true + ) + .padding(.horizontal, 20) + .padding(.bottom, 24) + } + } + .background( + Tj.Palette.sand + .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous)) + .ignoresSafeArea(edges: .bottom) + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + .presentationBackground(Tj.Palette.sand) + .presentationCornerRadius(Tj.Radius.xl) + } +} + +// MARK: - SymptomDayState + enum SymptomDayState { case startedToday, ongoing, endedToday var subtitle: String { switch self { - case .startedToday: return "今天开始" - case .ongoing: return "进行中" - case .endedToday: return "今天结束" + case .startedToday: return String(appLoc: "今天开始") + case .ongoing: return String(appLoc: "进行中") + case .endedToday: return String(appLoc: "今天结束") } } var badge: String { switch self { - case .startedToday: return "开始" - case .ongoing: return "持续" - case .endedToday: return "结束" + case .startedToday: return String(appLoc: "开始") + case .ongoing: return String(appLoc: "持续") + case .endedToday: return String(appLoc: "结束") } } diff --git a/康康/Features/Trends/SeriesBucket.swift b/康康/Features/Trends/SeriesBucket.swift index d0b0e78..eb19253 100644 --- a/康康/Features/Trends/SeriesBucket.swift +++ b/康康/Features/Trends/SeriesBucket.swift @@ -123,7 +123,7 @@ extension SeriesBucket { let sysLine = SeriesLine( id: "bp.systolic", seriesKey: "bp.systolic", - label: "收缩", + label: String(appLoc: "收缩"), color: Tj.Palette.brick, points: sysItems.compactMap { point(from: $0) }, referenceRange: m.effectiveRange(for: sysField, profile: profile) @@ -131,7 +131,7 @@ extension SeriesBucket { let diaLine = SeriesLine( id: "bp.diastolic", seriesKey: "bp.diastolic", - label: "舒张", + label: String(appLoc: "舒张"), color: Tj.Palette.leaf, points: diaItems.compactMap { point(from: $0) }, referenceRange: m.effectiveRange(for: diaField, profile: profile) @@ -144,7 +144,7 @@ extension SeriesBucket { return SeriesBucket( id: "bp", - title: "血压", + title: String(appLoc: "血压"), unit: "mmHg", lines: [sysLine, diaLine], latestDate: latest diff --git a/康康/Features/Trends/SeriesChartCard.swift b/康康/Features/Trends/SeriesChartCard.swift index 1b04525..93bc961 100644 --- a/康康/Features/Trends/SeriesChartCard.swift +++ b/康康/Features/Trends/SeriesChartCard.swift @@ -165,10 +165,10 @@ struct SeriesChartCard: View { let days = Calendar.current.dateComponents([.day], from: dom.lowerBound, to: dom.upperBound).day ?? 0 - if days <= 0 { return "今天" } - if days < 30 { return "\(days) 天" } - if days < 365 { return "\(days / 30) 个月" } - return "\(days / 365) 年" + if days <= 0 { return String(appLoc: "今天") } + if days < 30 { return String(appLoc: "\(days) 天") } + if days < 365 { return String(appLoc: "\(days / 30) 个月") } + return String(appLoc: "\(days / 365) 年") } private func formatValue(_ v: Double) -> String { diff --git a/康康/Features/Trends/TrendsView.swift b/康康/Features/Trends/TrendsView.swift index 8dc2707..d4e1ef0 100644 --- a/康康/Features/Trends/TrendsView.swift +++ b/康康/Features/Trends/TrendsView.swift @@ -6,8 +6,8 @@ enum CalendarMode: String, CaseIterable, Identifiable { var id: String { rawValue } var label: String { switch self { - case .month: return "月" - case .year: return "年" + case .month: return String(appLoc: "月") + case .year: return String(appLoc: "年") } } } @@ -31,7 +31,8 @@ struct TrendsView: View { @State private var mode: CalendarMode = .month @State private var anchor: Date = .now - @State private var selectedDay: SelectedDay? + /// 选中的当天 — 默认选今天,日历下方 inline 显示该日详情 + @State private var selectedDate: Date = .now private var profile: UserProfile? { profiles.first } @@ -44,7 +45,7 @@ struct TrendsView: View { private let calendar: Calendar = { var c = Calendar(identifier: .gregorian) c.firstWeekday = 2 - c.locale = Locale(identifier: "zh_CN") + c.locale = Locale.current return c }() @@ -66,6 +67,9 @@ struct TrendsView: View { anchorBar calendarBody legend + if mode == .month { + dayDetailInline + } seriesSection } .padding(.horizontal, 20) @@ -73,15 +77,31 @@ struct TrendsView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Tj.Palette.sand.ignoresSafeArea()) - .sheet(item: $selectedDay) { sel in - DayDetailSheet( - date: sel.date, + } + + /// 日历下方 inline 显示选中天的详情(symptoms / indicators / reports / diaries) + private var dayDetailInline: some View { + VStack(alignment: .leading, spacing: 0) { + DayDetailContent( + date: selectedDate, indicators: indicators, reports: reports, diaries: diaries, - symptoms: symptoms + symptoms: symptoms, + showHeader: true ) + .padding(14) } + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + ) + .animation(.snappy(duration: 0.2), value: selectedDate) } private var header: some View { @@ -91,7 +111,10 @@ struct TrendsView: View { .foregroundStyle(Tj.Palette.text) Spacer() Button { - anchor = .now + withAnimation(.snappy(duration: 0.2)) { + anchor = .now + selectedDate = .now + } } label: { Text("回到今天") .font(.system(size: 12)) @@ -164,18 +187,20 @@ struct TrendsView: View { } private var anchorTitle: String { - let f = DateFormatter() - f.locale = Locale(identifier: "zh_CN") - f.dateFormat = mode == .month ? "yyyy 年 M 月" : "yyyy 年" - return f.string(from: anchor) + let style: Date.FormatStyle = mode == .month + ? .dateTime.year().month() + : .dateTime.year() + return anchor.formatted(style) } @ViewBuilder private var calendarBody: some View { switch mode { case .month: - CalendarMonthGrid(monthAnchor: anchor, data: data) { day in - selectedDay = SelectedDay(date: day) + CalendarMonthGrid(monthAnchor: anchor, data: data, selectedDate: selectedDate) { day in + withAnimation(.snappy(duration: 0.2)) { + selectedDate = day + } } .padding(14) .background( @@ -231,10 +256,10 @@ struct TrendsView: View { .tracking(0.5) .foregroundStyle(Tj.Palette.text3) HStack(spacing: 14) { - legendItem(color: Tj.Palette.brick, label: "指标异常") - legendItem(color: Tj.Palette.amber, label: "症状持续中") - legendItem(color: Tj.Palette.ink2, label: "报告归档") - legendItem(color: Tj.Palette.leaf, label: "正常") + legendItem(color: Tj.Palette.brick, label: String(appLoc: "指标异常")) + legendItem(color: Tj.Palette.amber, label: String(appLoc: "症状持续中")) + legendItem(color: Tj.Palette.ink2, label: String(appLoc: "报告归档")) + legendItem(color: Tj.Palette.leaf, label: String(appLoc: "正常")) } } .padding(.top, 4) @@ -268,6 +293,14 @@ struct TrendsView: View { if let next = calendar.date(byAdding: component, value: delta, to: anchor) { withAnimation(.snappy) { anchor = next + // 翻月时把 selection 跟着走:同月内停在今天(如果是当前月)或 1 号 + if mode == .month { + if calendar.isDate(next, equalTo: .now, toGranularity: .month) { + selectedDate = .now + } else if let first = calendar.dateInterval(of: .month, for: next)?.start { + selectedDate = first + } + } } } } diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings new file mode 100644 index 0000000..33c21f4 --- /dev/null +++ b/康康/Localizable.xcstrings @@ -0,0 +1,11594 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "": {}, + " / %lld · 像扫描文档一样对准": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " / %lld · Align it like scanning a document" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " / %lld · 書類をスキャンするように合わせる" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " / %lld · 문서를 스캔하듯 맞추세요" + } + } + } + }, + "—": {}, + "·": {}, + "· · ·": {}, + "· %lld": {}, + "· 按%lld岁调整": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "· Adjusted for age %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "· %lld歳に合わせて調整" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "· %lld세 기준으로 조정" + } + } + } + }, + "···": {}, + "(偏瘦)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "(Underweight)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "(やせ気味)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "(저체중)" + } + } + } + }, + "(正常)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "(Normal)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "(正常)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "(정상)" + } + } + } + }, + "(空日记)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "(Empty diary)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "(空の日記)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "(빈 일기)" + } + } + } + }, + "(肥胖)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "(Obese)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "(肥満)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "(비만)" + } + } + } + }, + "(超重)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "(Overweight)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "(過体重)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "(과체중)" + } + } + } + }, + "「%@」是内置指标的名字 — 录入 grid 里会出现两个同名块": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "\"%@\" is a built-in indicator name — two blocks with the same name will appear in the entry grid" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「%@」は組み込み指標の名前です — 入力グリッドに同名のブロックが2つ表示されます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "'%@'은(는) 기본 제공 지표 이름이에요 — 입력 그리드에 같은 이름의 블록이 두 개 나타나요" + } + } + } + }, + "/": {}, + "%@ · 持续 %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · lasted %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · %2$@ 継続" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · %2$@ 지속" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "%1$@ · 持续 %2$@" + } + } + } + }, + "%@ — %@": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "%1$@ — %2$@" + } + } + } + }, + "%@ · %@": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "%1$@ · %2$@" + } + } + } + }, + "%@ · 共 %lld 页": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · %2$lld pages total" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · 全 %2$lld ページ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · 총 %2$lld 페이지" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "%1$@ · 共 %2$lld 页" + } + } + } + }, + "%@ %@%@": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "%1$@ %2$@%3$@" + } + } + } + }, + "%@ 解锁": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unlock with %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ でロック解除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@(으)로 잠금 해제" + } + } + } + }, + "%@型": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@型" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@형" + } + } + } + }, + "%lld": {}, + "%lld / %lld 项启用": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$lld / %2$lld enabled" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$lld / %2$lld 件 有効" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$lld / %2$lld 개 사용" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "%1$lld / %2$lld 项启用" + } + } + } + }, + "%lld / 5": {}, + "%lld 个": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 件" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld 개" + } + } + } + }, + "%lld 个建议": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld suggestions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 件の提案" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제안 %lld 개" + } + } + } + }, + "%lld 个月": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld months" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld か月" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld 개월" + } + } + } + }, + "%lld 份": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld reports" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 件" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld 건" + } + } + } + }, + "%lld 份 · %lld 项指标 · 端侧加密": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$lld reports · %2$lld indicators · on-device encryption" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$lld 件 · 指標 %2$lld 項目 · オンデバイス暗号化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$lld 건 · 지표 %2$lld 개 · 온디바이스 암호화" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "%1$lld 份 · %2$lld 项指标 · 端侧加密" + } + } + } + }, + "%lld 分钟": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld min" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 分" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld 분" + } + } + } + }, + "%lld 天": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld days" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld 일" + } + } + } + }, + "%lld 天 %lld 小时": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$lld d %2$lld h" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$lld 日 %2$lld 時間" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$lld 일 %2$lld 시간" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "%1$lld 天 %2$lld 小时" + } + } + } + }, + "%lld 小时": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld h" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 時間" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld 시간" + } + } + } + }, + "%lld 小时 %lld 分": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$lld h %2$lld min" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$lld 時間 %2$lld 分" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$lld 시간 %2$lld 분" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "%1$lld 小时 %2$lld 分" + } + } + } + }, + "%lld 年": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld years" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 年" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld 년" + } + } + } + }, + "%lld 条": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld entries" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 件" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld 건" + } + } + } + }, + "%lld 条 · 近 %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$lld entries · last %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$lld 件 · 直近 %2$@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$lld 건 · 최근 %2$@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "%1$lld 条 · 近 %2$@" + } + } + } + }, + "%lld 页 · 100%% 本地推理 · 已用 %llds": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$lld pages · 100%% on-device · %2$llds elapsed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$lld ページ · 100%% オンデバイス推論 · %2$llds 経過" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$lld 페이지 · 100%% 온디바이스 추론 · %2$llds 경과" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "%1$lld 页 · 100%% 本地推理 · 已用 %2$llds" + } + } + } + }, + "%lld 项": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld indicators" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld 개" + } + } + } + }, + "%lld 项偏高": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld high" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 項目 高い" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld 개 높음" + } + } + } + }, + "%lld 项启用": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld enabled" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 項目 有効" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld 개 사용" + } + } + } + }, + "%lld.": {}, + "%lld/%lld 就绪": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$lld/%2$lld ready" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$lld/%2$lld 準備完了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$lld/%2$lld 준비됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "%1$lld/%2$lld 就绪" + } + } + } + }, + "%lld%%": {}, + "%lld岁": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld yrs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld 歳" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld 세" + } + } + } + }, + "+%lld": {}, + "< 3.40": {}, + "⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "⚠️ Notifications are off. Turn them on in Settings → Kangkang → Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "⚠️ 通知が許可されていません。「設定 → Kangkang → 通知」で有効にしてください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "⚠️ 알림 권한이 꺼져 있어요. '설정 → Kangkang → 알림'에서 켜세요" + } + } + } + }, + "1 项偏低": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1 low" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 項目 低い" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "1 개 낮음" + } + } + } + }, + "3 页": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "3 pages" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "3 ページ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "3 페이지" + } + } + } + }, + "3 项偏高": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "3 high" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "3 項目 高い" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "3 개 높음" + } + } + } + }, + "3.84": {}, + "100% 本地推理 · 模型仅需下载一次": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "100% on-device · download the model just once" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "100% オンデバイス推論 · モデルのダウンロードは一度だけ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "100% 온디바이스 추론 · 모델은 한 번만 다운로드" + } + } + } + }, + "2026 / 05 / 25 · 协和医院体检中心": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "2026 / 05 / 25 · PUMC Hospital Checkup Center" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "2026 / 05 / 25 · 協和病院 健診センター" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "2026 / 05 / 25 · 협화병원 건강검진센터" + } + } + } + }, + "2026 春季年度体检": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "2026 Spring Annual Checkup" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "2026年 春の定期健康診断" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "2026 봄 연례 건강검진" + } + } + } + }, + "A 型": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type A" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "A型" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "A형" + } + } + } + }, + "AB 型": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type AB" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AB型" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AB형" + } + } + } + }, + "AI 已识别到 1 项指标": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AI detected 1 indicator" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AIが指標を1項目認識しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI가 지표 1개를 인식했어요" + } + } + } + }, + "AI 思考中… 本地推理,通常 5-10 秒": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AI is thinking… on-device inference, usually 5-10 sec" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AIが考えています… オンデバイス推論、通常5〜10秒" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI가 생각 중… 온디바이스 추론, 보통 5~10초" + } + } + } + }, + "AI 推理在设备本地完成;除下载 AI 模型外,App 不会主动上传你的健康数据。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AI inference runs locally on your device; aside from downloading the AI models, the app never uploads your health data." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AI推論はデバイス上でローカルに実行されます。AIモデルのダウンロードを除き、アプリがあなたの健康データを自発的にアップロードすることはありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI 추론은 기기에서 로컬로 처리됩니다. AI 모델 다운로드를 제외하면 앱이 건강 데이터를 임의로 업로드하지 않아요." + } + } + } + }, + "AI 模型尚未准备好": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AI model isn't ready yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AIモデルの準備ができていません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI 모델이 아직 준비되지 않았어요" + } + } + } + }, + "AI 模型尚未准备好,请先到「我的 · 模型管理」下载。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AI model isn't ready yet. Please download it under \"Me · Model Management\" first." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AIモデルの準備ができていません。先に「マイ · モデル管理」からダウンロードしてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI 모델이 아직 준비되지 않았어요. 먼저 「마이 · 모델 관리」에서 다운로드하세요." + } + } + } + }, + "AI 没有给出建议,请稍后重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AI didn't return any suggestions. Please try again later." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AIから提案が得られませんでした。後でもう一度お試しください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI가 제안을 내놓지 못했어요. 잠시 후 다시 시도하세요." + } + } + } + }, + "AI 解读基于通用健康知识生成,并不掌握你完整的病史与个体情况,仅供日常记录参考。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AI interpretations are generated from general health knowledge and do not account for your full medical history or individual condition. They are for everyday reference only." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AIによる解説は一般的な健康知識に基づいて生成されており、あなたの完全な病歴や個別の状況を把握しているわけではありません。日常の記録の参考程度にご利用ください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI 해석은 일반적인 건강 지식을 바탕으로 생성되며, 당신의 전체 병력이나 개별 상황을 알지 못해요. 일상 기록 참고용으로만 사용하세요." + } + } + } + }, + "AI 识别与解读可能出现错误或遗漏:拍照得到的数值、单位、参考范围请务必与原始报告核对,": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AI recognition and interpretation may contain errors or omissions: always verify the values, units, and reference ranges captured from photos against the original report." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AIの認識・解説には誤りや見落としが生じる場合があります。撮影で得られた数値・単位・基準範囲は必ず元のレポートと照合してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI 인식과 해석에는 오류나 누락이 있을 수 있어요. 촬영으로 얻은 수치, 단위, 참고 범위는 반드시 원본 리포트와 대조하세요." + } + } + } + }, + "AI 辅助 · 医生角度查漏补缺": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AI assist · gap-checking from a doctor's perspective" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AIアシスト · 医師の視点で抜け漏れチェック" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI 도움 · 의사 관점에서 빠진 부분 점검" + } + } + } + }, + "App 内所有 AI 生成的解读、趋势分析与问答内容仅供信息参考,": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All AI-generated interpretations, trend analyses, and Q&A content in the app are for informational reference only," + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリ内のAIが生成したすべての解説・トレンド分析・Q&Aの内容は情報の参考にとどまり、" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 내 AI가 생성한 모든 해석, 추세 분석, 질문·답변 내용은 정보 참고용일 뿐이며," + } + } + } + }, + "B 型": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type B" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "B型" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "B형" + } + } + } + }, + "BMI: %@ %@": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "BMI: %1$@ %2$@" + } + } + } + }, + "cm": {}, + "end": {}, + "Face ID 启动锁": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Face ID Lock" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Face IDロック" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Face ID 잠금" + } + } + } + }, + "hi": {}, + "kg": {}, + "LDL-C": {}, + "lo": {}, + "mmHg": {}, + "mmol/L": {}, + "O 型": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type O" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "O型" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "O형" + } + } + } + }, + "p.%lld": {}, + "QWEN2.5-VL · ON-DEVICE · SME2": {}, + "start": {}, + "VL 模型尚未就绪": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "VL model not ready yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "VLモデルはまだ準備できていません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "VL 모델이 아직 준비되지 않았어요" + } + } + } + }, + "VL 模型未就绪": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "VL model not ready" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "VLモデルが準備できていません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "VL 모델이 준비되지 않았어요" + } + } + } + }, + "VL 模型未就绪,先手动录入": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "VL model not ready, enter manually for now" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "VLモデルが準備できていません。まず手動で入力してください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "VL 모델이 준비되지 않았어요. 우선 수동으로 입력하세요" + } + } + } + }, + "VL 输出无法解析:%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not parse VL output: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "VLの出力を解析できません:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "VL 출력을 해석할 수 없어요: %@" + } + } + } + }, + "一": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mon" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "月" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "월" + } + } + } + }, + "一句话总结": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "One-line summary" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一言まとめ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "한 줄 요약" + } + } + } + }, + "一张图,几秒搞定": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "One photo, done in seconds" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "写真1枚、数秒で完了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사진 한 장, 몇 초면 끝" + } + } + } + }, + "三": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Wed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "水" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수" + } + } + } + }, + "上限": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upper limit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "上限" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상한" + } + } + } + }, + "下午好": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Good afternoon" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "こんにちは" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "좋은 오후예요" + } + } + } + }, + "下载中": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloading" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 중" + } + } + } + }, + "下载中…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloading…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 중…" + } + } + } + }, + "下载全部模型 · %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download all models · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのモデルをダウンロード · %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 모델 다운로드 · %@" + } + } + } + }, + "下载失败": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードに失敗しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드에 실패했어요" + } + } + } + }, + "下载失败(HTTP %lld)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download failed (HTTP %lld)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードに失敗しました(HTTP %lld)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드에 실패했어요(HTTP %lld)" + } + } + } + }, + "下限": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lower limit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下限" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "하한" + } + } + } + }, + "不愿透露": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Prefer not to say" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "回答しない" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "밝히고 싶지 않음" + } + } + } + }, + "不构成医疗建议,也不能替代执业医师、药师或其他专业人员的面诊、检查与意见。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This does not constitute medical advice and cannot replace in-person consultation, examination, or opinion from licensed physicians, pharmacists, or other professionals." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "これは医療上の助言を構成するものではなく、医師、薬剤師その他の専門家による対面診療、検査、意見に代わるものではありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "의료 조언이 아니며, 의사·약사 또는 기타 전문가의 대면 진료, 검사 및 소견을 대신할 수 없어요." + } + } + } + }, + "不知道": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Don't know" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "わからない" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모름" + } + } + } + }, + "两个模型都已就绪": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Both models are ready" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "両方のモデルが準備できました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "두 모델 모두 준비됐어요" + } + } + } + }, + "个人资料": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロフィール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "개인 정보" + } + } + } + }, + "主要功能": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Main features" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "主な機能" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주요 기능" + } + } + } + }, + "主页": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Home" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホーム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "홈" + } + } + } + }, + "二": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tue" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "火" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "화" + } + } + } + }, + "五": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fri" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "金" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "금" + } + } + } + }, + "仅供参考,不构成医疗建议": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "For reference only, not medical advice" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "参考用です。医療上の助言ではありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "참고용이며 의료 조언이 아니에요" + } + } + } + }, + "今天": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Today" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오늘" + } + } + } + }, + "今天开始": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Started today" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日から" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오늘 시작" + } + } + } + }, + "今天结束": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ended today" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日まで" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오늘 종료" + } + } + } + }, + "今天身体怎么样?吃了什么药、有什么感觉?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "How are you feeling today? What did you take, and how do you feel?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日の体調はどうですか?何の薬を飲み、どんな感じですか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오늘 몸 상태는 어떠세요? 어떤 약을 드셨고, 어떤 느낌인가요?" + } + } + } + }, + "从文件导入(离线)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import from file (offline)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルから取り込む(オフライン)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파일에서 가져오기(오프라인)" + } + } + } + }, + "从相册选 ≤5 张": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pick up to 5 from album" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アルバムから5枚まで選択" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앨범에서 최대 5장 선택" + } + } + } + }, + "任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "For any health decision (whether to seek care, take medication, adjust treatment, etc.), consult a qualified medical professional and follow their advice." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "あらゆる健康上の判断(受診・服薬・治療方針の変更など)については、専門の医療従事者に相談し、その意見に従ってください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 건강 관련 결정(진료 여부, 복약, 치료 방안 조정 등)은 전문 의료인과 상담하고 그 의견을 따르세요." + } + } + } + }, + "但本地 AI 相关功能可能无法运行。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "However, on-device AI features may not work." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ただし、オンデバイスAI関連の機能は動作しない場合があります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다만 온디바이스 AI 관련 기능은 작동하지 않을 수 있어요." + } + } + } + }, + "位置、可能诱因…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Location, possible triggers…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "部位、考えられる誘因…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "위치, 가능한 유발 요인…" + } + } + } + }, + "低密度脂蛋白": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "LDL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "LDLコレステロール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "LDL 콜레스테롤" + } + } + } + }, + "低密度脂蛋白 3.84 mmol/L ↑": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "LDL 3.84 mmol/L ↑" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "LDLコレステロール 3.84 mmol/L ↑" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "LDL 콜레스테롤 3.84 mmol/L ↑" + } + } + } + }, + "低密度脂蛋白胆固醇": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "LDL-C" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "LDLコレステロール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "LDL 콜레스테롤" + } + } + } + }, + "体 检 报 告 (第 %lld 页)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checkup Report (Page %lld)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "健康診断レポート(%lldページ)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건강검진 리포트(%lld페이지)" + } + } + } + }, + "体检报告": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checkup report" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "健康診断レポート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건강검진 리포트" + } + } + } + }, + "体检报告 · 影像报告": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checkup report · Imaging report" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "健康診断レポート · 画像レポート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건강검진 리포트 · 영상 리포트" + } + } + } + }, + "体检报告归档": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archive a checkup report" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "健康診断レポートを保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건강검진 리포트 보관" + } + } + } + }, + "体温": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Temperature" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "体温" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "체온" + } + } + } + }, + "体重": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Weight" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "体重" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "체중" + } + } + } + }, + "你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can photograph checkup reports, lab reports, and imaging materials; images and data are stored on your device by default;" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "健康診断レポート、検査結果、画像資料を撮影できます。画像とデータは既定で本体に保存されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건강검진 리포트, 검사 결과, 영상 자료를 촬영할 수 있어요. 이미지와 데이터는 기본적으로 기기에 저장돼요." + } + } + } + }, + "你的健康档案已加密保护": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your health records are protected with encryption" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "あなたの健康記録は暗号化で保護されています" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건강 기록이 암호화로 보호되고 있어요" + } + } + } + }, + "使用注意": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage notes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ご利用上の注意" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 시 주의사항" + } + } + } + }, + "使用蜂窝网络下载?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download over cellular?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モバイル通信でダウンロードしますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "셀룰러 네트워크로 다운로드할까요?" + } + } + } + }, + "例:最近血糖好像不稳,把过去三个月的化验单整理一下": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g. My glucose seems unstable lately—organize my lab reports from the past three months" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "例:最近血糖値が不安定なようなので、過去3か月の検査結果を整理したい" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예: 최근 혈당이 불안정한 것 같은데, 지난 3개월간의 검사 결과를 정리해 주세요" + } + } + } + }, + "例:我感冒3天了,把最近一个月的健康情况给医生看": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g. I've had a cold for 3 days—show my health over the past month to the doctor" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "例:風邪をひいて3日になります。直近1か月の健康状態を医師に見せたい" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예: 감기에 걸린 지 3일째인데, 최근 한 달간의 건강 상태를 의사에게 보여주고 싶어요" + } + } + } + }, + "例如:< 3.40 或 3.9 - 6.1": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g. < 3.40 or 3.9 - 6.1" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "例:< 3.40 または 3.9 - 6.1" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예: < 3.40 또는 3.9 - 6.1" + } + } + } + }, + "例如:cm / 步 / 小时": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g. cm / steps / hours" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "例:cm / 歩 / 時間" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예: cm / 걸음 / 시간" + } + } + } + }, + "例如:眼皮跳": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g. eyelid twitching" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "例:まぶたのけいれん" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예: 눈꺼풀 떨림" + } + } + } + }, + "例如:空腹采血": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g. fasting blood draw" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "例:空腹時採血" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예: 공복 채혈" + } + } + } + }, + "例如:腰围 / 步数 / 睡眠时长": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g. waist circumference / step count / sleep duration" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "例:腹囲 / 歩数 / 睡眠時間" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예: 허리둘레 / 걸음 수 / 수면 시간" + } + } + } + }, + "例如:血红蛋白": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g. hemoglobin" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "例:ヘモグロビン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예: 헤모글로빈" + } + } + } + }, + "保存": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장" + } + } + } + }, + "保存到记录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save to Records" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "記録に保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록에 저장" + } + } + } + }, + "保存后会出现在录入选项里": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "After saving, it will appear in the entry options" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存すると入力オプションに表示されます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장하면 입력 옵션에 표시돼요" + } + } + } + }, + "保存归档": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save to archive" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アーカイブに保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보관함에 저장" + } + } + } + }, + "偏低": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Low" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "低い" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "낮음" + } + } + } + }, + "偏低 ↓": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Low ↓" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "低い ↓" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "낮음 ↓" + } + } + } + }, + "偏高": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "High" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "高い" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "높음" + } + } + } + }, + "偏高 ↑": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "High ↑" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "高い ↑" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "높음 ↑" + } + } + } + }, + "健康日记": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Health diary" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "健康日記" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건강 일기" + } + } + } + }, + "健康记录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Health records" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "健康記録" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건강 기록" + } + } + } + }, + "像扫描文档一样翻页拍摄": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shoot page by page, just like scanning a document" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドキュメントをスキャンするようにページをめくって撮影" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "문서를 스캔하듯 페이지를 넘기며 촬영하세요" + } + } + } + }, + "先写几个字,AI 来帮忙补充": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jot down a few words and let AI help fill in the rest" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "数文字だけ書けば、AIが続きを補完します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "몇 글자만 적으면 AI가 나머지를 채워줘요" + } + } + } + }, + "免责声明": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disclaimer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "免責事項" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "면책 조항" + } + } + } + }, + "全部": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체" + } + } + } + }, + "全部 ›": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All ›" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて ›" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 ›" + } + } + } + }, + "全部保存(%lld)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save all (%lld)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて保存(%lld)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 저장(%lld)" + } + } + } + }, + "全部已关闭(%lld 条)": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All closed (%lld)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて終了しました(%lld件)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 종료됨(%lld건)" + } + } + } + }, + "六": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sat" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "土" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토" + } + } + } + }, + "共 %lld 页": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld pages total" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "全 %lld ページ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "총 %lld 페이지" + } + } + } + }, + "关于": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "概要" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정보" + } + } + } + }, + "关键报告归档": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archive key reports" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "重要なレポートをアーカイブ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주요 리포트 보관" + } + } + } + }, + "关闭": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じる" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "닫기" + } + } + } + }, + "其他": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기타" + } + } + } + }, + "内容": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Content" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内容" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내용" + } + } + } + }, + "再拍一项": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Capture another" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "もう一項目を撮影" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "한 항목 더 촬영" + } + } + } + }, + "再问一轮 · 让 AI 从新角度追问": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask again · Let AI follow up from a new angle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "もう一度質問 · AIが新たな視点で深掘り" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "한 번 더 질문 · AI가 새로운 관점에서 다시 물어봐요" + } + } + } + }, + "最近记录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recent records" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最近の記録" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최근 기록" + } + } + } + }, + "出生年份": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Birth year" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "生まれた年" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "출생 연도" + } + } + } + }, + "分享": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Share" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "共有" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공유" + } + } + } + }, + "切换后整个 App 立即生效,无需重启。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Changes take effect across the app immediately — no restart needed." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "切り替えるとアプリ全体に即時反映され、再起動は不要です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전환하면 앱 전체에 즉시 적용되며 재시작이 필요 없어요." + } + } + } + }, + "刚刚": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Just now" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "たった今" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "방금" + } + } + } + }, + "删除": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제" + } + } + } + }, + "删除后无法恢复。源记录(指标、症状等)不受影响。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This cannot be undone after deletion. Source records (indicators, symptoms, etc.) are not affected." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除すると元に戻せません。元の記録(指標、症状など)には影響しません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제 후에는 복구할 수 없어요. 원본 기록(지표, 증상 등)에는 영향을 주지 않아요." + } + } + } + }, + "删除提醒": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete reminder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リマインダーを削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림 삭제" + } + } + } + }, + "删除这项自定义指标": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete this custom indicator" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このカスタム指標を削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 사용자 지정 지표 삭제" + } + } + } + }, + "剧烈": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Severe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "激しい" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "심함" + } + } + } + }, + "加": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추가" + } + } + } + }, + "加一项": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add one" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一項目を追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "한 항목 추가" + } + } + } + }, + "加载模型…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading model…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モデルを読み込み中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모델 불러오는 중…" + } + } + } + }, + "化验单": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lab report" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検査結果" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검사 결과" + } + } + } + }, + "化验单 · 处方": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lab report · Prescription" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検査結果 · 処方" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검사 결과 · 처방" + } + } + } + }, + "化验项快捷(不进趋势)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lab item shortcuts (not in Trends)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検査項目クイック(トレンドに含めない)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검사 항목 바로가기(추세에 미포함)" + } + } + } + }, + "单位": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "単位" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "단위" + } + } + } + }, + "单位(可选)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unit (optional)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "単位(任意)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "단위(선택)" + } + } + } + }, + "单张报告": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Single report" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1枚のレポート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "단일 리포트" + } + } + } + }, + "原图与数据库采用系统级文件加密,随设备锁屏受到保护。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Original images and the database use system-level file encryption and are protected when your device is locked." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元画像とデータベースはシステムレベルのファイル暗号化を使用し、デバイスのロックに連動して保護されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원본 이미지와 데이터베이스는 시스템 수준의 파일 암호화를 사용하며 기기 잠금에 따라 보호됩니다." + } + } + } + }, + "参考": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reference" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "参考" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "참고" + } + } + } + }, + "参考 %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reference %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "参考 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "참고 %@" + } + } + } + }, + "参考范围": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reference range" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "基準範囲" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "참고 범위" + } + } + } + }, + "参考范围(可选)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reference range (optional)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "基準範囲(任意)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "참고 범위(선택)" + } + } + } + }, + "取消": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + } + } + }, + "取消(图片不保留)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel (image not kept)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル(画像は保存しません)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소(이미지 미보관)" + } + } + } + }, + "取消识别 · 改为手动录入": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel recognition · Switch to manual entry" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認識をキャンセル · 手動入力に切り替え" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인식 취소 · 수동 입력으로 전환" + } + } + } + }, + "可选开启 Face ID 启动锁,进一步保护隐私。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Optionally enable Face ID Lock for added privacy protection." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Face IDロックを任意で有効にすると、プライバシーをさらに保護できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Face ID 잠금을 선택적으로 켜면 개인정보를 더욱 보호할 수 있어요." + } + } + } + }, + "右上角 + 新建一个": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tap + in the top right to create one" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右上の + から新規作成します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽 위 +로 새로 만들어 보세요" + } + } + } + }, + "名称": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름" + } + } + } + }, + "周期提醒": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recurring reminder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "定期リマインダー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주기 알림" + } + } + } + }, + "周末": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Weekend" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "週末" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주말" + } + } + } + }, + "四": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "木" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "목" + } + } + } + }, + "回到今天": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to today" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日に戻る" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오늘로 돌아가기" + } + } + } + }, + "图例": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Legend" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "凡例" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "범례" + } + } + } + }, + "图标": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Icon" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アイコン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아이콘" + } + } + } + }, + "图片保存失败,手动录入并保留文本": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to save image; entered manually with text kept" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像の保存に失敗しました。手動入力してテキストを保持します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이미지 저장에 실패하여 수동 입력하고 텍스트를 보관합니다" + } + } + } + }, + "在「+ 新建 → 指标记录 → %@」记录一次": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Record one via \"+ New → Indicator → %@\"" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「+ 新規 → 指標記録 → %@」で一度記録します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "\"+ 새로 만들기 → 지표 기록 → %@\"에서 한 번 기록하세요" + } + } + } + }, + "在内存较小的旧机型上,App 仍可用于手动记录、归档与查看,": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On older devices with less memory, the app can still be used for manual recording, archiving, and viewing," + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メモリの少ない旧機種でも、手動記録・アーカイブ・閲覧にアプリを利用できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메모리가 적은 구형 기기에서도 수동 기록, 보관, 조회에 앱을 사용할 수 있어요," + } + } + } + }, + "在这里输入主诉……": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your chief complaint here…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここに主訴を入力してください……" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여기에 주요 증상을 입력하세요……" + } + } + } + }, + "在适用法律允许的范围内,因使用本 App 或依赖其中内容所产生的后果,由使用者自行承担。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "To the extent permitted by applicable law, you bear sole responsibility for any consequences arising from using this app or relying on its content." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "適用法令で認められる範囲において、本アプリの使用またはその内容への依存により生じた結果については、利用者ご自身が責任を負うものとします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "관련 법률이 허용하는 범위 내에서, 본 앱의 사용 또는 그 내용에 대한 의존으로 발생하는 결과는 사용자 본인이 책임집니다." + } + } + } + }, + "基本": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Basics" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "基本" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본" + } + } + } + }, + "基本信息": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Basic info" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "基本情報" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 정보" + } + } + } + }, + "处方": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Prescription" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "処方" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "처방" + } + } + } + }, + "备注(可选)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Note (optional)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メモ(任意)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메모(선택)" + } + } + } + }, + "备注(可选)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Note (optional)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メモ(任意)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메모(선택)" + } + } + } + }, + "复制": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복사" + } + } + } + }, + "多页报告": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Multi-page report" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "複数ページのレポート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여러 페이지 리포트" + } + } + } + }, + "多页报告可连拍,系统自动透视校正": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Capture multi-page reports in a series; perspective is auto-corrected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "複数ページのレポートは連続撮影でき、遠近補正は自動で行われます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여러 페이지 리포트는 연속 촬영할 수 있으며 원근 보정은 자동으로 처리돼요" + } + } + } + }, + "失败 · 重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed · Retry" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗 · 再試行" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패 · 재시도" + } + } + } + }, + "失败:%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패: %@" + } + } + } + }, + "女": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Female" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "女性" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여성" + } + } + } + }, + "如:协和医院": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g., Peking Union Medical College Hospital" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "例:協和病院" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예: 셰허병원" + } + } + } + }, + "如:春季年度体检": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g., Spring annual checkup" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "例:春の年次健康診断" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예: 봄 연례 건강검진" + } + } + } + }, + "如:母亲 高血压": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g., Mother, hypertension" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "例:母 高血圧" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예: 어머니 고혈압" + } + } + } + }, + "如:缬沙坦 80mg qd": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g., Valsartan 80mg qd" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "例:バルサルタン 80mg qd" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예: 발사르탄 80mg qd" + } + } + } + }, + "如:青霉素": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g., Penicillin" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "例:ペニシリン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예: 페니실린" + } + } + } + }, + "如出现身体不适或紧急情况,请及时就医或拨打当地急救电话,请勿依赖本 App 进行判断。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "If you feel unwell or face an emergency, seek medical care promptly or call your local emergency number. Do not rely on this app for judgment." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "体調不良や緊急時には、速やかに医療機関を受診するか、お住まいの地域の救急番号に電話してください。判断を本アプリに頼らないでください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "몸이 불편하거나 응급 상황이 발생하면 즉시 진료를 받거나 현지 응급 전화로 연락하세요. 판단을 본 앱에 의존하지 마세요." + } + } + } + }, + "完成": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "완료" + } + } + } + }, + "完成 ✓": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done ✓" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了 ✓" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "완료 ✓" + } + } + } + }, + "完整保存整份报告(可多页)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save the entire report (multiple pages supported)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レポート全体を保存します(複数ページ対応)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리포트 전체를 저장해요(여러 페이지 지원)" + } + } + } + }, + "家族史": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Family history" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "家族歴" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가족력" + } + } + } + }, + "密码": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パスワード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비밀번호" + } + } + } + }, + "对准异常的那一行就好 · 不用拍整张": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Just aim at the abnormal row · No need to capture the whole sheet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "異常のある行に合わせるだけでOK · 全体を撮る必要はありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이상이 있는 행만 맞추면 돼요 · 전체를 찍을 필요는 없어요" + } + } + } + }, + "导入失败:%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import failed: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポートに失敗しました:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가져오기 실패: %@" + } + } + } + }, + "导出": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Export" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エクスポート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내보내기" + } + } + } + }, + "导出身体档案": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Export health profile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "身体プロファイルをエクスポート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건강 프로필 내보내기" + } + } + } + }, + "将追加:": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Will append:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加されます:" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추가됩니다:" + } + } + } + }, + "尚未设置": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not set" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미설정" + } + } + } + }, + "尿酸 UA": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uric acid UA" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "尿酸 UA" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "요산 UA" + } + } + } + }, + "岁": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "yrs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "歳" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세" + } + } + } + }, + "工作日": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Weekday" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "平日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "평일" + } + } + } + }, + "已保存 %lld 页(端侧加密)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved %lld pages (on-device encryption)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lldページを保存しました(オンデバイス暗号化)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld페이지 저장됨(온디바이스 암호화)" + } + } + } + }, + "已关闭": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "꺼짐" + } + } + } + }, + "已取消": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancelled" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセルしました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소됨" + } + } + } + }, + "已处理 %.1fs · 比云端快 4.2×": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Processed in %.1fs · 4.2× faster than the cloud" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%.1f秒で処理 · クラウドより4.2倍高速" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%.1f초 처리 · 클라우드보다 4.2배 빠름" + } + } + } + }, + "已复制": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copied" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピーしました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복사됨" + } + } + } + }, + "已完成": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "완료" + } + } + } + }, + "已就绪": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ready" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "準備完了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "준비됨" + } + } + } + }, + "已开启 · %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オン · %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "켜짐 · %@" + } + } + } + }, + "已拍 1 页": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1 page captured" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1ページ撮影しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "1페이지 촬영됨" + } + } + } + }, + "已拍页面(3 页)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Captured pages (3)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "撮影したページ(3ページ)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "촬영한 페이지(3페이지)" + } + } + } + }, + "已持续 %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ongoing for %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@継続中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 지속됨" + } + } + } + }, + "已经有一个叫「%@」的自定义指标": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A custom indicator named “%@” already exists" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「%@」という名前のカスタム指標がすでにあります" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "“%@”(이)라는 사용자 지정 지표가 이미 있어요" + } + } + } + }, + "已裁剪": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cropped" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トリミングしました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잘림" + } + } + } + }, + "已识别边框 · 将自动透视校正": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edges detected · perspective will be corrected automatically" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "枠を検出 · 自動で台形補正します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "테두리 인식됨 · 원근 자동 보정" + } + } + } + }, + "已采纳": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Applied" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "採用しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "적용됨" + } + } + } + }, + "已隐藏 %lld": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld hidden" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld件を非表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld개 숨김" + } + } + } + }, + "已隐藏的长期监测": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hidden long-term monitoring" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "非表示の長期モニタリング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "숨겨진 장기 모니터링" + } + } + } + }, + "常见症状": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Common symptoms" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "よくある症状" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "흔한 증상" + } + } + } + }, + "年": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Year" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "年" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "년" + } + } + } + }, + "并以原始报告 / 化验单为准。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Always refer to the original report / lab results." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "必ず元のレポート / 検査結果をご確認ください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "반드시 원본 리포트 / 검사 결과를 기준으로 하세요." + } + } + } + }, + "康康": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kangkang" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kangkang" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kangkang" + } + } + } + }, + "康康 · 本地优先的健康档案 · %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kangkang · Local-first health records · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kangkang · ローカルファーストの健康記録 · %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kangkang · 로컬 우선 건강 기록 · %@" + } + } + } + }, + "康康 已锁定": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kangkang is locked" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kangkangはロックされています" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kangkang이 잠겼어요" + } + } + } + }, + "康康是一款以本地优先为设计原则的个人健康影像档案工具。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kangkang is a personal health imaging archive tool designed with a local-first principle." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kangkangは、ローカルファーストを設計原則とする個人向け健康画像アーカイブツールです。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kangkang은 로컬 우선을 설계 원칙으로 하는 개인 건강 영상 아카이브 도구입니다." + } + } + } + }, + "康康是一款健康信息记录与参考工具,并非医疗器械,不提供医疗诊断、用药或剂量建议、急诊判断等医疗服务。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kangkang is a tool for recording and referencing health information, not a medical device. It does not provide medical services such as diagnosis, medication or dosage advice, or emergency assessment." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kangkangは健康情報を記録・参照するためのツールであり、医療機器ではありません。医療診断、薬剤・用量の助言、救急判断などの医療サービスは提供しません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kangkang은 건강 정보를 기록하고 참고하기 위한 도구이며 의료기기가 아닙니다. 의료 진단, 약물·용량 권고, 응급 판단 등의 의료 서비스를 제공하지 않습니다." + } + } + } + }, + "开启一个提醒": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set a reminder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リマインダーを設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리마인더 설정" + } + } + } + }, + "开始": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작" + } + } + } + }, + "开始 AI 解读": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start AI interpretation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AI解読を開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI 해석 시작" + } + } + } + }, + "开始一个持续症状,结束时再点结束": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start an ongoing symptom, then tap End when it stops" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "継続する症状を開始し、終わったら「終了」をタップします" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지속되는 증상을 시작하고, 끝나면 종료를 누르세요" + } + } + } + }, + "开始于": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Started" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開始日時" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작 시점" + } + } + } + }, + "开始时间": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start time" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開始時間" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작 시간" + } + } + } + }, + "开始记录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start recording" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "記録を開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록 시작" + } + } + } + }, + "异常项": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Abnormal items" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "異常項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이상 항목" + } + } + } + }, + "异常项快拍": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Abnormal item quick capture" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "異常項目クイック撮影" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이상 항목 빠른 촬영" + } + } + } + }, + "强度": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Severity" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "強さ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "강도" + } + } + } + }, + "归档一份\n关键报告": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archive a\nkey report" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "重要なレポートを\n保管する" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주요 리포트를\n보관하기" + } + } + } + }, + "归档信息": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archive info" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保管情報" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보관 정보" + } + } + } + }, + "当前用药": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current medications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在の服薬" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 복용 약" + } + } + } + }, + "影像报告": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Imaging report" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像レポート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영상 리포트" + } + } + } + }, + "影像档案": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Imaging archive" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像アーカイブ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영상 아카이브" + } + } + } + }, + "待下载": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "To download" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード待ち" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 대기" + } + } + } + }, + "待输入": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "To enter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力待ち" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력 대기" + } + } + } + }, + "心率": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Heart rate" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "心拍数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "심박수" + } + } + } + }, + "快超时了,>%llds 会自动转为手动录入": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Almost timed out; after %llds it switches to manual entry automatically" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "まもなくタイムアウトします。%lld秒を超えると自動で手動入力に切り替わります" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "곧 시간이 초과돼요. %lld초를 넘으면 자동으로 수동 입력으로 전환돼요" + } + } + } + }, + "性别": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sex" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "性別" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "성별" + } + } + } + }, + "总胆固醇": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Total cholesterol" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "総コレステロール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "총콜레스테롤" + } + } + } + }, + "总胆固醇 TC 5.42 mmol/L": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Total cholesterol TC 5.42 mmol/L" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "総コレステロール TC 5.42 mmol/L" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "총콜레스테롤 TC 5.42 mmol/L" + } + } + } + }, + "总项": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Total" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "総項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 항목" + } + } + } + }, + "慢病(影响参考范围与 AI 解读)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chronic conditions (affect reference ranges and AI interpretation)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "慢性疾患(基準範囲とAI解釈に影響します)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "만성질환(참고 범위와 AI 해석에 영향)" + } + } + } + }, + "我的": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Me" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マイ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마이" + } + } + } + }, + "我的导出": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "My Exports" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マイエクスポート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내 내보내기" + } + } + } + }, + "我的导出 · %lld 份": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "My Exports · %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マイエクスポート · %lld件" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내 내보내기 · %lld건" + } + } + } + }, + "我的报告档案": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "My Report Archive" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マイレポートアーカイブ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내 리포트 보관함" + } + } + } + }, + "我知道了,开始拍": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Got it, start capturing" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "了解、撮影を始める" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알겠어요, 촬영 시작" + } + } + } + }, + "或者自己写": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Or write it yourself" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "または自分で入力" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "또는 직접 입력" + } + } + } + }, + "所有照片以 AES 加密存于本机沙盒。康康 服务端无法访问。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All photos are AES-encrypted and stored in this device's sandbox. Kangkang's servers cannot access them." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべての写真はAES暗号化され、本機のサンドボックスに保存されます。Kangkangのサーバーからはアクセスできません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 사진은 AES로 암호화되어 기기 샌드박스에 저장됩니다. Kangkang 서버는 접근할 수 없습니다." + } + } + } + }, + "所选文件夹缺少 config.json,不是有效的模型目录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The selected folder is missing config.json and is not a valid model directory" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したフォルダにconfig.jsonがなく、有効なモデルディレクトリではありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택한 폴더에 config.json이 없어 유효한 모델 디렉터리가 아닙니다" + } + } + } + }, + "手动填一项指标(免拍照)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter an indicator manually (no photo)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指標を手動で入力(撮影不要)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지표 직접 입력(촬영 없이)" + } + } + } + }, + "报告": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Report" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レポート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리포트" + } + } + } + }, + "报告归档": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archive Report" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レポートをアーカイブ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리포트 보관" + } + } + } + }, + "报告日期": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Report date" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レポート日付" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리포트 날짜" + } + } + } + }, + "报告类型": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Report type" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レポートの種類" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리포트 유형" + } + } + } + }, + "拍一张化验单,VL 自动识别": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Snap a lab report, and VL recognizes it automatically" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検査結果を撮影すると、VLが自動で認識します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검사 결과를 촬영하면 VL이 자동 인식해요" + } + } + } + }, + "拍报告的小贴士": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tips for photographing reports" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レポート撮影のヒント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리포트 촬영 팁" + } + } + } + }, + "拍摄报告": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Photograph Report" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レポートを撮影" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리포트 촬영" + } + } + } + }, + "拍摄识别": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Capture & Recognize" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "撮影して認識" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "촬영 인식" + } + } + } + }, + "拍照归档:拍体检 / 化验报告,尝试识别为结构化指标并存档": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Photo archive: capture a checkup or lab report, try to recognize it into structured indicators, and archive it" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "写真アーカイブ:健康診断または検査結果を撮影し、構造化された指標として認識を試み、保存します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사진 보관: 건강검진 또는 검사 결과를 촬영해 구조화된 지표로 인식을 시도하고 보관해요" + } + } + } + }, + "拍照识别报告 → 结构化指标": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Capture report → structured indicators" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レポートを撮影して認識 → 構造化された指標" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리포트 촬영 인식 → 구조화된 지표" + } + } + } + }, + "持续": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ongoing" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "継続" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지속" + } + } + } + }, + "持续 %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ongoing %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "継続 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지속 %@" + } + } + } + }, + "持续中": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ongoing" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "継続中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지속 중" + } + } + } + }, + "指标": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Indicator" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指標" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지표" + } + } + } + }, + "指标 · %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Indicator · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指標 · %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지표 · %@" + } + } + } + }, + "指标(%lld 项)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Indicators (%lld)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指標(%lld項目)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지표(%lld개)" + } + } + } + }, + "指标名": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Indicator name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指標名" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지표 이름" + } + } + } + }, + "指标名 · 可编辑": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Indicator name · editable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指標名 · 編集可能" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지표 이름 · 편집 가능" + } + } + } + }, + "指标异常": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Abnormal indicator" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指標の異常" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지표 이상" + } + } + } + }, + "指标记录": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Indicator Record" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指標の記録" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지표 기록" + } + } + } + }, + "按%lld岁调整": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Adjusted for age %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld歳に合わせて調整" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld세 기준으로 조정" + } + } + } + }, + "推理中…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Inferring…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "推論中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추론 중…" + } + } + } + }, + "推理失败:%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Inference failed: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "推論に失敗しました:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추론 실패: %@" + } + } + } + }, + "推理自检": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Inference Self-Check" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "推論セルフチェック" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추론 자가 점검" + } + } + } + }, + "推荐": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recommended" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "推奨" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추천" + } + } + } + }, + "推荐 iPhone 15 Pro / Pro Max 及之后发布的机型(含 iPhone 16 系列)。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recommended: iPhone 15 Pro / Pro Max and later models (including the iPhone 16 series)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "iPhone 15 Pro / Pro Max 以降のモデル(iPhone 16シリーズを含む)を推奨します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "iPhone 15 Pro / Pro Max 및 이후 출시 모델(iPhone 16 시리즈 포함)을 추천합니다." + } + } + } + }, + "推荐拍清晰的%@,多页报告可一次完成扫描。原图与解读全部本地加密保存,永不上传。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "We recommend capturing a clear %@; multi-page reports can be scanned all at once. Original images and interpretations are all encrypted and stored locally, never uploaded." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "鮮明な%@の撮影を推奨します。複数ページのレポートは一度にスキャンできます。原本画像と解釈はすべてローカルで暗号化して保存され、アップロードされることはありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선명한 %@을(를) 촬영하는 것을 추천해요. 여러 페이지 리포트는 한 번에 스캔할 수 있어요. 원본 이미지와 해석은 모두 기기에서 암호화 저장되며 절대 업로드되지 않아요." + } + } + } + }, + "提取指标 · 共 28 项": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Extracted indicators · 28 total" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "抽出した指標 · 全28項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추출 지표 · 총 28개" + } + } + } + }, + "提醒在录入「指标记录 · 长期监测」时开启": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable when entering “Indicator Record · Long-term Monitoring”" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「指標の記録 · 長期モニタリング」の入力時にオンにします" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "「지표 기록 · 장기 모니터링」 입력 시 켜세요" + } + } + } + }, + "摘要(可选)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Summary (optional)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "概要(任意)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "요약(선택)" + } + } + } + }, + "撰写报告": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Write Report" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レポートを作成" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리포트 작성" + } + } + } + }, + "支持删除记录,数据将从本机移除;数据保存在本机,不依赖云端备份。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Records can be deleted, and the data is removed from this device. Data is stored locally and does not rely on cloud backup." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "記録の削除に対応しており、データは本機から削除されます。データは本機に保存され、クラウドバックアップに依存しません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록 삭제를 지원하며, 데이터는 기기에서 제거됩니다. 데이터는 기기에 저장되며 클라우드 백업에 의존하지 않습니다." + } + } + } + }, + "收缩": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Systolic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "収縮" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수축" + } + } + } + }, + "收缩 ": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Systolic " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "収縮 " + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수축 " + } + } + } + }, + "收缩 / 舒张": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Systolic / Diastolic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "収縮 / 拡張" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수축 / 이완" + } + } + } + }, + "收缩压": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Systolic pressure" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "収縮期血圧" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수축기 혈압" + } + } + } + }, + "数值": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Value" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "数値" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수치" + } + } + } + }, + "数据保存在本设备:卸载 App 或删除数据后可能无法恢复,重要资料请自行留存原件。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Data is stored on this device: it may be unrecoverable after you uninstall the app or delete the data, so please keep your own originals of important documents." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "データは本端末に保存されます:アプリのアンインストールやデータ削除後は復元できない場合があります。重要な資料は原本をご自身で保管してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "데이터는 이 기기에 저장됩니다: 앱을 삭제하거나 데이터를 지운 후에는 복구하지 못할 수 있으니, 중요한 자료는 원본을 직접 보관하세요." + } + } + } + }, + "整体摘记": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Overall Summary" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "全体メモ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 요약" + } + } + } + }, + "整张图": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Whole image" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像全体" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 이미지" + } + } + } + }, + "整页入框,避免裁切到指标": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fit the whole page in frame to avoid cropping any indicators" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ全体をフレームに収め、指標が切れないようにしてください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지표가 잘리지 않도록 페이지 전체를 화면에 담으세요" + } + } + } + }, + "文件大小校验失败(预期 %lld,实际 %lld)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File size verification failed (expected %1$lld, got %2$lld)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルサイズの検証に失敗しました(予期 %1$lld、実際 %2$lld)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파일 크기 검증에 실패했습니다(예상 %1$lld, 실제 %2$lld)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "文件大小校验失败(预期 %1$lld,实际 %2$lld)" + } + } + } + }, + "文字日记": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Text diary" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テキスト日記" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "텍스트 일기" + } + } + } + }, + "文本解读 · 趋势 / 问答": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Text interpretation · Trends / Q&A" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テキスト解説 · トレンド / 質問応答" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "텍스트 해석 · 추세 / 질의응답" + } + } + } + }, + "新建": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로 만들기" + } + } + } + }, + "新建自定义指标": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New custom indicator" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム指標を作成" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 지표 만들기" + } + } + } + }, + "日": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sun" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일" + } + } + } + }, + "日记": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Diary" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日記" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일기" + } + } + } + }, + "早安": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Good morning" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "おはようございます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "좋은 아침이에요" + } + } + } + }, + "时间": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Time" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "時間" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시간" + } + } + } + }, + "昨天": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Yesterday" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "昨日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "어제" + } + } + } + }, + "显示": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시" + } + } + } + }, + "晚上好": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Good evening" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "こんばんは" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "좋은 저녁이에요" + } + } + } + }, + "暂停下载": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pause download" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを一時停止" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 일시정지" + } + } + } + }, + "更新一下原文,再让 AI 继续追问": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update the original text, then let the AI continue with follow-up questions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元の文章を更新してから、AIに続けて質問させましょう" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원문을 업데이트한 다음 AI가 이어서 질문하도록 하세요" + } + } + } + }, + "月": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Month" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "月" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "월" + } + } + } + }, + "未下载": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not downloaded" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未ダウンロード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 안 됨" + } + } + } + }, + "未使用": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unused" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未使用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미사용" + } + } + } + }, + "未开始": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not started" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작 안 함" + } + } + } + }, + "未知错误:%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown error: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なエラー:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없는 오류: %@" + } + } + } + }, + "未设置": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not set" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정 안 함" + } + } + } + }, + "未选": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not selected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未選択" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택 안 함" + } + } + } + }, + "未选日": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No date selected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日付未選択" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "날짜 미선택" + } + } + } + }, + "本 App 仅供健康信息记录与参考,不能替代专业医疗意见。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This app is for recording and referencing health information only and cannot replace professional medical advice." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本アプリは健康情報の記録と参考のみを目的としており、専門的な医療アドバイスに代わるものではありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "본 앱은 건강 정보의 기록 및 참고용으로만 제공되며, 전문적인 의료 조언을 대신할 수 없습니다." + } + } + } + }, + "本周": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This week" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今週" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이번 주" + } + } + } + }, + "本地 AI · 正在解读": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On-device AI · Interpreting" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンデバイスAI · 解析中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온디바이스 AI · 해석 중" + } + } + } + }, + "本地 AI 功能(拍照识别、解读、问答)需要约 8GB 内存,": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On-device AI features (photo recognition, interpretation, Q&A) require about 8GB of memory," + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンデバイスAI機能(写真認識、解説、質問応答)には約8GBのメモリが必要です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온디바이스 AI 기능(사진 인식, 해석, 질의응답)에는 약 8GB의 메모리가 필요합니다," + } + } + } + }, + "本地 AI 模型体积较大(约 4GB),首次使用需联网下载,建议在 Wi-Fi 环境进行;": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The on-device AI model is large (about 4GB) and requires an internet download on first use; we recommend doing this over Wi-Fi;" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンデバイスAIモデルはサイズが大きく(約4GB)、初回使用時にネット接続でのダウンロードが必要です。Wi-Fi環境での実施をおすすめします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온디바이스 AI 모델은 용량이 크며(약 4GB) 처음 사용할 때 인터넷 다운로드가 필요합니다. Wi-Fi 환경에서 진행하는 것을 권장합니다;" + } + } + } + }, + "本地 RAG · Qwen3 1.7B · 不上传任何数据": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On-device RAG · Qwen3 1.7B · No data uploaded" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンデバイスRAG · Qwen3 1.7B · データは一切アップロードしません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온디바이스 RAG · Qwen3 1.7B · 어떤 데이터도 업로드하지 않음" + } + } + } + }, + "本地优先的个人健康影像档案": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Local-first personal health imaging archive" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ローカル優先の個人健康画像アーカイブ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로컬 우선 개인 건강 영상 아카이브" + } + } + } + }, + "本地加密": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Local encryption" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ローカル暗号化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로컬 암호화" + } + } + } + }, + "本地处理 · 永不上传": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Processed on-device · Never uploaded" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンデバイス処理 · アップロードしません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온디바이스 처리 · 업로드하지 않음" + } + } + } + }, + "本地处理中 · 不会上传任何内容": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Processing on-device · Nothing will be uploaded" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンデバイスで処理中 · 何もアップロードしません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온디바이스에서 처리 중 · 아무것도 업로드하지 않음" + } + } + } + }, + "本地推理 · %.1f tok/s": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On-device inference · %.1f tok/s" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンデバイス推論 · %.1f tok/s" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온디바이스 추론 · %.1f tok/s" + } + } + } + }, + "本地识别中": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recognizing on-device" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンデバイスで認識中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온디바이스에서 인식 중" + } + } + } + }, + "本地识别中…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recognizing on-device…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンデバイスで認識中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온디바이스에서 인식 중…" + } + } + } + }, + "本地问答:基于你自己的档案问答,引用可点击回链到原记录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On-device Q&A: ask questions based on your own archive; citations are tappable and link back to the source record" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンデバイス質問応答:あなた自身のアーカイブに基づいて質問でき、引用をタップすると元の記録に戻れます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온디바이스 질의응답: 본인의 아카이브를 기반으로 질문하며, 인용을 탭하면 원본 기록으로 돌아갈 수 있습니다" + } + } + } + }, + "本月": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This month" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今月" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이번 달" + } + } + } + }, + "本机保存": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save to this device" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本体に保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기기에 저장" + } + } + } + }, + "本机提醒 · 不发任何数据": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On-device reminders · No data sent" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本体での通知 · データは送信しません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기기 내 알림 · 어떤 데이터도 전송하지 않음" + } + } + } + }, + "本机摘要": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On-device summary" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本体での要約" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기기 내 요약" + } + } + } + }, + "本次共检测 28 项,%@(血脂相关 2 项 + 尿酸)、%@(维生素 D)。整体趋势提示代谢风险有所抬升,建议优化饮食并复查血脂。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A total of 28 items were tested this time: %1$@ (2 lipid-related items + uric acid) and %2$@ (vitamin D). The overall trend suggests a slightly elevated metabolic risk; we recommend improving your diet and rechecking your blood lipids." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今回は計28項目を検査しました:%1$@(脂質関連2項目+尿酸)、%2$@(ビタミンD)。全体的な傾向として代謝リスクがやや上昇しており、食生活の改善と血中脂質の再検査をおすすめします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이번에 총 28개 항목을 검사했습니다: %1$@(지질 관련 2개 항목 + 요산), %2$@(비타민 D). 전반적인 추세는 대사 위험이 다소 높아진 것을 시사하므로, 식단 개선과 혈중 지질 재검사를 권장합니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "本次共检测 28 项,%1$@(血脂相关 2 项 + 尿酸)、%2$@(维生素 D)。整体趋势提示代谢风险有所抬升,建议优化饮食并复查血脂。" + } + } + } + }, + "本次已记录 %lld 项": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld items recorded this time" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今回 %lld 項目を記録しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이번에 %lld개 항목을 기록했습니다" + } + } + } + }, + "本次持续": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Duration this time" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今回の継続時間" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이번 지속 시간" + } + } + } + }, + "本设备未设置 Face ID 或密码": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Face ID or a passcode is not set up on this device" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この端末ではFace IDまたはパスコードが設定されていません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 기기에 Face ID 또는 암호가 설정되어 있지 않습니다" + } + } + } + }, + "机构(可选)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Institution (optional)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "機関(任意)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기관(선택)" + } + } + } + }, + "查看原图": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View original image" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元の画像を見る" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원본 이미지 보기" + } + } + } + }, + "标题": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Title" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイトル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제목" + } + } + } + }, + "校验中": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verifying" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検証中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검증 중" + } + } + } + }, + "核对后一次保存": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review, then save all at once" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "確認してまとめて保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인 후 한 번에 저장" + } + } + } + }, + "核对识别结果": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review recognition results" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認識結果を確認" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인식 결과 확인" + } + } + } + }, + "档案 · %lld": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Records · %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "記録 · %lld" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록 · %lld" + } + } + } + }, + "检索数据": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Searching data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "データを検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "데이터 검색" + } + } + } + }, + "模型加载失败:%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to load model: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モデルの読み込みに失敗しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모델 로드 실패: %@" + } + } + } + }, + "模型未就绪时 App 仍可使用,AI 功能会提示前往下载。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The app still works when the model isn't ready; AI features will prompt you to download it." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モデルが未準備でもアプリは使用でき、AI機能はダウンロードを案内します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모델이 준비되지 않아도 앱은 사용할 수 있으며, AI 기능은 다운로드를 안내합니다." + } + } + } + }, + "模型管理": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model Management" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モデル管理" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모델 관리" + } + } + } + }, + "模型约 %@,建议在 Wi-Fi 下下载。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The model is about %@; downloading over Wi-Fi is recommended." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モデルは約%@です。Wi-Fi環境でのダウンロードをおすすめします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모델은 약 %@입니다. Wi-Fi 환경에서 다운로드하는 것을 권장합니다." + } + } + } + }, + "模拟器没有摄像头,从相册选一张化验单/体检报告": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The simulator has no camera; pick a lab report / checkup report from your photos" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シミュレーターにはカメラがありません。アルバムから検査結果/健康診断レポートを選んでください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시뮬레이터에는 카메라가 없습니다. 앨범에서 검사 결과 / 건강검진 리포트를 선택하세요" + } + } + } + }, + "正在本地识别第 1 / 3 页…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recognizing page 1 / 3 on-device…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンデバイスで1 / 3ページ目を認識中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온디바이스로 1 / 3페이지 인식 중…" + } + } + } + }, + "正在本地识别第 2 / 3 页…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recognizing page 2 / 3 on-device…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンデバイスで2 / 3ページ目を認識中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온디바이스로 2 / 3페이지 인식 중…" + } + } + } + }, + "正在本地识别第 3 / 3 页…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recognizing page 3 / 3 on-device…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンデバイスで3 / 3ページ目を認識中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온디바이스로 3 / 3페이지 인식 중…" + } + } + } + }, + "正常": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "正常" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정상" + } + } + } + }, + "正常项": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Normal items" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "正常項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정상 항목" + } + } + } + }, + "每周 ": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Weekly " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "毎週 " + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "매주 " + } + } + } + }, + "每天": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Daily" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "毎日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "매일" + } + } + } + }, + "永久删除这份导出?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Permanently delete this export?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このエクスポートを完全に削除しますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 내보내기를 영구 삭제할까요?" + } + } + } + }, + "没有指标 — 点上方「加一项」补一行,或直接保存只存图片": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No indicators — tap “Add item” above to add a row, or save directly to keep only the image" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指標がありません — 上の「項目を追加」で1行追加するか、そのまま保存して画像のみ保存します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지표가 없습니다 — 위의 「항목 추가」를 눌러 한 줄 추가하거나, 그대로 저장하여 이미지만 저장하세요" + } + } + } + }, + "测试 PROMPT": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Test PROMPT" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テストPROMPT" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "테스트 PROMPT" + } + } + } + }, + "测量时间": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Measurement time" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "測定時刻" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "측정 시각" + } + } + } + }, + "添加你自己的长期监测项": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add your own long-term monitoring item" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "独自の長期モニタリング項目を追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "나만의 장기 모니터링 항목 추가" + } + } + } + }, + "点底部 + 号可以补一条": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tap the + at the bottom to add one" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "下の+ボタンで1件追加できます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "하단의 + 버튼으로 한 건 추가할 수 있어요" + } + } + } + }, + "点这里再开一次": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tap here to start again" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここをタップしてもう一度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여기를 눌러 다시 시작" + } + } + } + }, + "点这里完善你的资料": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tap here to complete your profile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここをタップしてプロフィールを入力" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여기를 눌러 프로필을 완성하세요" + } + } + } + }, + "状态": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Status" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "状態" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상태" + } + } + } + }, + "状态(按数值自动判)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Status (auto-determined by value)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "状態(数値で自動判定)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상태(수치로 자동 판정)" + } + } + } + }, + "理解意图": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Understanding intent" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "意図を理解" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "의도 파악" + } + } + } + }, + "甘油三酯": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Triglycerides" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中性脂肪" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중성지방" + } + } + } + }, + "甘油三酯 TG": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Triglycerides TG" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中性脂肪 TG" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중성지방 TG" + } + } + } + }, + "甘油三酯 TG 1.78 mmol/L": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Triglycerides TG 1.78 mmol/L" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中性脂肪 TG 1.78 mmol/L" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중성지방 TG 1.78 mmol/L" + } + } + } + }, + "生成失败:%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Generation failed: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "生成に失敗しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "생성 실패: %@" + } + } + } + }, + "生成报告": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Generate report" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レポートを生成" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리포트 생성" + } + } + } + }, + "生成整体摘要…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Generating overall summary…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "全体のサマリーを生成中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 요약 생성 중…" + } + } + } + }, + "生成新导出": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create new export" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいエクスポートを作成" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 내보내기 생성" + } + } + } + }, + "用 %lld 次": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Used %lld times" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld回使用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%lld회 사용" + } + } + } + }, + "用于自动判定 正常/偏高/偏低": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Used to auto-determine Normal / High / Low" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "正常/高い/低いの自動判定に使用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정상 / 높음 / 낮음 자동 판정에 사용" + } + } + } + }, + "男": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Male" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "男性" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "남성" + } + } + } + }, + "症状": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Symptom" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "症状" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "증상" + } + } + } + }, + "症状 · 已结束": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Symptom · Ended" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "症状 · 終了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "증상 · 종료됨" + } + } + } + }, + "症状 · 持续中": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Symptom · Ongoing" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "症状 · 継続中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "증상 · 진행 중" + } + } + } + }, + "症状开始": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Symptom onset" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "症状の開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "증상 시작" + } + } + } + }, + "症状持续中": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Symptom ongoing" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "症状継続中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "증상 진행 중" + } + } + } + }, + "空腹血糖": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fasting glucose" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "空腹時血糖" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공복 혈당" + } + } + } + }, + "空腹血糖 GLU": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fasting glucose GLU" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "空腹時血糖 GLU" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공복 혈당 GLU" + } + } + } + }, + "第 %lld 轮 · 基于你刚才更新的文本 · %lld 条": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Round %1$lld · Based on the text you just updated · %2$lld items" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$lld回目 · 先ほど更新したテキストに基づく · %2$lld件" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$lld회차 · 방금 업데이트한 텍스트 기반 · %2$lld건" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "第 %1$lld 轮 · 基于你刚才更新的文本 · %2$lld 条" + } + } + } + }, + "第 1 轮 · %lld 条": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Round 1 · %lld items" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1回目 · %lld件" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "1회차 · %lld건" + } + } + } + }, + "管理用药、复查、监测的周期提醒": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage recurring reminders for meds, follow-ups, and monitoring" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "服薬・再検査・モニタリングの定期リマインダーを管理" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복약·재검사·모니터링 정기 리마인더 관리" + } + } + } + }, + "类型": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "種類" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "유형" + } + } + } + }, + "糖化血红蛋白": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "HbA1c" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "HbA1c" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "HbA1c" + } + } + } + }, + "系统:iOS 17 或更新版本。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System: iOS 17 or later." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム: iOS 17以降。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템: iOS 17 이상." + } + } + } + }, + "纸张铺平,避免反光、阴影": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Flatten the paper and avoid glare or shadows" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "紙を平らにし、反射や影を避けてください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "종이를 평평하게 펴고 반사와 그림자를 피하세요" + } + } + } + }, + "结束": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "End" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "종료" + } + } + } + }, + "结束并保存": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "End and Save" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了して保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "종료하고 저장" + } + } + } + }, + "结束时再来点结束": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add more when ending" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了時にもう少し追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "종료할 때 좀 더 추가" + } + } + } + }, + "结束时间": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "End time" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了時刻" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "종료 시간" + } + } + } + }, + "结束症状": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "End symptom" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "症状を終了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "증상 종료" + } + } + } + }, + "结构化失败:%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Structuring failed: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "構造化に失敗しました:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "구조화 실패: %@" + } + } + } + }, + "结果解析失败:%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to parse result: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "結果の解析に失敗しました:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "결과 파싱 실패: %@" + } + } + } + }, + "给医生看的就诊摘要": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Visit summary for your doctor" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "医師に見せる受診サマリー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "의사에게 보여줄 진료 요약" + } + } + } + }, + "继续下载": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue download" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを続ける" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드 계속" + } + } + } + }, + "继续拍下一项": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Capture next item" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次の項目を撮影" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 항목 촬영" + } + } + } + }, + "维生素 D": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vitamin D" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ビタミンD" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비타민 D" + } + } + } + }, + "编辑": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "編集" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "편집" + } + } + } + }, + "编辑「%@」": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit “%@”" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「%@」を編集" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "“%@” 편집" + } + } + } + }, + "编辑/删除": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit / Delete" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "編集/削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "편집 / 삭제" + } + } + } + }, + "自定义": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정" + } + } + } + }, + "自定义慢病": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom chronic condition" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム慢性疾患" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 만성질환" + } + } + } + }, + "自定义指标": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom indicator" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム指標" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 지표" + } + } + } + }, + "自定义指标会出现在「+ 指标记录 → 长期监测」的 grid 里,可设提醒、进趋势": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom indicators appear in the grid under “+ Indicator Record → Long-term Monitoring,” where you can set reminders and add them to Trends" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム指標は「+ 指標記録 → 長期モニタリング」のグリッドに表示され、リマインダー設定やトレンドへの追加ができます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 지표는 “+ 지표 기록 → 장기 모니터링” 그리드에 표시되며, 알림 설정과 추세 추가가 가능합니다" + } + } + } + }, + "舒张": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Diastolic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拡張" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이완" + } + } + } + }, + "舒张 ": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Diastolic " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拡張 " + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이완 " + } + } + } + }, + "舒张压": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Diastolic pressure" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拡張期血圧" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이완기 혈압" + } + } + } + }, + "范围 %@ %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Range %1$@ %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "範囲 %1$@ %2$@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "범위 %1$@ %2$@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "new", + "value": "范围 %1$@ %2$@" + } + } + } + }, + "血压": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blood pressure" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "血圧" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "혈압" + } + } + } + }, + "血型": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blood type" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "血液型" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "혈액형" + } + } + } + }, + "血氧": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blood oxygen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "血中酸素" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "혈중 산소" + } + } + } + }, + "解锁康康,查看你的健康档案": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unlock Kangkang to view your health records" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kangkangのロックを解除して健康記録を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Kangkang 잠금을 해제하고 건강 기록을 확인하세요" + } + } + } + }, + "让 AI 帮我想想还能记什么": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Let AI suggest what else to record" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "他に記録できることをAIに考えてもらう" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI에게 더 기록할 것을 제안받기" + } + } + } + }, + "记录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Records" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "記録" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록" + } + } + } + }, + "记录什么?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "What to record?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "何を記録しますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "무엇을 기록할까요?" + } + } + } + }, + "记录会按时间归类显示": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Records are grouped and shown by time" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "記録は時系列で分類して表示されます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록은 시간별로 분류되어 표시됩니다" + } + } + } + }, + "记录指标": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Record indicator" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指標を記録" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지표 기록" + } + } + } + }, + "记录提醒": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Record reminder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "記録リマインダー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록 알림" + } + } + } + }, + "记录症状": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Log a symptom" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "症状を記録" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "증상 기록" + } + } + } + }, + "记录身体状态 · 可让 AI 多轮辅助查漏补缺": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Record your physical state · AI can assist over multiple turns to fill in the gaps" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "体調を記録 · AIが複数回にわたって抜け漏れを補助します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "신체 상태 기록 · AI가 여러 차례 도와 빠진 부분을 채워줘요" + } + } + } + }, + "记录身体状态、用药、感受 · 可让 AI 辅助": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Record your physical state, medications, and feelings · AI can assist" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "体調、服薬、感じたことを記録 · AIが補助します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "신체 상태, 복약, 느낌을 기록 · AI가 도와줘요" + } + } + } + }, + "设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The on-device AI model tries to rephrase technical indicators into plain-language explanations, helping you record and review your health changes." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンデバイスのAIモデルが専門的な指標をわかりやすい説明に言い換え、健康の変化を記録・振り返るお手伝いをします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "온디바이스 AI 모델이 전문 지표를 알기 쉬운 설명으로 풀어내어 건강 변화를 기록하고 돌아보도록 도와줘요." + } + } + } + }, + "设备要求": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Device requirements" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイス要件" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기기 요구 사항" + } + } + } + }, + "识别全程在本地,图片不会上传": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recognition runs entirely on-device; images are never uploaded" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認識はすべてオンデバイスで行われ、画像はアップロードされません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인식은 전부 온디바이스에서 이루어지며 이미지는 업로드되지 않아요" + } + } + } + }, + "识别失败:%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recognition failed: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認識に失敗しました:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인식 실패: %@" + } + } + } + }, + "识别没有读出指标,请手动补充": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No indicators were recognized; please add them manually" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指標を読み取れませんでした。手動で追加してください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지표를 인식하지 못했어요. 직접 추가해 주세요" + } + } + } + }, + "识别用时 0.4s · 本地": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recognized in 0.4s · on-device" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認識時間 0.4秒 · オンデバイス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인식 시간 0.4초 · 온디바이스" + } + } + } + }, + "识别超时(>%llds)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recognition timed out (>%llds)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認識がタイムアウトしました(>%llds)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인식 시간 초과 (>%llds)" + } + } + } + }, + "识别超时(>%llds),保留旧编辑": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recognition timed out (>%llds); keeping previous edits" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認識がタイムアウトしました(>%llds)。以前の編集を保持します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인식 시간 초과 (>%llds), 이전 편집을 유지합니다" + } + } + } + }, + "识别超时(>%llds),先手动录入": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recognition timed out (>%llds); enter manually for now" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認識がタイムアウトしました(>%llds)。まず手動で入力してください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인식 시간 초과 (>%llds), 우선 직접 입력하세요" + } + } + } + }, + "该测%@了": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Time to measure %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@を測る時間です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@를 측정할 시간이에요" + } + } + } + }, + "语言": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "言語" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "언어" + } + } + } + }, + "说说你想给医生看什么": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tell us what you want to show your doctor" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "医師に見せたいことを教えてください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "의사에게 보여주고 싶은 내용을 알려주세요" + } + } + } + }, + "请选择名为 %@ 的文件夹": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Please select the folder named %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ という名前のフォルダを選択してください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 라는 이름의 폴더를 선택하세요" + } + } + } + }, + "谷丙转氨酶": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "ALT" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ALT" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "ALT" + } + } + } + }, + "谷丙转氨酶、空腹血糖、糖化血红蛋白…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "ALT, fasting glucose, HbA1c…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ALT、空腹時血糖、HbA1c…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "ALT, 공복 혈당, HbA1c…" + } + } + } + }, + "谷草转氨酶": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AST" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AST" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AST" + } + } + } + }, + "超过参考上限 0.44,属轻度偏高。建议关注饮食结构(减少动物脂肪摄入),3 个月内复查。若家族有心血管病史,可与医生沟通是否需要药物干预。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "0.44 above the upper reference limit, mildly elevated. Consider adjusting your diet (reduce animal fat intake) and recheck within 3 months. If you have a family history of cardiovascular disease, talk to your doctor about whether medication is needed." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "基準上限を0.44超えており、軽度に高めです。食生活の見直し(動物性脂肪の摂取を減らす)を検討し、3か月以内に再検査してください。心血管疾患の家族歴がある場合は、薬物療法が必要かどうか医師にご相談ください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "참고 상한을 0.44 초과한 경미한 높음이에요. 식습관 점검(동물성 지방 섭취 줄이기)을 고려하고 3개월 이내에 재검사하세요. 심혈관 질환 가족력이 있다면 약물 치료가 필요한지 의사와 상담하세요." + } + } + } + }, + "超过参考上限 0.44,属轻度偏高。点击展开详细解读 ›": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "0.44 above the upper reference limit, mildly elevated. Tap to see the full explanation ›" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "基準上限を0.44超えており、軽度に高めです。タップして詳しい解説を表示 ›" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "참고 상한을 0.44 초과한 경미한 높음이에요. 탭하여 자세한 해설 보기 ›" + } + } + } + }, + "超过参考上限 0.44。建议关注饮食结构,3 个月内复查。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "0.44 above the upper reference limit. Consider adjusting your diet and recheck within 3 months." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "基準上限を0.44超えています。食生活の見直しを検討し、3か月以内に再検査してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "참고 상한을 0.44 초과했어요. 식습관을 점검하고 3개월 이내에 재검사하세요." + } + } + } + }, + "趋势": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Trends" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トレンド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추세" + } + } + } + }, + "跟随系统": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System default" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システムに従う" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 설정 따름" + } + } + } + }, + "跳过": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skip" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건너뛰기" + } + } + } + }, + "身体档案 · 历史导出": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Health profile · Export history" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "身体プロフィール · エクスポート履歴" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "신체 프로필 · 내보내기 기록" + } + } + } + }, + "身高": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Height" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "身長" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키" + } + } + } + }, + "轻微": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mild" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "軽度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "경미" + } + } + } + }, + "载脂蛋白 A1": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apolipoprotein A1" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アポリポタンパクA1" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아포지단백 A1" + } + } + } + }, + "载脂蛋白 A1 1.42 g/L": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apolipoprotein A1 1.42 g/L" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アポリポタンパクA1 1.42 g/L" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아포지단백 A1 1.42 g/L" + } + } + } + }, + "载脂蛋白 B": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apolipoprotein B" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アポリポタンパクB" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아포지단백 B" + } + } + } + }, + "载脂蛋白 B 1.04 g/L": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apolipoprotein B 1.04 g/L" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アポリポタンパクB 1.04 g/L" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아포지단백 B 1.04 g/L" + } + } + } + }, + "输入密码": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter password" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パスワードを入力" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비밀번호 입력" + } + } + } + }, + "过敏史": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allergy history" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アレルギー歴" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알레르기 이력" + } + } + } + }, + "运行中…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 중…" + } + } + } + }, + "运行推理自检": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run inference self-check" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "推論セルフチェックを実行" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추론 자가 점검 실행" + } + } + } + }, + "返回修改": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to edit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "戻って編集" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "돌아가서 수정" + } + } + } + }, + "还没有任何记录\n点底部 + 号开始": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No records yet\nTap the + at the bottom to start" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "まだ記録がありません\n下の + をタップして始めましょう" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 기록이 없어요\n하단의 + 를 눌러 시작하세요" + } + } + } + }, + "还没有任何记录,点底部 + 号开始第一条": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No records yet. Tap the + at the bottom to add your first one." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "まだ記録がありません。下の + をタップして最初の記録を始めましょう。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 기록이 없어요. 하단의 + 를 눌러 첫 기록을 시작하세요." + } + } + } + }, + "还没有导出过\n回到记录页右上角生成一份": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing exported yet\nGo back to Records and tap the top-right to create one" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "まだエクスポートしていません\n記録ページに戻り、右上から作成しましょう" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 내보낸 적이 없어요\n기록 페이지로 돌아가 우측 상단에서 하나 만드세요" + } + } + } + }, + "还没有自定义指标": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No custom indicators yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム指標がまだありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 지정 지표가 아직 없어요" + } + } + } + }, + "还没有记录提醒\n去「+ 指标记录」录入时打开": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No record reminders yet\nTurn one on while logging in “+ Indicator”" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "記録リマインダーがまだありません\n「+ 指標の記録」で入力する際にオンにしましょう" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록 알림이 아직 없어요\n「+ 지표 기록」에서 입력할 때 켜세요" + } + } + } + }, + "这一天还没有记录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No records for this day yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この日の記録はまだありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 날의 기록이 아직 없어요" + } + } + } + }, + "这个类别下没有记录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No records in this category" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このカテゴリーに記録がありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 카테고리에 기록이 없어요" + } + } + } + }, + "这是什么": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "What is this" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "これは何ですか" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이게 뭔가요" + } + } + } + }, + "进行中": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ongoing" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "進行中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "진행 중" + } + } + } + }, + "通俗解读:设备本地 AI 把指标与趋势转述为易懂的说明": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Plain-language explanation: on-device AI turns your indicators and trends into easy-to-understand notes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "わかりやすい解説:オンデバイスAIが指標やトレンドを理解しやすい説明に言い換えます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "쉬운 해설: 온디바이스 AI가 지표와 추세를 이해하기 쉬운 설명으로 바꿔줘요" + } + } + } + }, + "采纳": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apply" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "採用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "적용" + } + } + } + }, + "重新生成": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Regenerate" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再生成" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 생성" + } + } + } + }, + "重新识别": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Re-scan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再認識" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 인식" + } + } + } + }, + "重新识别没有读出新指标": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Re-scanning found no new indicators" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再認識しても新しい指標は読み取れませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 인식해도 새 지표를 읽지 못했어요" + } + } + } + }, + "重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再試行" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 시도" + } + } + } + }, + "长期监测": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Long-term monitoring" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "長期モニタリング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "장기 모니터링" + } + } + } + }, + "长期监测(进趋势)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Long-term monitoring (added to Trends)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "長期モニタリング(トレンドに追加)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "장기 모니터링 (추세에 추가)" + } + } + } + }, + "长期趋势:关注的指标可生成折线图和简要解读": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Long-term trends: indicators you follow can generate line charts and brief explanations" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "長期トレンド:注目している指標から折れ線グラフと簡単な解説を生成できます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "장기 추세: 주목하는 지표로 꺾은선 그래프와 간단한 해설을 만들 수 있어요" + } + } + } + }, + "隐私优先:健康数据不上传、无需注册账号": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Privacy first: health data is never uploaded, no account required" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プライバシー優先:健康データはアップロードされず、アカウント登録も不要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프라이버시 우선: 건강 데이터는 업로드되지 않고 계정 등록도 필요 없어요" + } + } + } + }, + "隐私保护": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Privacy protection" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プライバシー保護" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프라이버시 보호" + } + } + } + }, + "隐藏": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "非表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "숨기기" + } + } + } + }, + "预计耗时 5–8 秒 · 端侧 SME2 加速": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Estimated 5–8 sec · on-device SME2 acceleration" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "所要時間の目安 5~8秒 · オンデバイスSME2アクセラレーション" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예상 소요 5–8초 · 온디바이스 SME2 가속" + } + } + } + }, + "频率": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Frequency" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "頻度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "빈도" + } + } + } + }, + "餐后血糖": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Postprandial glucose" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "食後血糖" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "식후 혈당" + } + } + } + }, + "验证你本人,开启 Face ID 启动锁": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Verify it's you to enable Face ID Lock" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本人確認をして、Face IDロックを有効にします" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "본인을 인증하여 Face ID 잠금을 켜세요" + } + } + } + }, + "高密度脂蛋白": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "HDL cholesterol" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "HDLコレステロール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "HDL 콜레스테롤" + } + } + } + }, + "高密度脂蛋白 1.21 mmol/L": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "HDL cholesterol 1.21 mmol/L" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "HDLコレステロール 1.21 mmol/L" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "HDL 콜레스테롤 1.21 mmol/L" + } + } + } + }, + "头痛": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Headache" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "頭痛" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "두통" + } + } + } + }, + "咳嗽": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cough" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "咳" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기침" + } + } + } + }, + "腹痛": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Abdominal pain" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "腹痛" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복통" + } + } + } + }, + "发烧": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fever" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "発熱" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "발열" + } + } + } + }, + "恶心": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nausea" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "吐き気" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메스꺼움" + } + } + } + }, + "失眠": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Insomnia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不眠" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "불면" + } + } + } + }, + "疲劳": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fatigue" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "疲労" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "피로" + } + } + } + }, + "关节痛": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Joint pain" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "関節痛" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "관절통" + } + } + } + }, + "餐后 2h": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Postprandial 2h" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "食後2時間" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "식후 2시간" + } + } + } + }, + "无参考范围": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No reference range" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "基準範囲なし" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "참고 범위 없음" + } + } + } + }, + "高血压": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hypertension" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "高血圧" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "고혈압" + } + } + } + }, + "糖尿病": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Diabetes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "糖尿病" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "당뇨병" + } + } + } + }, + "冠心病": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Coronary heart disease" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "冠動脈疾患" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "관상동맥질환" + } + } + } + }, + "高血脂": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hyperlipidemia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "高脂血症" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "고지혈증" + } + } + } + }, + "甲状腺疾病": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thyroid disease" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "甲状腺疾患" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "갑상선 질환" + } + } + } + }, + "哮喘": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Asthma" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "喘息" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "천식" + } + } + } + }, + "慢性肾病": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chronic kidney disease" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "慢性腎臓病" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "만성 신장병" + } + } + } + }, + "抑郁/焦虑": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Depression / Anxiety" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "うつ・不安" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "우울/불안" + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/康康/Models/HealthExport.swift b/康康/Models/HealthExport.swift new file mode 100644 index 0000000..77b9310 --- /dev/null +++ b/康康/Models/HealthExport.swift @@ -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) + "…" + } +} diff --git a/康康/Models/Models.swift b/康康/Models/Models.swift index dcb609e..475032e 100644 --- a/康康/Models/Models.swift +++ b/康康/Models/Models.swift @@ -10,11 +10,11 @@ enum ReportType: String, Codable, CaseIterable { var label: String { switch self { - case .checkup: return "体检报告" - case .lab: return "化验单" - case .imaging: return "影像报告" - case .prescription: return "处方" - case .other: return "其他" + case .checkup: return String(appLoc: "体检报告") + case .lab: return String(appLoc: "化验单") + case .imaging: return String(appLoc: "影像报告") + case .prescription: return String(appLoc: "处方") + case .other: return String(appLoc: "其他") } } } @@ -250,12 +250,12 @@ final class MetricReminder { var isEveryDay: Bool { Set(weekdays) == Set(1...7) } var frequencyLabel: String { - if !enabled { return "已关闭" } - if isEveryDay { return "每天" } - if weekdays.isEmpty { return "未选日" } - let names = ["日", "一", "二", "三", "四", "五", "六"] + if !enabled { return String(appLoc: "已关闭") } + if isEveryDay { return String(appLoc: "每天") } + if weekdays.isEmpty { return String(appLoc: "未选日") } + let names = [String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六")] let sorted = weekdays.sorted() - return "每周 " + sorted.map { names[$0 - 1] }.joined() + return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined() } var timeLabel: String { diff --git a/康康/Models/UserProfile.swift b/康康/Models/UserProfile.swift index 6406a3e..39a7389 100644 --- a/康康/Models/UserProfile.swift +++ b/康康/Models/UserProfile.swift @@ -57,9 +57,9 @@ extension UserProfile { var label: String { switch self { - case .male: return "男" - case .female: return "女" - case .undisclosed: return "不愿透露" + case .male: return String(appLoc: "男") + case .female: return String(appLoc: "女") + case .undisclosed: return String(appLoc: "不愿透露") } } } @@ -78,7 +78,7 @@ extension UserProfile { /// 给 ProfileCard 一行预览:"38岁 · 男 · 175cm · 68kg · A型" var summaryLine: String { var parts: [String] = [] - if let age { parts.append("\(age)岁") } + if let age { parts.append(String(appLoc: "\(age)岁")) } if sex != .undisclosed { parts.append(sex.label) } if let h = heightCM { parts.append("\(h)cm") } if let w = weightKG { @@ -87,7 +87,7 @@ extension UserProfile { : String(format: "%.1fkg", w) parts.append(s) } - if !bloodTypeRaw.isEmpty { parts.append("\(bloodTypeRaw)型") } + if !bloodTypeRaw.isEmpty { parts.append(String(appLoc: "\(bloodTypeRaw)型")) } return parts.joined(separator: " · ") } diff --git a/康康/RootView.swift b/康康/RootView.swift index 21c425e..2cddbc8 100644 --- a/康康/RootView.swift +++ b/康康/RootView.swift @@ -4,10 +4,10 @@ enum TjTab: String, Hashable, CaseIterable { case home, records, trend, me var label: String { switch self { - case .home: return "主页" - case .records: return "记录" - case .trend: return "趋势" - case .me: return "我的" + case .home: return String(appLoc: "主页") + case .records: return String(appLoc: "记录") + case .trend: return String(appLoc: "趋势") + case .me: return String(appLoc: "我的") } } var icon: String { @@ -18,6 +18,15 @@ enum TjTab: String, Hashable, CaseIterable { case .me: return "person.circle" } } + /// 屏上从左到右的位置,用于决定页面 push 过渡的方向。 + var index: Int { + switch self { + case .home: return 0 + case .records: return 1 + case .trend: return 2 + case .me: return 3 + } + } } enum ActiveFlow: Identifiable { @@ -27,26 +36,39 @@ enum ActiveFlow: Identifiable { struct RootView: View { @State private var tab: TjTab = .home + /// 页面 push 过渡的来向:切到右侧 tab 时从 trailing 推入,切到左侧时从 leading 推入。 + @State private var pushEdge: Edge = .trailing @State private var showRecordSheet = false @State private var activeFlow: ActiveFlow? @State private var showSymptomStart = false @State private var showDiary = false @State private var showIndicator = false + @State private var showReminders = false + + /// 统一的 tab 切换入口:按方向设定 pushEdge,再带动画改 tab。 + /// 所有改 tab 的地方都走这里,保证过渡方向正确。 + private func select(_ newTab: TjTab) { + guard newTab != tab else { return } + pushEdge = newTab.index > tab.index ? .trailing : .leading + withAnimation(.easeInOut(duration: 0.26)) { tab = newTab } + } var body: some View { VStack(spacing: 0) { Group { switch tab { - case .home: HomeView(onTapArchive: { tab = .records }) + case .home: HomeView(onTapArchive: { select(.records) }) case .records: ArchiveListView() case .trend: TrendsView() case .me: MeView() } } .frame(maxWidth: .infinity, maxHeight: .infinity) + .id(tab) + .transition(.push(from: pushEdge)) TabBar(active: tab, - onTap: { tab = $0 }, + onTap: { select($0) }, onTapRecord: { showRecordSheet = true }) } .background(Tj.Palette.sand.ignoresSafeArea()) @@ -60,6 +82,7 @@ struct RootView: View { case .symptom: showSymptomStart = true case .diary: showDiary = true case .indicator: showIndicator = true + case .reminder: showReminders = true } } } @@ -73,6 +96,10 @@ struct RootView: View { .sheet(isPresented: $showIndicator) { IndicatorQuickSheet() } + .sheet(isPresented: $showReminders) { + // 列表页依赖外层 NavigationStack 提供标题栏;sheet 形态补「完成」按钮。 + NavigationStack { RemindersListView(presentedAsSheet: true) } + } #if os(iOS) .fullScreenCover(item: $activeFlow) { flow in switch flow { @@ -100,6 +127,8 @@ private struct TabBar: View { let onTap: (TjTab) -> Void let onTapRecord: () -> Void + @Namespace private var indicatorNS + private let cornerRadius: CGFloat = 22 private let slotHeight: CGFloat = 34 @@ -115,6 +144,7 @@ private struct TabBar: View { .padding(.top, 10) .padding(.bottom, 6) .background(barBackground) + .animation(.spring(response: 0.35, dampingFraction: 0.75), value: active) } private var barBackground: some View { @@ -143,6 +173,7 @@ private struct TabBar: View { Capsule() .fill(Tj.Palette.sand2) .frame(width: 44, height: slotHeight - 6) + .matchedGeometryEffect(id: "tabIndicator", in: indicatorNS) } Image(systemName: t.icon) .font(.system(size: 18, weight: isActive ? .semibold : .regular)) @@ -188,7 +219,7 @@ private struct TabBar: View { .buttonStyle(TabPressStyle()) } } - +// 你好 private struct TabPressStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label diff --git a/康康/Security/AppLock.swift b/康康/Security/AppLock.swift new file mode 100644 index 0000000..d75ff0e --- /dev/null +++ b/康康/Security/AppLock.swift @@ -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 + } +} diff --git a/康康/Security/AppLockContainer.swift b/康康/Security/AppLockContainer.swift new file mode 100644 index 0000000..f5b9134 --- /dev/null +++ b/康康/Security/AppLockContainer.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// 包裹 `RootView` 的薄薄一层:监听 scenePhase,按需在内容之上盖锁屏 / 隐私遮罩。 +/// RootView 本身零改动(对齐红线 §10.7「不重构现有 Tab 骨架」)。 +/// +/// 用法(KangkangApp):`AppLockContainer { RootView() }`。 +struct AppLockContainer: 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) + } + } +} diff --git a/康康/Security/LockScreenView.swift b/康康/Security/LockScreenView.swift new file mode 100644 index 0000000..6c34415 --- /dev/null +++ b/康康/Security/LockScreenView.swift @@ -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() +} diff --git a/康康/Services/CaptureService.swift b/康康/Services/CaptureService.swift index 7342896..33efdd3 100644 --- a/康康/Services/CaptureService.swift +++ b/康康/Services/CaptureService.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import SwiftData /// VL 解析结果(已结构化,可直接喂 SwiftData 模型构造)。 /// 与 Indicator/Report 字段近似但解耦 —— 这样 prompt schema 调整不污染数据层。 @@ -40,16 +41,14 @@ struct ParsedReport: Sendable { /// CaptureService 错误 — UI 决定怎么呈现(回退表单 vs 重试)。 enum CaptureError: Error, LocalizedError { case modelNotReady - case writeAssetFailed case inferenceFailed(String) case parseFailed(String) var errorDescription: String? { switch self { - case .modelNotReady: return "VL 模型尚未就绪" - case .writeAssetFailed: return "图片保存失败" - case .inferenceFailed(let m): return "识别失败:\(m)" - case .parseFailed(let m): return "结构化失败:\(m)" + case .modelNotReady: return String(appLoc: "VL 模型尚未就绪") + case .inferenceFailed(let m): return String(appLoc: "识别失败:\(m)") + case .parseFailed(let m): return String(appLoc: "结构化失败:\(m)") } } } @@ -60,38 +59,36 @@ actor CaptureService { static let shared = CaptureService() private init() {} - /// 写图 + VL 推理 + 解析 → ParsedReport。 - /// 任何阶段失败,都抛 CaptureError;UI 接住后切到「手动录入」表单。 - /// - Returns: (ParsedReport, [FileVault.SavedAsset]) 元组, - /// SavedAsset 列表用于后续构造 Asset @Model。 - func analyze(images: [UIImage]) async throws - -> (parsed: ParsedReport, assets: [FileVault.SavedAsset]) { + /// 对已写入 Vault 的 Asset 跑 VL,返回结构化 ParsedReport。 + /// 用于: + /// - UnifiedCaptureFlow 的初次识别(UI 先写图、再调本方法,失败/取消都能保留 assets 走手动录入) + /// - 录入表单顶部的「重新识别」按钮 + /// - C2「重新解读」(W5) + /// SwiftData 写回由调用方(MainActor)负责,见 `Report.applyReanalyzed(_:in:)`。 + /// 不直接接 @Model 类型,避免把非 Sendable 引用抛过 actor 边界。 + func reanalyze(assets: [FileVault.SavedAsset]) async throws -> ParsedReport { + try await runVL(on: assets) + } - // 1. 写图到 Vault(全程加密目录) - let assets: [FileVault.SavedAsset] + /// VL 推理 + JSON 解析的纯阶段。assets 必须已写入 Vault。 + private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport { do { - assets = try images.map { try FileVault.shared.writeJPEG($0) } + try await AIRuntime.shared.prepareVL() } catch { - throw CaptureError.writeAssetFailed + throw CaptureError.modelNotReady } - - // 2. VL 推理 - try await AIRuntime.shared.prepareVL() let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) } let raw: String do { raw = try await AIRuntime.shared.analyzeReport( imageURLs: urls, - prompt: VLPrompts.reportExtraction + prompt: VLPrompts.reportExtraction() ) } catch { throw CaptureError.inferenceFailed("\(error)") } - - // 3. JSON 解析(带容错:可能包含围栏 / 前后文字) do { - let parsed = try CaptureService.parseReportJSON(raw, pageCount: assets.count) - return (parsed, assets) + return try CaptureService.parseReportJSON(raw, pageCount: assets.count) } catch let CaptureError.parseFailed(msg) { throw CaptureError.parseFailed(msg) } catch { @@ -136,7 +133,7 @@ actor CaptureService { } return ParsedReport( - title: title.isEmpty ? "拍摄识别" : title, + title: title.isEmpty ? String(appLoc: "拍摄识别") : title, typeRaw: typeRaw, reportDate: reportDate, institution: institution, @@ -216,3 +213,53 @@ actor CaptureService { return .init(name: name, value: value, unit: unit, range: range, status: status) } } + +// MARK: - Report ↔ CaptureService 桥接(MainActor 侧) +// +// CaptureService 是 actor,不能直接收 Report(@Model 非 Sendable)。 +// 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() + } +} diff --git a/康康/Services/DiaryAssistService.swift b/康康/Services/DiaryAssistService.swift new file mode 100644 index 0000000..ca3cf6c --- /dev/null +++ b/康康/Services/DiaryAssistService.swift @@ -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. 去 ...(复用 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) + } +} diff --git a/康康/Services/HealthExportService.swift b/康康/Services/HealthExportService.swift new file mode 100644 index 0000000..c70e05b --- /dev/null +++ b/康康/Services/HealthExportService.swift @@ -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 { + 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 + ) + + // —— 流式去 ... 兜底 —— + // Prompt 里已加 Qwen3 的 `/no_think`,但模型偶尔仍带 thinking。 + // 用「全文累计 + 每 chunk 重清 + diff yield」: + // - thinking 阶段,UI 看到的 generated 始终为空 + // - 看到 后,真实内容流式出现 + 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( + 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( + 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( + 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( + 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: - 标签清理 + + /// 在全文累计上做一次性清理,返回应展示给用户的干净文本。 + /// 用「累计 + 重清 + diff yield」方式调用,确保: + /// - 配对 `...` 整段移除(包括空 think 块) + /// - 未闭合 `...`(还没等到闭标签)→ 全部暂存,等闭标签出现再放 + /// - Qwen3 偶尔只吐 `` 闭标签 → 它之前的内容也当 thinking 丢弃 + /// - 头部空白 trim,避免 `## 标题` 前面有多余空行 + static func stripThinkBlocks(_ raw: String) -> String { + var s = raw + + // 1. 反复删配对 ...(包括 think 块体为空的情况) + while let openR = s.range(of: ""), + let closeR = s.range(of: "", range: openR.upperBound..") { + s = String(s[..") { + s = String(s[closeR.upperBound...]) + } + + // 4. 顶部空白 trim + while let first = s.first, first.isWhitespace { + s.removeFirst() + } + return s + } +} diff --git a/康康/Services/ModelDownloadService.swift b/康康/Services/ModelDownloadService.swift index 8ef3e26..b56f872 100644 --- a/康康/Services/ModelDownloadService.swift +++ b/康康/Services/ModelDownloadService.swift @@ -132,7 +132,7 @@ final class ModelDownloadService { states[kind] = DownloadState(phase: .ready, receivedBytes: total, totalBytes: total, bytesPerSecond: 0) } else { - states[kind] = DownloadState(phase: .failed(message ?? "下载失败"), + states[kind] = DownloadState(phase: .failed(message ?? String(appLoc: "下载失败")), receivedBytes: completedBytes(for: kind), totalBytes: total, bytesPerSecond: 0) } diff --git a/康康/Services/ReminderService.swift b/康康/Services/ReminderService.swift index e45264c..a5d03a9 100644 --- a/康康/Services/ReminderService.swift +++ b/康康/Services/ReminderService.swift @@ -55,8 +55,8 @@ enum ReminderService { let center = UNUserNotificationCenter.current() let content = UNMutableNotificationContent() - content.title = "该测\(reminder.displayName)了" - content.body = "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次" + content.title = String(appLoc: "该测\(reminder.displayName)了") + content.body = String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次") content.sound = .default content.threadIdentifier = "kangkang.reminder.\(reminder.metricId)" diff --git a/康康Tests/ModelManifestTests.swift b/康康Tests/ModelManifestTests.swift index 1bf0cb4..91717c1 100644 --- a/康康Tests/ModelManifestTests.swift +++ b/康康Tests/ModelManifestTests.swift @@ -8,8 +8,8 @@ struct ModelManifestTests { #expect(ModelManifest.files(for: .llm).count == 9) } - @Test func vlHasElevenFunctionalFiles() { - #expect(ModelManifest.files(for: .vl).count == 11) + @Test func vlHasFourteenFunctionalFiles() { + #expect(ModelManifest.files(for: .vl).count == 14) } @Test func llmTotalBytesMatchesManifest() { @@ -17,7 +17,7 @@ struct ModelManifestTests { } @Test func vlTotalBytesMatchesManifest() { - #expect(ModelManifest.totalBytes(for: .vl) == 3_089_710_883) + #expect(ModelManifest.totalBytes(for: .vl) == 3_109_729_929) } @Test func excludesReadmeAndGitattributes() {