缺少代码差异信息,无法生成具体的commit message。

请提供 "code differences" 的具体内容,以便我能够根据代码变更情况生成符合 Angular 规范的中文 commit message。
This commit is contained in:
link2026
2026-06-07 09:40:59 +08:00
parent 675c33bea1
commit 60b6ad6d65
18 changed files with 1552 additions and 299 deletions

View File

@@ -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 trimkey = "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
View 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"

View File

@@ -410,7 +410,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements"; CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = F2C8C774FG; DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -462,7 +462,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements"; CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = F2C8C774FG; DEVELOPMENT_TEAM = F2C8C774FG;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -512,7 +512,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = F2C8C774FG; DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
@@ -539,7 +539,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = F2C8C774FG; DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
@@ -565,7 +565,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = F2C8C774FG; DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
@@ -591,7 +591,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = F2C8C774FG; DEVELOPMENT_TEAM = F2C8C774FG;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 17.0;

View File

@@ -66,7 +66,7 @@ JSON schema(严格):
- range 字段保留原文(如 "< 3.40""3.9 - 6.1""0 - 5"),不要解析成区间对象。 - range 字段保留原文(如 "< 3.40""3.9 - 6.1""0 - 5"),不要解析成区间对象。
- 无法识别的字段填空字符串(institution / summary)。 - 无法识别的字段填空字符串(institution / summary)。
- report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。 - report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。
- 不要发明指标。看不清的整行跳过。 - 不要发明指标。数值看不清的整行跳过;但**没有参考范围不是跳过的理由**,结论页叙述式文字(如「总胆红素: 23.0(μmol/L)↑」)同样要提取,range 填 "",status 按箭头/「偏高」等标记判断
- 化验单一般 type = "lab",体检套餐 = "checkup" - 化验单一般 type = "lab",体检套餐 = "checkup"
示例 1(化验单 · 单项): 示例 1(化验单 · 单项):
@@ -96,6 +96,7 @@ JSON schema(严格):
private static let regionExtractionTemplate: String = #""" private static let regionExtractionTemplate: String = #"""
你是一个医学化验单识别助手。下面给你的是一张化验单/体检报告的**局部照片**,通常只框住了一两行指标。 你是一个医学化验单识别助手。下面给你的是一张化验单/体检报告的**局部照片**,通常只框住了一两行指标。
照片内容可能是表格行,也可能是**结论页的叙述式文字**(如「九、检验:(1)总胆红素(TB): 23.0(μmol/L)↑」),两种都要提取。
请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。 请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
今天的日期是 {{TODAY}}。 今天的日期是 {{TODAY}}。
@@ -114,22 +115,28 @@ JSON schema(严格):
} }
规则: 规则:
- 只识别框内清楚可读的指标行,通常 1-3 行;看不清的整行跳过,绝不发明指标。 - 凡是「指标名 + 数值」清楚可读的,都要提取——**没有参考范围不是跳过的理由**。只有数值本身看不清才跳过,绝不发明指标。
- status 根据 value 与 range 自己判断:value > range 上限"high",< 下限"low",否则"normal" - status 判断优先级:① 文字旁的箭头或标记(↑/H/偏高"high",↓/L/偏低"low")最优先;② 没有标记时再用 value 与 range 比较;③ 都没有"normal"
- range 字段保留原文(如 "< 3.40""3.9 - 6.1""0 - 5"),不要解析成区间对象。 - range 字段保留原文(如 "< 3.40""3.9 - 6.1""0 - 5"),不要解析成区间对象;照片里没有参考范围就填 ""
- 识别不出单位/范围就填空字符串,不要编造。 - 识别不出单位/范围就填空字符串,不要编造。
- name 用规范指标名;如果同一行重复出现指标名(如「总胆红素(TB): 总胆红素: 23.0」),只取一次。
- 不要输出 title / institution / date / summary 等任何报告级字段,只输出 indicators 数组。 - 不要输出 title / institution / date / summary 等任何报告级字段,只输出 indicators 数组。
示例 1(单行): 示例 1(表格单行):
输入: 局部照片,清楚可读「低密度脂蛋白 3.84 mmol/L 参考 <3.40 ↑」 输入: 局部照片,清楚可读「低密度脂蛋白 3.84 mmol/L 参考 <3.40 ↑」
输出: 输出:
{"indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]} {"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」 输入: 局部照片,清楚可读「尿酸 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"}]} {"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: 现在请识别这张局部照片并输出 JSON:
"""# """#
} }

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

View File

