feat: add sleep report page

This commit is contained in:
czz
2026-05-08 11:30:37 +08:00
parent 054d8e3519
commit a6382d669b
26 changed files with 3249 additions and 6 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
/node_modules/ /node_modules/
/dist/ /dist/
/tmp/
/.swc/ /.swc/
/.codex/ /.codex/
/project.private.config.json /project.private.config.json

View File

@@ -4,10 +4,13 @@
当前首页已经改成了一版“设备绑定首页(无设备状态)”业务样式,方便你直接在这个基础上继续接真实接口和页面跳转。 当前首页已经改成了一版“设备绑定首页(无设备状态)”业务样式,方便你直接在这个基础上继续接真实接口和页面跳转。
当前项目还新增了一版“睡眠报告演示页”用于展示宝宝睡眠评分、呼吸状态、HRV、异常统计和 AI 分析建议等完整报告结构。
## 1. 目前已经包含什么 ## 1. 目前已经包含什么
- `Taro + React + TypeScript` 项目骨架 - `Taro + React + TypeScript` 项目骨架
- 首页设备绑定业务示例页面 - 首页设备绑定业务示例页面
- 睡眠报告演示页面
- 小程序 `AppID` 配置 - 小程序 `AppID` 配置
- `AGENTS.md` 协作规则文件 - `AGENTS.md` 协作规则文件
- 从开发到发布的中文说明 - 从开发到发布的中文说明
@@ -26,11 +29,31 @@
├─ app.config.ts ├─ app.config.ts
├─ app.scss ├─ app.scss
├─ app.tsx ├─ 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 └─ pages
─ index ─ index
│ ├─ index.config.ts
│ ├─ index.scss
│ └─ index.tsx
└─ report
├─ index.config.ts ├─ index.config.ts
├─ index.scss ├─ index.scss
─ index.tsx ─ index.tsx
├─ mock.ts
├─ report-utils.ts
└─ types.ts
``` ```
## 3. 你需要先准备什么 ## 3. 你需要先准备什么
@@ -139,6 +162,12 @@ npm run dev:weapp
6. 如果开发者工具提示编译目录相关问题,确认你已经先运行过: 6. 如果开发者工具提示编译目录相关问题,确认你已经先运行过:
`npm run dev:weapp` `npm run dev:weapp`
如果你要重点查看新增的睡眠报告页:
- 先进入首页
- 点击底部“报告”
- 或点击首页里的“查看睡眠报告”
## 11. 项目里的几个核心文件 ## 11. 项目里的几个核心文件
- [package.json](C:/Users/a/Documents/New%20project%203/package.json) - [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. 你接下来最常做的开发动作 ## 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/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` 1. 确保已填写真实 `AppID`
@@ -282,6 +358,20 @@ npm run build:weapp
- HTTPS - HTTPS
- 后台服务正常可访问 - 后台服务正常可访问
### 5. 如何验证报告页里的纯逻辑
当前项目新增了一个最小测试命令,用来验证报告页里的纯逻辑函数,比如:
- 睡眠评分等级映射
- 状态颜色映射
- mock 数据选择逻辑
执行:
```bash
npm run test:report
```
## 17. 建议你下一步怎么做 ## 17. 建议你下一步怎么做
如果你是零基础,推荐按这个顺序继续: 如果你是零基础,推荐按这个顺序继续:

View File

@@ -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<T>(
records: Record<string, Record<string, Record<string, T>>>,
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
<View className="device-report-entry" onClick={openReportPage}>
<View>
<Text className="device-report-entry__eyebrow"></Text>
<Text className="device-report-entry__title"></Text>
</View>
<Text className="device-report-entry__arrow">&gt;</Text>
</View>
```
- [ ] **Step 3: 新建最小报告页壳子**
```ts
export default definePageConfig({
navigationStyle: "custom"
});
```
```tsx
export default function ReportPage() {
return <View className="report-page"></View>;
}
```
```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<ReportDimension>("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
<View className="report-header">
<View className="report-header__back" onClick={() => Taro.navigateBack()} />
<View className="report-header__main">
<Text className="report-header__name">{currentRecord.babyName}</Text>
<Text className="report-header__date">{dateKey}</Text>
</View>
<View className="report-header__share" onClick={() => showToast("分享功能待接入")} />
</View>
```
```tsx
<View className="report-score-card">
<Text className="report-score-card__score">{currentRecord.score}</Text>
<Text className={`report-score-card__level report-score-card__level--${sleepLevel.tone}`}>
{sleepLevel.label}
</Text>
</View>
```
- [ ] **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
<View className="report-section">
<Text className="report-section__title"></Text>
<View className="trend-chart">
{currentRecord.sleepTrend.map((point, index) => (
<View
key={point.time}
className={`trend-chart__point ${selectedTrendTime === point.time ? "is-active" : ""}`}
style={{ left: `${index * 12}%`, bottom: `${point.score}%` }}
onClick={() => setSelectedTrendTime(point.time)}
/>
))}
</View>
</View>
```
```tsx
<View className="metric-grid">
{currentRecord.metrics.map((metric) => (
<View className="metric-card" key={metric.label}>
<Text className="metric-card__label">{metric.label}</Text>
<Text className="metric-card__value">
{metric.value}
<Text className="metric-card__unit">{metric.unit}</Text>
</Text>
<Text className={`metric-card__status metric-card__status--${getStatusTone(metric.status)}`}>
{metric.status}
</Text>
</View>
))}
</View>
```
- [ ] **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
<View className="report-section">
<Text className="report-section__title">AI </Text>
{currentRecord.aiSummary.map((item) => (
<View className="insight-card" key={item.title}>
<Text className="insight-card__title">{item.title}</Text>
<Text className="insight-card__body">{item.body}</Text>
</View>
))}
</View>
```
```tsx
<View className="report-section">
<Text className="report-section__title">HRV</Text>
{currentRecord.hrvMetrics.map((item) => (
<View className="kv-row" key={item.label}>
<Text>{item.label}</Text>
<Text>{item.value}</Text>
</View>
))}
</View>
```
```tsx
<View className="report-section">
<Text className="report-section__title"></Text>
<View className="daytime-grid">
{currentRecord.daytimePrediction.map((item) => (
<View className={`daytime-chip daytime-chip--${getStatusTone(item.status)}`} key={item.label}>
<Text>{item.label}</Text>
<Text>{item.status}</Text>
</View>
))}
</View>
</View>
```
- [ ] **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` 这些命名,避免偏离计划。

View File

@@ -5,7 +5,8 @@
"description": "Taro + React + TypeScript 微信小程序入门项目", "description": "Taro + React + TypeScript 微信小程序入门项目",
"scripts": { "scripts": {
"dev:weapp": "taro build --type weapp --watch", "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": { "dependencies": {
"@tarojs/components": "^4.0.0", "@tarojs/components": "^4.0.0",

View File

@@ -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);
});

View File

@@ -1,5 +1,5 @@
export default defineAppConfig({ export default defineAppConfig({
pages: ["pages/index/index"], pages: ["pages/index/index", "pages/report/index"],
window: { window: {
navigationBarTitleText: "新手小程序", navigationBarTitleText: "新手小程序",
navigationBarBackgroundColor: "#1AAD19", navigationBarBackgroundColor: "#1AAD19",

View File

@@ -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 (
<View className="report-card">
<View className="report-card__header">
<View>
<Text className="report-card__title">{title}</Text>
{subtitle ? <Text className="report-card__subtitle">{subtitle}</Text> : null}
</View>
{selectedPoint ? (
<View className="report-card__extra">
<Text className="report-card__badge">{selectedPoint.time}</Text>
</View>
) : null}
</View>
{selectedPoint ? (
<View className="report-chart__tooltip">
<Text className="report-chart__tooltip-title">{selectedPoint.label}</Text>
<Text className="report-chart__tooltip-value">{selectedPoint.value}</Text>
{selectedPoint.meta ? <Text className="report-chart__tooltip-meta">{selectedPoint.meta}</Text> : null}
</View>
) : null}
<View className={`report-chart report-chart--${mode}`}>
<View className="report-chart__grid">
{[0, 1, 2, 3].map((item) => (
<View className="report-chart__grid-line" key={item} />
))}
</View>
{points.map((item) => (
<View className="report-chart__item" key={item.id} style={{ left: `${item.x}%` }}>
{mode === "line" ? <View className="report-chart__stem" style={{ height: `${item.y}%` }} /> : null}
<View
className={`report-chart__point report-chart__point--${item.tone} ${selectedId === item.id ? "is-active" : ""}`}
style={{ bottom: `${item.y}%` }}
onClick={() => onSelect(item.id)}
/>
<Text className="report-chart__label">{item.time}</Text>
</View>
))}
</View>
</View>
);
}

View File

@@ -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 (
<View className={`report-chip-group ${compact ? "report-chip-group--compact" : ""}`}>
{options.map((item) => (
<View
className={`report-chip ${item.value === value ? "report-chip--active" : ""}`}
key={item.value}
onClick={() => onChange(item.value)}
>
<Text className={`report-chip__text ${item.value === value ? "report-chip__text--active" : ""}`}>{item.label}</Text>
</View>
))}
</View>
);
}

View File

@@ -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 (
<View className="report-card">
<View className="report-card__header">
<Text className="report-card__title"></Text>
</View>
<View className="daytime-list">
{items.map((item) => (
<View className="daytime-card" key={item.label}>
<View className="daytime-card__head">
<Text className="daytime-card__label">{item.label}</Text>
<Text className={`daytime-card__status daytime-card__status--${getStatusTone(item.status)}`}>{item.status}</Text>
</View>
<Text className="daytime-card__detail">{item.detail}</Text>
</View>
))}
</View>
</View>
);
}

View File

@@ -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 (
<View className="report-card">
<View className="report-card__header">
<Text className="report-card__title">{title}</Text>
</View>
<View className="distribution-card">
<View className="distribution-card__ring">
<View className="distribution-card__ring-inner">
<Text className="distribution-card__ring-text"></Text>
</View>
</View>
<View className="distribution-card__list">
{items.map((item) => (
<View className="distribution-card__row" key={item.label}>
<View className="distribution-card__legend">
<View className="distribution-card__dot" style={{ background: item.color }} />
<Text className="distribution-card__label">{item.label}</Text>
</View>
<Text className="distribution-card__value">{item.value}%</Text>
</View>
))}
</View>
</View>
</View>
);
}

View File

@@ -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 (
<View className="report-card">
<View className="report-card__header">
<Text className="report-card__title">{title}</Text>
</View>
<View className="insight-list">
{items.map((item) => (
<View className="insight-card" key={item.title}>
<View className={`insight-card__tone insight-card__tone--${item.tone}`} />
<View className="insight-card__content">
<Text className="insight-card__title">{item.title}</Text>
<Text className="insight-card__body">{item.body}</Text>
</View>
</View>
))}
</View>
</View>
);
}

View File

@@ -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 (
<View className="report-card">
<View className="report-card__header">
<Text className="report-card__title">{title}</Text>
</View>
<View className="kv-list">
{items.map((item) => (
<View className="kv-list__row" key={item.label}>
<Text className="kv-list__label">{item.label}</Text>
<View className="kv-list__value-wrap">
<Text className="kv-list__value">{item.value}</Text>
{item.status ? <Text className={`kv-list__status kv-list__status--${getStatusTone(item.status)}`}>{item.status}</Text> : null}
</View>
</View>
))}
</View>
</View>
);
}

View File

@@ -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 (
<View className="metric-grid">
{metrics.map((metric) => {
const tone = getStatusTone(metric.status);
return (
<View className="metric-card" key={metric.label}>
<Text className="metric-card__label">{metric.label}</Text>
<Text className="metric-card__value">
{metric.value}
<Text className="metric-card__unit">{metric.unit}</Text>
</Text>
<Text className={`metric-card__status metric-card__status--${tone}`}>{metric.status}</Text>
</View>
);
})}
</View>
);
}

View File

@@ -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 (
<View className="report-header">
<View className="report-header__action" onClick={onBack}>
<Text className="report-header__action-text">&lt;</Text>
</View>
<View className="report-header__content">
<Text className="report-header__title">{title}</Text>
<Text className="report-header__subtitle">{subtitle}</Text>
</View>
<View className="report-header__action report-header__action--share" onClick={onShare}>
<Text className="report-header__action-text"></Text>
</View>
</View>
);
}

View File

@@ -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 (
<View className="score-overview">
<View className="score-overview__top">
<View className="score-overview__ring">
<View className="score-overview__ring-inner">
<Text className="score-overview__caption"></Text>
<Text className="score-overview__score">{score}</Text>
</View>
</View>
<View className="score-overview__summary">
<Text className={`score-overview__level score-overview__level--${levelTone}`}>{levelLabel}</Text>
<Text className="score-overview__status">{qualityStatus}</Text>
<View className="score-overview__meta">
<View className="score-overview__meta-item">
<Text className="score-overview__meta-label"></Text>
<Text className="score-overview__meta-value">{totalSleep}</Text>
</View>
<View className="score-overview__meta-item">
<Text className="score-overview__meta-label"></Text>
<Text className="score-overview__meta-value">{asleepAt}</Text>
</View>
<View className="score-overview__meta-item">
<Text className="score-overview__meta-label"></Text>
<Text className="score-overview__meta-value">{wakeAt}</Text>
</View>
</View>
</View>
</View>
<View className="score-overview__factors">
{factors.map((item) => (
<View className="score-overview__factor" key={item.label}>
<Text className="score-overview__factor-label">{item.label}</Text>
<Text className="score-overview__factor-value">{item.score}</Text>
</View>
))}
</View>
</View>
);
}

View File

@@ -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 (
<View className="report-card">
<View className="report-card__header">
<View>
<Text className="report-card__title">{title}</Text>
{subtitle ? <Text className="report-card__subtitle">{subtitle}</Text> : null}
</View>
{extra ? <View className="report-card__extra">{extra}</View> : null}
</View>
{children}
</View>
);
}

