diff --git a/docs/superpowers/specs/2026-06-07-trends-overhaul-and-home-calendar-design.md b/docs/superpowers/specs/2026-06-07-trends-overhaul-and-home-calendar-design.md new file mode 100644 index 0000000..5c644b2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-trends-overhaul-and-home-calendar-design.md @@ -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? +// 支持 "3.9-6.1" / "3.9~6.1" / "3.9 - 6.1";单边("<5.2"/">40"/"≤120")暂返回 nil(图不画带,正常) +static func parseRange(_ raw: String) -> ClosedRange? +``` + +> **去重**:有 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 引用。 diff --git a/scripts/release-testflight.sh b/scripts/release-testflight.sh new file mode 100755 index 0000000..10653af --- /dev/null +++ b/scripts/release-testflight.sh @@ -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 <} +请先执行: + 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" < + + + + method + app-store-connect + destination + upload + teamID + $TEAM_ID + uploadSymbols + + manageAppVersionAndBuildNumber + + + +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" diff --git a/康康.xcodeproj/project.pbxproj b/康康.xcodeproj/project.pbxproj index 1a484b9..455d64f 100644 --- a/康康.xcodeproj/project.pbxproj +++ b/康康.xcodeproj/project.pbxproj @@ -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; diff --git a/康康/AI/Prompts/VLPrompts.swift b/康康/AI/Prompts/VLPrompts.swift index 1be6249..099c938 100644 --- a/康康/AI/Prompts/VLPrompts.swift +++ b/康康/AI/Prompts/VLPrompts.swift @@ -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: """# } diff --git a/康康/Features/Calendar/CalendarOverviewView.swift b/康康/Features/Calendar/CalendarOverviewView.swift new file mode 100644 index 0000000..48a24cb --- /dev/null +++ b/康康/Features/Calendar/CalendarOverviewView.swift @@ -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) +} diff --git a/康康/Features/Capture/UnifiedCaptureFlow.swift b/康康/Features/Capture/UnifiedCaptureFlow.swift index 480ed5f..ef3465c 100644 --- a/康康/Features/Capture/UnifiedCaptureFlow.swift +++ b/康康/Features/Capture/UnifiedCaptureFlow.swift @@ -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) } diff --git a/康康/Features/Home/HomeCalendarCard.swift b/康康/Features/Home/HomeCalendarCard.swift new file mode 100644 index 0000000..fb7ff46 --- /dev/null +++ b/康康/Features/Home/HomeCalendarCard.swift @@ -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.. 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] + } +} diff --git a/康康/Features/Home/HomeView.swift b/康康/Features/Home/HomeView.swift index 0420edf..2ef7fb9 100644 --- a/康康/Features/Home/HomeView.swift +++ b/康康/Features/Home/HomeView.swift @@ -40,6 +40,8 @@ struct HomeView: View { .padding(.top, 4) .padding(.bottom, 18) + HomeCalendarCard() + TodayRemindersCard() OngoingSymptomsCard() diff --git a/康康/Features/Quick/QuickRegionCaptureFlow.swift b/康康/Features/Quick/QuickRegionCaptureFlow.swift index 1222b8d..6f71dae 100644 --- a/康康/Features/Quick/QuickRegionCaptureFlow.swift +++ b/康康/Features/Quick/QuickRegionCaptureFlow.swift @@ -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) } diff --git a/康康/Features/Timeline/TimelineEntry.swift b/康康/Features/Timeline/TimelineEntry.swift index cdd9187..ef639e9 100644 --- a/康康/Features/Timeline/TimelineEntry.swift +++ b/康康/Features/Timeline/TimelineEntry.swift @@ -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 { diff --git a/康康/Features/Timeline/TimelineEntryDetailView.swift b/康康/Features/Timeline/TimelineEntryDetailView.swift index de663ce..5bc44eb 100644 --- a/康康/Features/Timeline/TimelineEntryDetailView.swift +++ b/康康/Features/Timeline/TimelineEntryDetailView.swift @@ -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) } } } diff --git a/康康/Features/Trends/SeriesBucket.swift b/康康/Features/Trends/SeriesBucket.swift index d354fb3..aff761c 100644 --- a/康康/Features/Trends/SeriesBucket.swift +++ b/康康/Features/Trends/SeriesBucket.swift @@ -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? { + 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.. 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 ) } diff --git a/康康/Features/Trends/TrendDetailView.swift b/康康/Features/Trends/TrendDetailView.swift new file mode 100644 index 0000000..0aee045 --- /dev/null +++ b/康康/Features/Trends/TrendDetailView.swift @@ -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? { + 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? { + 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 + } + } +} diff --git a/康康/Features/Trends/TrendRow.swift b/康康/Features/Trends/TrendRow.swift new file mode 100644 index 0000000..d10da9c --- /dev/null +++ b/康康/Features/Trends/TrendRow.swift @@ -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) + } +} diff --git a/康康/Features/Trends/TrendsView.swift b/康康/Features/Trends/TrendsView.swift index d4e1ef0..78d36df 100644 --- a/康康/Features/Trends/TrendsView.swift +++ b/康康/Features/Trends/TrendsView.swift @@ -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,267 +18,81 @@ 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 { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 18) { - header.padding(.top, 4) - modeSwitch - anchorBar - calendarBody - legend - if mode == .month { - dayDetailInline + NavigationStack { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 18) { + header.padding(.top, 4) + 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) } - .padding(.horizontal, 20) - .padding(.bottom, 24) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Tj.Palette.sand.ignoresSafeArea()) + .navigationBarHidden(true) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(Tj.Palette.sand.ignoresSafeArea()) - } - - /// 日历下方 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("回到今天") + Text("趋势") + .font(.tjTitle(26)) + .foregroundStyle(Tj.Palette.text) + } + + private func section(title: String, buckets: [SeriesBucket]) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .lastTextBaseline) { + Text(title) + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + Text("\(buckets.count) 项") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) + Spacer() } - .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 + VStack(spacing: 12) { + ForEach(buckets) { bucket in + NavigationLink { + TrendDetailView(bucket: bucket) + } label: { + TrendRow(bucket: bucket) } - } 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 + .buttonStyle(.plain) } } } } - @ViewBuilder - private var seriesSection: some View { - let buckets = seriesBuckets - if !buckets.isEmpty { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .lastTextBaseline) { - Text("长期监测") - .font(.tjH2()) - .foregroundStyle(Tj.Palette.text) - Text("\(buckets.count) 项") - .font(.system(size: 12)) - .foregroundStyle(Tj.Palette.text3) - Spacer() - } - .padding(.top, 8) - - VStack(spacing: 12) { - ForEach(buckets) { bucket in - SeriesChartCard(bucket: bucket) - } - } - } - } - } - - 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) } } diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings index f406c5b..a6bc3e1 100644 --- a/康康/Localizable.xcstrings +++ b/康康/Localizable.xcstrings @@ -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" : { @@ -12207,4 +12265,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/康康/Models/Models.swift b/康康/Models/Models.swift index f140d05..b1eb410 100644 --- a/康康/Models/Models.swift +++ b/康康/Models/Models.swift @@ -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 diff --git a/康康/Services/CaptureService.swift b/康康/Services/CaptureService.swift index e5ac6a6..527950a 100644 --- a/康康/Services/CaptureService.swift +++ b/康康/Services/CaptureService.swift @@ -479,7 +479,8 @@ extension Report { range: p.range, status: p.status, capturedAt: reportDate, - report: self + report: self, + source: .report ) ctx.insert(i) }