@@ -299,7 +299,8 @@ struct UnifiedCaptureFlow: View {
range: ind.range, range: ind.range,
status: ind.status, status: ind.status,
capturedAt: final.reportDate, capturedAt: final.reportDate,
report: report report: report,
source: .report
) )
ctx.insert(i) ctx.insert(i)
} }

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

View File

@@ -40,6 +40,8 @@ struct HomeView: View {
.padding(.top, 4) .padding(.top, 4)
.padding(.bottom, 18) .padding(.bottom, 18)
HomeCalendarCard()
TodayRemindersCard() TodayRemindersCard()
OngoingSymptomsCard() OngoingSymptomsCard()

View File

@@ -191,7 +191,8 @@ struct QuickRegionCaptureFlow: View {
unit: item.unit.trimmingCharacters(in: .whitespaces), unit: item.unit.trimmingCharacters(in: .whitespaces),
range: item.range.trimmingCharacters(in: .whitespaces), range: item.range.trimmingCharacters(in: .whitespaces),
status: item.status, status: item.status,
capturedAt: capturedAt capturedAt: capturedAt,
source: .quickCapture
) )
ctx.insert(indicator) ctx.insert(indicator)
} }

View File

@@ -173,7 +173,7 @@ struct TimelineEntry: Identifiable, Hashable {
if let report = i.report { if let report = i.report {
return String(appLoc: "指标 · \(report.title)") return String(appLoc: "指标 · \(report.title)")
} }
return String(appLoc: "异常项快拍") return i.source.label
} }
private static func indicatorValue(_ i: Indicator) -> String { private static func indicatorValue(_ i: Indicator) -> String {

View File

@@ -196,7 +196,7 @@ struct TimelineEntryDetailView: View {
divider divider
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) } if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt)) field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
field(String(appLoc: "来源"), i.report?.title ?? String(appLoc: "异常项快拍")) field(String(appLoc: "来源"), i.report?.title ?? i.source.label)
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) } if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
} }
} }

View File

@@ -2,6 +2,11 @@ import SwiftUI
import SwiftData import SwiftData
import Foundation import Foundation
///
/// - `.monitor`: / / ( seriesKey )
/// - `.lab`: 2 //( name+unit , seriesKey)
enum SeriesKind { case monitor, lab }
/// Trends 线 /// Trends 线
/// (//...)= 1 SeriesLine; = + 2 线 /// (//...)= 1 SeriesLine; = + 2 线
struct SeriesBucket: Identifiable { struct SeriesBucket: Identifiable {
@@ -10,6 +15,7 @@ struct SeriesBucket: Identifiable {
let unit: String let unit: String
let lines: [SeriesLine] let lines: [SeriesLine]
let latestDate: Date let latestDate: Date
let kind: SeriesKind
struct SeriesLine: Identifiable { struct SeriesLine: Identifiable {
let id: String 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 } 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, private static func buildSingle(key: String,
items: [Indicator], items: [Indicator],
profile: UserProfile?, profile: UserProfile?,
@@ -106,7 +182,8 @@ extension SeriesBucket {
title: title, title: title,
unit: unit, unit: unit,
lines: [line], lines: [line],
latestDate: latest.capturedAt latestDate: latest.capturedAt,
kind: .monitor
) )
} }
@@ -148,7 +225,8 @@ extension SeriesBucket {
title: String(appLoc: "血压"), title: String(appLoc: "血压"),
unit: "mmHg", unit: "mmHg",
lines: lines, lines: lines,
latestDate: latest latestDate: latest,
kind: .monitor
) )
} }

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

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

View File