View File

@@ -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 (
<View className="report-card">
<View className="report-card__header">
<Text className="report-card__title">{block.title}</Text>
</View>
<View className="summary-grid">
{block.items.map((item) => {
const tone = item.status ? getStatusTone(item.status) : "good";
return (
<View className="summary-grid__item" key={item.label}>
<Text className="summary-grid__label">{item.label}</Text>
<Text className="summary-grid__value">
{item.value}
{item.unit ? <Text className="summary-grid__unit">{item.unit}</Text> : null}
</Text>
{item.status ? <Text className={`summary-grid__status summary-grid__status--${tone}`}>{item.status}</Text> : null}
</View>
);
})}
</View>
</View>
);
}

View File

@@ -246,6 +246,55 @@
line-height: 1.7; 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 { .device-tabbar {
position: fixed; position: fixed;
left: 0; left: 0;

View File

@@ -108,7 +108,18 @@ export default function Index() {
showToast("后续可从这里进入设备管理页"); 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}功能待接入`); showToast(`${label}功能待接入`);
}; };
@@ -278,6 +289,15 @@ export default function Index() {
</View> </View>
</View> </View>
<View className="device-report-entry" onClick={openReportPage}>
<View className="device-report-entry__content">
<Text className="device-report-entry__eyebrow"></Text>
<Text className="device-report-entry__title"></Text>
<Text className="device-report-entry__desc"> AI </Text>
</View>
<Text className="device-report-entry__arrow">&gt;</Text>
</View>
<View className="device-notice-card"> <View className="device-notice-card">
<View className="device-notice-card__title-row"> <View className="device-notice-card__title-row">
<View className="device-notice-card__horn" /> <View className="device-notice-card__horn" />
@@ -295,7 +315,7 @@ export default function Index() {
<View className="device-tabbar"> <View className="device-tabbar">
{navItems.map((item) => ( {navItems.map((item) => (
<View className="device-tabbar__item" key={item.key} onClick={() => handleTabClick(item.label)}> <View className="device-tabbar__item" key={item.key} onClick={() => handleTabClick(item.key, item.label)}>
<View className={`device-tabbar__icon ${item.active ? "device-tabbar__icon--active" : ""}`}> <View className={`device-tabbar__icon ${item.active ? "device-tabbar__icon--active" : ""}`}>
{item.badge ? <View className="device-tabbar__badge" /> : null} {item.badge ? <View className="device-tabbar__badge" /> : null}
</View> </View>

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationStyle: "custom"
});

754
src/pages/report/index.scss Normal file
View File

@@ -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;
}

195
src/pages/report/index.tsx Normal file
View File

@@ -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<ReportDimension>("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 (
<ScrollView className="report-page" scrollY style={pageStyle}>
<View className="report-page__glow report-page__glow--one" />
<View className="report-page__glow report-page__glow--two" />
<View className="report-page__content">
<ReportPageHeader
title={currentRecord.babyName}
subtitle={currentRecord.dateLabel}
onBack={() => Taro.navigateBack({ delta: 1 })}
onShare={() =>
Taro.showToast({
title: "分享功能待接入",
icon: "none"
})
}
/>
<View className="report-toolbar">
<ReportChipGroup options={reportDimensions} value={dimension} onChange={handleDimensionChange} />
<View className="report-filters">
<View className="report-filter">
<Text className="report-filter__label"></Text>
<ReportChipGroup options={currentOptions.dates} value={dateKey} onChange={handleDateChange} compact />
</View>
<View className="report-filter">
<Text className="report-filter__label"></Text>
<ReportChipGroup options={currentOptions.rooms} value={roomKey} onChange={handleRoomChange} compact />
</View>
<View className="report-filter">
<Text className="report-filter__label"></Text>
<ReportChipGroup options={currentOptions.devices} value={deviceKey} onChange={handleDeviceChange} compact />
</View>
</View>
</View>
<ReportScoreOverview
score={currentRecord.score}
levelLabel={sleepLevel.label}
levelTone={sleepLevel.tone}
qualityStatus={currentRecord.qualityStatus}
totalSleep={currentRecord.totalSleep}
asleepAt={currentRecord.asleepAt}
wakeAt={currentRecord.wakeAt}
factors={currentRecord.scoreFactors}
/>
<ReportChartCard
title="睡眠质量趋势"
subtitle="整夜睡眠阶段与呼吸波动"
points={currentRecord.sleepTrend}
selectedId={selectedSleepTrend}
onSelect={setSelectedSleepTrend}
/>
<ReportMetricGrid metrics={currentRecord.metrics} />
<ReportInsightList title="AI 睡眠分析" items={currentRecord.aiSummary} />
<ReportInsightList title="改善建议" items={currentRecord.suggestions} />
<ReportChartCard
title="睡眠分数趋势"
subtitle="点击节点查看阶段评分"
points={currentRecord.scoreTrend}
selectedId={selectedScoreTrend}
onSelect={setSelectedScoreTrend}
/>
<ReportSummaryBlock block={currentRecord.anomalyStats} />
<ReportKvList title="心率变异性HRV" items={currentRecord.hrvMetrics} />
<ReportChartCard
title="心率散点图"
subtitle="横轴时间,纵轴心率值"
points={currentRecord.heartRatePoints}
selectedId={selectedHeartRatePoint}
onSelect={setSelectedHeartRatePoint}
mode="scatter"
/>
<ReportChartCard
title="呼吸波形图"
subtitle="呼吸波动与暂停标记"
points={currentRecord.breathingPoints}
selectedId={selectedBreathingPoint}
onSelect={setSelectedBreathingPoint}
/>
<ReportSummaryBlock block={currentRecord.oxygenSummary} />
<ReportSummaryBlock block={currentRecord.snoreSummary} />
<ReportChartCard
title="异常事件散点图"
subtitle="呼吸暂停、心率异常、哭闹、离床"
points={currentRecord.eventPoints}
selectedId={selectedEventPoint}
onSelect={setSelectedEventPoint}
mode="scatter"
/>
<ReportDistributionCard title="睡眠结构" items={currentRecord.sleepStructure} />
<ReportKvList title="自主神经分析" items={currentRecord.autonomicMetrics} />
<ReportDaytimePrediction items={currentRecord.daytimePrediction} />
</View>
</ScrollView>
);
}

865
src/pages/report/mock.ts Normal file
View File

@@ -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<ReportDimension, ReportDimensionOptions> = {
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<ReportDimension, Record<string, Record<string, Record<string, ReportRecord>>>> = {
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: "波动可控" }
]
})
}
}
}
};

View File

@@ -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<T>(
records: Record<string, Record<string, Record<string, T>>>,
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;
}

107
src/pages/report/types.ts Normal file
View File

@@ -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[];
};

View File

@@ -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"]
}