缺少代码差异信息,无法生成具体的commit message。
请提供 "code differences" 的具体内容,以便我能够根据代码变更情况生成符合 Angular 规范的中文 commit message。
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
# 趋势大改 + 健康日历移至主页 — 设计文档
|
||||
|
||||
> 日期:2026-06-07 · 状态:已定方案(用户授权直接实现,免确认)
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前「趋势」Tab(`TrendsView.swift`)把两件事混在一起:
|
||||
|
||||
1. **健康日历**(月/年视图 + 当日详情)—— 占据页面上半部分。
|
||||
2. **长期监测折线图**(`seriesSection`)—— 页面下半部分。
|
||||
|
||||
两个问题:
|
||||
|
||||
- **日历放错了地方**。它是「总览记录情况」的入口,更适合放在主页(用户每天第一眼看的页面),而不是埋在趋势 Tab 里。
|
||||
- **趋势能力太弱**。`SeriesBucket.build` **只按 `seriesKey` 分桶**,因此只有 8 个长期监测预设(血压/血糖/体温…)和自定义指标能成图。所有**没有 seriesKey 的指标**——报告里解析出来的化验项、VL 快拍、自由输入——即使在多份报告里反复出现(如「血红蛋白」体检了 3 次),也**完全看不到趋势**。
|
||||
|
||||
### 目标
|
||||
|
||||
1. **健康日历移到主页**:主页新增一张紧凑的「健康日历」卡(当前周的横条 + 本月记录摘要),点击展开完整的月/年总览页(可切月视图/年视图、看当日详情)。
|
||||
2. **趋势 Tab 重构**:对**任何出现 ≥2 次的指标**(不限于长期监测预设)做时间序列查看。趋势页变成一个「可成趋势的指标」总览列表(分长期监测 / 化验指标两段),点任一项进入详情页:大图表 + 参考范围带 + 统计摘要(最新/最高/最低/平均/对比上次)+ 时间范围筛选 + 数据点列表(点击跳当日详情)。
|
||||
|
||||
### 非目标(本次不做)
|
||||
|
||||
- **AI 趋势解读**:需要 AIRuntime + TrendService 跑通,风险大、与本次「时间序列查看」正交。本次预留 UI 位但不接 LLM,留作后续。
|
||||
- 不改 SwiftData schema(无 @Model 字段变更,规避迁移丢数据风险)。
|
||||
- 不改 `Localizable.xcstrings`(新文案用 `String(appLoc: "中文")`,无对应词条时优雅回退到中文 key,符合既有大量用法;避免 xcstrings 噪声 diff)。
|
||||
- 不动 TabBar 5 槽骨架、不动录入流程。
|
||||
|
||||
## 2. 架构总览
|
||||
|
||||
```
|
||||
主页 HomeView
|
||||
└─ HomeCalendarCard(自包含 @Query) ← 新增
|
||||
当前周横条 + "本月 N 天有记录" + chevron
|
||||
tap → fullScreenCover(CalendarOverviewView) ← 新增(从 TrendsView 抽出)
|
||||
|
||||
趋势 TrendsView(重写)
|
||||
└─ TrendSeriesList:两段 section
|
||||
├─ 长期监测(kind=.monitor:seriesKey 分桶,含血压合并/自定义)
|
||||
└─ 化验指标趋势(kind=.lab:按 name+unit 分桶,≥2 点)
|
||||
每行 TrendRow:名称 + 最新值/状态 + mini sparkline + 条数·跨度
|
||||
tap → TrendDetailView(bucket) ← 新增
|
||||
大图表 + 参考范围带 + 时间范围 chips + 统计摘要 + 数据点列表
|
||||
数据点 tap → DayDetailSheet(date)(复用)
|
||||
```
|
||||
|
||||
数据层只扩展 `SeriesBucket.build`,UI 层新增 4 个文件、改 2 个文件、删 1 段。
|
||||
|
||||
## 3. 数据层:`SeriesBucket` 扩展
|
||||
|
||||
文件:`Features/Trends/SeriesBucket.swift`(改)
|
||||
|
||||
### 3.1 新增 `kind` 区分两段
|
||||
|
||||
```swift
|
||||
enum SeriesKind { case monitor, lab } // monitor=长期监测预设/自定义/血压;lab=按名分组的化验项
|
||||
|
||||
struct SeriesBucket: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let unit: String
|
||||
let lines: [SeriesLine]
|
||||
let latestDate: Date
|
||||
let kind: SeriesKind // 新增
|
||||
let sourceIndicatorIDs: [String] // 新增:本桶包含的 Indicator persistentModelID 字符串,供详情页定位来源
|
||||
// ... SeriesLine / Point 不变
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 `build` 流程改为两段
|
||||
|
||||
1. **seriesKey 段(原逻辑,kind=.monitor)**:血压合并、单系列预设、自定义。这些桶里的 Indicator 标记为「已消费」。
|
||||
2. **name 段(新,kind=.lab)**:对**所有没有 seriesKey** 的 Indicator,按 `normalizedKey(name, unit)` 分桶;每桶 ≥ `minPoints` 才保留。参考范围从该桶**最新一条** Indicator 的 `range` 字符串解析。
|
||||
3. 两段合并返回,各自按 `latestDate` 倒序。详情/列表按 `kind` 分段。
|
||||
|
||||
```swift
|
||||
// name 归一化:trim + 小写 + 折叠内部空白;unit 同样 trim。key = "name|unit"
|
||||
static func normalizedKey(name: String, unit: String) -> String
|
||||
|
||||
// 解析参考范围字符串 → ClosedRange<Double>?
|
||||
// 支持 "3.9-6.1" / "3.9~6.1" / "3.9 - 6.1";单边("<5.2"/">40"/"≤120")暂返回 nil(图不画带,正常)
|
||||
static func parseRange(_ raw: String) -> ClosedRange<Double>?
|
||||
```
|
||||
|
||||
> **去重**:有 seriesKey 的指标只进 monitor 段;无 seriesKey 的只进 lab 段。即使同名也不混。
|
||||
> **状态着色**:lab 段每个 Point 的 `status` 直接取 Indicator.status(已由 VL/录入判定),无需重算。
|
||||
|
||||
## 4. UI:健康日历移至主页
|
||||
|
||||
### 4.1 `CalendarOverviewView`(新文件 `Features/Calendar/CalendarOverviewView.swift`)
|
||||
|
||||
把现 `TrendsView` 的日历部分**原样抽出**为独立页:`modeSwitch`(月/年)+ `anchorBar`(◀ 年月 ▶)+ `calendarBody`(`CalendarMonthGrid`/`CalendarYearGrid`)+ `legend` + 月视图下的 `dayDetailInline`。
|
||||
|
||||
- 自带 `@Query`(indicators/reports/diaries/symptoms/profiles/customMetrics)。
|
||||
- 接收可选 `initialDate`(从主页某天进入时定位选中)。
|
||||
- 包在 `NavigationStack`,标题「健康日历」,右上「完成」关闭(用于 fullScreenCover)。
|
||||
- `CalendarMonthGrid` / `CalendarYearGrid` / `CalendarMarkers` / `DayDetailSheet` **不改**,直接复用。
|
||||
|
||||
### 4.2 `HomeCalendarCard`(新文件 `Features/Home/HomeCalendarCard.swift`)
|
||||
|
||||
自包含组件(对齐 `TodayRemindersCard` 模式):
|
||||
|
||||
- 自带 `@Query`,`CalendarData.build` 计算标记。
|
||||
- **当前周横条**:周一→周日 7 个紧凑日格(日期数字 + 标记圆点,复用 `DayMarks` 颜色规则:异常红 / 报告灰 / 正常绿 / 日记浅灰;有进行中症状则该格底色淡 amber)。今天高亮。
|
||||
- 顶部标题「健康日历」+ 右侧「本月 N 天有记录 ›」。
|
||||
- 整卡可点 → `fullScreenCover(CalendarOverviewView())`;点某一天 → 带 `initialDate` 进入。
|
||||
- 样式走 `.tjCard()`,放在主页 `greeting` 之后、`TodayRemindersCard` 之前。
|
||||
|
||||
### 4.3 `HomeView` 改动
|
||||
|
||||
`body` 的 VStack 在 `greeting` 后插入 `HomeCalendarCard()`。其余不动。
|
||||
|
||||
## 5. UI:趋势 Tab 重构
|
||||
|
||||
### 5.1 `TrendsView`(重写)
|
||||
|
||||
移除所有日历相关代码(已迁到主页)。新结构:
|
||||
|
||||
- header「趋势」。
|
||||
- 若无可成趋势的桶 → 空状态(「还没有可成趋势的指标 / 同一指标记录满 2 次后会出现在这里」)。
|
||||
- 否则两段:
|
||||
- **长期监测**(`kind == .monitor`):标题 + 计数。
|
||||
- **化验指标趋势**(`kind == .lab`):标题 + 计数。
|
||||
- 每段 `ForEach` 渲染 `TrendRow`,点击 push/present `TrendDetailView`。
|
||||
- 导航:`TrendsView` 包 `NavigationStack`,行用 `NavigationLink` 进详情(趋势 Tab 当前无 NavigationStack,新增之)。
|
||||
|
||||
### 5.2 `TrendRow`(新文件 `Features/Trends/TrendRow.swift`)
|
||||
|
||||
紧凑行:
|
||||
|
||||
- 左:指标名 + 「N 条 · 近 X 个月」副标题。
|
||||
- 中:mini sparkline(小号 `Chart`,height≈36,无坐标轴,单/双线,异常点红)。
|
||||
- 右:最新值 + 单位(异常红)+ chevron。
|
||||
- `.tjCard(bordered: true)`。
|
||||
|
||||
### 5.3 `TrendDetailView`(新文件 `Features/Trends/TrendDetailView.swift`)
|
||||
|
||||
接收 `bucket: SeriesBucket`,自带 `@Query` 用于数据点→来源跳转。
|
||||
|
||||
- **大图表**(height≈220):复用 `SeriesChartCard` 的绘制逻辑(参考范围带 + catmullRom 折线 + 点 + 双线图例),但加坐标轴、按所选时间范围裁剪 domain。
|
||||
- **时间范围 chips**:全部 / 近1年 / 近6月 / 近3月(仅当跨度 > 该范围才显示对应 chip)。切换裁剪图表点 + 重算 domain + 重算统计。
|
||||
- **统计摘要卡**:最新值(带状态)/ 对比上次(Δ 绝对值+百分比+升降箭头,跨参考范围边界标红)/ 最低 / 最高 / 平均 / 记录数 / 时间跨度。文案模板拼装,不走 LLM。
|
||||
- **AI 解读占位**:一行灰字「AI 解读即将上线」(预留,不接 LLM)。
|
||||
- **数据点列表**(倒序):日期 + 值+单位 + 状态箭头/徽章;`onTapGesture` → `DayDetailSheet(date:)`(复用现有 sheet,给出当天来源上下文)。
|
||||
- 标题 = bucket.title。
|
||||
|
||||
血压(双线)在详情页:统计摘要按「收缩/舒张」分别给最新值;列表每行显示「收缩/舒张」两值。
|
||||
|
||||
## 6. 受影响文件清单
|
||||
|
||||
**新增**
|
||||
- `Features/Calendar/CalendarOverviewView.swift`
|
||||
- `Features/Home/HomeCalendarCard.swift`
|
||||
- `Features/Trends/TrendRow.swift`
|
||||
- `Features/Trends/TrendDetailView.swift`
|
||||
|
||||
**修改**
|
||||
- `Features/Trends/SeriesBucket.swift`(加 kind / sourceIndicatorIDs / name 段 / parseRange)
|
||||
- `Features/Trends/TrendsView.swift`(删日历,重写为趋势列表 + NavigationStack)
|
||||
- `Features/Home/HomeView.swift`(插入 HomeCalendarCard)
|
||||
- `康康.xcodeproj/project.pbxproj`(新文件加入 target — 若用 file-system-synchronized group 则免改;需确认)
|
||||
|
||||
**不改**:`CalendarMonthGrid/YearGrid/Markers/DayDetailSheet`、`SeriesChartCard`(详情页复用其绘制思路,可抽 helper 或直接内置)、Models、xcstrings、RootView/TabBar、录入流程。
|
||||
|
||||
## 7. 验证
|
||||
|
||||
- 构建无错误/无新警告(`DEVELOPER_DIR` 指完整 Xcode,touch 强制重编 — 见记忆 build-from-cli)。
|
||||
- 主页:日历卡显示当前周标记;点卡进总览;月/年切换;点某天→当日详情正确。
|
||||
- 趋势:制造同名指标 ≥2 条(如手动录两次「血红蛋白」或两份报告同含一项)→ 出现在「化验指标趋势」段;预设监测仍在「长期监测」段;详情图表/统计/数据点跳转正确;血压双线正常。
|
||||
- 空状态:全新库时两个页面都给出友好空态。
|
||||
|
||||
## 8. 风险与回退
|
||||
|
||||
- **range 解析覆盖不全**:单边区间("<5.2")暂不画带,图仍可用 —— 可接受,后续增强。
|
||||
- **lab 段噪声**:同名但单位不同的指标会分成两桶(key 含 unit)—— 正确行为。若用户名字录入不一致(「血红蛋白」vs「Hb」)会分开 —— demo 可接受,不做模糊归并。
|
||||
- **pbxproj**:若新文件未自动入 target,构建会报 missing symbol;届时手动加 build file 引用。
|
||||
88
scripts/release-testflight.sh
Executable file
88
scripts/release-testflight.sh
Executable file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# 一键发布 TestFlight:archive → export → 上传 App Store Connect
|
||||
# 用法:
|
||||
# ./scripts/release-testflight.sh # 用当前 build 号
|
||||
# BUMP=1 ./scripts/release-testflight.sh # 自动递增 build 号后再发布
|
||||
# 认证:依赖 Xcode 已登录的 Apple ID(Xcode → Settings → Accounts)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PROJECT="${PROJECT:-$ROOT_DIR/康康.xcodeproj}"
|
||||
SCHEME="${SCHEME:-康康}"
|
||||
CONFIGURATION="${CONFIGURATION:-Release}"
|
||||
BUILD_DIR="$ROOT_DIR/build/Release"
|
||||
ARCHIVE_PATH="$BUILD_DIR/${SCHEME}.xcarchive"
|
||||
EXPORT_PATH="$BUILD_DIR/export"
|
||||
EXPORT_PLIST="$BUILD_DIR/ExportOptions.plist"
|
||||
TEAM_ID="${TEAM_ID:-F2C8C774FG}"
|
||||
|
||||
require_full_xcode() {
|
||||
local developer_dir
|
||||
developer_dir="$(xcode-select -p 2>/dev/null || true)"
|
||||
if [[ "$developer_dir" != *"/Xcode.app/Contents/Developer"* ]]; then
|
||||
cat >&2 <<EOF
|
||||
error: 当前 developer directory 不是完整 Xcode:
|
||||
${developer_dir:-<unset>}
|
||||
请先执行:
|
||||
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_full_xcode
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
# 可选:递增 build 号
|
||||
if [[ "${BUMP:-0}" == "1" ]]; then
|
||||
CURRENT=$(sed -n 's/.*CURRENT_PROJECT_VERSION = \([0-9]*\);.*/\1/p' "$PROJECT/project.pbxproj" | head -1)
|
||||
NEXT=$((CURRENT + 1))
|
||||
sed -i '' "s/CURRENT_PROJECT_VERSION = $CURRENT;/CURRENT_PROJECT_VERSION = $NEXT;/g" "$PROJECT/project.pbxproj"
|
||||
echo "==> Build 号: $CURRENT → $NEXT"
|
||||
fi
|
||||
|
||||
BUILD_NUM=$(sed -n 's/.*CURRENT_PROJECT_VERSION = \([0-9]*\);.*/\1/p' "$PROJECT/project.pbxproj" | head -1)
|
||||
VERSION=$(sed -n 's/.*MARKETING_VERSION = \([0-9.]*\);.*/\1/p' "$PROJECT/project.pbxproj" | head -1)
|
||||
echo "==> 发布 v$VERSION ($BUILD_NUM)"
|
||||
|
||||
echo "==> [1/3] Archive..."
|
||||
rm -rf "$ARCHIVE_PATH"
|
||||
xcodebuild archive \
|
||||
-project "$PROJECT" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration "$CONFIGURATION" \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-allowProvisioningUpdates
|
||||
|
||||
echo "==> [2/3] 生成 ExportOptions.plist..."
|
||||
cat > "$EXPORT_PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<key>destination</key>
|
||||
<string>upload</string>
|
||||
<key>teamID</key>
|
||||
<string>$TEAM_ID</string>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
echo "==> [3/3] Export 并上传 App Store Connect..."
|
||||
rm -rf "$EXPORT_PATH"
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-exportOptionsPlist "$EXPORT_PLIST" \
|
||||
-exportPath "$EXPORT_PATH" \
|
||||
-allowProvisioningUpdates
|
||||
|
||||
echo ""
|
||||
echo "✅ v$VERSION ($BUILD_NUM) 已上传。App Store Connect 处理完成后(约 5-15 分钟)即可在 TestFlight 分发。"
|
||||
echo " https://appstoreconnect.apple.com/apps"
|
||||
@@ -410,7 +410,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -462,7 +462,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -512,7 +512,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
@@ -539,7 +539,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
@@ -565,7 +565,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
@@ -591,7 +591,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
|
||||
@@ -66,7 +66,7 @@ JSON schema(严格):
|
||||
- range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象。
|
||||
- 无法识别的字段填空字符串(institution / summary)。
|
||||
- report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。
|
||||
- 不要发明指标。看不清的整行跳过。
|
||||
- 不要发明指标。数值看不清的整行跳过;但**没有参考范围不是跳过的理由**,结论页叙述式文字(如「总胆红素: 23.0(μmol/L)↑」)同样要提取,range 填 "",status 按箭头/「偏高」等标记判断。
|
||||
- 化验单一般 type = "lab",体检套餐 = "checkup"。
|
||||
|
||||
示例 1(化验单 · 单项):
|
||||
@@ -96,6 +96,7 @@ JSON schema(严格):
|
||||
|
||||
private static let regionExtractionTemplate: String = #"""
|
||||
你是一个医学化验单识别助手。下面给你的是一张化验单/体检报告的**局部照片**,通常只框住了一两行指标。
|
||||
照片内容可能是表格行,也可能是**结论页的叙述式文字**(如「九、检验:(1)总胆红素(TB): 23.0(μmol/L)↑」),两种都要提取。
|
||||
请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||
|
||||
今天的日期是 {{TODAY}}。
|
||||
@@ -114,22 +115,28 @@ JSON schema(严格):
|
||||
}
|
||||
|
||||
规则:
|
||||
- 只识别框内清楚可读的指标行,通常 1-3 行;看不清的整行跳过,绝不发明指标。
|
||||
- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"。
|
||||
- range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象。
|
||||
- 凡是「指标名 + 数值」清楚可读的,都要提取——**没有参考范围不是跳过的理由**。只有数值本身看不清才跳过,绝不发明指标。
|
||||
- status 判断优先级:① 文字旁的箭头或标记(↑/H/偏高 → "high",↓/L/偏低 → "low")最优先;② 没有标记时再用 value 与 range 比较;③ 都没有 → "normal"。
|
||||
- range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象;照片里没有参考范围就填 ""。
|
||||
- 识别不出单位/范围就填空字符串,不要编造。
|
||||
- name 用规范指标名;如果同一行重复出现指标名(如「总胆红素(TB): 总胆红素: 23.0」),只取一次。
|
||||
- 不要输出 title / institution / date / summary 等任何报告级字段,只输出 indicators 数组。
|
||||
|
||||
示例 1(单行):
|
||||
示例 1(表格单行):
|
||||
输入: 局部照片,清楚可读「低密度脂蛋白 3.84 mmol/L 参考 <3.40 ↑」
|
||||
输出:
|
||||
{"indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]}
|
||||
|
||||
示例 2(两行):
|
||||
示例 2(表格两行):
|
||||
输入: 局部照片,清楚可读「尿酸 486 μmol/L 208-428」与「空腹血糖 5.2 mmol/L 3.9-6.1」
|
||||
输出:
|
||||
{"indicators":[{"name":"尿酸","value":"486","unit":"μmol/L","range":"208 - 428","status":"high"},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]}
|
||||
|
||||
示例 3(结论页叙述式 · 无参考范围,只有箭头):
|
||||
输入: 局部照片,体检结论文字「九、检验: (1)总胆红素(TB): 总胆红素: 23.0(μmol/L)↑」,周围还有其他结论文字
|
||||
输出:
|
||||
{"indicators":[{"name":"总胆红素","value":"23.0","unit":"μmol/L","range":"","status":"high"}]}
|
||||
|
||||
现在请识别这张局部照片并输出 JSON:
|
||||
"""#
|
||||
}
|
||||
|
||||
291
康康/Features/Calendar/CalendarOverviewView.swift
Normal file
291
康康/Features/Calendar/CalendarOverviewView.swift
Normal file
@@ -0,0 +1,291 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
enum CalendarMode: String, CaseIterable, Identifiable {
|
||||
case month, year
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .month: return String(appLoc: "月")
|
||||
case .year: return String(appLoc: "年")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 健康日历总览页。从主页 HomeCalendarCard 进入。
|
||||
/// 月/年切换 + 上下导航 + 图例 + 月视图下方当日详情。日历组件复用 CalendarMonthGrid / CalendarYearGrid。
|
||||
struct CalendarOverviewView: View {
|
||||
/// 进入时定位到的日期(从主页某天点入);nil → 今天。
|
||||
var initialDate: Date = .now
|
||||
/// fullScreenCover 形态下的关闭回调。
|
||||
var onClose: (() -> Void)?
|
||||
|
||||
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
||||
private var indicators: [Indicator]
|
||||
|
||||
@Query(sort: \Report.reportDate, order: .reverse)
|
||||
private var reports: [Report]
|
||||
|
||||
@Query(sort: \DiaryEntry.createdAt, order: .reverse)
|
||||
private var diaries: [DiaryEntry]
|
||||
|
||||
@Query(sort: \Symptom.startedAt, order: .reverse)
|
||||
private var symptoms: [Symptom]
|
||||
|
||||
@State private var mode: CalendarMode = .month
|
||||
@State private var anchor: Date = .now
|
||||
@State private var selectedDate: Date = .now
|
||||
|
||||
private let calendar: Calendar = {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.firstWeekday = 2
|
||||
c.locale = Locale.current
|
||||
return c
|
||||
}()
|
||||
|
||||
@MainActor
|
||||
private var data: CalendarData {
|
||||
CalendarData.build(
|
||||
indicators: indicators,
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
symptoms: symptoms
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
modeSwitch.padding(.top, 4)
|
||||
anchorBar
|
||||
calendarBody
|
||||
legend
|
||||
if mode == .month {
|
||||
dayDetailInline
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationTitle(String(appLoc: "健康日历"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.2)) {
|
||||
anchor = .now
|
||||
selectedDate = .now
|
||||
mode = .month
|
||||
}
|
||||
} label: {
|
||||
Text("回到今天")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if let onClose {
|
||||
Button(action: onClose) {
|
||||
Text("完成")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
anchor = initialDate
|
||||
selectedDate = initialDate
|
||||
}
|
||||
}
|
||||
|
||||
private var dayDetailInline: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
DayDetailContent(
|
||||
date: selectedDate,
|
||||
indicators: indicators,
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
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 modeSwitch: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(CalendarMode.allCases) { m in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
mode = m
|
||||
}
|
||||
} label: {
|
||||
Text(m.label)
|
||||
.font(.system(size: 13, weight: mode == m ? .semibold : .regular))
|
||||
.foregroundStyle(mode == m ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
Capsule().fill(mode == m ? Tj.Palette.ink : Color.clear)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
.background(Capsule().fill(Tj.Palette.paper))
|
||||
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
.frame(maxWidth: 220)
|
||||
}
|
||||
|
||||
private var anchorBar: some View {
|
||||
HStack {
|
||||
Button { shiftAnchor(-1) } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Circle().fill(Tj.Palette.paper))
|
||||
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(anchorTitle)
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.snappy, value: anchor)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button { shiftAnchor(1) } label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Circle().fill(Tj.Palette.paper))
|
||||
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isAnchorAtFuture)
|
||||
.opacity(isAnchorAtFuture ? 0.4 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var anchorTitle: String {
|
||||
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, selectedDate: selectedDate) { day in
|
||||
withAnimation(.snappy(duration: 0.2)) {
|
||||
selectedDate = day
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.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)
|
||||
)
|
||||
case .year:
|
||||
CalendarYearGrid(
|
||||
year: calendar.component(.year, from: anchor),
|
||||
data: data
|
||||
) { tappedMonth in
|
||||
anchor = tappedMonth
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
mode = .month
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var legend: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("图例")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
HStack(spacing: 14) {
|
||||
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)
|
||||
}
|
||||
|
||||
private func legendItem(color: Color, label: String) -> some View {
|
||||
HStack(spacing: 5) {
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(color)
|
||||
.frame(width: 14, height: 6)
|
||||
Text(label)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
}
|
||||
|
||||
private var isAnchorAtFuture: Bool {
|
||||
switch mode {
|
||||
case .month:
|
||||
return calendar.isDate(anchor, equalTo: .now, toGranularity: .month) ||
|
||||
anchor > .now
|
||||
case .year:
|
||||
let nowYear = calendar.component(.year, from: .now)
|
||||
let anchorYear = calendar.component(.year, from: anchor)
|
||||
return anchorYear >= nowYear
|
||||
}
|
||||
}
|
||||
|
||||
private func shiftAnchor(_ delta: Int) {
|
||||
let component: Calendar.Component = (mode == .month) ? .month : .year
|
||||
if let next = calendar.date(byAdding: component, value: delta, to: anchor) {
|
||||
withAnimation(.snappy) {
|
||||
anchor = next
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CalendarOverviewView(onClose: {})
|
||||
.modelContainer(for: [
|
||||
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self
|
||||
], inMemory: true)
|
||||
}
|
||||
@@ -299,7 +299,8 @@ struct UnifiedCaptureFlow: View {
|
||||
range: ind.range,
|
||||
status: ind.status,
|
||||
capturedAt: final.reportDate,
|
||||
report: report
|
||||
report: report,
|
||||
source: .report
|
||||
)
|
||||
ctx.insert(i)
|
||||
}
|
||||
|
||||
176
康康/Features/Home/HomeCalendarCard.swift
Normal file
176
康康/Features/Home/HomeCalendarCard.swift
Normal file
@@ -0,0 +1,176 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 主页「健康日历」卡:当前一周横条 + 本月记录摘要。
|
||||
/// 点整卡或某一天 → 打开 CalendarOverviewView 看月/年总览。自包含 @Query(对齐 TodayRemindersCard)。
|
||||
struct HomeCalendarCard: View {
|
||||
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
||||
private var indicators: [Indicator]
|
||||
|
||||
@Query(sort: \Report.reportDate, order: .reverse)
|
||||
private var reports: [Report]
|
||||
|
||||
@Query(sort: \DiaryEntry.createdAt, order: .reverse)
|
||||
private var diaries: [DiaryEntry]
|
||||
|
||||
@Query(sort: \Symptom.startedAt, order: .reverse)
|
||||
private var symptoms: [Symptom]
|
||||
|
||||
/// 打开总览时定位的日期(nil = 不展示)。
|
||||
@State private var openDay: SelectedDay?
|
||||
|
||||
private let calendar: Calendar = {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.firstWeekday = 2
|
||||
c.locale = Locale.current
|
||||
return c
|
||||
}()
|
||||
|
||||
@MainActor
|
||||
private var data: CalendarData {
|
||||
CalendarData.build(
|
||||
indicators: indicators,
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
symptoms: symptoms
|
||||
)
|
||||
}
|
||||
|
||||
/// 本周一 → 本周日。
|
||||
private var weekDays: [Date] {
|
||||
let today = calendar.startOfDay(for: .now)
|
||||
let weekdayIndex = (calendar.component(.weekday, from: today) - calendar.firstWeekday + 7) % 7
|
||||
guard let monday = calendar.date(byAdding: .day, value: -weekdayIndex, to: today) else {
|
||||
return []
|
||||
}
|
||||
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
|
||||
}
|
||||
|
||||
/// 本月有记录的天数(指标/报告/日记/症状任一)。
|
||||
private var daysWithRecordsThisMonth: Int {
|
||||
guard let interval = calendar.dateInterval(of: .month, for: .now) else { return 0 }
|
||||
let count = calendar.range(of: .day, in: .month, for: .now)?.count ?? 30
|
||||
var n = 0
|
||||
for i in 0..<count {
|
||||
guard let d = calendar.date(byAdding: .day, value: i, to: interval.start) else { continue }
|
||||
if data.marks(for: d, calendar: calendar).hasAnyEvent ||
|
||||
!data.ranges(touching: d, calendar: calendar).isEmpty {
|
||||
n += 1
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
header
|
||||
weekStrip
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.tjCard(bordered: true)
|
||||
.padding(.bottom, 18)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { openDay = SelectedDay(date: .now) }
|
||||
.fullScreenCover(item: $openDay) { day in
|
||||
CalendarOverviewView(initialDate: day.date, onClose: { openDay = nil })
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("健康日历")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
HStack(spacing: 3) {
|
||||
Text(summaryLine)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryLine: String {
|
||||
let n = daysWithRecordsThisMonth
|
||||
return n > 0 ? String(appLoc: "本月 \(n) 天有记录") : String(appLoc: "本月暂无记录")
|
||||
}
|
||||
|
||||
private var weekStrip: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(weekDays, id: \.self) { day in
|
||||
dayCell(day)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dayCell(_ day: Date) -> some View {
|
||||
let marks = data.marks(for: day, calendar: calendar)
|
||||
let ranges = data.ranges(touching: day, calendar: calendar)
|
||||
let isToday = calendar.isDateInToday(day)
|
||||
let hasSymptom = !ranges.isEmpty
|
||||
|
||||
return Button {
|
||||
openDay = SelectedDay(date: day)
|
||||
} label: {
|
||||
VStack(spacing: 5) {
|
||||
Text(weekdayLabel(day))
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.fill(cellFill(isToday: isToday, hasSymptom: hasSymptom))
|
||||
if isToday {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.ink, lineWidth: 1.2)
|
||||
}
|
||||
Text("\(calendar.component(.day, from: day))")
|
||||
.font(.system(size: 14, weight: isToday ? .bold : .regular))
|
||||
.foregroundStyle(isToday ? Tj.Palette.ink : Tj.Palette.text)
|
||||
}
|
||||
.frame(height: 38)
|
||||
marksDots(marks)
|
||||
.frame(height: 5)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func marksDots(_ marks: DayMarks) -> some View {
|
||||
HStack(spacing: 2) {
|
||||
if marks.abnormalCount > 0 {
|
||||
dot(Tj.Palette.brick)
|
||||
} else if marks.normalCount > 0 {
|
||||
dot(Tj.Palette.leaf)
|
||||
}
|
||||
if marks.reportCount > 0 { dot(Tj.Palette.ink2) }
|
||||
if marks.diaryCount > 0 { dot(Tj.Palette.text3.opacity(0.7)) }
|
||||
}
|
||||
}
|
||||
|
||||
private func dot(_ color: Color) -> some View {
|
||||
Circle().fill(color).frame(width: 4, height: 4)
|
||||
}
|
||||
|
||||
private func cellFill(isToday: Bool, hasSymptom: Bool) -> Color {
|
||||
if hasSymptom { return Tj.Palette.amber.opacity(0.18) }
|
||||
if isToday { return Tj.Palette.sand2 }
|
||||
return Tj.Palette.sand2.opacity(0.5)
|
||||
}
|
||||
|
||||
private func weekdayLabel(_ day: Date) -> String {
|
||||
let labels = [
|
||||
String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"),
|
||||
String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"),
|
||||
String(appLoc: "日")
|
||||
]
|
||||
let idx = (calendar.component(.weekday, from: day) - calendar.firstWeekday + 7) % 7
|
||||
return labels[idx]
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ struct HomeView: View {
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
HomeCalendarCard()
|
||||
|
||||
TodayRemindersCard()
|
||||
|
||||
OngoingSymptomsCard()
|
||||
|
||||
@@ -191,7 +191,8 @@ struct QuickRegionCaptureFlow: View {
|
||||
unit: item.unit.trimmingCharacters(in: .whitespaces),
|
||||
range: item.range.trimmingCharacters(in: .whitespaces),
|
||||
status: item.status,
|
||||
capturedAt: capturedAt
|
||||
capturedAt: capturedAt,
|
||||
source: .quickCapture
|
||||
)
|
||||
ctx.insert(indicator)
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ struct TimelineEntry: Identifiable, Hashable {
|
||||
if let report = i.report {
|
||||
return String(appLoc: "指标 · \(report.title)")
|
||||
}
|
||||
return String(appLoc: "异常项快拍")
|
||||
return i.source.label
|
||||
}
|
||||
|
||||
private static func indicatorValue(_ i: Indicator) -> String {
|
||||
|
||||
@@ -196,7 +196,7 @@ struct TimelineEntryDetailView: View {
|
||||
divider
|
||||
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
|
||||
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
|
||||
field(String(appLoc: "来源"), i.report?.title ?? String(appLoc: "异常项快拍"))
|
||||
field(String(appLoc: "来源"), i.report?.title ?? i.source.label)
|
||||
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ import SwiftUI
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
/// 趋势桶的来源类别。
|
||||
/// - `.monitor`:长期监测预设 / 自定义 / 血压(按 seriesKey 分组)。
|
||||
/// - `.lab`:任意出现 ≥2 次的化验/手动/报告指标(按 name+unit 分组,无 seriesKey)。
|
||||
enum SeriesKind { case monitor, lab }
|
||||
|
||||
/// 长期监测系列在 Trends 折线图里的展示桶。
|
||||
/// 单系列(血糖/体重/...)= 1 个 SeriesLine;血压特殊 = 收缩 + 舒张 2 条线同卡。
|
||||
struct SeriesBucket: Identifiable {
|
||||
@@ -10,6 +15,7 @@ struct SeriesBucket: Identifiable {
|
||||
let unit: String
|
||||
let lines: [SeriesLine]
|
||||
let latestDate: Date
|
||||
let kind: SeriesKind
|
||||
|
||||
struct SeriesLine: Identifiable {
|
||||
let id: String
|
||||
@@ -68,9 +74,79 @@ extension SeriesBucket {
|
||||
}
|
||||
}
|
||||
|
||||
// —— lab 段:任何没有 seriesKey 的指标,按 name+unit 归并;同名出现 ≥minPoints 次即成趋势。
|
||||
var labBuckets: [String: [Indicator]] = [:]
|
||||
for i in indicators {
|
||||
if let key = i.seriesKey, !key.isEmpty { continue } // seriesKey 指标只进 monitor 段
|
||||
let nk = normalizedKey(name: i.name, unit: i.unit)
|
||||
guard !nk.isEmpty else { continue }
|
||||
labBuckets[nk, default: []].append(i)
|
||||
}
|
||||
for (_, items) in labBuckets {
|
||||
guard items.count >= minPoints else { continue }
|
||||
if let bucket = buildLab(items: items) {
|
||||
results.append(bucket)
|
||||
}
|
||||
}
|
||||
|
||||
return results.sorted { $0.latestDate > $1.latestDate }
|
||||
}
|
||||
|
||||
/// name+unit 归一化:trim + 小写 + 折叠内部空白。空名返回空串(调用方跳过)。
|
||||
static func normalizedKey(name: String, unit: String) -> String {
|
||||
func norm(_ s: String) -> String {
|
||||
s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.components(separatedBy: .whitespacesAndNewlines)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
let n = norm(name)
|
||||
guard !n.isEmpty else { return "" }
|
||||
return n + "|" + norm(unit)
|
||||
}
|
||||
|
||||
/// 解析参考范围字符串 → ClosedRange。支持 "3.9-6.1" / "3.9~6.1" / "3.9 - 6.1"。
|
||||
/// 单边("<5.2" / ">40" / "≤120")暂返回 nil(图不画带,正常)。
|
||||
static func parseRange(_ raw: String) -> ClosedRange<Double>? {
|
||||
let s = raw.replacingOccurrences(of: "~", with: "~")
|
||||
.replacingOccurrences(of: "~", with: "-")
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)"#
|
||||
) else { return nil }
|
||||
let range = NSRange(s.startIndex..<s.endIndex, in: s)
|
||||
guard let m = regex.firstMatch(in: s, range: range),
|
||||
let r1 = Range(m.range(at: 1), in: s),
|
||||
let r2 = Range(m.range(at: 2), in: s),
|
||||
let lo = Double(s[r1]), let hi = Double(s[r2]),
|
||||
lo <= hi else { return nil }
|
||||
return lo...hi
|
||||
}
|
||||
|
||||
private static func buildLab(items: [Indicator]) -> SeriesBucket? {
|
||||
let sorted = items.sorted { $0.capturedAt < $1.capturedAt }
|
||||
guard let latest = sorted.last else { return nil }
|
||||
let points = sorted.compactMap { point(from: $0) }
|
||||
guard points.count >= 2 else { return nil } // 值无法解析为数字的会被丢弃,可能不足 2 点
|
||||
|
||||
let line = SeriesLine(
|
||||
id: "lab:\(latest.name)",
|
||||
seriesKey: "lab:\(latest.name)",
|
||||
label: nil,
|
||||
color: Tj.Palette.ink,
|
||||
points: points,
|
||||
referenceRange: parseRange(latest.range)
|
||||
)
|
||||
return SeriesBucket(
|
||||
id: "lab:\(normalizedKey(name: latest.name, unit: latest.unit))",
|
||||
title: latest.name,
|
||||
unit: latest.unit,
|
||||
lines: [line],
|
||||
latestDate: latest.capturedAt,
|
||||
kind: .lab
|
||||
)
|
||||
}
|
||||
|
||||
private static func buildSingle(key: String,
|
||||
items: [Indicator],
|
||||
profile: UserProfile?,
|
||||
@@ -106,7 +182,8 @@ extension SeriesBucket {
|
||||
title: title,
|
||||
unit: unit,
|
||||
lines: [line],
|
||||
latestDate: latest.capturedAt
|
||||
latestDate: latest.capturedAt,
|
||||
kind: .monitor
|
||||
)
|
||||
}
|
||||
|
||||
@@ -148,7 +225,8 @@ extension SeriesBucket {
|
||||
title: String(appLoc: "血压"),
|
||||
unit: "mmHg",
|
||||
lines: lines,
|
||||
latestDate: latest
|
||||
latestDate: latest,
|
||||
kind: .monitor
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
448
康康/Features/Trends/TrendDetailView.swift
Normal file
448
康康/Features/Trends/TrendDetailView.swift
Normal file
@@ -0,0 +1,448 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import Charts
|
||||
|
||||
/// 趋势详情:大图表 + 时间范围筛选 + 统计摘要 + 数据点列表(点击跳当日详情)。
|
||||
struct TrendDetailView: View {
|
||||
let bucket: SeriesBucket
|
||||
|
||||
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
||||
private var indicators: [Indicator]
|
||||
@Query(sort: \Report.reportDate, order: .reverse)
|
||||
private var reports: [Report]
|
||||
@Query(sort: \DiaryEntry.createdAt, order: .reverse)
|
||||
private var diaries: [DiaryEntry]
|
||||
@Query(sort: \Symptom.startedAt, order: .reverse)
|
||||
private var symptoms: [Symptom]
|
||||
|
||||
@State private var range: TrendRange = .all
|
||||
@State private var openDay: SelectedDay?
|
||||
|
||||
private let calendar = Calendar.current
|
||||
|
||||
// MARK: 时间范围裁剪
|
||||
|
||||
/// 锚点 = 最新一条记录的时间(数据稀疏时,"近3月"从最新记录倒推更有用)。
|
||||
private var anchorDate: Date {
|
||||
bucket.lines.flatMap(\.points).map(\.date).max() ?? .now
|
||||
}
|
||||
|
||||
private var fullSpanDays: Int {
|
||||
let dates = bucket.lines.flatMap(\.points).map(\.date)
|
||||
guard let lo = dates.min(), let hi = dates.max() else { return 0 }
|
||||
return calendar.dateComponents([.day], from: lo, to: hi).day ?? 0
|
||||
}
|
||||
|
||||
private var availableRanges: [TrendRange] {
|
||||
TrendRange.allCases.filter { r in
|
||||
guard let d = r.days else { return true } // .all 总显示
|
||||
return d < fullSpanDays
|
||||
}
|
||||
}
|
||||
|
||||
private func filtered(_ line: SeriesBucket.SeriesLine) -> [SeriesBucket.Point] {
|
||||
guard let days = range.days,
|
||||
let cutoff = calendar.date(byAdding: .day, value: -days, to: anchorDate) else {
|
||||
return line.points
|
||||
}
|
||||
return line.points.filter { $0.date >= cutoff }
|
||||
}
|
||||
|
||||
private var filteredLines: [SeriesBucket.SeriesLine] {
|
||||
bucket.lines.map { line in
|
||||
SeriesBucket.SeriesLine(
|
||||
id: line.id,
|
||||
seriesKey: line.seriesKey,
|
||||
label: line.label,
|
||||
color: line.color,
|
||||
points: filtered(line),
|
||||
referenceRange: line.referenceRange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if availableRanges.count > 1 {
|
||||
rangePicker
|
||||
}
|
||||
chartCard
|
||||
statsCard
|
||||
aiPlaceholder
|
||||
pointsList
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationTitle(bucket.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(item: $openDay) { day in
|
||||
DayDetailSheet(
|
||||
date: day.date,
|
||||
indicators: indicators,
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
symptoms: symptoms
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 时间范围切换
|
||||
|
||||
private var rangePicker: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(availableRanges) { r in
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.2)) { range = r }
|
||||
} label: {
|
||||
Text(r.label)
|
||||
.font(.system(size: 12, weight: range == r ? .semibold : .regular))
|
||||
.foregroundStyle(range == r ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(range == r ? Tj.Palette.ink : Color.clear))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
.background(Capsule().fill(Tj.Palette.paper))
|
||||
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
}
|
||||
|
||||
// MARK: 大图表
|
||||
|
||||
private var allFilteredPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)] {
|
||||
filteredLines.flatMap { line in line.points.map { (line, $0) } }
|
||||
}
|
||||
|
||||
private var dateDomain: ClosedRange<Date>? {
|
||||
let dates = allFilteredPoints.map(\.point.date)
|
||||
guard let lo = dates.min(), let hi = dates.max() else { return nil }
|
||||
if lo == hi {
|
||||
let earlier = calendar.date(byAdding: .hour, value: -12, to: lo) ?? lo
|
||||
let later = calendar.date(byAdding: .hour, value: 12, to: hi) ?? hi
|
||||
return earlier...later
|
||||
}
|
||||
return lo...hi
|
||||
}
|
||||
|
||||
private var valueDomain: ClosedRange<Double>? {
|
||||
var lo = Double.greatestFiniteMagnitude
|
||||
var hi = -Double.greatestFiniteMagnitude
|
||||
for (_, p) in allFilteredPoints {
|
||||
lo = min(lo, p.value); hi = max(hi, p.value)
|
||||
}
|
||||
for line in filteredLines {
|
||||
if let r = line.referenceRange {
|
||||
lo = min(lo, r.lowerBound); hi = max(hi, r.upperBound)
|
||||
}
|
||||
}
|
||||
guard lo <= hi else { return nil }
|
||||
let span = hi - lo
|
||||
let pad = span > 0 ? max(1, span * 0.12) : max(1, abs(lo) * 0.1)
|
||||
return (lo - pad)...(hi + pad)
|
||||
}
|
||||
|
||||
private var chartCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
chart.frame(height: 220)
|
||||
if filteredLines.count > 1 {
|
||||
legendLine
|
||||
}
|
||||
}
|
||||
.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)
|
||||
)
|
||||
}
|
||||
|
||||
private var chart: some View {
|
||||
Chart {
|
||||
ForEach(filteredLines) { line in
|
||||
if let r = line.referenceRange, let dom = dateDomain {
|
||||
RectangleMark(
|
||||
xStart: .value("start", dom.lowerBound),
|
||||
xEnd: .value("end", dom.upperBound),
|
||||
yStart: .value("lo", r.lowerBound),
|
||||
yEnd: .value("hi", r.upperBound)
|
||||
)
|
||||
.foregroundStyle(line.color.opacity(0.08))
|
||||
}
|
||||
}
|
||||
ForEach(filteredLines) { line in
|
||||
ForEach(line.points) { p in
|
||||
LineMark(
|
||||
x: .value("时间", p.date),
|
||||
y: .value(line.label ?? bucket.title, p.value),
|
||||
series: .value("series", line.id)
|
||||
)
|
||||
.foregroundStyle(line.color)
|
||||
.interpolationMethod(.catmullRom)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||
PointMark(
|
||||
x: .value("时间", p.date),
|
||||
y: .value(line.label ?? bucket.title, p.value)
|
||||
)
|
||||
.foregroundStyle(p.status == .normal ? line.color : Tj.Palette.brick)
|
||||
.symbolSize(p.status == .normal ? 26 : 44)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .automatic(desiredCount: 4)) { _ in
|
||||
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
|
||||
AxisValueLabel(format: .dateTime.month(.abbreviated).day())
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading, values: .automatic(desiredCount: 4)) { _ in
|
||||
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
|
||||
AxisValueLabel()
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
}
|
||||
}
|
||||
.chartYScale(domain: valueDomain ?? 0...1)
|
||||
}
|
||||
|
||||
private var legendLine: some View {
|
||||
HStack(spacing: 14) {
|
||||
ForEach(filteredLines) { line in
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(line.color).frame(width: 8, height: 8)
|
||||
Text(line.label ?? line.seriesKey)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 统计摘要
|
||||
|
||||
private var statsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(filteredLines) { line in
|
||||
lineStats(line)
|
||||
if line.id != filteredLines.last?.id {
|
||||
Divider().overlay(Tj.Palette.lineSoft)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func lineStats(_ line: SeriesBucket.SeriesLine) -> some View {
|
||||
let pts = line.points
|
||||
let values = pts.map(\.value)
|
||||
let latest = pts.last
|
||||
let prev = pts.count >= 2 ? pts[pts.count - 2] : nil
|
||||
let minV = values.min() ?? 0
|
||||
let maxV = values.max() ?? 0
|
||||
let avg = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if filteredLines.count > 1, let label = line.label {
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text(latest.map { fmt($0.value) } ?? "—")
|
||||
.font(.system(size: 28, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle((latest?.status ?? .normal) == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
Text(bucket.unit)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
if let delta = deltaText(latest: latest, prev: prev) {
|
||||
Text(delta.text)
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(delta.color)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
statCell(String(appLoc: "最低"), fmt(minV))
|
||||
statCell(String(appLoc: "最高"), fmt(maxV))
|
||||
statCell(String(appLoc: "平均"), fmt(avg))
|
||||
statCell(String(appLoc: "记录"), "\(pts.count)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statCell(_ label: String, _ value: String) -> some View {
|
||||
VStack(spacing: 3) {
|
||||
Text(value)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(label)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
/// 对比上次:Δ 绝对值 + 百分比 + 升降箭头;跨参考范围边界标红。
|
||||
private func deltaText(latest: SeriesBucket.Point?,
|
||||
prev: SeriesBucket.Point?) -> (text: String, color: Color)? {
|
||||
guard let latest, let prev else { return nil }
|
||||
let d = latest.value - prev.value
|
||||
let arrow = d > 0 ? "↑" : (d < 0 ? "↓" : "→")
|
||||
let pct = prev.value != 0 ? abs(d / prev.value) * 100 : 0
|
||||
let abnormalShift = (prev.status == .normal) != (latest.status == .normal)
|
||||
let color: Color = abnormalShift
|
||||
? Tj.Palette.brick
|
||||
: (d == 0 ? Tj.Palette.text3 : Tj.Palette.text2)
|
||||
let pctStr = pct > 0 ? String(format: " (%.0f%%)", pct) : ""
|
||||
return ("\(arrow) \(fmt(abs(d)))\(pctStr)", color)
|
||||
}
|
||||
|
||||
// MARK: AI 解读占位
|
||||
|
||||
private var aiPlaceholder: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("AI 趋势解读即将上线")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.sand2.opacity(0.6))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: 数据点列表
|
||||
|
||||
/// 跨线按天合并:每天一行,展示该天各线的值。倒序。
|
||||
private var pointRows: [PointRow] {
|
||||
var byDay: [Date: [String: SeriesBucket.Point]] = [:]
|
||||
for line in filteredLines {
|
||||
for p in line.points {
|
||||
let day = calendar.startOfDay(for: p.date)
|
||||
byDay[day, default: [:]][line.id] = p
|
||||
}
|
||||
}
|
||||
return byDay.keys.sorted(by: >).map { day in
|
||||
PointRow(day: day, byLine: byDay[day] ?? [:])
|
||||
}
|
||||
}
|
||||
|
||||
private struct PointRow: Identifiable {
|
||||
let day: Date
|
||||
let byLine: [String: SeriesBucket.Point]
|
||||
var id: TimeInterval { day.timeIntervalSince1970 }
|
||||
}
|
||||
|
||||
private var pointsList: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("全部记录")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
VStack(spacing: 8) {
|
||||
ForEach(pointRows) { row in
|
||||
Button {
|
||||
openDay = SelectedDay(date: row.day)
|
||||
} label: {
|
||||
pointRowView(row)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pointRowView(_ row: PointRow) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Text(row.day.formatted(.dateTime.year().month(.abbreviated).day()))
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer(minLength: 8)
|
||||
HStack(spacing: 10) {
|
||||
ForEach(filteredLines) { line in
|
||||
if let p = row.byLine[line.id] {
|
||||
HStack(spacing: 3) {
|
||||
if filteredLines.count > 1 {
|
||||
Circle().fill(line.color).frame(width: 6, height: 6)
|
||||
}
|
||||
Text(fmt(p.value) + arrow(p.status))
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(p.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.tjCard(bordered: true)
|
||||
}
|
||||
|
||||
private func arrow(_ status: IndicatorStatus) -> String {
|
||||
switch status {
|
||||
case .high: return " ↑"
|
||||
case .low: return " ↓"
|
||||
case .normal: return ""
|
||||
}
|
||||
}
|
||||
|
||||
private func fmt(_ v: Double) -> String {
|
||||
v.truncatingRemainder(dividingBy: 1) == 0
|
||||
? String(format: "%.0f", v)
|
||||
: String(format: "%.1f", v)
|
||||
}
|
||||
}
|
||||
|
||||
enum TrendRange: String, CaseIterable, Identifiable {
|
||||
case all, year, sixMonths, threeMonths
|
||||
var id: String { rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .all: return String(appLoc: "全部")
|
||||
case .year: return String(appLoc: "近1年")
|
||||
case .sixMonths: return String(appLoc: "近6月")
|
||||
case .threeMonths: return String(appLoc: "近3月")
|
||||
}
|
||||
}
|
||||
|
||||
/// nil = 不裁剪。
|
||||
var days: Int? {
|
||||
switch self {
|
||||
case .all: return nil
|
||||
case .year: return 365
|
||||
case .sixMonths: return 182
|
||||
case .threeMonths: return 91
|
||||
}
|
||||
}
|
||||
}
|
||||
113
康康/Features/Trends/TrendRow.swift
Normal file
113
康康/Features/Trends/TrendRow.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
/// 趋势列表的紧凑行:名称 + 条数/跨度 + mini sparkline + 最新值。
|
||||
struct TrendRow: View {
|
||||
let bucket: SeriesBucket
|
||||
|
||||
private var allPoints: [SeriesBucket.Point] {
|
||||
bucket.lines.flatMap(\.points)
|
||||
}
|
||||
|
||||
private var pointCount: Int { allPoints.count }
|
||||
|
||||
private var anyLatestAbnormal: Bool {
|
||||
bucket.lines.contains { ($0.latestPoint?.status ?? .normal) != .normal }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(bucket.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
sparkline
|
||||
.frame(width: 76, height: 34)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(latestValue)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(anyLatestAbnormal ? Tj.Palette.brick : Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Text(bucket.unit)
|
||||
.font(.system(size: 9, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.fixedSize()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.tjCard(bordered: true)
|
||||
}
|
||||
|
||||
private var sparkline: some View {
|
||||
Chart {
|
||||
ForEach(bucket.lines) { line in
|
||||
ForEach(line.points) { p in
|
||||
LineMark(
|
||||
x: .value("t", p.date),
|
||||
y: .value(line.label ?? bucket.title, p.value),
|
||||
series: .value("s", line.id)
|
||||
)
|
||||
.foregroundStyle(line.color)
|
||||
.interpolationMethod(.catmullRom)
|
||||
.lineStyle(StrokeStyle(lineWidth: 1.6))
|
||||
}
|
||||
}
|
||||
// 最新点高亮
|
||||
ForEach(bucket.lines) { line in
|
||||
if let p = line.latestPoint {
|
||||
PointMark(
|
||||
x: .value("t", p.date),
|
||||
y: .value("v", p.value)
|
||||
)
|
||||
.foregroundStyle(p.status == .normal ? line.color : Tj.Palette.brick)
|
||||
.symbolSize(28)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis(.hidden)
|
||||
.chartLegend(.hidden)
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
"\(pointCount) 条 · 近 \(spanLabel)"
|
||||
}
|
||||
|
||||
private var spanLabel: String {
|
||||
let dates = allPoints.map(\.date)
|
||||
guard let lo = dates.min(), let hi = dates.max() else { return "—" }
|
||||
let days = Calendar.current.dateComponents([.day], from: lo, to: hi).day ?? 0
|
||||
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 var latestValue: String {
|
||||
let parts = bucket.lines.compactMap { line -> String? in
|
||||
guard let p = line.latestPoint else { return nil }
|
||||
return formatValue(p.value)
|
||||
}
|
||||
return parts.joined(separator: "/")
|
||||
}
|
||||
|
||||
private func formatValue(_ v: Double) -> String {
|
||||
v.truncatingRemainder(dividingBy: 1) == 0
|
||||
? String(format: "%.0f", v)
|
||||
: String(format: "%.1f", v)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,15 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
enum CalendarMode: String, CaseIterable, Identifiable {
|
||||
case month, year
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .month: return String(appLoc: "月")
|
||||
case .year: return String(appLoc: "年")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 趋势 Tab。日历已迁至主页;此页专注「时间序列」:
|
||||
/// 任何出现 ≥2 次的指标都能成趋势,分「长期监测」(seriesKey)与「化验指标」(按名归并)两段。
|
||||
struct TrendsView: View {
|
||||
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
||||
private var indicators: [Indicator]
|
||||
|
||||
@Query(sort: \Report.reportDate, order: .reverse)
|
||||
private var reports: [Report]
|
||||
|
||||
@Query(sort: \DiaryEntry.createdAt, order: .reverse)
|
||||
private var diaries: [DiaryEntry]
|
||||
|
||||
@Query(sort: \Symptom.startedAt, order: .reverse)
|
||||
private var symptoms: [Symptom]
|
||||
|
||||
@Query private var profiles: [UserProfile]
|
||||
|
||||
@Query private var customMetrics: [CustomMonitorMetric]
|
||||
|
||||
@State private var mode: CalendarMode = .month
|
||||
@State private var anchor: Date = .now
|
||||
/// 选中的当天 — 默认选今天,日历下方 inline 显示该日详情
|
||||
@State private var selectedDate: Date = .now
|
||||
|
||||
private var profile: UserProfile? { profiles.first }
|
||||
|
||||
private var seriesBuckets: [SeriesBucket] {
|
||||
@@ -42,195 +18,48 @@ struct TrendsView: View {
|
||||
customMetrics: customMetrics)
|
||||
}
|
||||
|
||||
private let calendar: Calendar = {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.firstWeekday = 2
|
||||
c.locale = Locale.current
|
||||
return c
|
||||
}()
|
||||
|
||||
@MainActor
|
||||
private var data: CalendarData {
|
||||
CalendarData.build(
|
||||
indicators: indicators,
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
symptoms: symptoms
|
||||
)
|
||||
private var monitorBuckets: [SeriesBucket] {
|
||||
seriesBuckets.filter { $0.kind == .monitor }
|
||||
}
|
||||
private var labBuckets: [SeriesBucket] {
|
||||
seriesBuckets.filter { $0.kind == .lab }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
header.padding(.top, 4)
|
||||
modeSwitch
|
||||
anchorBar
|
||||
calendarBody
|
||||
legend
|
||||
if mode == .month {
|
||||
dayDetailInline
|
||||
if seriesBuckets.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
if !monitorBuckets.isEmpty {
|
||||
section(title: String(appLoc: "长期监测"), buckets: monitorBuckets)
|
||||
}
|
||||
if !labBuckets.isEmpty {
|
||||
section(title: String(appLoc: "化验指标趋势"), buckets: labBuckets)
|
||||
}
|
||||
}
|
||||
seriesSection
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
|
||||
/// 日历下方 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,
|
||||
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 {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text("趋势")
|
||||
.font(.tjTitle(26))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.2)) {
|
||||
anchor = .now
|
||||
selectedDate = .now
|
||||
}
|
||||
} label: {
|
||||
Text("回到今天")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private var modeSwitch: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(CalendarMode.allCases) { m in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
mode = m
|
||||
}
|
||||
} label: {
|
||||
Text(m.label)
|
||||
.font(.system(size: 13, weight: mode == m ? .semibold : .regular))
|
||||
.foregroundStyle(mode == m ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
Capsule().fill(mode == m ? Tj.Palette.ink : Color.clear)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
.background(Capsule().fill(Tj.Palette.paper))
|
||||
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
.frame(maxWidth: 220)
|
||||
}
|
||||
|
||||
private var anchorBar: some View {
|
||||
HStack {
|
||||
Button { shiftAnchor(-1) } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Circle().fill(Tj.Palette.paper))
|
||||
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(anchorTitle)
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.snappy, value: anchor)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button { shiftAnchor(1) } label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Circle().fill(Tj.Palette.paper))
|
||||
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isAnchorAtFuture)
|
||||
.opacity(isAnchorAtFuture ? 0.4 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var anchorTitle: String {
|
||||
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, selectedDate: selectedDate) { day in
|
||||
withAnimation(.snappy(duration: 0.2)) {
|
||||
selectedDate = day
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.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)
|
||||
)
|
||||
case .year:
|
||||
CalendarYearGrid(
|
||||
year: calendar.component(.year, from: anchor),
|
||||
data: data
|
||||
) { tappedMonth in
|
||||
anchor = tappedMonth
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
mode = .month
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var seriesSection: some View {
|
||||
let buckets = seriesBuckets
|
||||
if !buckets.isEmpty {
|
||||
private func section(title: String, buckets: [SeriesBucket]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text("长期监测")
|
||||
Text(title)
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("\(buckets.count) 项")
|
||||
@@ -238,71 +67,32 @@ struct TrendsView: View {
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(buckets) { bucket in
|
||||
SeriesChartCard(bucket: bucket)
|
||||
NavigationLink {
|
||||
TrendDetailView(bucket: bucket)
|
||||
} label: {
|
||||
TrendRow(bucket: bucket)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var legend: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("图例")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.tracking(0.5)
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 12) {
|
||||
TjPlaceholder(label: String(appLoc: "还没有可成趋势的指标"))
|
||||
.frame(height: 120)
|
||||
.frame(maxWidth: 260)
|
||||
Text("同一指标记录满 2 次后,会在这里出现时间序列")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
HStack(spacing: 14) {
|
||||
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)
|
||||
}
|
||||
|
||||
private func legendItem(color: Color, label: String) -> some View {
|
||||
HStack(spacing: 5) {
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(color)
|
||||
.frame(width: 14, height: 6)
|
||||
Text(label)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
}
|
||||
|
||||
private var isAnchorAtFuture: Bool {
|
||||
switch mode {
|
||||
case .month:
|
||||
return calendar.isDate(anchor, equalTo: .now, toGranularity: .month) ||
|
||||
anchor > .now
|
||||
case .year:
|
||||
let nowYear = calendar.component(.year, from: .now)
|
||||
let anchorYear = calendar.component(.year, from: anchor)
|
||||
return anchorYear >= nowYear
|
||||
}
|
||||
}
|
||||
|
||||
private func shiftAnchor(_ delta: Int) {
|
||||
let component: Calendar.Component = (mode == .month) ? .month : .year
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
},
|
||||
" / %lld · 像扫描文档一样对准" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -31,9 +32,6 @@
|
||||
},
|
||||
"·" : {
|
||||
|
||||
},
|
||||
"· %lld" : {
|
||||
|
||||
},
|
||||
"· 按%lld岁调整" : {
|
||||
"localizations" : {
|
||||
@@ -56,9 +54,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"···" : {
|
||||
|
||||
},
|
||||
"(偏瘦)" : {
|
||||
"localizations" : {
|
||||
@@ -773,6 +768,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld 项偏低" : {
|
||||
|
||||
},
|
||||
"%lld 项偏高" : {
|
||||
"localizations" : {
|
||||
@@ -818,6 +816,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld 项异常" : {
|
||||
|
||||
},
|
||||
"%lld." : {
|
||||
|
||||
@@ -907,6 +908,7 @@
|
||||
}
|
||||
},
|
||||
"1 项偏低" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -929,6 +931,7 @@
|
||||
}
|
||||
},
|
||||
"3 页" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -951,6 +954,7 @@
|
||||
}
|
||||
},
|
||||
"3 项偏高" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -998,6 +1002,7 @@
|
||||
|
||||
},
|
||||
"2026 / 05 / 25 · 协和医院体检中心" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -1020,6 +1025,7 @@
|
||||
}
|
||||
},
|
||||
"2026 春季年度体检" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -1402,12 +1408,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"p.%lld" : {
|
||||
|
||||
},
|
||||
"QWEN2.5-VL · ON-DEVICE · SME2" : {
|
||||
|
||||
},
|
||||
"start" : {
|
||||
|
||||
@@ -1548,6 +1548,7 @@
|
||||
}
|
||||
},
|
||||
"一张图,几秒搞定" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -1966,6 +1967,7 @@
|
||||
}
|
||||
},
|
||||
"仅供参考,不构成医疗建议" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -2211,6 +2213,7 @@
|
||||
}
|
||||
},
|
||||
"低密度脂蛋白" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -2256,6 +2259,7 @@
|
||||
}
|
||||
},
|
||||
"低密度脂蛋白胆固醇" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -2278,6 +2282,7 @@
|
||||
}
|
||||
},
|
||||
"体 检 报 告 (第 %lld 页)" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -2322,6 +2327,7 @@
|
||||
}
|
||||
},
|
||||
"体检报告 · 影像报告" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -2740,6 +2746,7 @@
|
||||
}
|
||||
},
|
||||
"保存归档" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -2916,6 +2923,7 @@
|
||||
}
|
||||
},
|
||||
"像扫描文档一样翻页拍摄" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -3671,6 +3679,7 @@
|
||||
}
|
||||
},
|
||||
"化验单 · 处方" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -3759,6 +3768,7 @@
|
||||
}
|
||||
},
|
||||
"单张报告" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -4478,6 +4488,7 @@
|
||||
}
|
||||
},
|
||||
"多页报告" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -5031,6 +5042,7 @@
|
||||
}
|
||||
},
|
||||
"尿酸 UA" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -5166,6 +5178,7 @@
|
||||
|
||||
},
|
||||
"已处理 %.1fs · 比云端快 4.2×" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -5276,6 +5289,7 @@
|
||||
}
|
||||
},
|
||||
"已拍 1 页" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -5298,6 +5312,7 @@
|
||||
}
|
||||
},
|
||||
"已拍页面(3 页)" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -5390,6 +5405,7 @@
|
||||
|
||||
},
|
||||
"已识别边框 · 将自动透视校正" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -5698,6 +5714,7 @@
|
||||
}
|
||||
},
|
||||
"开始 AI 解读" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -5808,6 +5825,7 @@
|
||||
}
|
||||
},
|
||||
"异常项" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -5877,6 +5895,7 @@
|
||||
}
|
||||
},
|
||||
"归档一份\n关键报告" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -5899,6 +5918,7 @@
|
||||
}
|
||||
},
|
||||
"归档信息" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -6100,6 +6120,7 @@
|
||||
}
|
||||
},
|
||||
"总胆固醇" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -6145,6 +6166,7 @@
|
||||
}
|
||||
},
|
||||
"总项" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -6365,6 +6387,7 @@
|
||||
}
|
||||
},
|
||||
"所有照片以 AES 加密存于本机沙盒。康康 服务端无法访问。" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -6522,6 +6545,7 @@
|
||||
}
|
||||
},
|
||||
"报告类型" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -7020,6 +7044,7 @@
|
||||
}
|
||||
},
|
||||
"推荐" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -7064,6 +7089,7 @@
|
||||
}
|
||||
},
|
||||
"推荐拍清晰的%@,多页报告可一次完成扫描。原图与解读全部本地加密保存,永不上传。" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -7086,6 +7112,7 @@
|
||||
}
|
||||
},
|
||||
"提取指标 · 共 28 项" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -7376,6 +7403,7 @@
|
||||
}
|
||||
},
|
||||
"整体摘记" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -7398,6 +7426,7 @@
|
||||
}
|
||||
},
|
||||
"整张图" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -8051,6 +8080,7 @@
|
||||
}
|
||||
},
|
||||
"本地 AI · 正在解读" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -8205,6 +8235,7 @@
|
||||
}
|
||||
},
|
||||
"本地处理中 · 不会上传任何内容" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -8381,6 +8412,7 @@
|
||||
}
|
||||
},
|
||||
"本机摘要" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -8403,6 +8435,7 @@
|
||||
}
|
||||
},
|
||||
"本次共检测 28 项,%@(血脂相关 2 项 + 尿酸)、%@(维生素 D)。整体趋势提示代谢风险有所抬升,建议优化饮食并复查血脂。" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -8526,6 +8559,7 @@
|
||||
|
||||
},
|
||||
"查看原图" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -8797,6 +8831,7 @@
|
||||
}
|
||||
},
|
||||
"正在本地识别第 1 / 3 页…" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -8819,6 +8854,7 @@
|
||||
}
|
||||
},
|
||||
"正在本地识别第 2 / 3 页…" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -8841,6 +8877,7 @@
|
||||
}
|
||||
},
|
||||
"正在本地识别第 3 / 3 页…" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -8885,6 +8922,7 @@
|
||||
}
|
||||
},
|
||||
"正常项" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -9268,6 +9306,7 @@
|
||||
}
|
||||
},
|
||||
"甘油三酯" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -9290,6 +9329,7 @@
|
||||
}
|
||||
},
|
||||
"甘油三酯 TG" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -9379,6 +9419,7 @@
|
||||
}
|
||||
},
|
||||
"生成整体摘要…" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -9791,6 +9832,7 @@
|
||||
}
|
||||
},
|
||||
"糖化血红蛋白" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -10100,6 +10142,7 @@
|
||||
}
|
||||
},
|
||||
"维生素 D" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -10122,6 +10165,7 @@
|
||||
}
|
||||
},
|
||||
"编辑" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -10142,6 +10186,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"编辑 / 删除" : {
|
||||
|
||||
},
|
||||
"编辑「%@」" : {
|
||||
"localizations" : {
|
||||
@@ -10166,6 +10213,7 @@
|
||||
}
|
||||
},
|
||||
"编辑/删除" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -10389,6 +10437,7 @@
|
||||
|
||||
},
|
||||
"范围 %@ %@" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -11006,6 +11055,7 @@
|
||||
}
|
||||
},
|
||||
"谷丙转氨酶" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -11028,6 +11078,7 @@
|
||||
}
|
||||
},
|
||||
"谷丙转氨酶、空腹血糖、糖化血红蛋白…" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -11050,6 +11101,7 @@
|
||||
}
|
||||
},
|
||||
"谷草转氨酶" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -11118,6 +11170,7 @@
|
||||
}
|
||||
},
|
||||
"超过参考上限 0.44。建议关注饮食结构,3 个月内复查。" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -11184,6 +11237,7 @@
|
||||
}
|
||||
},
|
||||
"跳过" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -11272,6 +11326,7 @@
|
||||
}
|
||||
},
|
||||
"载脂蛋白 A1" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -11317,6 +11372,7 @@
|
||||
}
|
||||
},
|
||||
"载脂蛋白 B" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -12007,6 +12063,7 @@
|
||||
}
|
||||
},
|
||||
"预计耗时 5–8 秒 · 端侧 SME2 加速" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -12117,6 +12174,7 @@
|
||||
}
|
||||
},
|
||||
"高密度脂蛋白" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
|
||||
@@ -5,6 +5,20 @@ enum IndicatorStatus: String, Codable, CaseIterable {
|
||||
case high, low, normal
|
||||
}
|
||||
|
||||
/// 指标录入来源。manual = 「记录指标」手动录入;quickCapture = 异常项快拍(VL);report = 报告归档携带。
|
||||
/// 旧数据无此字段 → 默认 manual(轻量迁移)。
|
||||
enum IndicatorSource: String, Codable, CaseIterable {
|
||||
case manual, quickCapture, report
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .manual: return String(appLoc: "手动记录")
|
||||
case .quickCapture: return String(appLoc: "异常项快拍")
|
||||
case .report: return String(appLoc: "报告归档")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ReportType: String, Codable, CaseIterable {
|
||||
case checkup, lab, imaging, prescription, other
|
||||
|
||||
@@ -38,6 +52,9 @@ final class Indicator {
|
||||
/// 用途:Trends 按 seriesKey 分组;Timeline 配对(如 bp.systolic + bp.diastolic 合并)。
|
||||
var seriesKey: String?
|
||||
|
||||
/// 录入来源(IndicatorSource.rawValue)。带默认值 → SwiftData 轻量迁移,旧记录视为手动。
|
||||
var sourceRaw: String = IndicatorSource.manual.rawValue
|
||||
|
||||
init(name: String,
|
||||
value: String,
|
||||
unit: String,
|
||||
@@ -48,7 +65,8 @@ final class Indicator {
|
||||
report: Report? = nil,
|
||||
asset: Asset? = nil,
|
||||
pinned: Bool = false,
|
||||
seriesKey: String? = nil) {
|
||||
seriesKey: String? = nil,
|
||||
source: IndicatorSource = .manual) {
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.unit = unit
|
||||
@@ -60,11 +78,16 @@ final class Indicator {
|
||||
self.asset = asset
|
||||
self.pinned = pinned
|
||||
self.seriesKey = seriesKey
|
||||
self.sourceRaw = source.rawValue
|
||||
}
|
||||
|
||||
var status: IndicatorStatus {
|
||||
IndicatorStatus(rawValue: statusRaw) ?? .normal
|
||||
}
|
||||
|
||||
var source: IndicatorSource {
|
||||
IndicatorSource(rawValue: sourceRaw) ?? .manual
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
|
||||
@@ -479,7 +479,8 @@ extension Report {
|
||||
range: p.range,
|
||||
status: p.status,
|
||||
capturedAt: reportDate,
|
||||
report: self
|
||||
report: self,
|
||||
source: .report
|
||||
)
|
||||
ctx.insert(i)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user