@@ -1,39 +1,15 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
enum CalendarMode: String, CaseIterable, Identifiable { /// Tab;:
case month, year /// 2 ,(seriesKey)()
var id: String { rawValue }
var label: String {
switch self {
case .month: return String(appLoc: "")
case .year: return String(appLoc: "")
}
}
}
struct TrendsView: View { struct TrendsView: View {
@Query(sort: \Indicator.capturedAt, order: .reverse) @Query(sort: \Indicator.capturedAt, order: .reverse)
private var indicators: [Indicator] 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 profiles: [UserProfile]
@Query private var customMetrics: [CustomMonitorMetric] @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 profile: UserProfile? { profiles.first }
private var seriesBuckets: [SeriesBucket] { private var seriesBuckets: [SeriesBucket] {
@@ -42,195 +18,48 @@ struct TrendsView: View {
customMetrics: customMetrics) customMetrics: customMetrics)
} }
private let calendar: Calendar = { private var monitorBuckets: [SeriesBucket] {
var c = Calendar(identifier: .gregorian) seriesBuckets.filter { $0.kind == .monitor }
c.firstWeekday = 2 }
c.locale = Locale.current private var labBuckets: [SeriesBucket] {
return c seriesBuckets.filter { $0.kind == .lab }
}()
@MainActor
private var data: CalendarData {
CalendarData.build(
indicators: indicators,
reports: reports,
diaries: diaries,
symptoms: symptoms
)
} }
var body: some View { var body: some View {
NavigationStack {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
header.padding(.top, 4) header.padding(.top, 4)
modeSwitch if seriesBuckets.isEmpty {
anchorBar emptyState
calendarBody } else {
legend if !monitorBuckets.isEmpty {
if mode == .month { section(title: String(appLoc: "长期监测"), buckets: monitorBuckets)
dayDetailInline }
if !labBuckets.isEmpty {
section(title: String(appLoc: "化验指标趋势"), buckets: labBuckets)
}
} }
seriesSection
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 24) .padding(.bottom, 24)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea()) .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 { private var header: some View {
HStack(alignment: .lastTextBaseline) {
Text("趋势") Text("趋势")
.font(.tjTitle(26)) .font(.tjTitle(26))
.foregroundStyle(Tj.Palette.text) .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 { private func section(title: String, buckets: [SeriesBucket]) -> 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 {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .lastTextBaseline) { HStack(alignment: .lastTextBaseline) {
Text("长期监测") Text(title)
.font(.tjH2()) .font(.tjH2())
.foregroundStyle(Tj.Palette.text) .foregroundStyle(Tj.Palette.text)
Text("\(buckets.count)") Text("\(buckets.count)")
@@ -238,71 +67,32 @@ struct TrendsView: View {
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
Spacer() Spacer()
} }
.padding(.top, 8)
VStack(spacing: 12) { VStack(spacing: 12) {
ForEach(buckets) { bucket in ForEach(buckets) { bucket in
SeriesChartCard(bucket: bucket) NavigationLink {
TrendDetailView(bucket: bucket)
} label: {
TrendRow(bucket: bucket)
} }
.buttonStyle(.plain)
} }
} }
} }
} }
private var legend: some View { private var emptyState: some View {
VStack(alignment: .leading, spacing: 8) { VStack(spacing: 12) {
Text("图例") TjPlaceholder(label: String(appLoc: "还没有可成趋势的指标"))
.font(.system(size: 11, weight: .semibold)) .frame(height: 120)
.tracking(0.5) .frame(maxWidth: 260)
Text("同一指标记录满 2 次后,会在这里出现时间序列")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3) .foregroundStyle(Tj.Palette.text3)
HStack(spacing: 14) { .multilineTextAlignment(.center)
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
}
}
}
} }
.frame(maxWidth: .infinity)
.padding(.top, 60)
} }
} }

View File

