From 054d8e351974ed9c78f13a3c822eed925b168c5e Mon Sep 17 00:00:00 2001 From: czz <862977248@qq.com> Date: Fri, 8 May 2026 09:17:09 +0800 Subject: [PATCH 1/2] docs: add sleep report page design spec --- .../specs/2026-05-08-sleep-report-design.md | 467 ++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-08-sleep-report-design.md diff --git a/docs/superpowers/specs/2026-05-08-sleep-report-design.md b/docs/superpowers/specs/2026-05-08-sleep-report-design.md new file mode 100644 index 0000000..1a2f4a7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-sleep-report-design.md @@ -0,0 +1,467 @@ +# 睡眠报告页设计说明 + +**日期:** 2026-05-08 + +## 目标 + +新增一个“健康报告页 / 睡眠健康分析页”,为用户提供宝宝睡眠质量、呼吸状态、心率状态、HRV 分析、异常事件统计、睡眠评分和 AI 分析建议等完整的睡眠健康信息展示能力。 + +首版定位为“可交互演示版”: + +- 页面结构完整 +- 使用本地 mock 数据驱动 +- 支持核心筛选和切换交互 +- 图表以轻量演示方式实现 +- 暂不接入真实后端接口 + +## 范围 + +- 新增独立页面 `pages/report/index` +- 首页保留现有设备绑定页 +- 新增两个进入报告页的入口: + - 底部导航里的“报告” + - 首页主体区域里的“查看睡眠报告”入口 +- 报告页支持 `日报 / 周报 / 月报` 切换 +- 报告页支持日期、房间、设备筛选 +- 报告页包含评分总览、趋势图、统计卡片、AI 分析、异常统计、HRV、低氧、打鼾、睡眠结构、日间状态预测等模块 +- 不接入真实接口,不新增大型依赖,不引入全局状态管理 + +## 页面定位 + +这是一个面向宝宝睡眠监测场景的深色数据报告页。页面需要延续当前首页已经建立的深色监测风格,让“首页”和“报告页”看起来属于同一个产品,而不是两个割裂的界面。 + +页面重点不是营销展示,而是帮助用户快速判断一晚睡眠是否稳定、有没有异常风险、接下来应关注什么。 + +## 页面结构 + +### 1. 页面入口 + +- 首页底部导航“报告”点击后跳转到报告页 +- 首页新增一个明显的“查看睡眠报告”入口卡片或快捷入口 +- 报告页顶部返回按钮点击后返回首页 + +### 2. 顶部区域 + +- 返回按钮 +- 宝宝名称 +- 日期展示与切换 +- 分享按钮 + +说明: + +- 分享按钮首版只做 `toast` 占位,提示“分享功能待接入” +- 宝宝名称使用 mock 数据,如“安安的睡眠报告” + +### 3. 时间维度切换 + +- 日报 +- 周报 +- 月报 + +默认选中: + +- 日报 + +切换后行为: + +- 重新读取对应维度的 mock 数据 +- 同步更新评分、趋势图、统计卡片和分析模块 + +### 4. 顶部筛选区 + +- 日期选择 +- 房间选择 +- 设备选择 + +首版交互: + +- 使用页面内本地状态维护当前筛选条件 +- 切换筛选项后刷新页面展示内容 +- 日期切换以预设 mock 日期集合实现,不引入复杂日期组件 + +### 5. 评分总览区 + +显示内容: + +- 综合睡眠评分 +- 睡眠评级 +- 睡眠质量状态 +- 评分组成项:入睡速度、睡眠稳定性、呼吸状态、心率状态、异常事件 + +评分规则: + +- `90-100`:优秀 +- `75-89`:良好 +- `60-74`:合格 +- `0-59`:异常 + +视觉要求: + +- 使用明显的中心评分视觉 +- 颜色随等级变化 +- 同时展示评分细分项,避免只有总分 + +### 6. 睡眠趋势与核心图表区 + +包含两组主要内容: + +- 整夜睡眠趋势图 +- 睡眠分数趋势图 + +整夜趋势图展示: + +- 深睡 +- 浅睡 +- REM +- 清醒 +- 呼吸波动 + +睡眠分数趋势图展示: + +- 时间 +- 睡眠评分 +- 阶段变化 + +首版支持: + +- 切换时间维度后刷新图表 +- 点击数据点后显示当前时间点的简要数值 + +首版不做: + +- 手势缩放 +- 复杂联动高亮 +- 大型图表引擎能力 + +### 7. 睡眠数据统计卡片区 + +展示以下 9 项核心指标: + +1. 入睡时长 +2. 平均呼吸率 +3. 平均血氧 +4. 呼吸暂停次数 +5. 平均 HRV +6. 平均心率 +7. 异常翻身次数 +8. 呼吸暂停时长 +9. 心率过快次数 + +每张卡片显示: + +- 指标名称 +- 数值 +- 单位 +- 状态标签 + +状态分级: + +- 正常 +- 注意 +- 异常 + +### 8. AI 分析模块 + +模块拆分为两部分: + +- AI 睡眠分析摘要 +- 睡眠改善建议 + +分析内容包括: + +- 睡眠稳定性分析 +- 呼吸状态分析 +- 心率变化分析 +- 异常风险提醒 +- 睡眠改善建议 + +首版要求: + +- 文案由本地 mock 数据提供 +- 页面结构按真实业务报告组织 +- 保留后续接 AI 接口的扩展空间 + +### 9. 深度分析模块 + +包含以下内容: + +- 异常统计模块 +- HRV 分析模块 +- 心率散点图 +- 呼吸波形图 +- 低氧统计模块 +- 打鼾监测模块 +- 异常事件散点图 +- 睡眠结构模块 +- 自主神经分析模块 +- 日间状态预测模块 + +各模块展示重点如下: + +#### 异常统计 + +- 心率异常 +- 呼吸异常 +- 血氧异常 +- 呼吸暂停 +- 离床行为 + +显示: + +- 次数 +- 最大值 +- 平均值 +- 持续时长 + +#### HRV 分析 + +- HRV 值 +- RMSSD +- SDNN +- LF/HF +- 压力指数 + +状态: + +- 正常 +- 压力偏高 +- 疲劳风险 + +#### 心率散点图 + +- 横轴:时间 +- 纵轴:心率值 +- 异常点高亮 + +#### 呼吸波形图 + +- 呼吸波动曲线 +- 异常点标记 +- 呼吸暂停标记 + +#### 低氧统计 + +- 最低血氧 +- 低氧次数 +- 持续时长 + +状态: + +- 正常 +- 低氧风险 +- 高风险 + +#### 打鼾监测 + +- 打鼾时长 +- 打鼾频率 +- 峰值强度 + +#### 异常事件散点图 + +事件类型: + +- 呼吸暂停 +- 心率异常 +- 哭闹 +- 离床 + +#### 睡眠结构 + +- 深睡比例 +- 浅睡比例 +- REM 比例 +- 清醒比例 + +#### 自主神经分析 + +- 交感活跃度 +- 副交感活跃度 +- 压力状态 + +#### 日间状态预测 + +- 精神状态 +- 疲劳程度 +- 情绪状态 + +状态颜色: + +- 优秀:绿色 +- 良好:青色 +- 注意:橙色 +- 异常:红色 + +## 交互设计 + +### 1. 首页跳转 + +- 点击底部导航“报告”进入报告页 +- 点击首页快捷入口进入报告页 + +### 2. 报表维度切换 + +- 点击 `日报 / 周报 / 月报` +- 更新当前维度状态 +- 刷新当前维度下的所有展示数据 + +### 3. 筛选切换 + +- 日期切换:切换到不同 mock 日期 +- 房间切换:切换到不同房间 mock 数据 +- 设备切换:切换到不同设备 mock 数据 + +### 4. 图表点按 + +- 点击折线图或散点图中的数据点 +- 页面显示对应时间点的数据提示 + +### 5. 分享 + +- 点击分享按钮 +- 弹出“分享功能待接入” + +## 数据组织设计 + +建议新增本地 mock 数据文件: + +- `src/pages/report/mock.ts` + +数据按三个时间维度组织: + +- `daily` +- `weekly` +- `monthly` + +每个维度下再包含不同日期、房间、设备的数据组合。每组数据至少包含: + +- 页面头部信息 +- 睡眠评分总览 +- 评分组成项 +- 趋势图数据 +- 核心统计卡片 +- AI 分析文案 +- 异常统计数据 +- HRV 数据 +- 心率与呼吸图表数据 +- 低氧、打鼾、睡眠结构、自主神经、日间状态数据 + +这样做的目的: + +- 首版无需后端即可完整演示 +- 后续接接口时,页面结构可以保持不变 +- 只需要把 mock 数据来源替换成接口返回 + +## 实现建议 + +### 1. 页面与组件拆分 + +新增页面: + +- `src/pages/report/index.tsx` +- `src/pages/report/index.scss` +- `src/pages/report/index.config.ts` + +建议补充少量组件,避免单文件过长: + +- `src/components/report-header/` +- `src/components/report-score-card/` +- `src/components/report-metric-grid/` +- `src/components/report-chart-card/` + +拆分原则: + +- 页面文件负责组装和状态控制 +- 组件负责展示某一类稳定模块 +- 保持文件数量适中,优先让新手易读 + +### 2. 状态管理 + +仅使用 React 自带状态管理当前页面所需状态: + +- 当前时间维度 +- 当前日期 +- 当前房间 +- 当前设备 +- 当前图表选中点 + +不引入全局状态库。 + +### 3. 图表实现方式 + +首版不引入重型图表依赖。 + +推荐实现方式: + +- 使用 `View` 组合、绝对定位、渐变背景和简单几何块实现折线、散点、柱条和波形效果 +- 圆环评分使用纯样式方案或可被 Taro 兼容的轻量视觉实现 +- 图表重点是表达业务信息和交互反馈,而不是实现完整图表引擎 + +这样更符合当前项目目标: + +- 依赖更少 +- 结构更简单 +- 更适合零基础项目维护 + +## 视觉方向 + +- 延续首页现有深色监测风格 +- 页面背景保持深灰蓝色系 +- 模块之间通过卡片分组,但避免过度装饰 +- 信息密度高,但层次必须清楚 +- 使用状态色表达风险等级: + - 绿色:优秀 / 正常 + - 青色:良好 + - 橙色:注意 + - 红色:异常 + +## 实现边界 + +首版明确不包含以下内容: + +- 真实接口请求 +- 真正的小程序分享链路 +- 手势缩放图表 +- 图表间复杂联动 +- 大型第三方 UI 库 +- 大型第三方图表库 +- 全局状态管理方案 + +## 验收标准 + +### 页面与导航 + +- 首页正常显示,不影响原有设备绑定逻辑 +- 底部“报告”点击可进入报告页 +- 首页新增入口点击可进入报告页 +- 报告页返回按钮可回到首页 + +### 交互行为 + +- `日报 / 周报 / 月报` 切换后,页面摘要和图表同步变化 +- 日期、房间、设备切换后,报告内容同步变化 +- 点击趋势图或散点图数据点时,能显示对应数值提示 +- 分享按钮点击后有 `toast` 占位反馈 + +### 视觉与内容 + +- 页面整体风格与首页一致 +- 核心模块齐全,长页面滚动时层级清晰 +- 状态色使用一致,不混乱 +- 重点信息在首屏和前几屏内能快速看到 + +### 代码与维护 + +- 不新增重型依赖 +- 使用本地 mock 数据驱动 +- 代码结构保持简单,适合新手阅读 +- `README.md` 更新新增报告页说明 + +### 验证方式 + +- 至少运行一次 `npm run build:weapp` +- 确认没有新增 TypeScript 或 Taro 构建错误 +- 对可抽离的纯逻辑补最小必要测试,例如: + - 评分等级映射 + - 状态颜色映射 + - mock 数据选择逻辑 From a6382d669baa90316cb9642da29246938e2eb3ad Mon Sep 17 00:00:00 2001 From: czz <862977248@qq.com> Date: Fri, 8 May 2026 11:30:37 +0800 Subject: [PATCH 2/2] feat: add sleep report page --- .gitignore | 1 + README.md | 94 +- .../plans/2026-05-08-sleep-report-page.md | 658 +++++++++++++ package.json | 3 +- scripts/tests/report-utils.test.cjs | 45 + src/app.config.ts | 2 +- src/components/report/chart-card.tsx | 66 ++ src/components/report/chip-group.tsx | 29 + src/components/report/daytime-prediction.tsx | 28 + src/components/report/distribution-card.tsx | 35 + src/components/report/insight-list.tsx | 28 + src/components/report/kv-list.tsx | 29 + src/components/report/metric-grid.tsx | 28 + src/components/report/page-header.tsx | 25 + src/components/report/score-overview.tsx | 63 ++ src/components/report/section-card.tsx | 24 + src/components/report/summary-block.tsx | 33 + src/pages/index/index.scss | 49 + src/pages/index/index.tsx | 24 +- src/pages/report/index.config.ts | 3 + src/pages/report/index.scss | 754 +++++++++++++++ src/pages/report/index.tsx | 195 ++++ src/pages/report/mock.ts | 865 ++++++++++++++++++ src/pages/report/report-utils.ts | 55 ++ src/pages/report/types.ts | 107 +++ tsconfig.report-tests.json | 12 + 26 files changed, 3249 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-08-sleep-report-page.md create mode 100644 scripts/tests/report-utils.test.cjs create mode 100644 src/components/report/chart-card.tsx create mode 100644 src/components/report/chip-group.tsx create mode 100644 src/components/report/daytime-prediction.tsx create mode 100644 src/components/report/distribution-card.tsx create mode 100644 src/components/report/insight-list.tsx create mode 100644 src/components/report/kv-list.tsx create mode 100644 src/components/report/metric-grid.tsx create mode 100644 src/components/report/page-header.tsx create mode 100644 src/components/report/score-overview.tsx create mode 100644 src/components/report/section-card.tsx create mode 100644 src/components/report/summary-block.tsx create mode 100644 src/pages/report/index.config.ts create mode 100644 src/pages/report/index.scss create mode 100644 src/pages/report/index.tsx create mode 100644 src/pages/report/mock.ts create mode 100644 src/pages/report/report-utils.ts create mode 100644 src/pages/report/types.ts create mode 100644 tsconfig.report-tests.json diff --git a/.gitignore b/.gitignore index e9e2379..d56d6b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /node_modules/ /dist/ +/tmp/ /.swc/ /.codex/ /project.private.config.json diff --git a/README.md b/README.md index f58a044..cd5958f 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,13 @@ 当前首页已经改成了一版“设备绑定首页(无设备状态)”业务样式,方便你直接在这个基础上继续接真实接口和页面跳转。 +当前项目还新增了一版“睡眠报告演示页”,用于展示宝宝睡眠评分、呼吸状态、HRV、异常统计和 AI 分析建议等完整报告结构。 + ## 1. 目前已经包含什么 - `Taro + React + TypeScript` 项目骨架 - 首页设备绑定业务示例页面 +- 睡眠报告演示页面 - 小程序 `AppID` 配置 - `AGENTS.md` 协作规则文件 - 从开发到发布的中文说明 @@ -26,11 +29,31 @@ ├─ app.config.ts ├─ app.scss ├─ app.tsx + ├─ components + │ └─ report + │ ├─ chart-card.tsx + │ ├─ chip-group.tsx + │ ├─ daytime-prediction.tsx + │ ├─ distribution-card.tsx + │ ├─ insight-list.tsx + │ ├─ kv-list.tsx + │ ├─ metric-grid.tsx + │ ├─ page-header.tsx + │ ├─ score-overview.tsx + │ ├─ section-card.tsx + │ └─ summary-block.tsx └─ pages - └─ index + ├─ index + │ ├─ index.config.ts + │ ├─ index.scss + │ └─ index.tsx + └─ report ├─ index.config.ts ├─ index.scss - └─ index.tsx + ├─ index.tsx + ├─ mock.ts + ├─ report-utils.ts + └─ types.ts ``` ## 3. 你需要先准备什么 @@ -139,6 +162,12 @@ npm run dev:weapp 6. 如果开发者工具提示编译目录相关问题,确认你已经先运行过: `npm run dev:weapp` +如果你要重点查看新增的睡眠报告页: + +- 先进入首页 +- 点击底部“报告” +- 或点击首页里的“查看睡眠报告” + ## 11. 项目里的几个核心文件 - [package.json](C:/Users/a/Documents/New%20project%203/package.json) @@ -182,6 +211,29 @@ npm run dev:weapp - 设备列表 - 报告、消息、我的等页面 +## 12.1 当前报告页做了什么 + +现在项目里已经新增一个独立的“睡眠报告页”,主要包含: + +- 首页底部“报告”入口跳转 +- 首页主体里的“查看睡眠报告”快捷入口 +- `日报 / 周报 / 月报` 三种时间维度切换 +- 日期、房间、设备三个筛选条件 +- 睡眠评分总览 +- 睡眠趋势图和睡眠分数趋势图 +- 9 项核心指标卡片 +- AI 睡眠分析和改善建议 +- 异常统计、HRV、心率散点图、呼吸波形图 +- 低氧统计、打鼾监测、异常事件散点图 +- 睡眠结构、自主神经分析、日间状态预测 + +说明: + +- 当前报告页使用本地 mock 数据驱动 +- 图表是轻量前端演示实现,没有引入大型图表依赖 +- 各板块尽量拆成了可复用组件,便于你继续改 +- 分享功能当前还是前端占位提示,后续可再接真实能力 + ## 13. 你接下来最常做的开发动作 ### 改页面文案和逻辑 @@ -190,6 +242,24 @@ npm run dev:weapp - [src/pages/index/index.tsx](C:/Users/a/Documents/New%20project%203/src/pages/index/index.tsx) +### 改报告页 + +编辑: + +- [src/pages/report/index.tsx](C:/Users/a/Documents/New%20project%203/src/pages/report/index.tsx) + +### 改报告页 mock 数据 + +编辑: + +- [src/pages/report/mock.ts](C:/Users/a/Documents/New%20project%203/src/pages/report/mock.ts) + +### 改报告页组件 + +编辑: + +- `src/components/report/` 目录下的组件文件 + ### 改全局样式 编辑: @@ -219,6 +289,12 @@ src - 在微信开发者工具里查看编译后的模拟器效果 - 扫码和蓝牙相关能力更推荐使用真机调试,因为开发者工具里不一定能完整模拟真实权限和设备搜索环境 +如果你在改报告页,推荐同步检查: + +- 报表维度切换后数据有没有变化 +- 日期、房间、设备切换后内容有没有同步 +- 点击图表节点后提示值有没有更新 + ### 真机预览 1. 确保已填写真实 `AppID` @@ -282,6 +358,20 @@ npm run build:weapp - HTTPS - 后台服务正常可访问 +### 5. 如何验证报告页里的纯逻辑 + +当前项目新增了一个最小测试命令,用来验证报告页里的纯逻辑函数,比如: + +- 睡眠评分等级映射 +- 状态颜色映射 +- mock 数据选择逻辑 + +执行: + +```bash +npm run test:report +``` + ## 17. 建议你下一步怎么做 如果你是零基础,推荐按这个顺序继续: diff --git a/docs/superpowers/plans/2026-05-08-sleep-report-page.md b/docs/superpowers/plans/2026-05-08-sleep-report-page.md new file mode 100644 index 0000000..86a3aeb --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-sleep-report-page.md @@ -0,0 +1,658 @@ +# Sleep Report Page Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为当前小程序新增一个可交互的睡眠报告页,并在首页和底部导航中接入跳转入口,同时保持项目结构简单、适合新手继续维护。 + +**Architecture:** 报告页采用单页纵向滚动布局,使用本地 mock 数据驱动 `日报 / 周报 / 月报`、日期、房间、设备切换。纯逻辑抽到独立工具文件中,用最小测试基建验证评分等级、状态颜色和 mock 选择逻辑;页面层保持轻量组件拆分,不引入大型图表或状态管理依赖。 + +**Tech Stack:** Taro 4、React 18、TypeScript、SCSS、Node.js 内置 `node:test` + +--- + +## File Structure + +- Create: `src/pages/report/index.tsx` + - 报告页主文件,负责状态切换、数据选择、页面组装和点击反馈 +- Create: `src/pages/report/index.scss` + - 报告页整体样式和各个数据卡片、图表占位样式 +- Create: `src/pages/report/index.config.ts` + - 报告页页面配置 +- Create: `src/pages/report/mock.ts` + - 睡眠报告 mock 数据和维度、日期、房间、设备选项 +- Create: `src/pages/report/report-utils.ts` + - 评分等级、状态色、mock 选择等纯逻辑 +- Create: `scripts/tests/report-utils.test.cjs` + - 使用 Node 内置测试的 JS 测试文件 +- Create: `tsconfig.report-tests.json` + - 仅为纯逻辑测试编译 `report-utils.ts` +- Modify: `package.json` + - 增加最小测试命令 +- Modify: `src/app.config.ts` + - 注册报告页 +- Modify: `src/pages/index/index.tsx` + - 底部导航与首页入口接入报告页跳转 +- Modify: `src/pages/index/index.scss` + - 首页报告入口样式和导航点击态微调 +- Modify: `README.md` + - 更新新增报告页和运行说明 + +### Task 1: 建立可测试的报告工具函数与最小测试基建 + +**Files:** +- Create: `src/pages/report/report-utils.ts` +- Create: `scripts/tests/report-utils.test.cjs` +- Create: `tsconfig.report-tests.json` +- Modify: `package.json` + +- [ ] **Step 1: 写评分和数据选择工具的失败测试** + +```js +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + getSleepLevel, + getStatusTone, + pickReportRecord +} = require("../../tmp/report-tests/report-utils.js"); + +test("getSleepLevel maps score 65 to 合格", () => { + assert.deepEqual(getSleepLevel(65), { + label: "合格", + tone: "warning" + }); +}); + +test("getStatusTone maps 异常 to danger", () => { + assert.equal(getStatusTone("异常"), "danger"); +}); + +test("pickReportRecord falls back to first device record", () => { + const record = pickReportRecord( + { + "2026-05-08": { + roomA: { + deviceA: { score: 65 }, + deviceB: { score: 79 } + } + } + }, + "2026-05-08", + "roomA", + "missing-device" + ); + + assert.equal(record.score, 65); +}); +``` + +- [ ] **Step 2: 运行测试并确认当前失败** + +Run: + +```bash +npm run test:report +``` + +Expected: + +- 命令失败 +- 报错提示 `report-utils.js` 不存在或相关导出不存在 + +- [ ] **Step 3: 写最小实现与测试编译配置** + +```ts +export type SleepLevel = "excellent" | "good" | "warning" | "danger"; + +export function getSleepLevel(score: number) { + if (score >= 90) return { label: "优秀", tone: "excellent" as const }; + if (score >= 75) return { label: "良好", tone: "good" as const }; + if (score >= 60) return { label: "合格", tone: "warning" as const }; + return { label: "异常", tone: "danger" as const }; +} + +export function getStatusTone(status: string) { + if (status === "正常" || status === "优秀") return "excellent"; + if (status === "良好") return "good"; + if (status === "注意" || status === "合格") return "warning"; + return "danger"; +} + +export function pickReportRecord( + records: Record>>, + dateKey: string, + roomKey: string, + deviceKey: string +) { + const rooms = records[dateKey] ?? {}; + const devices = rooms[roomKey] ?? {}; + + if (devices[deviceKey]) { + return devices[deviceKey]; + } + + const firstDevice = Object.values(devices)[0]; + + if (!firstDevice) { + throw new Error("未找到报告数据"); + } + + return firstDevice; +} +``` + +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "tmp/report-tests", + "module": "CommonJS", + "target": "ES2020", + "declaration": false + }, + "include": ["src/pages/report/report-utils.ts"] +} +``` + +```json +{ + "scripts": { + "test:report": "tsc -p tsconfig.report-tests.json && node --test scripts/tests/report-utils.test.cjs" + } +} +``` + +- [ ] **Step 4: 再次运行测试并确认通过** + +Run: + +```bash +npm run test:report +``` + +Expected: + +- `3` 个测试通过 +- 退出码为 `0` + +- [ ] **Step 5: 提交这一小步** + +```bash +git add package.json tsconfig.report-tests.json src/pages/report/report-utils.ts scripts/tests/report-utils.test.cjs +git commit -m "test: add report utils coverage" +``` + +### Task 2: 注册报告页并接入首页两个入口 + +**Files:** +- Modify: `src/app.config.ts` +- Modify: `src/pages/index/index.tsx` +- Modify: `src/pages/index/index.scss` +- Create: `src/pages/report/index.config.ts` +- Create: `src/pages/report/index.tsx` +- Create: `src/pages/report/index.scss` + +- [ ] **Step 1: 先为首页跳转行为写失败测试或可验证占位** + +由于当前项目没有页面级单测基础,这一步使用最小可验证方式:先在报告页文件不存在的状态下运行编译,确认新增页面前不能通过对应页面注册与引用。 + +Run: + +```bash +npm run build:weapp +``` + +Expected: + +- 当前构建通过 +- 但还没有报告页,也不存在新的页面注册和跳转逻辑 + +- [ ] **Step 2: 注册报告页并在首页接入跳转** + +```ts +export default defineAppConfig({ + pages: ["pages/index/index", "pages/report/index"], + window: { + navigationBarTitleText: "新手小程序", + navigationBarBackgroundColor: "#1AAD19", + navigationBarTextStyle: "white", + backgroundTextStyle: "light", + backgroundColor: "#f6f7fb" + } +}); +``` + +```ts +const navItems = [ + { key: "home", label: "首页", active: true }, + { key: "report", label: "报告", active: false }, + { key: "assistant", label: "小e", active: false }, + { key: "message", label: "消息", active: false, badge: true }, + { key: "mine", label: "我的", active: false } +]; + +const openReportPage = () => { + Taro.navigateTo({ url: "/pages/report/index" }); +}; + +const handleTabClick = (key: string, label: string) => { + if (key === "report") { + openReportPage(); + return; + } + + showToast(`${label}功能待接入`); +}; +``` + +```tsx + + + 昨夜睡眠分析 + 查看睡眠报告 + + > + +``` + +- [ ] **Step 3: 新建最小报告页壳子** + +```ts +export default definePageConfig({ + navigationStyle: "custom" +}); +``` + +```tsx +export default function ReportPage() { + return 睡眠报告加载中; +} +``` + +```scss +.report-page { + min-height: 100vh; + background: linear-gradient(180deg, #1e2432 0%, #191f2c 100%); +} +``` + +- [ ] **Step 4: 运行构建确认页面注册与跳转壳子可编译** + +Run: + +```bash +npm run build:weapp +``` + +Expected: + +- 构建通过 +- 编译产物包含 `pages/report` + +- [ ] **Step 5: 提交这一小步** + +```bash +git add src/app.config.ts src/pages/index/index.tsx src/pages/index/index.scss src/pages/report/index.config.ts src/pages/report/index.tsx src/pages/report/index.scss +git commit -m "feat: add sleep report page entry" +``` + +### Task 3: 接入 mock 数据、筛选状态和顶部总览区 + +**Files:** +- Create: `src/pages/report/mock.ts` +- Modify: `src/pages/report/index.tsx` +- Modify: `src/pages/report/index.scss` +- Modify: `src/pages/report/report-utils.ts` + +- [ ] **Step 1: 为 mock 选择逻辑补一个新的失败测试** + +```js +test("pickReportRecord returns selected device when it exists", () => { + const record = pickReportRecord( + { + "2026-05-08": { + roomA: { + deviceA: { score: 65 }, + deviceB: { score: 88 } + } + } + }, + "2026-05-08", + "roomA", + "deviceB" + ); + + assert.equal(record.score, 88); +}); +``` + +- [ ] **Step 2: 运行测试并确认新用例失败** + +Run: + +```bash +npm run test:report +``` + +Expected: + +- 新增用例失败,提示逻辑未覆盖或实现未更新 + +- [ ] **Step 3: 写报告页 mock 数据、顶部状态和总览区最小实现** + +```ts +export const reportDimensions = ["daily", "weekly", "monthly"] as const; + +export const reportOptions = { + daily: { + dates: ["2026-05-08", "2026-05-07"], + rooms: [ + { label: "婴儿房", value: "roomA" }, + { label: "主卧", value: "roomB" } + ], + devices: [ + { label: "床垫监测仪 A1", value: "deviceA" }, + { label: "床垫监测仪 B2", value: "deviceB" } + ] + } +}; +``` + +```tsx +const [dimension, setDimension] = useState("daily"); +const [dateKey, setDateKey] = useState(reportOptions.daily.dates[0]); +const [roomKey, setRoomKey] = useState(reportOptions.daily.rooms[0].value); +const [deviceKey, setDeviceKey] = useState(reportOptions.daily.devices[0].value); + +const currentRecord = pickReportRecord(reportRecords[dimension], dateKey, roomKey, deviceKey); +const sleepLevel = getSleepLevel(currentRecord.score); +``` + +```tsx + + Taro.navigateBack()} /> + + {currentRecord.babyName} + {dateKey} + + showToast("分享功能待接入")} /> + +``` + +```tsx + + {currentRecord.score} + + {sleepLevel.label} + + +``` + +- [ ] **Step 4: 运行测试和构建** + +Run: + +```bash +npm run test:report +npm run build:weapp +``` + +Expected: + +- 报告工具测试全部通过 +- 报告页能完成顶部区、维度切换和筛选区的编译 + +- [ ] **Step 5: 提交这一小步** + +```bash +git add src/pages/report/mock.ts src/pages/report/index.tsx src/pages/report/index.scss src/pages/report/report-utils.ts scripts/tests/report-utils.test.cjs +git commit -m "feat: add report header and summary state" +``` + +### Task 4: 实现评分总览、趋势图和核心统计卡片 + +**Files:** +- Modify: `src/pages/report/index.tsx` +- Modify: `src/pages/report/index.scss` + +- [ ] **Step 1: 先为状态色映射补一个失败测试** + +```js +test("getStatusTone maps 合格 to warning", () => { + assert.equal(getStatusTone("合格"), "warning"); +}); +``` + +- [ ] **Step 2: 运行测试并确认失败** + +Run: + +```bash +npm run test:report +``` + +Expected: + +- 新增状态映射用例失败 + +- [ ] **Step 3: 实现评分构成、主趋势图、睡眠分数趋势图和 9 项统计卡片** + +```tsx + + 睡眠质量趋势 + + {currentRecord.sleepTrend.map((point, index) => ( + setSelectedTrendTime(point.time)} + /> + ))} + + +``` + +```tsx + + {currentRecord.metrics.map((metric) => ( + + {metric.label} + + {metric.value} + {metric.unit} + + + {metric.status} + + + ))} + +``` + +- [ ] **Step 4: 运行测试与构建,确认可交互卡片和趋势点正常编译** + +Run: + +```bash +npm run test:report +npm run build:weapp +``` + +Expected: + +- 所有测试通过 +- 报告页的评分、趋势图和统计卡片编译通过 + +- [ ] **Step 5: 提交这一小步** + +```bash +git add src/pages/report/index.tsx src/pages/report/index.scss src/pages/report/report-utils.ts scripts/tests/report-utils.test.cjs +git commit -m "feat: add report charts and metrics" +``` + +### Task 5: 实现 AI 分析与深度分析模块 + +**Files:** +- Modify: `src/pages/report/index.tsx` +- Modify: `src/pages/report/index.scss` +- Modify: `src/pages/report/mock.ts` + +- [ ] **Step 1: 为缺省报告数据错误路径补一个失败测试** + +```js +test("pickReportRecord throws when no room data exists", () => { + assert.throws( + () => pickReportRecord({}, "2026-05-08", "roomA", "deviceA"), + /未找到报告数据/ + ); +}); +``` + +- [ ] **Step 2: 运行测试并确认失败** + +Run: + +```bash +npm run test:report +``` + +Expected: + +- 新增错误路径用例失败 + +- [ ] **Step 3: 实现 AI 摘要、异常统计、HRV、心率散点、呼吸波形、低氧、打鼾、睡眠结构、自主神经和日间预测模块** + +```tsx + + AI 分析 + {currentRecord.aiSummary.map((item) => ( + + {item.title} + {item.body} + + ))} + +``` + +```tsx + + 心率变异性(HRV) + {currentRecord.hrvMetrics.map((item) => ( + + {item.label} + {item.value} + + ))} + +``` + +```tsx + + 日间状态预测 + + {currentRecord.daytimePrediction.map((item) => ( + + {item.label} + {item.status} + + ))} + + +``` + +- [ ] **Step 4: 运行测试与构建** + +Run: + +```bash +npm run test:report +npm run build:weapp +``` + +Expected: + +- 错误路径和已有逻辑测试通过 +- 报告页所有深度分析模块通过编译 + +- [ ] **Step 5: 提交这一小步** + +```bash +git add src/pages/report/index.tsx src/pages/report/index.scss src/pages/report/mock.ts src/pages/report/report-utils.ts scripts/tests/report-utils.test.cjs +git commit -m "feat: complete sleep report sections" +``` + +### Task 6: 更新 README 并做最终验证 + +**Files:** +- Modify: `README.md` +- Modify: `src/app.config.ts` +- Modify: `src/pages/index/index.tsx` +- Modify: `src/pages/index/index.scss` +- Modify: `src/pages/report/index.tsx` +- Modify: `src/pages/report/index.scss` +- Modify: `src/pages/report/index.config.ts` +- Modify: `src/pages/report/mock.ts` +- Modify: `src/pages/report/report-utils.ts` +- Modify: `package.json` +- Modify: `tsconfig.report-tests.json` +- Modify: `scripts/tests/report-utils.test.cjs` + +- [ ] **Step 1: 更新 README 的页面说明和运行命令说明** + +```md +## 1. 目前已经包含什么 + +- 首页设备绑定业务示例页面 +- 睡眠报告演示页 + +## 13. 你接下来最常做的开发动作 + +### 改报告页 + +编辑: + +- `src/pages/report/index.tsx` +- `src/pages/report/mock.ts` +``` + +- [ ] **Step 2: 运行完整验证** + +Run: + +```bash +npm run test:report +npm run build:weapp +``` + +Expected: + +- 报告页工具测试全部通过 +- Taro 小程序构建通过 +- 无新增依赖安装步骤 + +- [ ] **Step 3: 检查最终改动范围** + +Run: + +```bash +git status --short +``` + +Expected: + +- 只出现本计划涉及的文件改动 + +- [ ] **Step 4: 提交最终实现** + +```bash +git add README.md package.json tsconfig.report-tests.json scripts/tests/report-utils.test.cjs src/app.config.ts src/pages/index/index.tsx src/pages/index/index.scss src/pages/report/index.config.ts src/pages/report/index.tsx src/pages/report/index.scss src/pages/report/mock.ts src/pages/report/report-utils.ts +git commit -m "feat: add sleep report page" +``` + +## Self-Review + +- 规格要求的两处入口、顶部区域、三种时间维度、筛选区、评分总览、趋势图、统计卡片、AI 分析、深度分析模块、README 更新和验证命令都已经映射到具体任务。 +- 计划没有使用 `TODO`、`TBD`、`类似上一步` 这类占位写法。 +- 任务里的函数名、文件路径和命令保持一致,后续执行时应继续沿用 `getSleepLevel`、`getStatusTone`、`pickReportRecord` 这些命名,避免偏离计划。 diff --git a/package.json b/package.json index 7767705..e2509ee 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "description": "Taro + React + TypeScript 微信小程序入门项目", "scripts": { "dev:weapp": "taro build --type weapp --watch", - "build:weapp": "taro build --type weapp" + "build:weapp": "taro build --type weapp", + "test:report": "tsc -p tsconfig.report-tests.json && node scripts/tests/report-utils.test.cjs" }, "dependencies": { "@tarojs/components": "^4.0.0", diff --git a/scripts/tests/report-utils.test.cjs b/scripts/tests/report-utils.test.cjs new file mode 100644 index 0000000..138a14e --- /dev/null +++ b/scripts/tests/report-utils.test.cjs @@ -0,0 +1,45 @@ +const assert = require("node:assert/strict"); +const { + getSleepLevel, + getStatusTone, + pickReportRecord +} = require("../../tmp/report-tests/report-utils.js"); + +function run(name, fn) { + try { + fn(); + console.log(`PASS ${name}`); + } catch (error) { + console.error(`FAIL ${name}`); + throw error; + } +} + +run("getSleepLevel maps score 65 to 合格", () => { + assert.deepEqual(getSleepLevel(65), { + label: "合格", + tone: "warning" + }); +}); + +run("getStatusTone maps 异常 to danger", () => { + assert.equal(getStatusTone("异常"), "danger"); +}); + +run("pickReportRecord falls back to first device record", () => { + const record = pickReportRecord( + { + "2026-05-08": { + roomA: { + deviceA: { score: 65 }, + deviceB: { score: 79 } + } + } + }, + "2026-05-08", + "roomA", + "missing-device" + ); + + assert.equal(record.score, 65); +}); diff --git a/src/app.config.ts b/src/app.config.ts index 4e2430c..59372e8 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -1,5 +1,5 @@ export default defineAppConfig({ - pages: ["pages/index/index"], + pages: ["pages/index/index", "pages/report/index"], window: { navigationBarTitleText: "新手小程序", navigationBarBackgroundColor: "#1AAD19", diff --git a/src/components/report/chart-card.tsx b/src/components/report/chart-card.tsx new file mode 100644 index 0000000..c704b61 --- /dev/null +++ b/src/components/report/chart-card.tsx @@ -0,0 +1,66 @@ +import { Text, View } from "@tarojs/components"; +import type { TrendPoint } from "../../pages/report/types"; + +type ReportChartCardProps = { + title: string; + subtitle?: string; + points: TrendPoint[]; + selectedId: string; + onSelect: (id: string) => void; + mode?: "line" | "scatter"; +}; + +export function ReportChartCard({ + title, + subtitle, + points, + selectedId, + onSelect, + mode = "line" +}: ReportChartCardProps) { + const selectedPoint = points.find((item) => item.id === selectedId) ?? points[0]; + + return ( + + + + {title} + {subtitle ? {subtitle} : null} + + {selectedPoint ? ( + + {selectedPoint.time} + + ) : null} + + + {selectedPoint ? ( + + {selectedPoint.label} + {selectedPoint.value} + {selectedPoint.meta ? {selectedPoint.meta} : null} + + ) : null} + + + + {[0, 1, 2, 3].map((item) => ( + + ))} + + + {points.map((item) => ( + + {mode === "line" ? : null} + onSelect(item.id)} + /> + {item.time} + + ))} + + + ); +} diff --git a/src/components/report/chip-group.tsx b/src/components/report/chip-group.tsx new file mode 100644 index 0000000..d01d58a --- /dev/null +++ b/src/components/report/chip-group.tsx @@ -0,0 +1,29 @@ +import { Text, View } from "@tarojs/components"; + +type ChipOption = { + label: string; + value: string; +}; + +type ReportChipGroupProps = { + options: ChipOption[]; + value: string; + onChange: (value: string) => void; + compact?: boolean; +}; + +export function ReportChipGroup({ options, value, onChange, compact = false }: ReportChipGroupProps) { + return ( + + {options.map((item) => ( + onChange(item.value)} + > + {item.label} + + ))} + + ); +} diff --git a/src/components/report/daytime-prediction.tsx b/src/components/report/daytime-prediction.tsx new file mode 100644 index 0000000..9599312 --- /dev/null +++ b/src/components/report/daytime-prediction.tsx @@ -0,0 +1,28 @@ +import { Text, View } from "@tarojs/components"; +import type { DaytimeState } from "../../pages/report/types"; +import { getStatusTone } from "../../pages/report/report-utils"; + +type ReportDaytimePredictionProps = { + items: DaytimeState[]; +}; + +export function ReportDaytimePrediction({ items }: ReportDaytimePredictionProps) { + return ( + + + 日间状态预测 + + + {items.map((item) => ( + + + {item.label} + {item.status} + + {item.detail} + + ))} + + + ); +} diff --git a/src/components/report/distribution-card.tsx b/src/components/report/distribution-card.tsx new file mode 100644 index 0000000..1f2bed4 --- /dev/null +++ b/src/components/report/distribution-card.tsx @@ -0,0 +1,35 @@ +import { Text, View } from "@tarojs/components"; +import type { DistributionItem } from "../../pages/report/types"; + +type ReportDistributionCardProps = { + title: string; + items: DistributionItem[]; +}; + +export function ReportDistributionCard({ title, items }: ReportDistributionCardProps) { + return ( + + + {title} + + + + + 睡眠结构 + + + + {items.map((item) => ( + + + + {item.label} + + {item.value}% + + ))} + + + + ); +} diff --git a/src/components/report/insight-list.tsx b/src/components/report/insight-list.tsx new file mode 100644 index 0000000..c2a162f --- /dev/null +++ b/src/components/report/insight-list.tsx @@ -0,0 +1,28 @@ +import { Text, View } from "@tarojs/components"; +import type { InsightItem } from "../../pages/report/types"; + +type ReportInsightListProps = { + title: string; + items: InsightItem[]; +}; + +export function ReportInsightList({ title, items }: ReportInsightListProps) { + return ( + + + {title} + + + {items.map((item) => ( + + + + {item.title} + {item.body} + + + ))} + + + ); +} diff --git a/src/components/report/kv-list.tsx b/src/components/report/kv-list.tsx new file mode 100644 index 0000000..e09ff2e --- /dev/null +++ b/src/components/report/kv-list.tsx @@ -0,0 +1,29 @@ +import { Text, View } from "@tarojs/components"; +import type { KvMetric } from "../../pages/report/types"; +import { getStatusTone } from "../../pages/report/report-utils"; + +type ReportKvListProps = { + title: string; + items: KvMetric[]; +}; + +export function ReportKvList({ title, items }: ReportKvListProps) { + return ( + + + {title} + + + {items.map((item) => ( + + {item.label} + + {item.value} + {item.status ? {item.status} : null} + + + ))} + + + ); +} diff --git a/src/components/report/metric-grid.tsx b/src/components/report/metric-grid.tsx new file mode 100644 index 0000000..c9562ba --- /dev/null +++ b/src/components/report/metric-grid.tsx @@ -0,0 +1,28 @@ +import { Text, View } from "@tarojs/components"; +import type { MetricCard } from "../../pages/report/types"; +import { getStatusTone } from "../../pages/report/report-utils"; + +type ReportMetricGridProps = { + metrics: MetricCard[]; +}; + +export function ReportMetricGrid({ metrics }: ReportMetricGridProps) { + return ( + + {metrics.map((metric) => { + const tone = getStatusTone(metric.status); + + return ( + + {metric.label} + + {metric.value} + {metric.unit} + + {metric.status} + + ); + })} + + ); +} diff --git a/src/components/report/page-header.tsx b/src/components/report/page-header.tsx new file mode 100644 index 0000000..e9b19ec --- /dev/null +++ b/src/components/report/page-header.tsx @@ -0,0 +1,25 @@ +import { Text, View } from "@tarojs/components"; + +type ReportPageHeaderProps = { + title: string; + subtitle: string; + onBack: () => void; + onShare: () => void; +}; + +export function ReportPageHeader({ title, subtitle, onBack, onShare }: ReportPageHeaderProps) { + return ( + + + < + + + {title} + {subtitle} + + + 分享 + + + ); +} diff --git a/src/components/report/score-overview.tsx b/src/components/report/score-overview.tsx new file mode 100644 index 0000000..9156ad8 --- /dev/null +++ b/src/components/report/score-overview.tsx @@ -0,0 +1,63 @@ +import { Text, View } from "@tarojs/components"; +import type { ScoreFactor } from "../../pages/report/types"; + +type ReportScoreOverviewProps = { + score: number; + levelLabel: string; + levelTone: string; + qualityStatus: string; + totalSleep: string; + asleepAt: string; + wakeAt: string; + factors: ScoreFactor[]; +}; + +export function ReportScoreOverview({ + score, + levelLabel, + levelTone, + qualityStatus, + totalSleep, + asleepAt, + wakeAt, + factors +}: ReportScoreOverviewProps) { + return ( + + + + + 睡眠评分 + {score} + + + + {levelLabel} + {qualityStatus} + + + 总睡眠 + {totalSleep} + + + 入睡 + {asleepAt} + + + 醒来 + {wakeAt} + + + + + + {factors.map((item) => ( + + {item.label} + {item.score} + + ))} + + + ); +} diff --git a/src/components/report/section-card.tsx b/src/components/report/section-card.tsx new file mode 100644 index 0000000..da72acb --- /dev/null +++ b/src/components/report/section-card.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; +import { Text, View } from "@tarojs/components"; + +type SectionCardProps = { + title: string; + subtitle?: string; + extra?: ReactNode; + children: ReactNode; +}; + +export function ReportSectionCard({ title, subtitle, extra, children }: SectionCardProps) { + return ( + + + + {title} + {subtitle ? {subtitle} : null} + + {extra ? {extra} : null} + + {children} + + ); +} diff --git a/src/components/report/summary-block.tsx b/src/components/report/summary-block.tsx new file mode 100644 index 0000000..5f83469 --- /dev/null +++ b/src/components/report/summary-block.tsx @@ -0,0 +1,33 @@ +import { Text, View } from "@tarojs/components"; +import type { SummaryBlock } from "../../pages/report/types"; +import { getStatusTone } from "../../pages/report/report-utils"; + +type ReportSummaryBlockProps = { + block: SummaryBlock; +}; + +export function ReportSummaryBlock({ block }: ReportSummaryBlockProps) { + return ( + + + {block.title} + + + {block.items.map((item) => { + const tone = item.status ? getStatusTone(item.status) : "good"; + + return ( + + {item.label} + + {item.value} + {item.unit ? {item.unit} : null} + + {item.status ? {item.status} : null} + + ); + })} + + + ); +} diff --git a/src/pages/index/index.scss b/src/pages/index/index.scss index 62c4826..7320eb1 100644 --- a/src/pages/index/index.scss +++ b/src/pages/index/index.scss @@ -246,6 +246,55 @@ line-height: 1.7; } +.device-report-entry { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 22rpx; + padding: 24rpx; + border-radius: 22rpx; + background: + radial-gradient(circle at top right, rgba(54, 228, 170, 0.18), transparent 42%), + rgba(42, 48, 66, 0.96); + box-shadow: + inset 0 0 0 2rpx rgba(255, 255, 255, 0.03), + 0 14rpx 28rpx rgba(6, 10, 22, 0.18); +} + +.device-report-entry__content { + flex: 1; + padding-right: 20rpx; +} + +.device-report-entry__eyebrow { + display: block; + color: #39e6ad; + font-size: 20rpx; +} + +.device-report-entry__title { + display: block; + margin-top: 8rpx; + color: #f3f7ff; + font-size: 30rpx; + font-weight: 600; +} + +.device-report-entry__desc { + display: block; + margin-top: 10rpx; + color: #96a1b5; + font-size: 22rpx; + line-height: 1.6; +} + +.device-report-entry__arrow { + color: #d8e1ed; + font-size: 28rpx; +} + .device-tabbar { position: fixed; left: 0; diff --git a/src/pages/index/index.tsx b/src/pages/index/index.tsx index 276668b..c1cee64 100644 --- a/src/pages/index/index.tsx +++ b/src/pages/index/index.tsx @@ -108,7 +108,18 @@ export default function Index() { showToast("后续可从这里进入设备管理页"); }; - const handleTabClick = (label: string) => { + const openReportPage = () => { + Taro.navigateTo({ + url: "/pages/report/index" + }); + }; + + const handleTabClick = (key: string, label: string) => { + if (key === "report") { + openReportPage(); + return; + } + showToast(`${label}功能待接入`); }; @@ -278,6 +289,15 @@ export default function Index() { + + + 昨夜睡眠分析 + 查看睡眠报告 + 进入宝宝的睡眠评分、呼吸状态和 AI 分析报告 + + > + + @@ -295,7 +315,7 @@ export default function Index() { {navItems.map((item) => ( - handleTabClick(item.label)}> + handleTabClick(item.key, item.label)}> {item.badge ? : null} diff --git a/src/pages/report/index.config.ts b/src/pages/report/index.config.ts new file mode 100644 index 0000000..714447e --- /dev/null +++ b/src/pages/report/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationStyle: "custom" +}); diff --git a/src/pages/report/index.scss b/src/pages/report/index.scss new file mode 100644 index 0000000..7528c92 --- /dev/null +++ b/src/pages/report/index.scss @@ -0,0 +1,754 @@ +.report-page { + position: relative; + min-height: 100vh; + background: linear-gradient(180deg, #161d29 0%, #101620 100%); + color: #eef5ff; +} + +.report-page__content { + position: relative; + z-index: 1; + min-height: 100vh; + padding: calc(var(--report-top-gap, 0px) + 20rpx) 24rpx 56rpx; + box-sizing: border-box; +} + +.report-page__glow { + position: absolute; + border-radius: 50%; + pointer-events: none; +} + +.report-page__glow--one { + top: 18rpx; + right: -54rpx; + width: 320rpx; + height: 320rpx; + background: radial-gradient(circle, rgba(43, 226, 198, 0.18) 0%, rgba(43, 226, 198, 0) 68%); +} + +.report-page__glow--two { + top: 260rpx; + left: -90rpx; + width: 280rpx; + height: 280rpx; + background: radial-gradient(circle, rgba(36, 174, 255, 0.14) 0%, rgba(36, 174, 255, 0) 70%); +} + +.report-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24rpx; +} + +.report-header__action { + display: flex; + align-items: center; + justify-content: center; + min-width: 68rpx; + height: 52rpx; + padding: 0 16rpx; + border-radius: 18rpx; + background: rgba(32, 42, 58, 0.82); + box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.04); +} + +.report-header__action--share { + min-width: 96rpx; +} + +.report-header__action-text { + color: #dce7f8; + font-size: 24rpx; +} + +.report-header__content { + flex: 1; + padding: 0 20rpx; +} + +.report-header__title { + display: block; + color: #f7fbff; + font-size: 34rpx; + font-weight: 600; +} + +.report-header__subtitle { + display: block; + margin-top: 6rpx; + color: #8ea0b6; + font-size: 22rpx; +} + +.report-toolbar { + margin-bottom: 24rpx; +} + +.report-filters { + margin-top: 16rpx; + padding: 22rpx; + border-radius: 24rpx; + background: rgba(24, 33, 47, 0.92); + box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.04); +} + +.report-filter + .report-filter { + margin-top: 18rpx; +} + +.report-filter__label { + display: block; + margin-bottom: 10rpx; + color: #91a3b8; + font-size: 22rpx; +} + +.report-chip-group { + display: flex; + flex-wrap: wrap; + gap: 12rpx; +} + +.report-chip-group--compact { + gap: 10rpx; +} + +.report-chip { + min-width: 112rpx; + padding: 14rpx 20rpx; + border-radius: 18rpx; + background: rgba(32, 43, 60, 0.95); + box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.04); +} + +.report-chip--active { + background: linear-gradient(135deg, rgba(47, 224, 190, 0.24), rgba(36, 174, 255, 0.18)); + box-shadow: + inset 0 0 0 2rpx rgba(52, 222, 194, 0.42), + 0 10rpx 28rpx rgba(18, 129, 165, 0.24); +} + +.report-chip__text { + color: #9eb0c5; + font-size: 24rpx; + text-align: center; +} + +.report-chip-group--compact .report-chip { + min-width: 0; + padding: 10rpx 18rpx; + border-radius: 16rpx; +} + +.report-chip-group--compact .report-chip__text { + font-size: 22rpx; +} + +.report-chip__text--active { + color: #ecfffb; + font-weight: 600; +} + +.score-overview, +.report-card, +.metric-grid { + margin-bottom: 24rpx; +} + +.score-overview { + padding: 24rpx; + border-radius: 28rpx; + background: rgba(24, 33, 47, 0.94); + box-shadow: + inset 0 0 0 2rpx rgba(255, 255, 255, 0.04), + 0 20rpx 40rpx rgba(4, 10, 20, 0.2); +} + +.score-overview__top { + display: flex; + gap: 24rpx; +} + +.score-overview__ring { + display: flex; + align-items: center; + justify-content: center; + width: 248rpx; + height: 248rpx; + border-radius: 50%; + background: + radial-gradient(circle at center, rgba(16, 23, 35, 0.92) 0 59%, transparent 60%), + conic-gradient(#2de1c2 0 33%, #23b6ff 33% 61%, #ffbf4d 61% 82%, #ff6b6b 82% 100%); +} + +.score-overview__ring-inner { + display: flex; + flex-direction: column; + align-items: center; +} + +.score-overview__caption { + color: #8da0b5; + font-size: 22rpx; +} + +.score-overview__score { + margin-top: 8rpx; + color: #ff9950; + font-size: 72rpx; + font-weight: 700; + line-height: 1; +} + +.score-overview__summary { + flex: 1; +} + +.score-overview__level { + display: inline-flex; + align-items: center; + height: 44rpx; + padding: 0 18rpx; + border-radius: 999rpx; + font-size: 22rpx; + font-weight: 600; +} + +.score-overview__level--excellent, +.metric-card__status--excellent, +.summary-grid__status--excellent, +.kv-list__status--excellent, +.daytime-card__status--excellent { + background: rgba(45, 225, 194, 0.16); + color: #4ef0cb; +} + +.score-overview__level--good, +.metric-card__status--good, +.summary-grid__status--good, +.kv-list__status--good, +.daytime-card__status--good { + background: rgba(35, 182, 255, 0.16); + color: #67cbff; +} + +.score-overview__level--warning, +.metric-card__status--warning, +.summary-grid__status--warning, +.kv-list__status--warning, +.daytime-card__status--warning { + background: rgba(255, 191, 77, 0.16); + color: #ffc766; +} + +.score-overview__level--danger, +.metric-card__status--danger, +.summary-grid__status--danger, +.kv-list__status--danger, +.daytime-card__status--danger { + background: rgba(255, 107, 107, 0.16); + color: #ff8787; +} + +.score-overview__status { + display: block; + margin-top: 18rpx; + color: #e7eef8; + font-size: 30rpx; + font-weight: 600; + line-height: 1.4; +} + +.score-overview__meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12rpx; + margin-top: 20rpx; +} + +.score-overview__meta-item { + padding: 16rpx; + border-radius: 18rpx; + background: rgba(18, 26, 39, 0.88); +} + +.score-overview__meta-label { + display: block; + color: #90a1b5; + font-size: 20rpx; +} + +.score-overview__meta-value { + display: block; + margin-top: 8rpx; + color: #f2f7ff; + font-size: 26rpx; + font-weight: 600; +} + +.score-overview__factors { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12rpx; + margin-top: 22rpx; +} + +.score-overview__factor { + padding: 14rpx 10rpx; + border-radius: 16rpx; + background: rgba(18, 26, 39, 0.88); + text-align: center; +} + +.score-overview__factor-label { + display: block; + color: #91a3b8; + font-size: 20rpx; + line-height: 1.4; +} + +.score-overview__factor-value { + display: block; + margin-top: 6rpx; + color: #f4f8ff; + font-size: 28rpx; + font-weight: 700; +} + +.report-card { + padding: 24rpx; + border-radius: 28rpx; + background: rgba(24, 33, 47, 0.94); + box-shadow: + inset 0 0 0 2rpx rgba(255, 255, 255, 0.04), + 0 16rpx 36rpx rgba(4, 10, 20, 0.18); +} + +.report-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20rpx; + margin-bottom: 18rpx; +} + +.report-card__title { + color: #f4f8ff; + font-size: 28rpx; + font-weight: 600; +} + +.report-card__subtitle { + display: block; + margin-top: 6rpx; + color: #90a3b8; + font-size: 22rpx; +} + +.report-card__badge { + display: inline-flex; + align-items: center; + height: 40rpx; + padding: 0 14rpx; + border-radius: 999rpx; + background: rgba(35, 182, 255, 0.16); + color: #67cbff; + font-size: 20rpx; +} + +.report-chart__tooltip { + margin-bottom: 16rpx; + padding: 16rpx 18rpx; + border-radius: 18rpx; + background: rgba(16, 23, 35, 0.86); +} + +.report-chart__tooltip-title { + display: block; + color: #f3f8ff; + font-size: 24rpx; + font-weight: 600; +} + +.report-chart__tooltip-value { + display: block; + margin-top: 8rpx; + color: #41e0c4; + font-size: 30rpx; + font-weight: 700; +} + +.report-chart__tooltip-meta { + display: block; + margin-top: 8rpx; + color: #8ea1b8; + font-size: 22rpx; + line-height: 1.5; +} + +.report-chart { + position: relative; + height: 260rpx; + padding: 16rpx 18rpx 48rpx; + border-radius: 24rpx; + background: rgba(16, 23, 35, 0.84); + overflow: hidden; +} + +.report-chart--scatter { + height: 220rpx; +} + +.report-chart__grid { + position: absolute; + inset: 16rpx 18rpx 48rpx; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.report-chart__grid-line { + border-top: 2rpx dashed rgba(255, 255, 255, 0.06); +} + +.report-chart__item { + position: absolute; + top: 16rpx; + bottom: 48rpx; + width: 44rpx; + margin-left: -22rpx; +} + +.report-chart__stem { + position: absolute; + left: 50%; + bottom: 0; + width: 4rpx; + margin-left: -2rpx; + border-radius: 999rpx; + background: linear-gradient(180deg, rgba(35, 182, 255, 0.18), rgba(45, 225, 194, 0.7)); +} + +.report-chart__point { + position: absolute; + left: 50%; + width: 16rpx; + height: 16rpx; + margin-left: -8rpx; + border-radius: 50%; + box-shadow: 0 0 0 6rpx rgba(255, 255, 255, 0.03); +} + +.report-chart__point.is-active { + width: 22rpx; + height: 22rpx; + margin-left: -11rpx; + box-shadow: 0 0 0 10rpx rgba(255, 255, 255, 0.05); +} + +.report-chart__point--excellent { + background: #2de1c2; +} + +.report-chart__point--good { + background: #23b6ff; +} + +.report-chart__point--warning { + background: #ffbf4d; +} + +.report-chart__point--danger { + background: #ff6b6b; +} + +.report-chart__label { + position: absolute; + left: 50%; + bottom: -6rpx; + width: 72rpx; + margin-left: -36rpx; + color: #8193a9; + font-size: 18rpx; + text-align: center; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16rpx; +} + +.metric-card { + padding: 22rpx; + border-radius: 24rpx; + background: rgba(24, 33, 47, 0.94); + box-shadow: + inset 0 0 0 2rpx rgba(255, 255, 255, 0.04), + 0 14rpx 30rpx rgba(4, 10, 20, 0.18); +} + +.metric-card__label { + display: block; + color: #90a2b7; + font-size: 22rpx; +} + +.metric-card__value { + display: block; + margin-top: 12rpx; + color: #f4f8ff; + font-size: 34rpx; + font-weight: 700; +} + +.metric-card__unit { + margin-left: 8rpx; + color: #8fa3b7; + font-size: 20rpx; + font-weight: 400; +} + +.metric-card__status, +.summary-grid__status, +.kv-list__status, +.daytime-card__status { + display: inline-flex; + align-items: center; + height: 38rpx; + margin-top: 14rpx; + padding: 0 14rpx; + border-radius: 999rpx; + font-size: 20rpx; + font-weight: 600; +} + +.insight-list { + display: flex; + flex-direction: column; + gap: 14rpx; +} + +.insight-card { + display: flex; + gap: 14rpx; + padding: 18rpx; + border-radius: 20rpx; + background: rgba(16, 23, 35, 0.84); +} + +.insight-card__tone { + width: 8rpx; + border-radius: 999rpx; +} + +.insight-card__tone--excellent { + background: #2de1c2; +} + +.insight-card__tone--good { + background: #23b6ff; +} + +.insight-card__tone--warning { + background: #ffbf4d; +} + +.insight-card__tone--danger { + background: #ff6b6b; +} + +.insight-card__content { + flex: 1; +} + +.insight-card__title { + display: block; + color: #f3f8ff; + font-size: 24rpx; + font-weight: 600; +} + +.insight-card__body { + display: block; + margin-top: 8rpx; + color: #91a3b8; + font-size: 22rpx; + line-height: 1.7; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14rpx; +} + +.summary-grid__item { + padding: 18rpx; + border-radius: 20rpx; + background: rgba(16, 23, 35, 0.84); +} + +.summary-grid__label { + display: block; + color: #8ea1b8; + font-size: 22rpx; +} + +.summary-grid__value { + display: block; + margin-top: 12rpx; + color: #f3f8ff; + font-size: 30rpx; + font-weight: 700; +} + +.summary-grid__unit { + margin-left: 6rpx; + color: #8fa2b9; + font-size: 20rpx; + font-weight: 400; +} + +.kv-list { + display: flex; + flex-direction: column; + gap: 12rpx; +} + +.kv-list__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16rpx; + padding: 16rpx 0; + border-bottom: 2rpx solid rgba(255, 255, 255, 0.04); +} + +.kv-list__row:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.kv-list__label { + color: #8fa1b6; + font-size: 22rpx; +} + +.kv-list__value-wrap { + display: flex; + align-items: center; + gap: 12rpx; +} + +.kv-list__value { + color: #f3f8ff; + font-size: 24rpx; + font-weight: 600; +} + +.distribution-card { + display: flex; + align-items: center; + gap: 24rpx; +} + +.distribution-card__ring { + display: flex; + align-items: center; + justify-content: center; + width: 188rpx; + height: 188rpx; + border-radius: 50%; + background: + radial-gradient(circle at center, rgba(16, 23, 35, 0.92) 0 54%, transparent 55%), + conic-gradient(#2de1c2 0 29%, #23b6ff 29% 66%, #ffb84d 66% 88%, #ff6b6b 88% 100%); +} + +.distribution-card__ring-inner { + display: flex; + align-items: center; + justify-content: center; + width: 112rpx; + height: 112rpx; + border-radius: 50%; + background: rgba(16, 23, 35, 0.94); +} + +.distribution-card__ring-text { + color: #d7e3f3; + font-size: 20rpx; + text-align: center; + line-height: 1.5; +} + +.distribution-card__list { + flex: 1; + display: flex; + flex-direction: column; + gap: 14rpx; +} + +.distribution-card__row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.distribution-card__legend { + display: flex; + align-items: center; +} + +.distribution-card__dot { + width: 14rpx; + height: 14rpx; + margin-right: 10rpx; + border-radius: 50%; +} + +.distribution-card__label { + color: #8fa2b8; + font-size: 22rpx; +} + +.distribution-card__value { + color: #f3f8ff; + font-size: 24rpx; + font-weight: 600; +} + +.daytime-list { + display: flex; + flex-direction: column; + gap: 14rpx; +} + +.daytime-card { + padding: 18rpx; + border-radius: 20rpx; + background: rgba(16, 23, 35, 0.84); +} + +.daytime-card__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16rpx; +} + +.daytime-card__label { + color: #f3f8ff; + font-size: 24rpx; + font-weight: 600; +} + +.daytime-card__detail { + display: block; + margin-top: 10rpx; + color: #8ea1b8; + font-size: 22rpx; + line-height: 1.6; +} diff --git a/src/pages/report/index.tsx b/src/pages/report/index.tsx new file mode 100644 index 0000000..a523617 --- /dev/null +++ b/src/pages/report/index.tsx @@ -0,0 +1,195 @@ +import { ScrollView, Text, View } from "@tarojs/components"; +import Taro from "@tarojs/taro"; +import type { CSSProperties } from "react"; +import { useMemo, useState } from "react"; +import { ReportChartCard } from "../../components/report/chart-card"; +import { ReportChipGroup } from "../../components/report/chip-group"; +import { ReportDaytimePrediction } from "../../components/report/daytime-prediction"; +import { ReportDistributionCard } from "../../components/report/distribution-card"; +import { ReportInsightList } from "../../components/report/insight-list"; +import { ReportKvList } from "../../components/report/kv-list"; +import { ReportMetricGrid } from "../../components/report/metric-grid"; +import { ReportPageHeader } from "../../components/report/page-header"; +import { ReportScoreOverview } from "../../components/report/score-overview"; +import { ReportSummaryBlock } from "../../components/report/summary-block"; +import { reportDimensions, reportOptions, reportRecords } from "./mock"; +import { getSleepLevel, pickReportRecord } from "./report-utils"; +import type { ReportDimension } from "./types"; +import "./index.scss"; + +function getDefaultSelection(dimension: ReportDimension) { + const options = reportOptions[dimension]; + + return { + dateKey: options.dates[0].value, + roomKey: options.rooms[0].value, + deviceKey: options.devices[0].value + }; +} + +export default function ReportPage() { + const [dimension, setDimension] = useState("daily"); + const initialSelection = getDefaultSelection("daily"); + const [dateKey, setDateKey] = useState(initialSelection.dateKey); + const [roomKey, setRoomKey] = useState(initialSelection.roomKey); + const [deviceKey, setDeviceKey] = useState(initialSelection.deviceKey); + + const currentOptions = reportOptions[dimension]; + const currentRecord = useMemo( + () => pickReportRecord(reportRecords[dimension], dateKey, roomKey, deviceKey), + [dateKey, deviceKey, dimension, roomKey] + ); + const sleepLevel = getSleepLevel(currentRecord.score); + const [selectedSleepTrend, setSelectedSleepTrend] = useState(currentRecord.sleepTrend[0].id); + const [selectedScoreTrend, setSelectedScoreTrend] = useState(currentRecord.scoreTrend[0].id); + const [selectedHeartRatePoint, setSelectedHeartRatePoint] = useState(currentRecord.heartRatePoints[0].id); + const [selectedBreathingPoint, setSelectedBreathingPoint] = useState(currentRecord.breathingPoints[0].id); + const [selectedEventPoint, setSelectedEventPoint] = useState(currentRecord.eventPoints[0].id); + + const resetSelections = (nextDimension: ReportDimension, nextDate: string, nextRoom: string, nextDevice: string) => { + const record = pickReportRecord(reportRecords[nextDimension], nextDate, nextRoom, nextDevice); + setSelectedSleepTrend(record.sleepTrend[0].id); + setSelectedScoreTrend(record.scoreTrend[0].id); + setSelectedHeartRatePoint(record.heartRatePoints[0].id); + setSelectedBreathingPoint(record.breathingPoints[0].id); + setSelectedEventPoint(record.eventPoints[0].id); + }; + + const handleDimensionChange = (nextValue: string) => { + const nextDimension = nextValue as ReportDimension; + const nextSelection = getDefaultSelection(nextDimension); + + setDimension(nextDimension); + setDateKey(nextSelection.dateKey); + setRoomKey(nextSelection.roomKey); + setDeviceKey(nextSelection.deviceKey); + resetSelections(nextDimension, nextSelection.dateKey, nextSelection.roomKey, nextSelection.deviceKey); + }; + + const handleDateChange = (nextDate: string) => { + setDateKey(nextDate); + resetSelections(dimension, nextDate, roomKey, deviceKey); + }; + + const handleRoomChange = (nextRoom: string) => { + setRoomKey(nextRoom); + resetSelections(dimension, dateKey, nextRoom, deviceKey); + }; + + const handleDeviceChange = (nextDevice: string) => { + setDeviceKey(nextDevice); + resetSelections(dimension, dateKey, roomKey, nextDevice); + }; + + const pageStyle = { + "--report-top-gap": `${(typeof Taro.getWindowInfo === "function" ? Taro.getWindowInfo().statusBarHeight : Taro.getSystemInfoSync().statusBarHeight) || 0}px` + } as CSSProperties; + + return ( + + + + + + Taro.navigateBack({ delta: 1 })} + onShare={() => + Taro.showToast({ + title: "分享功能待接入", + icon: "none" + }) + } + /> + + + + + + + 日期 + + + + 房间 + + + + 设备 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/pages/report/mock.ts b/src/pages/report/mock.ts new file mode 100644 index 0000000..d29f46f --- /dev/null +++ b/src/pages/report/mock.ts @@ -0,0 +1,865 @@ +import type { ReportDimension, ReportDimensionOptions, ReportRecord } from "./types"; + +function makeTrendPoints( + prefix: string, + values: Array<{ time: string; label: string; value: number; y: number; tone: "excellent" | "good" | "warning" | "danger"; meta?: string }> +) { + return values.map((item, index) => ({ + id: `${prefix}-${item.time}-${index}`, + time: item.time, + label: item.label, + value: item.value, + x: index * (values.length === 1 ? 0 : 100 / (values.length - 1)), + y: item.y, + tone: item.tone, + meta: item.meta + })); +} + +function createRecord(config: { + babyName: string; + dateLabel: string; + roomLabel: string; + deviceLabel: string; + score: number; + qualityStatus: string; + totalSleep: string; + asleepAt: string; + wakeAt: string; + metrics: ReportRecord["metrics"]; + aiSummary: ReportRecord["aiSummary"]; + suggestions: ReportRecord["suggestions"]; + anomalyStats: ReportRecord["anomalyStats"]; + hrvMetrics: ReportRecord["hrvMetrics"]; + heartRatePoints: ReportRecord["heartRatePoints"]; + breathingPoints: ReportRecord["breathingPoints"]; + oxygenSummary: ReportRecord["oxygenSummary"]; + snoreSummary: ReportRecord["snoreSummary"]; + eventPoints: ReportRecord["eventPoints"]; + sleepStructure: ReportRecord["sleepStructure"]; + autonomicMetrics: ReportRecord["autonomicMetrics"]; + daytimePrediction: ReportRecord["daytimePrediction"]; +}): ReportRecord { + return { + babyName: config.babyName, + dateLabel: config.dateLabel, + roomLabel: config.roomLabel, + deviceLabel: config.deviceLabel, + score: config.score, + qualityStatus: config.qualityStatus, + totalSleep: config.totalSleep, + asleepAt: config.asleepAt, + wakeAt: config.wakeAt, + scoreFactors: [ + { label: "入睡速度", score: 72 }, + { label: "睡眠稳定性", score: 68 }, + { label: "呼吸状态", score: 61 }, + { label: "心率状态", score: 74 }, + { label: "异常事件", score: 59 } + ], + sleepTrend: makeTrendPoints("sleep-trend", [ + { time: "22:10", label: "浅睡", value: 62, y: 42, tone: "good", meta: "浅睡,呼吸平稳" }, + { time: "23:30", label: "深睡", value: 78, y: 74, tone: "excellent", meta: "深睡段,翻身较少" }, + { time: "01:00", label: "REM", value: 58, y: 56, tone: "warning", meta: "REM,呼吸波动略高" }, + { time: "02:40", label: "深睡", value: 82, y: 81, tone: "excellent", meta: "深睡,血氧稳定" }, + { time: "04:20", label: "清醒", value: 38, y: 28, tone: "danger", meta: "短时清醒,伴随翻身" }, + { time: "05:50", label: "浅睡", value: 64, y: 52, tone: "good", meta: "临近醒来,波动回落" } + ]), + scoreTrend: makeTrendPoints("score-trend", [ + { time: "22", label: "22点", value: 71, y: 46, tone: "good" }, + { time: "23", label: "23点", value: 79, y: 68, tone: "excellent" }, + { time: "00", label: "0点", value: 74, y: 59, tone: "good" }, + { time: "02", label: "2点", value: 66, y: 44, tone: "warning" }, + { time: "04", label: "4点", value: 58, y: 26, tone: "danger" }, + { time: "06", label: "6点", value: 69, y: 48, tone: "good" } + ]), + metrics: config.metrics, + aiSummary: config.aiSummary, + suggestions: config.suggestions, + anomalyStats: config.anomalyStats, + hrvMetrics: config.hrvMetrics, + heartRatePoints: config.heartRatePoints, + breathingPoints: config.breathingPoints, + oxygenSummary: config.oxygenSummary, + snoreSummary: config.snoreSummary, + eventPoints: config.eventPoints, + sleepStructure: config.sleepStructure, + autonomicMetrics: config.autonomicMetrics, + daytimePrediction: config.daytimePrediction + }; +} + +const baseHeartRate = makeTrendPoints("heart", [ + { time: "22:00", label: "22:00", value: 89, y: 34, tone: "good" }, + { time: "23:10", label: "23:10", value: 92, y: 46, tone: "good" }, + { time: "00:30", label: "00:30", value: 95, y: 59, tone: "warning" }, + { time: "02:10", label: "02:10", value: 98, y: 72, tone: "danger" }, + { time: "03:50", label: "03:50", value: 90, y: 41, tone: "good" }, + { time: "05:40", label: "05:40", value: 87, y: 30, tone: "excellent" } +]); + +const baseBreathing = makeTrendPoints("breath", [ + { time: "22:30", label: "22:30", value: 20, y: 45, tone: "excellent" }, + { time: "23:40", label: "23:40", value: 21, y: 53, tone: "good" }, + { time: "01:10", label: "01:10", value: 24, y: 67, tone: "warning" }, + { time: "02:50", label: "02:50", value: 26, y: 78, tone: "danger" }, + { time: "04:10", label: "04:10", value: 23, y: 58, tone: "warning" }, + { time: "05:30", label: "05:30", value: 21, y: 49, tone: "good" } +]); + +const baseEvents = makeTrendPoints("event", [ + { time: "00:42", label: "暂停", value: 1, y: 64, tone: "danger", meta: "呼吸暂停 11 秒" }, + { time: "02:16", label: "哭闹", value: 1, y: 32, tone: "warning", meta: "轻微哭闹后恢复" }, + { time: "03:48", label: "心率", value: 1, y: 78, tone: "danger", meta: "心率峰值 106 bpm" }, + { time: "05:05", label: "离床", value: 1, y: 46, tone: "good", meta: "离床 2 分钟后返回" } +]); + +export const reportDimensions: Array<{ label: string; value: ReportDimension }> = [ + { label: "日报", value: "daily" }, + { label: "周报", value: "weekly" }, + { label: "月报", value: "monthly" } +]; + +export const reportOptions: Record = { + daily: { + dates: [ + { label: "05-08", value: "2026-05-08" }, + { label: "05-07", value: "2026-05-07" } + ], + rooms: [ + { label: "婴儿房", value: "roomA" }, + { label: "主卧", value: "roomB" } + ], + devices: [ + { label: "监测垫 A1", value: "deviceA" }, + { label: "监测垫 B2", value: "deviceB" } + ] + }, + weekly: { + dates: [ + { label: "本周", value: "2026-week-19" }, + { label: "上周", value: "2026-week-18" } + ], + rooms: [ + { label: "婴儿房", value: "roomA" }, + { label: "主卧", value: "roomB" } + ], + devices: [ + { label: "监测垫 A1", value: "deviceA" }, + { label: "监测垫 B2", value: "deviceB" } + ] + }, + monthly: { + dates: [ + { label: "05月", value: "2026-05" }, + { label: "04月", value: "2026-04" } + ], + rooms: [ + { label: "婴儿房", value: "roomA" }, + { label: "主卧", value: "roomB" } + ], + devices: [ + { label: "监测垫 A1", value: "deviceA" }, + { label: "监测垫 B2", value: "deviceB" } + ] + } +}; + +export const reportRecords: Record>>> = { + daily: { + "2026-05-08": { + roomA: { + deviceA: createRecord({ + babyName: "安安的睡眠报告", + dateLabel: "2026-05-08 日报", + roomLabel: "婴儿房", + deviceLabel: "监测垫 A1", + score: 65, + qualityStatus: "呼吸波动较明显", + totalSleep: "8h 12m", + asleepAt: "22:12", + wakeAt: "06:24", + metrics: [ + { label: "入睡时长", value: "21", unit: "min", status: "正常" }, + { label: "平均呼吸率", value: "22", unit: "次/分", status: "注意" }, + { label: "平均血氧", value: "95", unit: "%", status: "正常" }, + { label: "呼吸暂停次数", value: "4", unit: "次", status: "异常" }, + { label: "平均 HRV", value: "33", unit: "ms", status: "注意" }, + { label: "平均心率", value: "92", unit: "bpm", status: "正常" }, + { label: "异常翻身次数", value: "2", unit: "次", status: "注意" }, + { label: "暂停总时长", value: "2.2", unit: "min", status: "异常" }, + { label: "心率过快次数", value: "2", unit: "次", status: "异常" } + ], + aiSummary: [ + { title: "睡眠稳定性", body: "整夜睡眠节律完整,但凌晨 4 点前后出现一次明显清醒,影响连续深睡时长。", tone: "warning" }, + { title: "呼吸状态", body: "呼吸波动在 REM 阶段增大,并出现 4 次短时呼吸暂停,需要继续观察。", tone: "danger" }, + { title: "心率变化", body: "心率整体处于可接受范围,凌晨阶段有 2 次短时升高。", tone: "good" } + ], + suggestions: [ + { title: "睡姿建议", body: "建议保持侧卧或轻微抬高头肩,帮助改善夜间呼吸通畅度。", tone: "good" }, + { title: "环境建议", body: "卧室温度保持 24 到 26 度,减少翻身和惊醒。", tone: "excellent" } + ], + anomalyStats: { + title: "异常统计", + items: [ + { label: "心率异常", value: "2", unit: "次", status: "异常" }, + { label: "呼吸异常", value: "3", unit: "次", status: "注意" }, + { label: "血氧异常", value: "1", unit: "次", status: "注意" }, + { label: "呼吸暂停", value: "4", unit: "次", status: "异常" }, + { label: "离床行为", value: "1", unit: "次", status: "正常" } + ] + }, + hrvMetrics: [ + { label: "HRV", value: "33 ms", status: "注意" }, + { label: "RMSSD", value: "18.6 ms", status: "注意" }, + { label: "SDNN", value: "26.4 ms", status: "注意" }, + { label: "LF/HF", value: "1.82", status: "压力偏高" }, + { label: "压力指数", value: "中等偏高", status: "压力偏高" } + ], + heartRatePoints: baseHeartRate, + breathingPoints: baseBreathing, + oxygenSummary: { + title: "低氧统计", + items: [ + { label: "最低血氧", value: "91", unit: "%", status: "注意" }, + { label: "低氧次数", value: "2", unit: "次", status: "注意" }, + { label: "持续时长", value: "0.8", unit: "min", status: "低氧风险" } + ] + }, + snoreSummary: { + title: "打鼾监测", + items: [ + { label: "打鼾时长", value: "90", unit: "s", status: "注意" }, + { label: "打鼾频率", value: "1.7", unit: "次/h", status: "注意" }, + { label: "峰值强度", value: "61", unit: "dB", status: "异常" } + ] + }, + eventPoints: baseEvents, + sleepStructure: [ + { label: "深睡", value: 29, color: "#2de1c2" }, + { label: "浅睡", value: 37, color: "#23b6ff" }, + { label: "REM", value: 22, color: "#ffb84d" }, + { label: "清醒", value: 12, color: "#ff6b6b" } + ], + autonomicMetrics: [ + { label: "交感活跃度", value: "64%", status: "压力偏高" }, + { label: "副交感活跃度", value: "36%", status: "注意" }, + { label: "压力状态", value: "夜间恢复一般", status: "注意" } + ], + daytimePrediction: [ + { label: "精神状态", status: "良好", detail: "上午精神尚可,午后略易疲劳" }, + { label: "疲劳程度", status: "注意", detail: "建议安排午睡" }, + { label: "情绪状态", status: "良好", detail: "总体稳定,惊醒后安抚需求略高" } + ] + }), + deviceB: createRecord({ + babyName: "安安的睡眠报告", + dateLabel: "2026-05-08 日报", + roomLabel: "婴儿房", + deviceLabel: "监测垫 B2", + score: 79, + qualityStatus: "整体良好", + totalSleep: "8h 34m", + asleepAt: "21:58", + wakeAt: "06:32", + metrics: [ + { label: "入睡时长", value: "18", unit: "min", status: "正常" }, + { label: "平均呼吸率", value: "21", unit: "次/分", status: "正常" }, + { label: "平均血氧", value: "96", unit: "%", status: "正常" }, + { label: "呼吸暂停次数", value: "2", unit: "次", status: "注意" }, + { label: "平均 HRV", value: "38", unit: "ms", status: "良好" }, + { label: "平均心率", value: "90", unit: "bpm", status: "正常" }, + { label: "异常翻身次数", value: "1", unit: "次", status: "正常" }, + { label: "暂停总时长", value: "0.9", unit: "min", status: "注意" }, + { label: "心率过快次数", value: "1", unit: "次", status: "注意" } + ], + aiSummary: [ + { title: "睡眠稳定性", body: "整夜清醒次数少,深睡占比相对更高。", tone: "excellent" }, + { title: "呼吸状态", body: "偶发短时波动,但整体处于可接受范围。", tone: "good" }, + { title: "心率变化", body: "夜间心率波动平缓,恢复表现较好。", tone: "excellent" } + ], + suggestions: [ + { title: "保持节律", body: "继续维持当前作息,建议固定 22 点前入睡。", tone: "excellent" }, + { title: "环境监测", body: "保持空气湿度,减少凌晨轻微鼻塞对呼吸的影响。", tone: "good" } + ], + anomalyStats: { + title: "异常统计", + items: [ + { label: "心率异常", value: "1", unit: "次", status: "注意" }, + { label: "呼吸异常", value: "2", unit: "次", status: "注意" }, + { label: "血氧异常", value: "0", unit: "次", status: "正常" }, + { label: "呼吸暂停", value: "2", unit: "次", status: "注意" }, + { label: "离床行为", value: "0", unit: "次", status: "正常" } + ] + }, + hrvMetrics: [ + { label: "HRV", value: "38 ms", status: "良好" }, + { label: "RMSSD", value: "22.4 ms", status: "良好" }, + { label: "SDNN", value: "28.1 ms", status: "良好" }, + { label: "LF/HF", value: "1.31", status: "正常" }, + { label: "压力指数", value: "稳定", status: "正常" } + ], + heartRatePoints: baseHeartRate.map((item) => ({ ...item, value: item.value - 2, y: Math.max(item.y - 6, 18) })), + breathingPoints: baseBreathing.map((item) => ({ ...item, value: item.value - 1, y: Math.max(item.y - 8, 18), tone: item.y > 70 ? "warning" : "good" })), + oxygenSummary: { + title: "低氧统计", + items: [ + { label: "最低血氧", value: "93", unit: "%", status: "正常" }, + { label: "低氧次数", value: "1", unit: "次", status: "注意" }, + { label: "持续时长", value: "0.3", unit: "min", status: "正常" } + ] + }, + snoreSummary: { + title: "打鼾监测", + items: [ + { label: "打鼾时长", value: "36", unit: "s", status: "正常" }, + { label: "打鼾频率", value: "0.8", unit: "次/h", status: "正常" }, + { label: "峰值强度", value: "54", unit: "dB", status: "注意" } + ] + }, + eventPoints: baseEvents.map((item, index) => ({ ...item, y: item.y - (index % 2 === 0 ? 8 : 5), tone: index === 2 ? "warning" : "good" })), + sleepStructure: [ + { label: "深睡", value: 34, color: "#2de1c2" }, + { label: "浅睡", value: 33, color: "#23b6ff" }, + { label: "REM", value: 21, color: "#ffb84d" }, + { label: "清醒", value: 12, color: "#ff6b6b" } + ], + autonomicMetrics: [ + { label: "交感活跃度", value: "52%", status: "正常" }, + { label: "副交感活跃度", value: "48%", status: "良好" }, + { label: "压力状态", value: "恢复较好", status: "良好" } + ], + daytimePrediction: [ + { label: "精神状态", status: "优秀", detail: "清晨精神恢复较好" }, + { label: "疲劳程度", status: "良好", detail: "白天精力可维持较久" }, + { label: "情绪状态", status: "良好", detail: "稳定,哭闹概率较低" } + ] + }) + }, + roomB: { + deviceA: createRecord({ + babyName: "安安的睡眠报告", + dateLabel: "2026-05-08 日报", + roomLabel: "主卧", + deviceLabel: "监测垫 A1", + score: 71, + qualityStatus: "环境切换带来轻微波动", + totalSleep: "7h 58m", + asleepAt: "22:20", + wakeAt: "06:18", + metrics: [ + { label: "入睡时长", value: "28", unit: "min", status: "注意" }, + { label: "平均呼吸率", value: "22", unit: "次/分", status: "注意" }, + { label: "平均血氧", value: "95", unit: "%", status: "正常" }, + { label: "呼吸暂停次数", value: "3", unit: "次", status: "异常" }, + { label: "平均 HRV", value: "31", unit: "ms", status: "注意" }, + { label: "平均心率", value: "93", unit: "bpm", status: "注意" }, + { label: "异常翻身次数", value: "3", unit: "次", status: "注意" }, + { label: "暂停总时长", value: "1.6", unit: "min", status: "注意" }, + { label: "心率过快次数", value: "2", unit: "次", status: "异常" } + ], + aiSummary: [ + { title: "睡眠稳定性", body: "入睡速度偏慢,前半夜轻睡占比偏高。", tone: "warning" }, + { title: "呼吸状态", body: "凌晨阶段存在短时波动,建议继续观察。", tone: "warning" }, + { title: "心率变化", body: "整体稳定,但清醒后恢复速度略慢。", tone: "good" } + ], + suggestions: [ + { title: "环境建议", body: "尽量保持固定睡眠环境,减少换房影响。", tone: "good" }, + { title: "安抚建议", body: "入睡前降低灯光刺激,缩短入睡等待时间。", tone: "excellent" } + ], + anomalyStats: { + title: "异常统计", + items: [ + { label: "心率异常", value: "2", unit: "次", status: "异常" }, + { label: "呼吸异常", value: "2", unit: "次", status: "注意" }, + { label: "血氧异常", value: "1", unit: "次", status: "注意" }, + { label: "呼吸暂停", value: "3", unit: "次", status: "异常" }, + { label: "离床行为", value: "0", unit: "次", status: "正常" } + ] + }, + hrvMetrics: [ + { label: "HRV", value: "31 ms", status: "注意" }, + { label: "RMSSD", value: "17.2 ms", status: "注意" }, + { label: "SDNN", value: "24.3 ms", status: "注意" }, + { label: "LF/HF", value: "1.95", status: "压力偏高" }, + { label: "压力指数", value: "恢复一般", status: "注意" } + ], + heartRatePoints: baseHeartRate.map((item, index) => ({ ...item, value: item.value + (index === 3 ? 5 : 1), y: Math.min(item.y + 3, 88), tone: index === 3 ? "danger" : item.tone })), + breathingPoints: baseBreathing, + oxygenSummary: { + title: "低氧统计", + items: [ + { label: "最低血氧", value: "92", unit: "%", status: "注意" }, + { label: "低氧次数", value: "2", unit: "次", status: "注意" }, + { label: "持续时长", value: "0.6", unit: "min", status: "低氧风险" } + ] + }, + snoreSummary: { + title: "打鼾监测", + items: [ + { label: "打鼾时长", value: "66", unit: "s", status: "注意" }, + { label: "打鼾频率", value: "1.2", unit: "次/h", status: "注意" }, + { label: "峰值强度", value: "58", unit: "dB", status: "注意" } + ] + }, + eventPoints: baseEvents, + sleepStructure: [ + { label: "深睡", value: 27, color: "#2de1c2" }, + { label: "浅睡", value: 39, color: "#23b6ff" }, + { label: "REM", value: 20, color: "#ffb84d" }, + { label: "清醒", value: 14, color: "#ff6b6b" } + ], + autonomicMetrics: [ + { label: "交感活跃度", value: "61%", status: "压力偏高" }, + { label: "副交感活跃度", value: "39%", status: "注意" }, + { label: "压力状态", value: "恢复一般", status: "注意" } + ], + daytimePrediction: [ + { label: "精神状态", status: "良好", detail: "上午精力尚可" }, + { label: "疲劳程度", status: "注意", detail: "中午前后易疲劳" }, + { label: "情绪状态", status: "注意", detail: "易受惊醒影响" } + ] + }) + } + }, + "2026-05-07": { + roomA: { + deviceA: createRecord({ + babyName: "安安的睡眠报告", + dateLabel: "2026-05-07 日报", + roomLabel: "婴儿房", + deviceLabel: "监测垫 A1", + score: 73, + qualityStatus: "接近良好,异常次数减少", + totalSleep: "8h 05m", + asleepAt: "22:05", + wakeAt: "06:10", + metrics: [ + { label: "入睡时长", value: "20", unit: "min", status: "正常" }, + { label: "平均呼吸率", value: "21", unit: "次/分", status: "正常" }, + { label: "平均血氧", value: "96", unit: "%", status: "正常" }, + { label: "呼吸暂停次数", value: "2", unit: "次", status: "注意" }, + { label: "平均 HRV", value: "35", unit: "ms", status: "良好" }, + { label: "平均心率", value: "91", unit: "bpm", status: "正常" }, + { label: "异常翻身次数", value: "2", unit: "次", status: "注意" }, + { label: "暂停总时长", value: "1.1", unit: "min", status: "注意" }, + { label: "心率过快次数", value: "1", unit: "次", status: "注意" } + ], + aiSummary: [ + { title: "睡眠稳定性", body: "较前一日更平稳,深睡时长增加。", tone: "good" }, + { title: "呼吸状态", body: "夜间呼吸事件减少,整体向好。", tone: "good" }, + { title: "心率变化", body: "无明显高风险波动。", tone: "excellent" } + ], + suggestions: [ + { title: "继续观察", body: "建议保持现有睡前流程,持续观察一周趋势。", tone: "excellent" }, + { title: "设备摆放", body: "保持设备平整贴合,提升呼吸监测稳定度。", tone: "good" } + ], + anomalyStats: { + title: "异常统计", + items: [ + { label: "心率异常", value: "1", unit: "次", status: "注意" }, + { label: "呼吸异常", value: "2", unit: "次", status: "注意" }, + { label: "血氧异常", value: "0", unit: "次", status: "正常" }, + { label: "呼吸暂停", value: "2", unit: "次", status: "注意" }, + { label: "离床行为", value: "0", unit: "次", status: "正常" } + ] + }, + hrvMetrics: [ + { label: "HRV", value: "35 ms", status: "良好" }, + { label: "RMSSD", value: "21.3 ms", status: "良好" }, + { label: "SDNN", value: "27.5 ms", status: "良好" }, + { label: "LF/HF", value: "1.48", status: "正常" }, + { label: "压力指数", value: "稳定", status: "正常" } + ], + heartRatePoints: baseHeartRate.map((item) => ({ ...item, value: item.value - 1, y: Math.max(item.y - 3, 20) })), + breathingPoints: baseBreathing.map((item) => ({ ...item, y: Math.max(item.y - 4, 18), tone: item.y > 70 ? "warning" : "good" })), + oxygenSummary: { + title: "低氧统计", + items: [ + { label: "最低血氧", value: "94", unit: "%", status: "正常" }, + { label: "低氧次数", value: "1", unit: "次", status: "注意" }, + { label: "持续时长", value: "0.2", unit: "min", status: "正常" } + ] + }, + snoreSummary: { + title: "打鼾监测", + items: [ + { label: "打鼾时长", value: "41", unit: "s", status: "正常" }, + { label: "打鼾频率", value: "0.9", unit: "次/h", status: "正常" }, + { label: "峰值强度", value: "52", unit: "dB", status: "注意" } + ] + }, + eventPoints: baseEvents.map((item, index) => ({ ...item, y: item.y - (index + 4), tone: index === 0 ? "warning" : "good" })), + sleepStructure: [ + { label: "深睡", value: 31, color: "#2de1c2" }, + { label: "浅睡", value: 36, color: "#23b6ff" }, + { label: "REM", value: 21, color: "#ffb84d" }, + { label: "清醒", value: 12, color: "#ff6b6b" } + ], + autonomicMetrics: [ + { label: "交感活跃度", value: "55%", status: "正常" }, + { label: "副交感活跃度", value: "45%", status: "良好" }, + { label: "压力状态", value: "恢复尚可", status: "良好" } + ], + daytimePrediction: [ + { label: "精神状态", status: "良好", detail: "上午状态稳定" }, + { label: "疲劳程度", status: "良好", detail: "午后需要短暂休息" }, + { label: "情绪状态", status: "良好", detail: "整体平稳" } + ] + }) + } + } + }, + weekly: { + "2026-week-19": { + roomA: { + deviceA: createRecord({ + babyName: "安安的睡眠周报", + dateLabel: "第 19 周周报", + roomLabel: "婴儿房", + deviceLabel: "监测垫 A1", + score: 76, + qualityStatus: "本周整体良好,凌晨波动仍需关注", + totalSleep: "8h 18m", + asleepAt: "22:08", + wakeAt: "06:26", + metrics: [ + { label: "平均入睡", value: "19", unit: "min", status: "正常" }, + { label: "平均呼吸率", value: "21.4", unit: "次/分", status: "正常" }, + { label: "平均血氧", value: "95.8", unit: "%", status: "正常" }, + { label: "呼吸暂停次数", value: "2.4", unit: "次/晚", status: "注意" }, + { label: "平均 HRV", value: "36", unit: "ms", status: "良好" }, + { label: "平均心率", value: "91", unit: "bpm", status: "正常" }, + { label: "异常翻身次数", value: "1.6", unit: "次/晚", status: "正常" }, + { label: "暂停总时长", value: "1.1", unit: "min", status: "注意" }, + { label: "心率过快次数", value: "1.1", unit: "次/晚", status: "注意" } + ], + aiSummary: [ + { title: "周趋势", body: "本周睡眠评分稳定在良好区间,后半周连续性提升。", tone: "good" }, + { title: "呼吸状态", body: "周中仍有 2 晚出现轻微呼吸事件,建议继续观察。", tone: "warning" }, + { title: "心率恢复", body: "整体恢复表现良好。", tone: "excellent" } + ], + suggestions: [ + { title: "继续观察周趋势", body: "建议下周继续关注凌晨 2 点到 4 点的呼吸波动。", tone: "good" }, + { title: "保持睡前节律", body: "稳定洗漱、喂奶和安抚流程。", tone: "excellent" } + ], + anomalyStats: { + title: "异常统计", + items: [ + { label: "心率异常", value: "7", unit: "次", status: "注意" }, + { label: "呼吸异常", value: "11", unit: "次", status: "注意" }, + { label: "血氧异常", value: "3", unit: "次", status: "注意" }, + { label: "呼吸暂停", value: "17", unit: "次", status: "注意" }, + { label: "离床行为", value: "2", unit: "次", status: "正常" } + ] + }, + hrvMetrics: [ + { label: "HRV", value: "36 ms", status: "良好" }, + { label: "RMSSD", value: "20.8 ms", status: "良好" }, + { label: "SDNN", value: "29.0 ms", status: "良好" }, + { label: "LF/HF", value: "1.56", status: "正常" }, + { label: "压力指数", value: "稳定", status: "正常" } + ], + heartRatePoints: baseHeartRate, + breathingPoints: baseBreathing, + oxygenSummary: { + title: "低氧统计", + items: [ + { label: "最低血氧", value: "92", unit: "%", status: "注意" }, + { label: "低氧次数", value: "5", unit: "次", status: "注意" }, + { label: "持续时长", value: "1.7", unit: "min", status: "低氧风险" } + ] + }, + snoreSummary: { + title: "打鼾监测", + items: [ + { label: "打鼾时长", value: "230", unit: "s", status: "注意" }, + { label: "打鼾频率", value: "1.1", unit: "次/h", status: "注意" }, + { label: "峰值强度", value: "60", unit: "dB", status: "异常" } + ] + }, + eventPoints: baseEvents, + sleepStructure: [ + { label: "深睡", value: 32, color: "#2de1c2" }, + { label: "浅睡", value: 35, color: "#23b6ff" }, + { label: "REM", value: 22, color: "#ffb84d" }, + { label: "清醒", value: 11, color: "#ff6b6b" } + ], + autonomicMetrics: [ + { label: "交感活跃度", value: "56%", status: "正常" }, + { label: "副交感活跃度", value: "44%", status: "良好" }, + { label: "压力状态", value: "本周恢复较平衡", status: "良好" } + ], + daytimePrediction: [ + { label: "精神状态", status: "良好", detail: "本周白天精神总体平稳" }, + { label: "疲劳程度", status: "良好", detail: "午后有轻度疲劳趋势" }, + { label: "情绪状态", status: "优秀", detail: "情绪稳定度较高" } + ] + }) + } + }, + "2026-week-18": { + roomA: { + deviceA: createRecord({ + babyName: "安安的睡眠周报", + dateLabel: "第 18 周周报", + roomLabel: "婴儿房", + deviceLabel: "监测垫 A1", + score: 69, + qualityStatus: "周内波动偏多,需关注呼吸质量", + totalSleep: "7h 56m", + asleepAt: "22:16", + wakeAt: "06:12", + metrics: [ + { label: "平均入睡", value: "24", unit: "min", status: "注意" }, + { label: "平均呼吸率", value: "22.6", unit: "次/分", status: "注意" }, + { label: "平均血氧", value: "95.1", unit: "%", status: "正常" }, + { label: "呼吸暂停次数", value: "3.8", unit: "次/晚", status: "异常" }, + { label: "平均 HRV", value: "31", unit: "ms", status: "注意" }, + { label: "平均心率", value: "93", unit: "bpm", status: "注意" }, + { label: "异常翻身次数", value: "2.5", unit: "次/晚", status: "注意" }, + { label: "暂停总时长", value: "1.9", unit: "min", status: "异常" }, + { label: "心率过快次数", value: "2.0", unit: "次/晚", status: "异常" } + ], + aiSummary: [ + { title: "周趋势", body: "周内夜间清醒次数偏多,连续深睡不足。", tone: "warning" }, + { title: "呼吸状态", body: "呼吸暂停事件较上周增加。", tone: "danger" }, + { title: "恢复情况", body: "HRV 较低,夜间恢复一般。", tone: "warning" } + ], + suggestions: [ + { title: "优先观察呼吸事件", body: "建议结合视频或临床建议继续观察。", tone: "danger" }, + { title: "调整环境", body: "减少卧室闷热和干燥问题。", tone: "good" } + ], + anomalyStats: { + title: "异常统计", + items: [ + { label: "心率异常", value: "11", unit: "次", status: "异常" }, + { label: "呼吸异常", value: "16", unit: "次", status: "异常" }, + { label: "血氧异常", value: "6", unit: "次", status: "注意" }, + { label: "呼吸暂停", value: "24", unit: "次", status: "异常" }, + { label: "离床行为", value: "3", unit: "次", status: "注意" } + ] + }, + hrvMetrics: [ + { label: "HRV", value: "31 ms", status: "注意" }, + { label: "RMSSD", value: "16.8 ms", status: "注意" }, + { label: "SDNN", value: "24.8 ms", status: "注意" }, + { label: "LF/HF", value: "2.04", status: "压力偏高" }, + { label: "压力指数", value: "恢复一般", status: "压力偏高" } + ], + heartRatePoints: baseHeartRate.map((item) => ({ ...item, value: item.value + 2, y: Math.min(item.y + 5, 86) })), + breathingPoints: baseBreathing.map((item) => ({ ...item, value: item.value + 1, y: Math.min(item.y + 5, 84), tone: item.y > 60 ? "warning" : item.tone })), + oxygenSummary: { + title: "低氧统计", + items: [ + { label: "最低血氧", value: "90", unit: "%", status: "低氧风险" }, + { label: "低氧次数", value: "8", unit: "次", status: "低氧风险" }, + { label: "持续时长", value: "2.6", unit: "min", status: "高风险" } + ] + }, + snoreSummary: { + title: "打鼾监测", + items: [ + { label: "打鼾时长", value: "320", unit: "s", status: "异常" }, + { label: "打鼾频率", value: "1.8", unit: "次/h", status: "注意" }, + { label: "峰值强度", value: "64", unit: "dB", status: "异常" } + ] + }, + eventPoints: baseEvents.map((item, index) => ({ ...item, y: Math.min(item.y + index * 4, 88), tone: index > 1 ? "danger" : "warning" })), + sleepStructure: [ + { label: "深睡", value: 25, color: "#2de1c2" }, + { label: "浅睡", value: 40, color: "#23b6ff" }, + { label: "REM", value: 21, color: "#ffb84d" }, + { label: "清醒", value: 14, color: "#ff6b6b" } + ], + autonomicMetrics: [ + { label: "交感活跃度", value: "63%", status: "压力偏高" }, + { label: "副交感活跃度", value: "37%", status: "注意" }, + { label: "压力状态", value: "紧张偏高", status: "压力偏高" } + ], + daytimePrediction: [ + { label: "精神状态", status: "注意", detail: "白天精神波动较明显" }, + { label: "疲劳程度", status: "异常", detail: "更易困倦和烦躁" }, + { label: "情绪状态", status: "注意", detail: "需要更多安抚" } + ] + }) + } + } + }, + monthly: { + "2026-05": { + roomA: { + deviceA: createRecord({ + babyName: "安安的睡眠月报", + dateLabel: "2026 年 05 月", + roomLabel: "婴儿房", + deviceLabel: "监测垫 A1", + score: 81, + qualityStatus: "本月整体良好,趋势向稳", + totalSleep: "8h 20m", + asleepAt: "22:03", + wakeAt: "06:23", + metrics: [ + { label: "平均入睡", value: "18", unit: "min", status: "正常" }, + { label: "平均呼吸率", value: "21.2", unit: "次/分", status: "正常" }, + { label: "平均血氧", value: "96.2", unit: "%", status: "良好" }, + { label: "呼吸暂停次数", value: "1.9", unit: "次/晚", status: "注意" }, + { label: "平均 HRV", value: "39", unit: "ms", status: "良好" }, + { label: "平均心率", value: "90", unit: "bpm", status: "正常" }, + { label: "异常翻身次数", value: "1.2", unit: "次/晚", status: "正常" }, + { label: "暂停总时长", value: "0.8", unit: "min", status: "注意" }, + { label: "心率过快次数", value: "0.8", unit: "次/晚", status: "正常" } + ], + aiSummary: [ + { title: "月趋势", body: "本月睡眠评分逐步上升,后半月深睡比例提升明显。", tone: "excellent" }, + { title: "呼吸状态", body: "呼吸事件较前月减少,风险总体下降。", tone: "good" }, + { title: "恢复质量", body: "HRV 和夜间恢复状态均有改善。", tone: "excellent" } + ], + suggestions: [ + { title: "延续当前节律", body: "建议保持现有入睡时间和安抚流程。", tone: "excellent" }, + { title: "关注季节变化", body: "温湿度变化时继续留意夜间呼吸状态。", tone: "good" } + ], + anomalyStats: { + title: "异常统计", + items: [ + { label: "心率异常", value: "18", unit: "次", status: "注意" }, + { label: "呼吸异常", value: "26", unit: "次", status: "注意" }, + { label: "血氧异常", value: "7", unit: "次", status: "注意" }, + { label: "呼吸暂停", value: "39", unit: "次", status: "注意" }, + { label: "离床行为", value: "4", unit: "次", status: "正常" } + ] + }, + hrvMetrics: [ + { label: "HRV", value: "39 ms", status: "良好" }, + { label: "RMSSD", value: "23.1 ms", status: "良好" }, + { label: "SDNN", value: "30.4 ms", status: "良好" }, + { label: "LF/HF", value: "1.38", status: "正常" }, + { label: "压力指数", value: "稳定", status: "良好" } + ], + heartRatePoints: baseHeartRate.map((item) => ({ ...item, y: Math.max(item.y - 5, 16), tone: item.y > 60 ? "good" : "excellent" })), + breathingPoints: baseBreathing.map((item) => ({ ...item, y: Math.max(item.y - 7, 20), tone: item.y > 70 ? "warning" : "good" })), + oxygenSummary: { + title: "低氧统计", + items: [ + { label: "最低血氧", value: "93", unit: "%", status: "正常" }, + { label: "低氧次数", value: "10", unit: "次", status: "注意" }, + { label: "持续时长", value: "3.4", unit: "min", status: "低氧风险" } + ] + }, + snoreSummary: { + title: "打鼾监测", + items: [ + { label: "打鼾时长", value: "540", unit: "s", status: "注意" }, + { label: "打鼾频率", value: "1.0", unit: "次/h", status: "正常" }, + { label: "峰值强度", value: "58", unit: "dB", status: "注意" } + ] + }, + eventPoints: baseEvents.map((item, index) => ({ ...item, y: Math.max(item.y - (index * 6 + 8), 20), tone: index === 0 ? "warning" : "good" })), + sleepStructure: [ + { label: "深睡", value: 35, color: "#2de1c2" }, + { label: "浅睡", value: 32, color: "#23b6ff" }, + { label: "REM", value: 22, color: "#ffb84d" }, + { label: "清醒", value: 11, color: "#ff6b6b" } + ], + autonomicMetrics: [ + { label: "交感活跃度", value: "51%", status: "正常" }, + { label: "副交感活跃度", value: "49%", status: "良好" }, + { label: "压力状态", value: "恢复平衡", status: "良好" } + ], + daytimePrediction: [ + { label: "精神状态", status: "优秀", detail: "本月白天精神恢复稳定" }, + { label: "疲劳程度", status: "良好", detail: "疲劳风险较低" }, + { label: "情绪状态", status: "优秀", detail: "整体情绪稳定" } + ] + }) + } + }, + "2026-04": { + roomA: { + deviceA: createRecord({ + babyName: "安安的睡眠月报", + dateLabel: "2026 年 04 月", + roomLabel: "婴儿房", + deviceLabel: "监测垫 A1", + score: 74, + qualityStatus: "月内波动仍在改善中", + totalSleep: "8h 02m", + asleepAt: "22:14", + wakeAt: "06:16", + metrics: [ + { label: "平均入睡", value: "22", unit: "min", status: "注意" }, + { label: "平均呼吸率", value: "22.1", unit: "次/分", status: "注意" }, + { label: "平均血氧", value: "95.4", unit: "%", status: "正常" }, + { label: "呼吸暂停次数", value: "2.8", unit: "次/晚", status: "注意" }, + { label: "平均 HRV", value: "34", unit: "ms", status: "注意" }, + { label: "平均心率", value: "92", unit: "bpm", status: "正常" }, + { label: "异常翻身次数", value: "1.9", unit: "次/晚", status: "注意" }, + { label: "暂停总时长", value: "1.4", unit: "min", status: "注意" }, + { label: "心率过快次数", value: "1.4", unit: "次/晚", status: "注意" } + ], + aiSummary: [ + { title: "月趋势", body: "月初波动较大,月末开始回稳。", tone: "warning" }, + { title: "呼吸状态", body: "呼吸事件仍有下降空间。", tone: "warning" }, + { title: "恢复质量", body: "夜间恢复在逐步提升。", tone: "good" } + ], + suggestions: [ + { title: "继续保持观察", body: "建议继续以周趋势方式跟踪。", tone: "good" }, + { title: "保持环境稳定", body: "夜间温湿度波动控制有助于维持呼吸平稳。", tone: "good" } + ], + anomalyStats: { + title: "异常统计", + items: [ + { label: "心率异常", value: "24", unit: "次", status: "注意" }, + { label: "呼吸异常", value: "34", unit: "次", status: "注意" }, + { label: "血氧异常", value: "10", unit: "次", status: "注意" }, + { label: "呼吸暂停", value: "51", unit: "次", status: "异常" }, + { label: "离床行为", value: "6", unit: "次", status: "注意" } + ] + }, + hrvMetrics: [ + { label: "HRV", value: "34 ms", status: "注意" }, + { label: "RMSSD", value: "19.2 ms", status: "注意" }, + { label: "SDNN", value: "27.1 ms", status: "注意" }, + { label: "LF/HF", value: "1.74", status: "注意" }, + { label: "压力指数", value: "轻度偏高", status: "注意" } + ], + heartRatePoints: baseHeartRate, + breathingPoints: baseBreathing, + oxygenSummary: { + title: "低氧统计", + items: [ + { label: "最低血氧", value: "91", unit: "%", status: "注意" }, + { label: "低氧次数", value: "14", unit: "次", status: "低氧风险" }, + { label: "持续时长", value: "4.8", unit: "min", status: "高风险" } + ] + }, + snoreSummary: { + title: "打鼾监测", + items: [ + { label: "打鼾时长", value: "610", unit: "s", status: "注意" }, + { label: "打鼾频率", value: "1.3", unit: "次/h", status: "注意" }, + { label: "峰值强度", value: "62", unit: "dB", status: "异常" } + ] + }, + eventPoints: baseEvents, + sleepStructure: [ + { label: "深睡", value: 28, color: "#2de1c2" }, + { label: "浅睡", value: 38, color: "#23b6ff" }, + { label: "REM", value: 21, color: "#ffb84d" }, + { label: "清醒", value: 13, color: "#ff6b6b" } + ], + autonomicMetrics: [ + { label: "交感活跃度", value: "59%", status: "注意" }, + { label: "副交感活跃度", value: "41%", status: "注意" }, + { label: "压力状态", value: "轻度偏高", status: "注意" } + ], + daytimePrediction: [ + { label: "精神状态", status: "良好", detail: "白天状态较 5 月略弱" }, + { label: "疲劳程度", status: "注意", detail: "午后更易疲劳" }, + { label: "情绪状态", status: "良好", detail: "波动可控" } + ] + }) + } + } + } +}; diff --git a/src/pages/report/report-utils.ts b/src/pages/report/report-utils.ts new file mode 100644 index 0000000..d8ce15d --- /dev/null +++ b/src/pages/report/report-utils.ts @@ -0,0 +1,55 @@ +export type ReportTone = "excellent" | "good" | "warning" | "danger"; + +export function getSleepLevel(score: number): { label: string; tone: ReportTone } { + if (score >= 90) { + return { label: "优秀", tone: "excellent" }; + } + + if (score >= 75) { + return { label: "良好", tone: "good" }; + } + + if (score >= 60) { + return { label: "合格", tone: "warning" }; + } + + return { label: "异常", tone: "danger" }; +} + +export function getStatusTone(status: string): ReportTone { + if (status === "正常" || status === "优秀") { + return "excellent"; + } + + if (status === "良好") { + return "good"; + } + + if (status === "注意" || status === "合格") { + return "warning"; + } + + return "danger"; +} + +export function pickReportRecord( + records: Record>>, + dateKey: string, + roomKey: string, + deviceKey: string +): T { + const rooms = records[dateKey] ?? {}; + const devices = rooms[roomKey] ?? {}; + + if (deviceKey in devices) { + return devices[deviceKey]; + } + + const fallback = Object.values(devices)[0]; + + if (!fallback) { + throw new Error("未找到报告数据"); + } + + return fallback; +} diff --git a/src/pages/report/types.ts b/src/pages/report/types.ts new file mode 100644 index 0000000..5236aeb --- /dev/null +++ b/src/pages/report/types.ts @@ -0,0 +1,107 @@ +import type { ReportTone } from "./report-utils"; + +export type ReportDimension = "daily" | "weekly" | "monthly"; + +export type ReportOption = { + label: string; + value: string; +}; + +export type ReportChipOption = { + label: string; + value: string; +}; + +export type ScoreFactor = { + label: string; + score: number; +}; + +export type TrendPoint = { + id: string; + time: string; + label: string; + value: number; + x: number; + y: number; + tone: ReportTone; + meta?: string; +}; + +export type MetricCard = { + label: string; + value: string; + unit: string; + status: string; +}; + +export type InsightItem = { + title: string; + body: string; + tone: ReportTone; +}; + +export type SummaryItem = { + label: string; + value: string; + unit?: string; + status?: string; +}; + +export type SummaryBlock = { + title: string; + items: SummaryItem[]; +}; + +export type KvMetric = { + label: string; + value: string; + status?: string; +}; + +export type DistributionItem = { + label: string; + value: number; + color: string; +}; + +export type DaytimeState = { + label: string; + status: string; + detail: string; +}; + +export type ReportRecord = { + babyName: string; + dateLabel: string; + roomLabel: string; + deviceLabel: string; + score: number; + qualityStatus: string; + totalSleep: string; + asleepAt: string; + wakeAt: string; + scoreFactors: ScoreFactor[]; + sleepTrend: TrendPoint[]; + scoreTrend: TrendPoint[]; + metrics: MetricCard[]; + aiSummary: InsightItem[]; + suggestions: InsightItem[]; + anomalyStats: SummaryBlock; + hrvMetrics: KvMetric[]; + heartRatePoints: TrendPoint[]; + breathingPoints: TrendPoint[]; + oxygenSummary: SummaryBlock; + snoreSummary: SummaryBlock; + eventPoints: TrendPoint[]; + sleepStructure: DistributionItem[]; + autonomicMetrics: KvMetric[]; + daytimePrediction: DaytimeState[]; +}; + +export type ReportDimensionOptions = { + dates: ReportOption[]; + rooms: ReportOption[]; + devices: ReportOption[]; +}; + diff --git a/tsconfig.report-tests.json b/tsconfig.report-tests.json new file mode 100644 index 0000000..f094f83 --- /dev/null +++ b/tsconfig.report-tests.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "tmp/report-tests", + "module": "CommonJS", + "target": "ES2020", + "declaration": false, + "types": [] + }, + "include": ["src/pages/report/report-utils.ts"] +}