@@ -5,6 +5,7 @@
}, },
" / %lld · 像扫描文档一样对准" : { " / %lld · 像扫描文档一样对准" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -31,9 +32,6 @@
}, },
"·" : { "·" : {
},
"· %lld" : {
}, },
"· 按%lld岁调整" : { "· 按%lld岁调整" : {
"localizations" : { "localizations" : {
@@ -56,9 +54,6 @@
} }
} }
} }
},
"···" : {
}, },
"(偏瘦)" : { "(偏瘦)" : {
"localizations" : { "localizations" : {
@@ -773,6 +768,9 @@
} }
} }
} }
},
"%lld 项偏低" : {
}, },
"%lld 项偏高" : { "%lld 项偏高" : {
"localizations" : { "localizations" : {
@@ -818,6 +816,9 @@
} }
} }
} }
},
"%lld 项异常" : {
}, },
"%lld." : { "%lld." : {
@@ -907,6 +908,7 @@
} }
}, },
"1 项偏低" : { "1 项偏低" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -929,6 +931,7 @@
} }
}, },
"3 页" : { "3 页" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -951,6 +954,7 @@
} }
}, },
"3 项偏高" : { "3 项偏高" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -998,6 +1002,7 @@
}, },
"2026 / 05 / 25 · 协和医院体检中心" : { "2026 / 05 / 25 · 协和医院体检中心" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -1020,6 +1025,7 @@
} }
}, },
"2026 春季年度体检" : { "2026 春季年度体检" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -1402,12 +1408,6 @@
} }
} }
} }
},
"p.%lld" : {
},
"QWEN2.5-VL · ON-DEVICE · SME2" : {
}, },
"start" : { "start" : {
@@ -1548,6 +1548,7 @@
} }
}, },
"一张图,几秒搞定" : { "一张图,几秒搞定" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -1966,6 +1967,7 @@
} }
}, },
"仅供参考,不构成医疗建议" : { "仅供参考,不构成医疗建议" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -2211,6 +2213,7 @@
} }
}, },
"低密度脂蛋白" : { "低密度脂蛋白" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -2256,6 +2259,7 @@
} }
}, },
"低密度脂蛋白胆固醇" : { "低密度脂蛋白胆固醇" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -2278,6 +2282,7 @@
} }
}, },
"体 检 报 告 (第 %lld 页)" : { "体 检 报 告 (第 %lld 页)" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -2322,6 +2327,7 @@
} }
}, },
"体检报告 · 影像报告" : { "体检报告 · 影像报告" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -2740,6 +2746,7 @@
} }
}, },
"保存归档" : { "保存归档" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -2916,6 +2923,7 @@
} }
}, },
"像扫描文档一样翻页拍摄" : { "像扫描文档一样翻页拍摄" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -3671,6 +3679,7 @@
} }
}, },
"化验单 · 处方" : { "化验单 · 处方" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -3759,6 +3768,7 @@
} }
}, },
"单张报告" : { "单张报告" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -4478,6 +4488,7 @@
} }
}, },
"多页报告" : { "多页报告" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -5031,6 +5042,7 @@
} }
}, },
"尿酸 UA" : { "尿酸 UA" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -5166,6 +5178,7 @@
}, },
"已处理 %.1fs · 比云端快 4.2×" : { "已处理 %.1fs · 比云端快 4.2×" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -5276,6 +5289,7 @@
} }
}, },
"已拍 1 页" : { "已拍 1 页" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -5298,6 +5312,7 @@
} }
}, },
"已拍页面3 页)" : { "已拍页面3 页)" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -5390,6 +5405,7 @@
}, },
"已识别边框 · 将自动透视校正" : { "已识别边框 · 将自动透视校正" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -5698,6 +5714,7 @@
} }
}, },
"开始 AI 解读" : { "开始 AI 解读" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -5808,6 +5825,7 @@
} }
}, },
"异常项" : { "异常项" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -5877,6 +5895,7 @@
} }
}, },
"归档一份\n关键报告" : { "归档一份\n关键报告" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -5899,6 +5918,7 @@
} }
}, },
"归档信息" : { "归档信息" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -6100,6 +6120,7 @@
} }
}, },
"总胆固醇" : { "总胆固醇" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -6145,6 +6166,7 @@
} }
}, },
"总项" : { "总项" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -6365,6 +6387,7 @@
} }
}, },
"所有照片以 AES 加密存于本机沙盒。康康 服务端无法访问。" : { "所有照片以 AES 加密存于本机沙盒。康康 服务端无法访问。" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -6522,6 +6545,7 @@
} }
}, },
"报告类型" : { "报告类型" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -7020,6 +7044,7 @@
} }
}, },
"推荐" : { "推荐" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -7064,6 +7089,7 @@
} }
}, },
"推荐拍清晰的%@,多页报告可一次完成扫描。原图与解读全部本地加密保存,永不上传。" : { "推荐拍清晰的%@,多页报告可一次完成扫描。原图与解读全部本地加密保存,永不上传。" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -7086,6 +7112,7 @@
} }
}, },
"提取指标 · 共 28 项" : { "提取指标 · 共 28 项" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -7376,6 +7403,7 @@
} }
}, },
"整体摘记" : { "整体摘记" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -7398,6 +7426,7 @@
} }
}, },
"整张图" : { "整张图" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -8051,6 +8080,7 @@
} }
}, },
"本地 AI · 正在解读" : { "本地 AI · 正在解读" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -8205,6 +8235,7 @@
} }
}, },
"本地处理中 · 不会上传任何内容" : { "本地处理中 · 不会上传任何内容" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -8381,6 +8412,7 @@
} }
}, },
"本机摘要" : { "本机摘要" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -8403,6 +8435,7 @@
} }
}, },
"本次共检测 28 项,%@(血脂相关 2 项 + 尿酸)、%@(维生素 D。整体趋势提示代谢风险有所抬升建议优化饮食并复查血脂。" : { "本次共检测 28 项,%@(血脂相关 2 项 + 尿酸)、%@(维生素 D。整体趋势提示代谢风险有所抬升建议优化饮食并复查血脂。" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -8526,6 +8559,7 @@
}, },
"查看原图" : { "查看原图" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -8797,6 +8831,7 @@
} }
}, },
"正在本地识别第 1 / 3 页…" : { "正在本地识别第 1 / 3 页…" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -8819,6 +8854,7 @@
} }
}, },
"正在本地识别第 2 / 3 页…" : { "正在本地识别第 2 / 3 页…" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -8841,6 +8877,7 @@
} }
}, },
"正在本地识别第 3 / 3 页…" : { "正在本地识别第 3 / 3 页…" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -8885,6 +8922,7 @@
} }
}, },
"正常项" : { "正常项" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -9268,6 +9306,7 @@
} }
}, },
"甘油三酯" : { "甘油三酯" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -9290,6 +9329,7 @@
} }
}, },
"甘油三酯 TG" : { "甘油三酯 TG" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -9379,6 +9419,7 @@
} }
}, },
"生成整体摘要…" : { "生成整体摘要…" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -9791,6 +9832,7 @@
} }
}, },
"糖化血红蛋白" : { "糖化血红蛋白" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -10100,6 +10142,7 @@
} }
}, },
"维生素 D" : { "维生素 D" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -10122,6 +10165,7 @@
} }
}, },
"编辑" : { "编辑" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -10142,6 +10186,9 @@
} }
} }
} }
},
"编辑 / 删除" : {
}, },
"编辑「%@」" : { "编辑「%@」" : {
"localizations" : { "localizations" : {
@@ -10166,6 +10213,7 @@
} }
}, },
"编辑/删除" : { "编辑/删除" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -10389,6 +10437,7 @@
}, },
"范围 %@ %@" : { "范围 %@ %@" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -11006,6 +11055,7 @@
} }
}, },
"谷丙转氨酶" : { "谷丙转氨酶" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -11028,6 +11078,7 @@
} }
}, },
"谷丙转氨酶、空腹血糖、糖化血红蛋白…" : { "谷丙转氨酶、空腹血糖、糖化血红蛋白…" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -11050,6 +11101,7 @@
} }
}, },
"谷草转氨酶" : { "谷草转氨酶" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -11118,6 +11170,7 @@
} }
}, },
"超过参考上限 0.44。建议关注饮食结构3 个月内复查。" : { "超过参考上限 0.44。建议关注饮食结构3 个月内复查。" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -11184,6 +11237,7 @@
} }
}, },
"跳过" : { "跳过" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -11272,6 +11326,7 @@
} }
}, },
"载脂蛋白 A1" : { "载脂蛋白 A1" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -11317,6 +11372,7 @@
} }
}, },
"载脂蛋白 B" : { "载脂蛋白 B" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -12007,6 +12063,7 @@
} }
}, },
"预计耗时 58 秒 · 端侧 SME2 加速" : { "预计耗时 58 秒 · 端侧 SME2 加速" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -12117,6 +12174,7 @@
} }
}, },
"高密度脂蛋白" : { "高密度脂蛋白" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {

View File

@@ -5,6 +5,20 @@ enum IndicatorStatus: String, Codable, CaseIterable {
case high, low, normal 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 { enum ReportType: String, Codable, CaseIterable {
case checkup, lab, imaging, prescription, other case checkup, lab, imaging, prescription, other
@@ -38,6 +52,9 @@ final class Indicator {
/// :Trends seriesKey ;Timeline ( bp.systolic + bp.diastolic ) /// :Trends seriesKey ;Timeline ( bp.systolic + bp.diastolic )
var seriesKey: String? var seriesKey: String?
/// (IndicatorSource.rawValue) SwiftData ,
var sourceRaw: String = IndicatorSource.manual.rawValue
init(name: String, init(name: String,
value: String, value: String,
unit: String, unit: String,
@@ -48,7 +65,8 @@ final class Indicator {
report: Report? = nil, report: Report? = nil,
asset: Asset? = nil, asset: Asset? = nil,
pinned: Bool = false, pinned: Bool = false,
seriesKey: String? = nil) { seriesKey: String? = nil,
source: IndicatorSource = .manual) {
self.name = name self.name = name
self.value = value self.value = value
self.unit = unit self.unit = unit
@@ -60,11 +78,16 @@ final class Indicator {
self.asset = asset self.asset = asset
self.pinned = pinned self.pinned = pinned
self.seriesKey = seriesKey self.seriesKey = seriesKey
self.sourceRaw = source.rawValue
} }
var status: IndicatorStatus { var status: IndicatorStatus {
IndicatorStatus(rawValue: statusRaw) ?? .normal IndicatorStatus(rawValue: statusRaw) ?? .normal
} }
var source: IndicatorSource {
IndicatorSource(rawValue: sourceRaw) ?? .manual
}
} }
@Model @Model

View File

@@ -479,7 +479,8 @@ extension Report {
range: p.range, range: p.range,
status: p.status, status: p.status,
capturedAt: reportDate, capturedAt: reportDate,
report: self report: self,
source: .report
) )
ctx.insert(i) ctx.insert(i)
} }