Compare commits

...

12 Commits

Author SHA1 Message Date
czz
42fde98037 Merge branch 'codex/profile-page-merge' 2026-05-09 16:31:06 +08:00
czz
b6a3719d2b style: 调整个人信息页头部布局 2026-05-09 16:30:29 +08:00
czz
a3227ba519 Merge branch 'codex/profile-page-merge' 2026-05-09 15:57:35 +08:00
czz
b7fa2fce9d refactor: 收口公共AppBar组件 2026-05-09 15:55:05 +08:00
czz
ef70bfc71c feat: 重做个人信息页交互 2026-05-09 15:50:51 +08:00
czz
f3eb25b035 fix: 统一二级页导航栏样式 2026-05-08 15:24:03 +08:00
czz
6d93440334 feat: 实现申请报修页面 2026-05-08 15:09:41 +08:00
czz
c42344e38a fix: unify main tab navigation behavior 2026-05-08 15:04:59 +08:00
czz
9a2f382dc9 feat: 完善我的设备入口页 2026-05-08 14:46:31 +08:00
czz
9885f131da Merge commit 'a6382d6'
# Conflicts:
#	README.md
#	src/app.config.ts
#	src/pages/index/index.scss
#	src/pages/index/index.tsx
2026-05-08 11:39:05 +08:00
czz
a6382d669b feat: add sleep report page 2026-05-08 11:30:37 +08:00
czz
054d8e3519 docs: add sleep report page design spec 2026-05-08 09:17:09 +08:00
62 changed files with 6246 additions and 293 deletions

1
.gitignore vendored
View File

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

View File

@@ -8,6 +8,7 @@
- 保持项目结构简单、易懂、易修改
- 使用 `Taro + React + TypeScript` 作为核心技术栈
- 明确这是“微信小程序”项目,不是原生 App 或 H5 项目
- 所有说明尽量使用中文
- 在保证可运行的前提下,减少复杂依赖
@@ -51,9 +52,27 @@
- 底部导航
- 列表项
- 状态块
- 图标使用保持语义统一:
- 如果页面有“返回上一级页面”操作,优先使用 `back.svg`
- 如果页面有“加号/新增”操作,优先使用 `add.svg`
- 如果一个结构或样式在两个及以上页面/区域重复出现,优先抽成可复用组件,而不是复制粘贴。
- 组件设计尽量保持“样式可配置、职责单一、命名清晰”,避免做过度复杂的大而全组件。
## 小程序设计适配约定
- 本项目是微信小程序,后续收到的设计稿即使来自 App也不能直接按 App 页面生搬硬套。
- 设计稿如果是 App 视觉稿,落地时必须同时考虑:
- 手机系统状态栏安全区
- 微信小程序原生胶囊按钮区域
- 微信小程序页面顶部 appbar 的可用空间
- 顶部按钮、标题、搜索框、头像、返回区等靠近页面顶部的元素,优先基于小程序原生 `statusBarHeight``getMenuButtonBoundingClientRect()` 等信息定位,而不是只按设计稿静态像素值摆放。
- 当 App 设计稿顶部结构与微信小程序原生导航区域冲突时,优先保证小程序可用性和对齐关系,再尽量还原设计视觉。
- 页面评审时,顶部区域要重点检查这几项:
- 是否遮挡原生胶囊
- 是否与原生胶囊水平对齐
- 是否预留了不同机型的顶部安全区
- 是否在真机上仍然成立
## 修改边界
- 可以新增或修改当前工作区内与本项目直接相关的文件。

130
README.md
View File

@@ -3,16 +3,20 @@
这是一个适合零基础开发者的 `Taro + React + TypeScript` 微信小程序项目模板。你后续会用 React 组件方式开发页面,再编译成微信小程序代码进行预览和发布。
当前首页已经改成了一版“设备绑定首页(无设备状态)”业务样式,方便你直接在这个基础上继续接真实接口和页面跳转。
项目里也已经开始整理公共能力:
- 颜色统一收口到全局主题变量,后续页面尽量不要再写散落的十六进制颜色
- 通用 UI 优先拆成可复用组件,方便后续多页面共用
当前项目还新增了一版“睡眠报告演示页”用于展示宝宝睡眠评分、呼吸状态、HRV、异常统计和 AI 分析建议等完整报告结构。
同时也补上了一版“申请报修演示页”,用于展示设备报修表单、附件上传与提交成功后的详情页流程。
## 1. 目前已经包含什么
- `Taro + React + TypeScript` 项目骨架
- 首页设备绑定业务示例页面
- 睡眠报告演示页面
- 申请报修演示页面
- 小程序 `AppID` 配置
- `AGENTS.md` 协作规则文件
- 从开发到发布的中文说明
@@ -31,11 +35,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. 你需要先准备什么
@@ -144,6 +168,12 @@ npm run dev:weapp
6. 如果开发者工具提示编译目录相关问题,确认你已经先运行过:
`npm run dev:weapp`
如果你要重点查看新增的睡眠报告页:
- 先进入首页
- 点击底部“报告”
- 或点击首页里的“查看睡眠报告”
## 11. 项目里的几个核心文件
- [package.json](C:/Users/a/Documents/New%20project%203/package.json)
@@ -187,6 +217,49 @@ npm run dev:weapp
- 设备列表
- 报告、消息、我的等页面
## 12.1 当前报告页做了什么
现在项目里已经新增一个独立的“睡眠报告页”,主要包含:
- 首页底部“报告”入口跳转
- 首页主体里的“查看睡眠报告”快捷入口
- `日报 / 周报 / 月报` 三种时间维度切换
- 日期、房间、设备三个筛选条件
- 睡眠评分总览
- 睡眠趋势图和睡眠分数趋势图
- 9 项核心指标卡片
- AI 睡眠分析和改善建议
- 异常统计、HRV、心率散点图、呼吸波形图
- 低氧统计、打鼾监测、异常事件散点图
- 睡眠结构、自主神经分析、日间状态预测
说明:
- 当前报告页使用本地 mock 数据驱动
- 图表是轻量前端演示实现,没有引入大型图表依赖
- 各板块尽量拆成了可复用组件,便于你继续改
- 分享功能当前还是前端占位提示,后续可再接真实能力
## 12.2 当前报修页做了什么
现在项目里已经把原来的“设备报修”占位页补成了一版可交互的“申请报修页”,主要包含:
- 使用小程序原生导航栏显示“申请报修”
- `体征监测设备 / AI摄像头` 两种设备类型切换
- 已绑定设备 ID 选择与设备参数自动带出
- 60 字以内的问题描述输入与实时字数统计
- 调用小程序 `chooseMedia` 选择图片或视频附件
- 图片最多 9 张、视频最多 1 个的前端限制
- 联系人和手机号填写
- 提交前必填校验与手机号格式校验
- mock 提交成功后跳转到“报修详情”占位页
说明:
- 当前报修页使用本地 mock 数据驱动
- 附件当前只做本地选择、预览和删除,不上传到真实服务器
- 历史记录按钮当前还是前端提示占位,后续可再接真实列表页
## 13. 你接下来最常做的开发动作
### 改公共主题色
@@ -207,6 +280,31 @@ 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/` 目录下的组件文件
### 改报修页
编辑:
- [src/pages/repair/index.tsx](C:/Users/a/Documents/New%20project%203/src/pages/repair/index.tsx)
- [src/pages/repair-detail/index.tsx](C:/Users/a/Documents/New%20project%203/src/pages/repair-detail/index.tsx)
### 改全局样式
编辑:
@@ -236,6 +334,12 @@ src
- 在微信开发者工具里查看编译后的模拟器效果
- 扫码和蓝牙相关能力更推荐使用真机调试,因为开发者工具里不一定能完整模拟真实权限和设备搜索环境
如果你在改报告页,推荐同步检查:
- 报表维度切换后数据有没有变化
- 日期、房间、设备切换后内容有没有同步
- 点击图表节点后提示值有没有更新
### 真机预览
1. 确保已填写真实 `AppID`
@@ -299,6 +403,26 @@ npm run build:weapp
- HTTPS
- 后台服务正常可访问
### 5. 如何验证报告页里的纯逻辑
当前项目新增了一个最小测试命令,用来验证报告页里的纯逻辑函数,比如:
- 睡眠评分等级映射
- 状态颜色映射
- mock 数据选择逻辑
执行:
```bash
npm run test:report
```
如果你要验证报修页里的表单和附件规则纯逻辑,可以执行:
```bash
npm run test:repair
```
## 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

@@ -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 数据选择逻辑

14
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"@tarojs/webpack5-runner": "^4.0.0",
"@types/react": "^18.2.66",
"babel-preset-taro": "^4.0.0",
"miniprogram-api-typings": "^5.2.0",
"typescript": "^5.4.5"
}
},
@@ -10249,6 +10250,13 @@
"miniprogram-exparser": "latest"
}
},
"node_modules/j-component/node_modules/miniprogram-api-typings": {
"version": "3.12.3",
"resolved": "https://registry.npmmirror.com/miniprogram-api-typings/-/miniprogram-api-typings-3.12.3.tgz",
"integrity": "sha512-o7bOfrU28MEMCBWo83nXv0ROQSBFxJcfCl4f2wTYqah64ipC5RGqLJfvWJTWhlQt2ECVwspSzM8LgvnfMo7TEQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jackspeak": {
"version": "2.3.6",
"resolved": "https://registry.npmmirror.com/jackspeak/-/jackspeak-2.3.6.tgz",
@@ -11395,9 +11403,9 @@
}
},
"node_modules/miniprogram-api-typings": {
"version": "3.12.3",
"resolved": "https://registry.npmmirror.com/miniprogram-api-typings/-/miniprogram-api-typings-3.12.3.tgz",
"integrity": "sha512-o7bOfrU28MEMCBWo83nXv0ROQSBFxJcfCl4f2wTYqah64ipC5RGqLJfvWJTWhlQt2ECVwspSzM8LgvnfMo7TEQ==",
"version": "5.2.0",
"resolved": "https://registry.npmmirror.com/miniprogram-api-typings/-/miniprogram-api-typings-5.2.0.tgz",
"integrity": "sha512-dkel1zG/eAfApabCtZnr9Y69+5z89GtWVPb6aCTvTJ0gu9mk+A0wCwdxlKWReFfXhcvhuonFrfYDwfSnSEkxsA==",
"dev": true,
"license": "MIT"
},

View File

@@ -5,7 +5,9 @@
"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",
"test:repair": "tsc -p tsconfig.report-tests.json && node scripts/tests/repair-utils.test.cjs"
},
"dependencies": {
"@tarojs/components": "^4.0.0",
@@ -24,6 +26,7 @@
"@tarojs/webpack5-runner": "^4.0.0",
"@types/react": "^18.2.66",
"babel-preset-taro": "^4.0.0",
"miniprogram-api-typings": "^5.2.0",
"typescript": "^5.4.5"
}
}

View File

@@ -0,0 +1,40 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const appBarSource = fs.readFileSync(
path.join(__dirname, "../../src/components/app-bar/index.tsx"),
"utf8"
);
const appBarStyles = fs.readFileSync(
path.join(__dirname, "../../src/components/app-bar/index.scss"),
"utf8"
);
function run(name, fn) {
try {
fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
throw error;
}
}
run("AppBar back action uses the shared back.svg asset", () => {
assert.match(appBarSource, /back\.svg/);
assert.match(appBarSource, /<Image[\s\S]*className="app-bar__back-icon"/);
});
run("AppBar back action no longer renders a text arrow", () => {
assert.doesNotMatch(appBarSource, /&lt;|>\s*<\s*</);
});
run("AppBar back icon uses a fixed mini-program sized constraint", () => {
assert.match(appBarStyles, /\.app-bar__back-icon\s*\{[\s\S]*width:\s*32rpx;/);
assert.match(appBarStyles, /\.app-bar__back-icon\s*\{[\s\S]*height:\s*32rpx;/);
});
run("AppBar props stay focused on title and back navigation only", () => {
assert.doesNotMatch(appBarSource, /subtitle\?:|eyebrow\?:|align\?:|rightText\?:|onRightAction\?:|leftSlot\?:|rightSlot\?:|bottomSlot\?:/);
});

View File

@@ -0,0 +1,30 @@
const assert = require("node:assert/strict");
const { getDeviceCards } = require("../../tmp/devices-tests/device-data.js");
function run(name, fn) {
try {
fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
throw error;
}
}
run("getDeviceCards returns the three expected device groups in order", () => {
const cards = getDeviceCards();
assert.deepEqual(
cards.map((item) => item.key),
["vitals", "smart-bed", "ai-camera"]
);
});
run("getDeviceCards exposes display-ready status copy for each card", () => {
const cards = getDeviceCards();
assert.deepEqual(
cards.map((item) => item.statusText),
["未绑定设备", "1 台设备在线", "权限待确认"]
);
});

View File

@@ -0,0 +1,45 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const messagePageStyles = fs.readFileSync(
path.join(__dirname, "../../src/pages/message/index.scss"),
"utf8"
);
const messagePageSource = fs.readFileSync(
path.join(__dirname, "../../src/pages/message/index.tsx"),
"utf8"
);
function run(name, fn) {
try {
fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
throw error;
}
}
run("message tabs container spans full width without manual gap spacing", () => {
assert.match(messagePageStyles, /\.message-tabs\s*\{[\s\S]*width:\s*100%;/);
assert.doesNotMatch(messagePageStyles, /\.message-tabs\s*\{[\s\S]*gap:\s*110rpx;/);
});
run("each message tab item takes half of the row", () => {
assert.match(messagePageStyles, /\.message-tabs__item\s*\{[\s\S]*flex:\s*1;/);
assert.match(messagePageStyles, /\.message-tabs__item\s*\{[\s\S]*min-width:\s*0;/);
});
run("message tab label and unread dot are grouped in a centered content wrapper", () => {
assert.match(messagePageSource, /className="message-tabs__content"/);
assert.match(messagePageStyles, /\.message-tabs__content\s*\{[\s\S]*display:\s*inline-flex;/);
});
run("message tabs are rendered below AppBar instead of inside bottomSlot", () => {
assert.doesNotMatch(messagePageSource, /bottomSlot=\{/);
assert.match(
messagePageSource,
/<AppBar[\s\S]*\/>\s*<View className="message-tabs">/
);
});

View File

@@ -0,0 +1,85 @@
const assert = require("node:assert/strict");
const {
appendRepairAttachments,
clampDescription,
isValidPhone,
buildDeviceMap,
normalizeChosenFiles,
validateRepairForm
} = require("../../tmp/report-tests/repair/repair-utils.js");
function run(name, fn) {
try {
fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
throw error;
}
}
run("clampDescription trims to 60 chars", () => {
assert.equal(clampDescription("a".repeat(61)).length, 60);
});
run("isValidPhone accepts mainland mobile number", () => {
assert.equal(isValidPhone("13689569989"), true);
});
run("validateRepairForm rejects empty problem description", () => {
assert.equal(
validateRepairForm({
selectedDeviceId: "A1",
description: "",
contactName: "张小龙",
phone: "13689569989"
}),
"请填写问题描述"
);
});
run("buildDeviceMap groups devices by type", () => {
const map = buildDeviceMap([
{ id: "A1", type: "monitor", label: "设备A", params: "P1" },
{ id: "B1", type: "camera", label: "设备B", params: "P2" }
]);
assert.equal(map.monitor.length, 1);
assert.equal(map.camera[0].id, "B1");
});
run("normalizeChosenFiles tags images and videos", () => {
const files = normalizeChosenFiles([
{ tempFilePath: "x/a.png", fileType: "image", size: 10 },
{ tempFilePath: "x/b.mp4", fileType: "video", size: 20, thumbTempFilePath: "x/b.jpg", duration: 5 }
]);
assert.equal(files[0].kind, "image");
assert.equal(files[1].kind, "video");
assert.equal(files[1].thumbPath, "x/b.jpg");
});
run("appendRepairAttachments blocks second video", () => {
const result = appendRepairAttachments(
[{ id: "v1", kind: "video", path: "x/1.mp4" }],
[{ id: "v2", kind: "video", path: "x/2.mp4" }]
);
assert.equal(result.errorMessage, "最多上传1个视频");
assert.equal(result.attachments.length, 1);
});
run("appendRepairAttachments limits images to 9", () => {
const existing = Array.from({ length: 8 }, (_, index) => ({
id: `img-${index}`,
kind: "image",
path: `x/${index}.png`
}));
const result = appendRepairAttachments(existing, [
{ id: "img-8", kind: "image", path: "x/8.png" },
{ id: "img-9", kind: "image", path: "x/9.png" }
]);
assert.equal(result.errorMessage, "最多上传9张图片");
assert.equal(result.attachments.length, 9);
});

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,6 +1,7 @@
export default defineAppConfig({
pages: [
"pages/index/index",
"pages/report/index",
"pages/message/index",
"pages/mine/index",
"pages/profile/index",
@@ -8,15 +9,16 @@ export default defineAppConfig({
"pages/support/index",
"pages/devices/index",
"pages/repair/index",
"pages/repair-detail/index",
"pages/feedback/index",
"pages/videos/index",
"pages/follow-us/index"
],
window: {
navigationBarTitleText: "新手小程序",
navigationBarBackgroundColor: "#1AAD19",
navigationBarBackgroundColor: "#1f2534",
navigationBarTextStyle: "white",
backgroundTextStyle: "light",
backgroundColor: "#f6f7fb"
backgroundColor: "#1b2130"
}
});

View File

@@ -1,6 +1,7 @@
import "./app.scss";
import type { ReactNode } from "react";
function App(props) {
function App(props: { children?: ReactNode }) {
const { children } = props;
return children ?? null;
}

11
src/assets/svg/add.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg viewBox="0 0 37.1797 37.1798" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="37.179688" height="37.179787" fill="none" customFrame="#000000">
<g id="组 9850">
<g id="组 9848">
<circle id="椭圆 1364" cx="18.5898952" cy="18.5898952" r="17.5898952" stroke="rgb(255,255,255)" stroke-linecap="round" stroke-linejoin="round" stroke-width="0.000000" />
</g>
<g id="组 9849">
<line id="直线 573" x1="10.6748047" x2="26.5057106" y1="18.5898972" y2="18.5898972" stroke="rgb(255,255,255)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.000000" />
<line id="直线 574" x1="0" x2="15.830905" y1="0" y2="0" stroke="rgb(255,255,255)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.000000" transform="matrix(0,1,-1,0,18.5898,10.6744)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 841 B

6
src/assets/svg/back.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg viewBox="0 0 42 42" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="42.000000" height="42.000000" fill="none" customFrame="#000000">
<g id="组 10388">
<rect id="矩形 3479" width="42.000000" height="42.000000" x="0.000000" y="0.000000" />
<path id="路径 15460" d="M28.2715 6.45681L13.7285 21L28.2715 35.5432" stroke="rgb(255,255,255)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.000000" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 473 B

View File

@@ -0,0 +1,65 @@
.app-bar {
position: relative;
z-index: 10;
width: 100%;
box-sizing: border-box;
padding-top: var(--appbar-top-inset);
}
.app-bar__row {
width: 100%;
min-height: var(--appbar-menu-height);
display: flex;
align-items: center;
box-sizing: border-box;
}
.app-bar__side {
width: var(--appbar-capsule-safe-width);
min-width: var(--appbar-capsule-safe-width);
display: flex;
align-items: center;
}
.app-bar__side--left {
justify-content: flex-start;
}
.app-bar__side--right {
justify-content: flex-end;
}
.app-bar__content {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.app-bar__title {
color: #f3f7ff;
font-size: 32rpx;
font-weight: 600;
line-height: 1.4;
}
.app-bar__action {
min-width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
}
.app-bar__action--placeholder {
opacity: 0;
}
.app-bar__back-icon {
width: 32rpx;
height: 32rpx;
display: block;
flex-shrink: 0;
}

View File

@@ -0,0 +1,62 @@
import { Image, Text, View } from "@tarojs/components";
import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
import backIcon from "../../assets/svg/back.svg";
import { getAppBarMetrics } from "../../utils/app-bar";
import type { AppBarMetrics } from "../../utils/app-bar-metrics";
import "./index.scss";
type AppBarProps = {
title: string;
showBack?: boolean;
onBack?: () => void;
};
const defaultMetrics: AppBarMetrics = {
topInset: 0,
menuTop: 6,
menuHeight: 32,
capsuleSafeWidth: 96
};
export default function AppBar({
title,
showBack = false,
onBack
}: AppBarProps) {
const [metrics, setMetrics] = useState<AppBarMetrics>(defaultMetrics);
useEffect(() => {
setMetrics(getAppBarMetrics());
}, []);
const style = {
"--appbar-top-inset": `${metrics.topInset}px`,
"--appbar-menu-height": `${metrics.menuHeight}px`,
"--appbar-capsule-safe-width": `${metrics.capsuleSafeWidth}px`
} as CSSProperties;
return (
<View className="app-bar" style={style}>
<View className="app-bar__row">
<View className="app-bar__side app-bar__side--left">
{showBack ? (
<View className="app-bar__action" onClick={onBack}>
<Image className="app-bar__back-icon" src={backIcon} mode="aspectFit" />
</View>
) : (
<View className="app-bar__action app-bar__action--placeholder" />
)}
</View>
<View className="app-bar__content">
<Text className="app-bar__title">{title}</Text>
</View>
<View className="app-bar__side app-bar__side--right">
<View className="app-bar__action app-bar__action--placeholder" />
</View>
</View>
</View>
);
}

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

@@ -1,4 +1,6 @@
import { Text, View } from "@tarojs/components";
import { navigateBackWithFallback } from "../../utils/app-bar";
import AppBar from "../app-bar";
import "./index.scss";
export type SecondaryPageItem = {
@@ -27,9 +29,10 @@ export default function SecondaryPage(props: SecondaryPageProps) {
<View className="secondary-page__halo secondary-page__halo--large" />
<View className="secondary-page__halo secondary-page__halo--small" />
<AppBar title={title} showBack onBack={() => navigateBackWithFallback("/pages/mine/index")} />
<View className="secondary-page__hero">
<Text className="secondary-page__eyebrow">{eyebrow}</Text>
<Text className="secondary-page__title">{title}</Text>
<Text className="secondary-page__description">{description}</Text>
</View>

View File

@@ -0,0 +1,49 @@
export type DeviceCardTone = "muted" | "online" | "warning";
export type DeviceCardKey = "vitals" | "smart-bed" | "ai-camera";
export type DeviceCardIcon = "vitals" | "bed" | "camera";
export type DeviceCard = {
key: DeviceCardKey;
title: string;
summary: string;
statusText: string;
statusTone: DeviceCardTone;
actionText: string;
icon: DeviceCardIcon;
};
const deviceCards: DeviceCard[] = [
{
key: "vitals",
title: "体征监测设备",
summary: "支持心率、血氧、血压与呼吸等关键体征监测。",
statusText: "未绑定设备",
statusTone: "muted",
actionText: "点击查看设备专区",
icon: "vitals"
},
{
key: "smart-bed",
title: "智能床 / 床垫",
summary: "覆盖睡眠监测、翻身检测、离床提醒与睡眠质量分析。",
statusText: "1 台设备在线",
statusTone: "online",
actionText: "点击查看设备专区",
icon: "bed"
},
{
key: "ai-camera",
title: "AI 摄像头",
summary: "可用于跌倒识别、异常行为预警与实时监护联动。",
statusText: "权限待确认",
statusTone: "warning",
actionText: "点击查看设备专区",
icon: "camera"
}
];
export function getDeviceCards() {
return deviceCards;
}

View File

@@ -1,3 +1,243 @@
.devices-page {
display: block;
position: relative;
min-height: 100vh;
padding: 0 24rpx 44rpx;
box-sizing: border-box;
background: linear-gradient(
180deg,
var(--color-bg-page-gradient-start) 0%,
var(--color-bg-page) 42%,
var(--color-bg-page-gradient-end) 100%
);
overflow: hidden;
}
.devices-page__halo {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.devices-page__halo--large {
top: -100rpx;
right: -20rpx;
width: 360rpx;
height: 360rpx;
background:
radial-gradient(circle, rgba(54, 228, 170, 0.06) 0 24%, transparent 25% 42%, rgba(104, 116, 146, 0.18) 43% 62%, transparent 63%),
radial-gradient(circle at center, rgba(255, 255, 255, 0.05), transparent 72%);
}
.devices-page__halo--small {
top: 140rpx;
right: 110rpx;
width: 170rpx;
height: 120rpx;
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.04), transparent 70%);
}
.devices-page__subtitle {
position: relative;
z-index: 1;
display: block;
margin: 12rpx 0 24rpx;
color: var(--color-text-secondary);
font-size: 24rpx;
line-height: 1.6;
}
.devices-page__content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.devices-page__card {
position: relative;
min-height: 280rpx;
padding: 28rpx 32rpx;
border-radius: 48rpx;
background:
linear-gradient(180deg, rgba(43, 50, 68, 0.98) 0%, rgba(39, 45, 62, 0.98) 100%),
var(--color-bg-surface);
box-shadow:
inset 0 0 0 2rpx var(--color-border-light),
var(--shadow-surface);
overflow: hidden;
transform: scale(1);
transition: transform 180ms ease, box-shadow 180ms ease, filter 180ms ease;
}
.devices-page__card::before {
position: absolute;
inset: 0;
content: "";
background: linear-gradient(90deg, rgba(255, 255, 255, 0.04), transparent 30%);
pointer-events: none;
}
.devices-page__card--hover {
transform: scale(0.985);
filter: brightness(1.05);
box-shadow:
inset 0 0 0 2rpx rgba(255, 255, 255, 0.06),
0 18rpx 36rpx rgba(4, 10, 24, 0.26);
}
.devices-page__copy {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 430rpx;
min-height: 224rpx;
}
.devices-page__title {
color: var(--color-text-white);
font-size: 38rpx;
font-weight: 600;
line-height: 1.3;
}
.devices-page__summary {
margin-top: 20rpx;
color: var(--color-text-secondary);
font-size: 24rpx;
line-height: 1.7;
}
.devices-page__status {
display: inline-flex;
align-items: center;
margin-top: 22rpx;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.06);
}
.devices-page__status--online {
background: rgba(54, 228, 170, 0.12);
}
.devices-page__status--warning {
background: rgba(255, 157, 87, 0.14);
}
.devices-page__status-dot {
width: 12rpx;
height: 12rpx;
margin-right: 12rpx;
border-radius: 50%;
background: var(--color-text-muted);
box-shadow: 0 0 0 6rpx rgba(255, 255, 255, 0.02);
}
.devices-page__status--online .devices-page__status-dot {
background: var(--color-text-accent);
box-shadow: 0 0 0 6rpx rgba(54, 228, 170, 0.08);
}
.devices-page__status--warning .devices-page__status-dot {
background: var(--color-warning-main);
box-shadow: 0 0 0 6rpx rgba(255, 157, 87, 0.08);
}
.devices-page__status-text {
color: var(--color-text-white);
font-size: 22rpx;
}
.devices-page__action {
margin-top: auto;
color: var(--color-text-muted);
font-size: 22rpx;
line-height: 1.5;
}
.devices-page__icon {
position: absolute;
right: 18rpx;
bottom: 4rpx;
width: 260rpx;
height: 200rpx;
opacity: 0.14;
}
.devices-page__icon::before,
.devices-page__icon::after {
position: absolute;
content: "";
box-sizing: border-box;
}
.devices-page__icon--vitals::before {
left: 26rpx;
top: 26rpx;
width: 126rpx;
height: 126rpx;
border: 18rpx solid rgba(255, 255, 255, 0.72);
border-right-color: transparent;
border-bottom-color: transparent;
border-radius: 70rpx 70rpx 0 0;
transform: rotate(-45deg);
}
.devices-page__icon--vitals::after {
left: 58rpx;
top: 98rpx;
width: 148rpx;
height: 20rpx;
border-radius: 999rpx;
background: rgba(18, 26, 39, 0.78);
box-shadow:
-48rpx -28rpx 0 -4rpx rgba(18, 26, 39, 0.78),
-22rpx -2rpx 0 -4rpx rgba(18, 26, 39, 0.78),
38rpx 0 0 -4rpx rgba(18, 26, 39, 0.78);
transform: rotate(0deg);
}
.devices-page__icon--bed::before {
left: 36rpx;
right: 22rpx;
bottom: 42rpx;
height: 72rpx;
border-radius: 34rpx 34rpx 30rpx 30rpx;
background: rgba(255, 255, 255, 0.68);
transform: skewX(-18deg);
}
.devices-page__icon--bed::after {
left: 62rpx;
right: 44rpx;
top: 28rpx;
height: 88rpx;
border-radius: 48rpx 48rpx 38rpx 38rpx;
border: 14rpx solid rgba(18, 26, 39, 0.75);
border-left-width: 0;
border-bottom-width: 18rpx;
transform: rotate(12deg);
}
.devices-page__icon--camera::before {
left: 70rpx;
top: 18rpx;
width: 128rpx;
height: 128rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.7);
box-shadow: inset 0 0 0 26rpx rgba(18, 26, 39, 0.72);
}
.devices-page__icon--camera::after {
left: 74rpx;
bottom: 28rpx;
width: 122rpx;
height: 68rpx;
border-radius: 18rpx 18rpx 30rpx 30rpx;
background: rgba(255, 255, 255, 0.62);
clip-path: polygon(18% 0, 82% 0, 100% 100%, 0 100%);
}

View File

@@ -1,29 +1,54 @@
import SecondaryPage, { type SecondaryPageSection } from "../../components/secondary-page";
import { Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import AppBar from "../../components/app-bar";
import { navigateBackWithFallback } from "../../utils/app-bar";
import { getDeviceCards, type DeviceCard } from "./device-data";
import "./index.scss";
const sections: SecondaryPageSection[] = [
{
title: "设备概览",
items: [
{ label: "已绑定设备", value: "0 台" },
{ label: "设备共享管理", value: "待接入" },
{ label: "添加设备", value: "可复用首页绑定能力" }
]
},
{
title: "当前状态",
items: [{ label: "设备列表", value: "暂无已绑定设备" }]
}
];
const deviceCards = getDeviceCards();
export default function DevicesPage() {
const handleCardClick = (card: DeviceCard) => {
Taro.showToast({
title: `${card.title}页面待完善`,
icon: "none"
});
};
return (
<SecondaryPage
eyebrow="DEVICES"
title="我的设备"
description="这里先保留设备管理骨架,后续可以接真实设备列表、设备状态和共享管理。"
sections={sections}
footerTip="如果后面要对接设备绑定,可以优先复用首页已经实现的扫码和蓝牙入口。"
/>
<View className="devices-page">
<View className="devices-page__halo devices-page__halo--large" />
<View className="devices-page__halo devices-page__halo--small" />
<AppBar title="我的设备" showBack onBack={() => navigateBackWithFallback("/pages/mine/index")} />
<Text className="devices-page__subtitle"></Text>
<View className="devices-page__content">
{deviceCards.map((card) => (
<View
className="devices-page__card"
key={card.key}
hoverClass="devices-page__card--hover"
hoverStartTime={20}
hoverStayTime={120}
onClick={() => handleCardClick(card)}
>
<View className="devices-page__copy">
<Text className="devices-page__title">{card.title}</Text>
<Text className="devices-page__summary">{card.summary}</Text>
<View className={`devices-page__status devices-page__status--${card.statusTone}`}>
<View className="devices-page__status-dot" />
<Text className="devices-page__status-text">{card.statusText}</Text>
</View>
<Text className="devices-page__action">{card.actionText}</Text>
</View>
<View className={`devices-page__icon devices-page__icon--${card.icon}`} />
</View>
))}
</View>
</View>
);
}

View File

@@ -1,7 +1,7 @@
.device-page {
position: relative;
min-height: 100vh;
padding: calc(var(--top-safe-height, 0px) + 24rpx) 24rpx 156rpx;
padding: 0 24rpx 156rpx;
box-sizing: border-box;
background: linear-gradient(180deg, var(--color-bg-page-gradient-start) 0%, var(--color-bg-page-gradient-end) 100%);
overflow: hidden;
@@ -34,40 +34,48 @@
.device-header {
position: relative;
z-index: 1;
width: 100%;
padding-right: calc(var(--menu-safe-width, 0px) + 12rpx);
margin-bottom: 8rpx;
min-height: calc((var(--menu-top, 0px) - var(--top-safe-height, 0px) - 24rpx) + var(--menu-height, 32px) + 36rpx + 6rpx);
}
.device-header__top-actions {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
margin: 12rpx 0 18rpx;
}
.device-header__login {
position: absolute;
left: 0;
top: calc(var(--menu-top, 0px) - var(--top-safe-height, 0px) - 24rpx);
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 92rpx;
height: var(--menu-height, 32px);
min-height: 56rpx;
padding: 0 24rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, var(--color-brand-start) 0%, var(--color-brand-end) 100%);
color: var(--color-text-white);
font-size: 22rpx;
line-height: var(--menu-height, 32px);
text-align: center;
box-shadow: 0 12rpx 24rpx var(--color-brand-shadow);
}
.device-header__add {
position: absolute;
right: calc(var(--menu-safe-width, 0px) + 2rpx);
top: calc((var(--menu-top, 0px) - var(--top-safe-height, 0px) - 24rpx) + var(--menu-height, 32px) + 6rpx);
display: inline-flex;
align-items: center;
justify-content: center;
width: 36rpx;
height: 36rpx;
border: 2rpx solid var(--color-border-strong);
border-radius: 50%;
}
.device-header__login-text {
color: var(--color-text-white);
font-size: 22rpx;
}
.device-header__add-text {
color: var(--color-text-white);
font-size: 30rpx;
line-height: 30rpx;
text-align: center;
line-height: 1;
}
.device-summary {
@@ -172,3 +180,52 @@
font-size: 22rpx;
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;
}

View File

@@ -1,10 +1,11 @@
import { Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import type { CSSProperties } from "react";
import { useEffect, useRef, useState } from "react";
import ActionButton from "../../components/action-button";
import BottomTabbar, { type TabbarItem } from "../../components/bottom-tabbar";
import AppBar from "../../components/app-bar";
import BottomTabbar from "../../components/bottom-tabbar";
import PanelCard from "../../components/panel-card";
import { createMainTabItems, handleMainTabNavigation } from "../../utils/main-tabbar";
import "./index.scss";
type BluetoothStatus = "idle" | "searching" | "empty" | "success";
@@ -21,14 +22,6 @@ const notices = [
"3. 扫码功能需开启相机权限"
];
const navItems: TabbarItem[] = [
{ 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 mockBluetoothDevices: DeviceCandidate[] = [
{ id: "mock-thermo-01", name: "体征监测设备 A1", source: "mock" },
{ id: "mock-thermo-02", name: "体征监测设备 B2", source: "mock" }
@@ -56,24 +49,9 @@ function parseDeviceCode(result?: string) {
export default function Index() {
const [deviceCount, setDeviceCount] = useState(0);
const [bluetoothStatus, setBluetoothStatus] = useState<BluetoothStatus>("idle");
const [topSafeHeight, setTopSafeHeight] = useState(0);
const [menuSafeWidth, setMenuSafeWidth] = useState(0);
const [menuTop, setMenuTop] = useState(0);
const [menuHeight, setMenuHeight] = useState(0);
const discoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const windowInfo = typeof Taro.getWindowInfo === "function" ? Taro.getWindowInfo() : Taro.getSystemInfoSync();
const menuButtonRect =
typeof Taro.getMenuButtonBoundingClientRect === "function" ? Taro.getMenuButtonBoundingClientRect() : null;
const safeTop = menuButtonRect?.bottom || windowInfo.statusBarHeight || 0;
const safeRight = menuButtonRect ? windowInfo.windowWidth - menuButtonRect.left : 0;
setTopSafeHeight(safeTop);
setMenuSafeWidth(safeRight);
setMenuTop(menuButtonRect?.top || Math.max((windowInfo.statusBarHeight || 0) + 6, 0));
setMenuHeight(menuButtonRect?.height || 32);
return () => {
if (discoveryTimerRef.current) {
clearTimeout(discoveryTimerRef.current);
@@ -115,23 +93,7 @@ export default function Index() {
showToast("后续可从这里进入设备管理页");
};
const handleTabClick = (item: TabbarItem) => {
if (item.key === "home") {
return;
}
if (item.key === "message") {
Taro.redirectTo({ url: "/pages/message/index" });
return;
}
if (item.key === "mine") {
Taro.navigateTo({ url: "/pages/mine/index" });
return;
}
showToast(`${item.label}功能待接入`);
};
const navItems = createMainTabItems("home");
const handleScanBind = async () => {
try {
@@ -259,25 +221,19 @@ export default function Index() {
}
};
const pageStyle = {
"--top-safe-height": `${topSafeHeight}px`,
"--menu-safe-width": `${menuSafeWidth}px`,
"--menu-top": `${menuTop}px`,
"--menu-height": `${menuHeight}px`
} as CSSProperties;
return (
<View className="device-page" style={pageStyle}>
<View className="device-page">
<View className="device-page__halo device-page__halo--large" />
<View className="device-page__halo device-page__halo--small" />
<View className="device-header">
<AppBar title="" />
<View className="device-header__top-actions">
<View className="device-header__login" onClick={handleLogin}>
<Text className="device-header__login-text"></Text>
</View>
<View className="device-header__add" onClick={handleAdd}>
+
<Text className="device-header__add-text">+</Text>
</View>
</View>
@@ -294,6 +250,15 @@ export default function Index() {
<ActionButton icon="bluetooth" label="蓝牙搜附近的设备" secondary onClick={handleBluetoothBind} />
</PanelCard>
<View className="device-report-entry" onClick={() => Taro.navigateTo({ url: "/pages/report/index" })}>
<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>
<PanelCard className="device-notice-card" warning>
<View className="device-notice-card__title-row">
<View className="device-notice-card__horn" />
@@ -309,7 +274,7 @@ export default function Index() {
</View>
</PanelCard>
<BottomTabbar items={navItems} onItemClick={handleTabClick} />
<BottomTabbar items={navItems} onItemClick={(item) => handleMainTabNavigation(item, "home")} />
</View>
);
}

View File

@@ -1,7 +1,7 @@
.message-page {
position: relative;
min-height: 100vh;
padding: calc(var(--top-safe-height, 0px) + 24rpx) 24rpx 156rpx;
padding: 0 24rpx 156rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #1d2331 0%, #171d29 100%);
overflow: hidden;
@@ -29,37 +29,39 @@
background: radial-gradient(circle, rgba(86, 116, 184, 0.12), transparent 72%);
}
.message-header {
.message-tabs {
position: relative;
z-index: 1;
width: 100%;
padding-right: calc(var(--menu-safe-width, 0px) + 12rpx);
margin-bottom: 10rpx;
box-sizing: border-box;
min-height: calc((var(--menu-top, 0px) - var(--top-safe-height, 0px) - 24rpx) + var(--menu-height, 32px) + 24rpx);
}
.message-tabs {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 110rpx;
min-height: inherit;
padding: 0;
margin-top: 12rpx;
margin-bottom: 24rpx;
}
.message-tabs__item {
position: relative;
flex: 1;
height: 64rpx;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
min-width: 120rpx;
}
.message-tabs__content {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8rpx;
}
.message-tabs__label {
color: #8f97ac;
font-size: 24rpx;
line-height: 1.3;
text-align: center;
}
.message-tabs__label--active {
@@ -68,13 +70,12 @@
}
.message-tabs__dot {
position: absolute;
top: 6rpx;
right: -10rpx;
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #ff4d4f;
flex-shrink: 0;
transform: translateY(-8rpx);
}
.message-tabs__line {

View File

@@ -1,8 +1,8 @@
import { Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
import BottomTabbar, { type TabbarItem } from "../../components/bottom-tabbar";
import { useState } from "react";
import AppBar from "../../components/app-bar";
import BottomTabbar from "../../components/bottom-tabbar";
import { createMainTabItems, handleMainTabNavigation } from "../../utils/main-tabbar";
import "./index.scss";
type MessageTabKey = "vital" | "system";
@@ -84,79 +84,35 @@ const fieldLabels = {
occurredAt: "发生时间"
};
const navItems: TabbarItem[] = [
{ key: "home", label: "首页", active: false },
{ key: "report", label: "报告", active: false },
{ key: "assistant", label: "小e", active: false },
{ key: "message", label: "消息", active: true, badge: true },
{ key: "mine", label: "我的", active: false }
];
export default function MessagePage() {
const [activeTab, setActiveTab] = useState<MessageTabKey>("vital");
const [topSafeHeight, setTopSafeHeight] = useState(0);
const [menuSafeWidth, setMenuSafeWidth] = useState(0);
const [menuTop, setMenuTop] = useState(0);
const [menuHeight, setMenuHeight] = useState(0);
const currentMessages = messageList.filter((item) => item.category === activeTab);
useEffect(() => {
const windowInfo = typeof Taro.getWindowInfo === "function" ? Taro.getWindowInfo() : Taro.getSystemInfoSync();
const menuButtonRect =
typeof Taro.getMenuButtonBoundingClientRect === "function" ? Taro.getMenuButtonBoundingClientRect() : null;
const safeTop = menuButtonRect?.bottom || windowInfo.statusBarHeight || 0;
const safeRight = menuButtonRect ? windowInfo.windowWidth - menuButtonRect.left : 0;
setTopSafeHeight(safeTop);
setMenuSafeWidth(safeRight);
setMenuTop(menuButtonRect?.top || Math.max((windowInfo.statusBarHeight || 0) + 6, 0));
setMenuHeight(menuButtonRect?.height || 32);
}, []);
const handleTabClick = (item: TabbarItem) => {
if (item.key === "message") {
return;
}
if (item.key === "home") {
Taro.redirectTo({ url: "/pages/index/index" });
return;
}
Taro.showToast({
title: `${item.label}功能待接入`,
icon: "none"
});
};
const pageStyle = {
"--top-safe-height": `${topSafeHeight}px`,
"--menu-safe-width": `${menuSafeWidth}px`,
"--menu-top": `${menuTop}px`,
"--menu-height": `${menuHeight}px`
} as CSSProperties;
const navItems = createMainTabItems("message");
return (
<View className="message-page" style={pageStyle}>
<View className="message-page">
<View className="message-page__glow message-page__glow--top" />
<View className="message-page__glow message-page__glow--side" />
<View className="message-header">
<AppBar title="" />
<View className="message-tabs">
{tabs.map((item) => {
const isActive = item.key === activeTab;
return (
<View className="message-tabs__item" key={item.key} onClick={() => setActiveTab(item.key)}>
<View className="message-tabs__content">
<Text className={`message-tabs__label ${isActive ? "message-tabs__label--active" : ""}`}>{item.label}</Text>
{item.hasDot ? <View className="message-tabs__dot" /> : null}
</View>
{isActive ? <View className="message-tabs__line" /> : null}
</View>
);
})}
</View>
</View>
<View className="message-list">
{currentMessages.map((item) => (
@@ -191,7 +147,7 @@ export default function MessagePage() {
))}
</View>
<BottomTabbar items={navItems} onItemClick={handleTabClick} />
<BottomTabbar items={navItems} onItemClick={(item) => handleMainTabNavigation(item, "message")} />
</View>
);
}

View File

@@ -1,7 +1,7 @@
.mine-page {
position: relative;
min-height: 100vh;
padding: calc(var(--top-safe-height, 0px) + 34rpx) 24rpx 170rpx;
padding: 0 24rpx 170rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #1f2534 0%, #171d29 100%);
overflow: hidden;
@@ -31,6 +31,14 @@
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.04), transparent 70%);
}
.mine-page__top-actions {
position: relative;
z-index: 1;
display: flex;
justify-content: flex-end;
margin: 12rpx 0 18rpx;
}
.mine-page__header-card {
position: relative;
z-index: 1;
@@ -132,7 +140,6 @@
flex-direction: column;
align-items: flex-end;
padding-left: 18rpx;
padding-right: calc(var(--menu-safe-width, 0px) + 6rpx);
}
.mine-page__icon-actions {

View File

@@ -1,7 +1,8 @@
import { Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
import AppBar from "../../components/app-bar";
import BottomTabbar from "../../components/bottom-tabbar";
import { createMainTabItems, handleMainTabNavigation } from "../../utils/main-tabbar";
import "./index.scss";
type UserProfile = {
@@ -37,29 +38,7 @@ const featureItems: FeatureItem[] = [
{ key: "version", label: "当前版本", value: "V1.0.2504.12", icon: "version" }
];
const tabItems = [
{ key: "home", label: "首页" },
{ key: "report", label: "报告" },
{ key: "assistant", label: "小e" },
{ key: "message", label: "消息", badge: true },
{ key: "mine", label: "我的", active: true }
];
export default function MinePage() {
const [topSafeHeight, setTopSafeHeight] = useState(0);
const [menuSafeWidth, setMenuSafeWidth] = useState(0);
useEffect(() => {
const windowInfo = typeof Taro.getWindowInfo === "function" ? Taro.getWindowInfo() : Taro.getSystemInfoSync();
const menuButtonRect =
typeof Taro.getMenuButtonBoundingClientRect === "function" ? Taro.getMenuButtonBoundingClientRect() : null;
const safeTop = menuButtonRect?.bottom || windowInfo.statusBarHeight || 0;
const safeRight = menuButtonRect ? windowInfo.windowWidth - menuButtonRect.left : 0;
setTopSafeHeight(safeTop);
setMenuSafeWidth(safeRight);
}, []);
const showToast = (title: string) => {
Taro.showToast({
title,
@@ -80,36 +59,21 @@ export default function MinePage() {
showToast("当前已是最新版本");
};
const handleTabClick = (key: string, label: string) => {
if (key === "mine") {
return;
}
if (key === "home") {
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack();
} else {
Taro.redirectTo({ url: "/pages/index/index" });
}
return;
}
showToast(`${label}功能待接入`);
};
const pageStyle = {
"--top-safe-height": `${topSafeHeight}px`,
"--menu-safe-width": `${menuSafeWidth}px`
} as CSSProperties;
const tabItems = createMainTabItems("mine");
return (
<View className="mine-page" style={pageStyle}>
<View className="mine-page">
<View className="mine-page__halo mine-page__halo--large" />
<View className="mine-page__halo mine-page__halo--small" />
<AppBar title="" />
<View className="mine-page__top-actions">
<View className="mine-page__icon-actions">
<View className="mine-page__circle-action mine-page__circle-action--support" onClick={() => openPage("/pages/support/index")} />
<View className="mine-page__circle-action mine-page__circle-action--settings" onClick={() => openPage("/pages/settings/index")} />
</View>
</View>
<View className="mine-page__header-card">
<View className="mine-page__header-main">
<View className="mine-page__avatar">
@@ -135,11 +99,6 @@ export default function MinePage() {
</View>
<View className="mine-page__header-actions">
<View className="mine-page__icon-actions">
<View className="mine-page__circle-action mine-page__circle-action--support" onClick={() => openPage("/pages/support/index")} />
<View className="mine-page__circle-action mine-page__circle-action--settings" onClick={() => openPage("/pages/settings/index")} />
</View>
<Text className="mine-page__profile-link" onClick={() => openPage("/pages/profile/index")}>
</Text>
@@ -165,16 +124,7 @@ export default function MinePage() {
))}
</View>
<View className="mine-page__tabbar">
{tabItems.map((item) => (
<View className="mine-page__tabbar-item" key={item.key} onClick={() => handleTabClick(item.key, item.label)}>
<View className={`mine-page__tabbar-icon ${item.active ? "mine-page__tabbar-icon--active" : ""}`}>
{item.badge ? <View className="mine-page__tabbar-badge" /> : null}
</View>
<Text className={`mine-page__tabbar-label ${item.active ? "mine-page__tabbar-label--active" : ""}`}>{item.label}</Text>
</View>
))}
</View>
<BottomTabbar items={tabItems} onItemClick={(item) => handleMainTabNavigation(item, "mine")} />
</View>
);
}

View File

@@ -1,3 +1,4 @@
export default definePageConfig({
navigationStyle: "custom",
navigationBarTitleText: "个人信息"
});

View File

@@ -1,3 +1,225 @@
.profile-page {
display: block;
min-height: 100vh;
box-sizing: border-box;
padding: calc(var(--profile-top-safe-height, 0px) + 22rpx) 0 72rpx;
background:
linear-gradient(180deg, rgba(39, 44, 60, 0.98) 0 150rpx, transparent 150rpx),
linear-gradient(180deg, #181d2a 0%, #171c29 100%);
color: var(--color-text-primary);
}
.profile-page__nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 26rpx;
}
.profile-page__nav-spacer {
width: 112rpx;
height: 72rpx;
}
.profile-page__back {
display: flex;
align-items: center;
justify-content: flex-start;
width: 112rpx;
height: 72rpx;
}
.profile-page__back-icon {
width: 24rpx;
height: 24rpx;
border-left: 4rpx solid var(--color-text-white);
border-bottom: 4rpx solid var(--color-text-white);
transform: rotate(45deg);
}
.profile-page__title {
color: var(--color-text-white);
font-size: 52rpx;
font-weight: 600;
letter-spacing: 2rpx;
}
.profile-page__save {
display: flex;
align-items: center;
justify-content: center;
min-width: 126rpx;
height: 60rpx;
padding: 0 24rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, var(--color-brand-start), var(--color-brand-end));
box-shadow: 0 12rpx 24rpx var(--color-brand-shadow-soft);
}
.profile-page__save-text {
color: var(--color-text-white);
font-size: 28rpx;
}
.profile-page__action-bar {
display: flex;
justify-content: flex-end;
padding: 18rpx 30rpx 0;
}
.profile-page__content {
padding: 56rpx 30rpx 0;
}
.profile-page__avatar-block {
display: flex;
flex-direction: column;
align-items: center;
}
.profile-page__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 154rpx;
height: 154rpx;
border-radius: 50%;
background:
radial-gradient(circle at 50% 34%, #f8fafc 0 20rpx, transparent 20rpx),
linear-gradient(180deg, #b8c1cf 0%, #7f8ba0 100%);
box-shadow: 0 16rpx 40rpx rgba(7, 10, 20, 0.26);
overflow: hidden;
}
.profile-page__avatar-image {
width: 100%;
height: 100%;
border-radius: 50%;
background: linear-gradient(180deg, #dbe2ed 0%, #aeb8c7 100%);
}
.profile-page__avatar-placeholder {
position: relative;
width: 100%;
height: 100%;
border-radius: 50%;
background:
radial-gradient(circle at 50% 34%, #f6f8fb 0 24rpx, transparent 25rpx),
linear-gradient(180deg, #d9dfea 0 58%, #b7c1d1 58% 100%);
}
.profile-page__avatar-head {
position: absolute;
top: 36rpx;
left: 50%;
width: 42rpx;
height: 42rpx;
margin-left: -21rpx;
border-radius: 50%;
background: #2d3650;
}
.profile-page__avatar-body {
position: absolute;
left: 50%;
bottom: 24rpx;
width: 90rpx;
height: 54rpx;
margin-left: -45rpx;
border-radius: 50rpx 50rpx 28rpx 28rpx;
background: #2d3650;
}
.profile-page__avatar-tip {
margin-top: 42rpx;
color: #08e0da;
font-size: 30rpx;
}
.profile-page__nickname-block {
margin: 110rpx 30rpx 0;
padding: 0 12rpx 18rpx;
border-top: 2rpx solid rgba(255, 255, 255, 0.28);
border-bottom: 2rpx solid rgba(255, 255, 255, 0.28);
}
.profile-page__nickname-input {
width: 100%;
height: 108rpx;
color: rgba(255, 255, 255, 0.64);
font-size: 34rpx;
line-height: 108rpx;
text-align: center;
}
.profile-page__nickname-placeholder {
color: rgba(255, 255, 255, 0.28);
}
.profile-page__info-card {
margin-top: 92rpx;
padding: 18rpx 36rpx;
border-radius: 30rpx;
background: rgba(41, 46, 61, 0.96);
box-shadow:
inset 0 0 0 2rpx rgba(255, 255, 255, 0.03),
0 16rpx 34rpx rgba(5, 9, 18, 0.18);
}
.profile-page__info-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 154rpx;
gap: 20rpx;
border-bottom: 2rpx solid rgba(255, 255, 255, 0.04);
}
.profile-page__info-row--last {
border-bottom: 0;
}
.profile-page__info-main {
display: flex;
align-items: baseline;
gap: 16rpx;
min-width: 0;
}
.profile-page__info-label {
color: var(--color-text-white);
font-size: 34rpx;
}
.profile-page__info-value {
color: rgba(255, 255, 255, 0.42);
font-size: 28rpx;
}
.profile-page__info-value--muted {
color: rgba(255, 255, 255, 0.26);
}
.profile-page__info-action {
display: flex;
align-items: center;
gap: 18rpx;
flex-shrink: 0;
}
.profile-page__info-action-text {
color: #08e0da;
font-size: 28rpx;
}
.profile-page__info-action-text--muted {
color: rgba(255, 255, 255, 0.42);
}
.profile-page__info-action-text--bound {
color: #08e0da;
}
.profile-page__info-arrow {
color: rgba(255, 255, 255, 0.72);
font-size: 28rpx;
}

View File

@@ -1,26 +1,184 @@
import SecondaryPage, { type SecondaryPageSection } from "../../components/secondary-page";
import { Input, Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
import "./index.scss";
const sections: SecondaryPageSection[] = [
{
title: "资料编辑",
items: [
{ label: "头像", value: "支持后续替换" },
{ label: "昵称", value: "张天爱" },
{ label: "手机号", value: "135****2598" },
{ label: "修改密码", value: "待接入" }
]
type ProfileState = {
avatar: string;
nickname: string;
phone: string;
email: string;
wechatBound: boolean;
};
type InfoAction = "replacePhone" | "replaceEmail" | "bindWechat";
const defaultProfile: ProfileState = {
avatar: "",
nickname: "玛利亚",
phone: "139****0753",
email: "",
wechatBound: false
};
function getActionToast(action: InfoAction) {
switch (action) {
case "replacePhone":
return "手机号更换功能待接入";
case "replaceEmail":
return "邮箱更换功能待接入";
case "bindWechat":
return "微信绑定功能待接入";
default:
return "功能待接入";
}
];
}
export default function ProfilePage() {
const [profile, setProfile] = useState(defaultProfile);
const [draftNickname, setDraftNickname] = useState(defaultProfile.nickname);
const [topSafeHeight, setTopSafeHeight] = useState(0);
useEffect(() => {
const windowInfo = typeof Taro.getWindowInfo === "function" ? Taro.getWindowInfo() : Taro.getSystemInfoSync();
const menuButtonRect =
typeof Taro.getMenuButtonBoundingClientRect === "function" ? Taro.getMenuButtonBoundingClientRect() : null;
const safeTop = menuButtonRect?.bottom || windowInfo.statusBarHeight || 0;
setTopSafeHeight(safeTop);
}, []);
const pageStyle = {
"--profile-top-safe-height": `${topSafeHeight}px`
} as CSSProperties;
const showToast = (title: string, icon: "none" | "success" = "none") => {
Taro.showToast({
title,
icon
});
};
const handleBack = () => {
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack({ delta: 1 });
return;
}
Taro.redirectTo({ url: "/pages/mine/index" });
};
const handleSave = () => {
const nickname = draftNickname.trim() || defaultProfile.nickname;
setProfile((current) => ({
...current,
nickname
}));
setDraftNickname(nickname);
showToast("保存成功", "success");
};
const handleAvatarClick = () => {
showToast("头像更换功能待接入");
};
const handleInfoAction = (action: InfoAction) => {
showToast(getActionToast(action));
};
return (
<SecondaryPage
eyebrow="PROFILE"
title="个人信息"
description="这里先保留资料编辑骨架,后续接入真实账号体系时可以直接补上头像上传、昵称编辑和密码修改。"
sections={sections}
footerTip="当前页面以结构预留为主,暂不提交真实修改。"
<View className="profile-page" style={pageStyle}>
<View className="profile-page__nav">
<View className="profile-page__back" onClick={handleBack}>
<View className="profile-page__back-icon" />
</View>
<Text className="profile-page__title"></Text>
<View className="profile-page__nav-spacer" />
</View>
<View className="profile-page__action-bar">
<View className="profile-page__save" onClick={handleSave}>
<Text className="profile-page__save-text"></Text>
</View>
</View>
<View className="profile-page__content">
<View className="profile-page__avatar-block" onClick={handleAvatarClick}>
<View className="profile-page__avatar">
{profile.avatar ? (
<View className="profile-page__avatar-image" />
) : (
<View className="profile-page__avatar-placeholder">
<View className="profile-page__avatar-head" />
<View className="profile-page__avatar-body" />
</View>
)}
</View>
<Text className="profile-page__avatar-tip"></Text>
</View>
<View className="profile-page__nickname-block">
<Input
className="profile-page__nickname-input"
value={draftNickname}
maxlength={20}
placeholder="请输入昵称"
placeholderClass="profile-page__nickname-placeholder"
onInput={(event) => setDraftNickname(event.detail.value)}
/>
</View>
<View className="profile-page__info-card">
<View className="profile-page__info-row" onClick={() => handleInfoAction("replacePhone")}>
<View className="profile-page__info-main">
<Text className="profile-page__info-label"></Text>
<Text className="profile-page__info-value">({profile.phone})</Text>
</View>
<View className="profile-page__info-action">
<Text className="profile-page__info-action-text"></Text>
<Text className="profile-page__info-arrow">&gt;</Text>
</View>
</View>
<View className="profile-page__info-row" onClick={() => handleInfoAction("replaceEmail")}>
<View className="profile-page__info-main">
<Text className="profile-page__info-label"></Text>
<Text className="profile-page__info-value profile-page__info-value--muted">
{profile.email || "暂未填写"}
</Text>
</View>
<View className="profile-page__info-action">
<Text className="profile-page__info-action-text"></Text>
<Text className="profile-page__info-arrow">&gt;</Text>
</View>
</View>
<View className="profile-page__info-row profile-page__info-row--last" onClick={() => handleInfoAction("bindWechat")}>
<View className="profile-page__info-main">
<Text className="profile-page__info-label"></Text>
</View>
<View className="profile-page__info-action">
<Text
className={`profile-page__info-action-text ${
profile.wechatBound ? "profile-page__info-action-text--bound" : "profile-page__info-action-text--muted"
}`}
>
{profile.wechatBound ? "已绑定" : "去绑定"}
</Text>
<Text className="profile-page__info-arrow">&gt;</Text>
</View>
</View>
</View>
</View>
</View>
);
}

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: "报修详情",
navigationBarBackgroundColor: "#0B1220",
navigationBarTextStyle: "white",
backgroundColor: "#0B1220"
});

View File

@@ -0,0 +1,166 @@
.repair-detail-page {
position: relative;
min-height: 100vh;
padding: 0 24rpx 72rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #0b1220 0%, #121a2c 100%);
overflow: hidden;
}
.repair-detail-page__glow {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.repair-detail-page__glow--left {
top: -60rpx;
left: -100rpx;
width: 300rpx;
height: 300rpx;
background: radial-gradient(circle, rgba(53, 229, 179, 0.16) 0%, rgba(53, 229, 179, 0) 72%);
}
.repair-detail-page__glow--right {
top: 340rpx;
right: -120rpx;
width: 340rpx;
height: 340rpx;
background: radial-gradient(circle, rgba(59, 130, 246, 0.14) 0%, rgba(59, 130, 246, 0) 72%);
}
.repair-detail-page__hero,
.repair-detail-page__card,
.repair-detail-page__button {
position: relative;
z-index: 1;
}
.repair-detail-page__header-note {
position: relative;
z-index: 1;
display: block;
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.58);
font-size: 24rpx;
text-align: center;
}
.repair-detail-page__hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 18rpx 0 38rpx;
}
.repair-detail-page__success-ring {
position: relative;
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: linear-gradient(135deg, rgba(53, 229, 179, 0.3), rgba(32, 201, 191, 0.12));
box-shadow: inset 0 0 0 2rpx rgba(53, 229, 179, 0.28);
}
.repair-detail-page__success-check {
position: absolute;
top: 32rpx;
left: 42rpx;
width: 22rpx;
height: 40rpx;
border-right: 6rpx solid #35e5b3;
border-bottom: 6rpx solid #35e5b3;
transform: rotate(40deg);
}
.repair-detail-page__status {
margin-top: 22rpx;
color: #ffffff;
font-size: 40rpx;
font-weight: 600;
}
.repair-detail-page__hint {
width: 560rpx;
margin-top: 16rpx;
color: rgba(255, 255, 255, 0.58);
font-size: 24rpx;
text-align: center;
line-height: 1.7;
}
.repair-detail-page__card {
margin-top: 22rpx;
padding: 28rpx;
border-radius: 28rpx;
background: rgba(34, 40, 57, 0.94);
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.03);
}
.repair-detail-page__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
}
.repair-detail-page__row + .repair-detail-page__row {
margin-top: 20rpx;
}
.repair-detail-page__label,
.repair-detail-page__meta-label {
color: rgba(255, 255, 255, 0.44);
font-size: 24rpx;
}
.repair-detail-page__value,
.repair-detail-page__meta-value {
color: rgba(255, 255, 255, 0.9);
font-size: 24rpx;
text-align: right;
word-break: break-all;
}
.repair-detail-page__value--accent {
color: #35e5b3;
}
.repair-detail-page__section-title {
display: block;
color: rgba(255, 255, 255, 0.92);
font-size: 26rpx;
}
.repair-detail-page__description {
display: block;
margin-top: 18rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 24rpx;
line-height: 1.8;
}
.repair-detail-page__meta-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 2rpx solid rgba(255, 255, 255, 0.05);
}
.repair-detail-page__button {
height: 96rpx;
margin-top: 44rpx;
border: none;
border-radius: 999rpx;
background: linear-gradient(90deg, var(--color-brand-start) 0%, var(--color-brand-end) 100%);
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
line-height: 96rpx;
}
.repair-detail-page__button::after {
border: none;
}

View File

@@ -0,0 +1,104 @@
import { Button, Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import AppBar from "../../components/app-bar";
import { navigateBackWithFallback } from "../../utils/app-bar";
import {
REPAIR_DEVICE_TYPE_LABELS,
REPAIR_DRAFT_STORAGE_KEY,
type RepairSubmissionSnapshot
} from "../repair/repair-utils";
import "./index.scss";
function getAttachmentSummary(detail?: RepairSubmissionSnapshot) {
if (!detail) {
return "0 个附件";
}
const imageCount = detail.attachments.filter((item) => item.kind === "image").length;
const videoCount = detail.attachments.filter((item) => item.kind === "video").length;
if (!imageCount && !videoCount) {
return "0 个附件";
}
return `${imageCount} 张图片 / ${videoCount} 个视频`;
}
export default function RepairDetailPage() {
const detail = Taro.getStorageSync(REPAIR_DRAFT_STORAGE_KEY) as RepairSubmissionSnapshot | undefined;
return (
<View className="repair-detail-page">
<View className="repair-detail-page__glow repair-detail-page__glow--left" />
<View className="repair-detail-page__glow repair-detail-page__glow--right" />
<AppBar title="报修详情" showBack onBack={() => navigateBackWithFallback("/pages/repair/index")} />
<Text className="repair-detail-page__header-note"></Text>
<View className="repair-detail-page__hero">
<View className="repair-detail-page__success-ring">
<View className="repair-detail-page__success-check" />
</View>
<Text className="repair-detail-page__status"></Text>
<Text className="repair-detail-page__hint"></Text>
</View>
<View className="repair-detail-page__card">
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label"></Text>
<Text className="repair-detail-page__value">{detail?.ticketNo || "--"}</Text>
</View>
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label"></Text>
<Text className="repair-detail-page__value">{detail?.createdAt || "--"}</Text>
</View>
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label"></Text>
<Text className="repair-detail-page__value repair-detail-page__value--accent">{detail?.statusLabel || "已提交,待审核"}</Text>
</View>
</View>
<View className="repair-detail-page__card">
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label"></Text>
<Text className="repair-detail-page__value">
{detail ? REPAIR_DEVICE_TYPE_LABELS[detail.deviceType] : "--"}
</Text>
</View>
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label">ID</Text>
<Text className="repair-detail-page__value">{detail?.deviceId || "--"}</Text>
</View>
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label"></Text>
<Text className="repair-detail-page__value">{detail?.deviceParams || "--"}</Text>
</View>
</View>
<View className="repair-detail-page__card">
<Text className="repair-detail-page__section-title"></Text>
<Text className="repair-detail-page__description">{detail?.description || "暂无描述"}</Text>
<View className="repair-detail-page__meta-row">
<Text className="repair-detail-page__meta-label"></Text>
<Text className="repair-detail-page__meta-value">{getAttachmentSummary(detail)}</Text>
</View>
</View>
<View className="repair-detail-page__card">
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label"></Text>
<Text className="repair-detail-page__value">{detail?.contactName || "--"}</Text>
</View>
<View className="repair-detail-page__row">
<Text className="repair-detail-page__label"></Text>
<Text className="repair-detail-page__value">{detail?.phone || "--"}</Text>
</View>
</View>
<Button className="repair-detail-page__button" onClick={() => navigateBackWithFallback("/pages/repair/index")}>
</Button>
</View>
);
}

View File

@@ -1,3 +1,6 @@
export default definePageConfig({
navigationBarTitleText: "设备报修"
navigationBarTitleText: "申请报修",
navigationBarBackgroundColor: "#0B1220",
navigationBarTextStyle: "white",
backgroundColor: "#0B1220"
});

View File

@@ -1,3 +1,389 @@
.repair-page {
position: relative;
min-height: 100vh;
padding: 0 24rpx 72rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #0b1220 0%, #121a2c 100%);
overflow: hidden;
}
.repair-page__glow {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.repair-page__glow--left {
top: 60rpx;
left: -120rpx;
width: 320rpx;
height: 320rpx;
background: radial-gradient(circle, rgba(53, 229, 179, 0.18) 0%, rgba(53, 229, 179, 0) 72%);
}
.repair-page__glow--right {
top: 260rpx;
right: -120rpx;
width: 340rpx;
height: 340rpx;
background: radial-gradient(circle, rgba(76, 118, 214, 0.14) 0%, rgba(76, 118, 214, 0) 72%);
}
.repair-page__tabs,
.repair-page__card,
.repair-page__add-card,
.repair-page__submit {
position: relative;
z-index: 1;
}
.repair-page__header-row {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
margin: 12rpx 0 24rpx;
}
.repair-page__header-subtitle {
flex: 1;
color: rgba(255, 255, 255, 0.58);
font-size: 24rpx;
line-height: 1.6;
}
.repair-page__header-action {
flex-shrink: 0;
color: #35e5b3;
font-size: 24rpx;
}
.repair-page__tabs {
display: flex;
gap: 24rpx;
margin-bottom: 24rpx;
}
.repair-page__tab {
min-width: 218rpx;
padding: 24rpx 28rpx;
border-radius: 999rpx;
background: rgba(34, 40, 57, 0.88);
text-align: center;
box-sizing: border-box;
}
.repair-page__tab--active {
background: linear-gradient(90deg, var(--color-brand-start) 0%, var(--color-brand-end) 100%);
box-shadow: 0 18rpx 38rpx rgba(24, 178, 156, 0.24);
}
.repair-page__tab-text {
color: rgba(255, 255, 255, 0.72);
font-size: 26rpx;
}
.repair-page__tab-text--active {
color: #ffffff;
font-weight: 600;
}
.repair-page__card,
.repair-page__add-card {
border-radius: 28rpx;
background: rgba(34, 40, 57, 0.94);
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.03);
}
.repair-page__card {
padding: 28rpx;
}
.repair-page__card--contact {
margin-top: 28rpx;
}
.repair-page__field-row {
display: flex;
align-items: center;
gap: 22rpx;
}
.repair-page__field-row + .repair-page__field-row {
margin-top: 26rpx;
}
.repair-page__field-label {
width: 124rpx;
flex-shrink: 0;
color: rgba(255, 255, 255, 0.9);
font-size: 26rpx;
}
.repair-page__field-box {
display: flex;
align-items: center;
width: 0;
min-height: 84rpx;
flex: 1;
padding: 0 24rpx;
border-radius: 22rpx;
background: rgba(17, 24, 39, 0.96);
box-sizing: border-box;
}
.repair-page__field-box--select {
justify-content: space-between;
}
.repair-page__field-box--disabled {
opacity: 0.8;
}
.repair-page__field-value {
color: #f7fbff;
font-size: 26rpx;
}
.repair-page__field-value--muted {
color: rgba(255, 255, 255, 0.42);
}
.repair-page__chevron {
width: 16rpx;
height: 16rpx;
border-right: 2rpx solid rgba(255, 255, 255, 0.68);
border-bottom: 2rpx solid rgba(255, 255, 255, 0.68);
transform: rotate(45deg);
}
.repair-page__textarea-wrap {
margin-top: 28rpx;
padding: 24rpx 24rpx 58rpx;
border-radius: 24rpx;
background: rgba(17, 24, 39, 0.96);
}
.repair-page__textarea {
width: 100%;
height: 180rpx;
color: #f5f7fb;
font-size: 28rpx;
line-height: 1.6;
}
.repair-page__placeholder {
color: rgba(255, 255, 255, 0.32);
}
.repair-page__textarea-count {
position: absolute;
right: 52rpx;
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.34);
font-size: 22rpx;
}
.repair-page__upload-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 340rpx;
margin-top: 26rpx;
padding: 32rpx;
border-radius: 28rpx;
background: rgba(17, 24, 39, 0.92);
}
.repair-page__camera {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 28rpx;
}
.repair-page__camera-top {
width: 26rpx;
height: 10rpx;
margin-right: 52rpx;
border-radius: 10rpx 10rpx 0 0;
background: rgba(255, 255, 255, 0.4);
}
.repair-page__camera-body {
display: flex;
align-items: center;
justify-content: center;
width: 86rpx;
height: 58rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.4);
}
.repair-page__camera-lens {
width: 28rpx;
height: 28rpx;
border: 6rpx solid rgba(17, 24, 39, 0.9);
border-radius: 50%;
}
.repair-page__upload-title {
color: rgba(255, 255, 255, 0.56);
font-size: 28rpx;
text-align: center;
line-height: 1.5;
}
.repair-page__upload-subtitle {
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.32);
font-size: 22rpx;
}
.repair-page__attachment-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 22rpx;
}
.repair-page__attachment {
position: relative;
overflow: hidden;
border-radius: 22rpx;
background: rgba(12, 19, 31, 0.86);
}
.repair-page__attachment-image,
.repair-page__video-cover {
display: block;
width: 100%;
height: 180rpx;
}
.repair-page__video-cover {
position: relative;
background: linear-gradient(135deg, rgba(32, 201, 191, 0.18), rgba(67, 100, 247, 0.18));
}
.repair-page__video-badge {
position: absolute;
left: 18rpx;
bottom: 18rpx;
padding: 6rpx 12rpx;
border-radius: 999rpx;
background: rgba(11, 18, 32, 0.74);
color: #ffffff;
font-size: 20rpx;
letter-spacing: 1rpx;
}
.repair-page__attachment-meta {
padding: 16rpx 18rpx 18rpx;
}
.repair-page__attachment-name,
.repair-page__attachment-size {
display: block;
}
.repair-page__attachment-name {
color: rgba(255, 255, 255, 0.88);
font-size: 22rpx;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.repair-page__attachment-size {
margin-top: 4rpx;
color: rgba(255, 255, 255, 0.34);
font-size: 20rpx;
}
.repair-page__attachment-delete {
position: absolute;
top: 12rpx;
right: 12rpx;
display: flex;
align-items: center;
justify-content: center;
width: 42rpx;
height: 42rpx;
border-radius: 50%;
background: rgba(11, 18, 32, 0.75);
}
.repair-page__attachment-delete-text {
color: #ffffff;
font-size: 28rpx;
line-height: 1;
}
.repair-page__add-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
min-height: 124rpx;
margin-top: 28rpx;
}
.repair-page__plus {
position: relative;
width: 54rpx;
height: 54rpx;
border-radius: 50%;
background: rgba(18, 201, 191, 0.18);
}
.repair-page__plus-line {
position: absolute;
top: 50%;
left: 50%;
background: #22d3c5;
transform: translate(-50%, -50%);
}
.repair-page__plus-line--horizontal {
width: 24rpx;
height: 2rpx;
}
.repair-page__plus-line--vertical {
width: 2rpx;
height: 24rpx;
}
.repair-page__add-text {
color: rgba(255, 255, 255, 0.38);
font-size: 22rpx;
}
.repair-page__input {
width: 100%;
color: #f7fbff;
font-size: 26rpx;
}
.repair-page__submit {
height: 96rpx;
margin-top: 44rpx;
border: none;
border-radius: 999rpx;
background: linear-gradient(90deg, var(--color-brand-start) 0%, var(--color-brand-end) 100%);
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
line-height: 96rpx;
box-shadow: 0 24rpx 42rpx rgba(24, 178, 156, 0.22);
}
.repair-page__submit::after {
border: none;
}

View File

@@ -1,28 +1,389 @@
import SecondaryPage, { type SecondaryPageSection } from "../../components/secondary-page";
import { Button, Image, Input, Text, Textarea, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import { useMemo, useState } from "react";
import AppBar from "../../components/app-bar";
import { navigateBackWithFallback } from "../../utils/app-bar";
import {
REPAIR_DESCRIPTION_LIMIT,
REPAIR_DEVICE_TYPE_LABELS,
REPAIR_DRAFT_STORAGE_KEY,
type RepairAttachment,
type RepairDeviceOption,
type RepairDeviceType,
type RepairSubmissionSnapshot,
appendRepairAttachments,
buildDeviceMap,
clampDescription,
normalizeChosenFiles,
validateRepairForm
} from "./repair-utils";
import "./index.scss";
const sections: SecondaryPageSection[] = [
{
title: "报修申请",
items: [
{ label: "提交报修申请" },
{ label: "上传故障图片", value: "待接入上传能力" },
{ label: "填写设备问题", value: "待接入表单" }
]
},
{
title: "售后进度",
items: [{ label: "维修进度查询", value: "后续接接口" }]
}
const repairDevices: RepairDeviceOption[] = [
{ id: "A9876456546", type: "monitor", label: "A9876456546", params: "3AW1 / 654616313" },
{ id: "A3648201488", type: "monitor", label: "A3648201488", params: "5BZ2 / 882730114" },
{ id: "C7842037781", type: "camera", label: "C7842037781", params: "AI-CAM / 220184900" },
{ id: "C7842037782", type: "camera", label: "C7842037782", params: "AI-CAM / 220184901" }
];
const groupedDevices = buildDeviceMap(repairDevices);
const deviceTypeOrder: RepairDeviceType[] = ["monitor", "camera"];
function delay(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function formatAttachmentSize(size?: number) {
if (!size) {
return "";
}
if (size >= 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(1)}MB`;
}
return `${Math.max(size / 1024, 0.1).toFixed(1)}KB`;
}
function createTicketNo() {
return `BX${Date.now().toString().slice(-8)}`;
}
export default function RepairPage() {
const [deviceType, setDeviceType] = useState<RepairDeviceType>("monitor");
const [selectedDeviceId, setSelectedDeviceId] = useState(groupedDevices.monitor[0]?.id || "");
const [description, setDescription] = useState("");
const [attachments, setAttachments] = useState<RepairAttachment[]>([]);
const [contactName, setContactName] = useState("张小龙");
const [phone, setPhone] = useState("13689569989");
const [submitting, setSubmitting] = useState(false);
const currentDevices = groupedDevices[deviceType];
const selectedDevice =
currentDevices.find((item) => item.id === selectedDeviceId) || currentDevices[0] || repairDevices[0];
const imageCount = attachments.filter((item) => item.kind === "image").length;
const videoCount = attachments.filter((item) => item.kind === "video").length;
const uploadHint = useMemo(() => {
if (deviceType === "camera") {
return "点击上传AI摄像头故障照片或视频";
}
return "点击上传体征设备故障照片或视频";
}, [deviceType]);
const showToast = (title: string) => {
Taro.showToast({
title,
icon: "none"
});
};
const handleTypeChange = (nextType: RepairDeviceType) => {
if (nextType === deviceType) {
return;
}
const nextDevices = groupedDevices[nextType];
setDeviceType(nextType);
setSelectedDeviceId(nextDevices[0]?.id || "");
};
const handleSelectDevice = async () => {
if (!currentDevices.length) {
showToast("当前暂无可选设备");
return;
}
try {
const result = await Taro.showActionSheet({
itemList: currentDevices.map((item) => item.label)
});
const nextDevice = currentDevices[result.tapIndex];
if (nextDevice) {
setSelectedDeviceId(nextDevice.id);
}
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (!message.includes("cancel")) {
showToast("设备选择失败");
}
}
};
const handleChooseMedia = async () => {
try {
const result = await Taro.chooseMedia({
count: 9,
mediaType: ["image", "video"],
sourceType: ["album", "camera"]
});
const chosen = normalizeChosenFiles((result.tempFiles || []) as never[]);
if (!chosen.length) {
return;
}
const merged = appendRepairAttachments(attachments, chosen);
setAttachments(merged.attachments);
if (merged.errorMessage) {
showToast(merged.errorMessage);
}
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (message.includes("cancel")) {
return;
}
showToast("上传失败,请重试");
}
};
const handlePreviewAttachment = (attachment: RepairAttachment) => {
if (attachment.kind === "image") {
Taro.previewImage({
current: attachment.path,
urls: attachments.filter((item) => item.kind === "image").map((item) => item.path)
});
return;
}
const previewMedia = (Taro as unknown as {
previewMedia?: (options: {
current?: number;
sources: Array<{ url: string; type: "image" | "video"; poster?: string }>;
}) => Promise<unknown>;
}).previewMedia;
if (typeof previewMedia === "function") {
void previewMedia({
current: 0,
sources: [
{
url: attachment.path,
type: "video",
poster: attachment.thumbPath
}
]
});
return;
}
showToast("当前环境暂不支持视频预览");
};
const handleDeleteAttachment = (attachmentId: string, event: { stopPropagation?: () => void }) => {
event.stopPropagation?.();
setAttachments((prev) => prev.filter((item) => item.id !== attachmentId));
};
const handleSubmit = async () => {
const errorMessage = validateRepairForm({
selectedDeviceId,
description,
contactName,
phone
});
if (errorMessage) {
showToast(errorMessage);
return;
}
if (!selectedDevice) {
showToast("请选择设备");
return;
}
const payload: RepairSubmissionSnapshot = {
ticketNo: createTicketNo(),
deviceType,
deviceTypeLabel: REPAIR_DEVICE_TYPE_LABELS[deviceType],
deviceId: selectedDevice.id,
deviceParams: selectedDevice.params,
description: description.trim(),
attachments,
contactName: contactName.trim(),
phone: phone.trim(),
createdAt: new Date().toLocaleString("zh-CN", { hour12: false }),
statusLabel: "已提交,待审核"
};
setSubmitting(true);
try {
await delay(800);
Taro.setStorageSync(REPAIR_DRAFT_STORAGE_KEY, payload);
await Taro.showToast({
title: "提交成功",
icon: "success"
});
await Taro.navigateTo({ url: "/pages/repair-detail/index" });
} catch {
showToast("提交失败,请稍后重试");
} finally {
setSubmitting(false);
}
};
return (
<SecondaryPage
eyebrow="REPAIR"
title="设备报修"
description="这个页面先把报修流程的主要节点放齐,后面接图片上传和工单接口时改动会比较小。"
sections={sections}
<View className="repair-page">
<View className="repair-page__glow repair-page__glow--left" />
<View className="repair-page__glow repair-page__glow--right" />
<AppBar
title="申请报修"
showBack
onBack={() => navigateBackWithFallback("/pages/mine/index")}
/>
<View className="repair-page__header-row">
<Text className="repair-page__header-subtitle"></Text>
<Text className="repair-page__header-action" onClick={() => showToast("历史记录功能待开放")}>
</Text>
</View>
<View className="repair-page__tabs">
{deviceTypeOrder.map((item) => (
<View
className={`repair-page__tab ${item === deviceType ? "repair-page__tab--active" : ""}`}
key={item}
onClick={() => handleTypeChange(item)}
>
<Text className={`repair-page__tab-text ${item === deviceType ? "repair-page__tab-text--active" : ""}`}>
{REPAIR_DEVICE_TYPE_LABELS[item]}
</Text>
</View>
))}
</View>
<View className="repair-page__card">
<View className="repair-page__field-row">
<Text className="repair-page__field-label">ID</Text>
<View className="repair-page__field-box repair-page__field-box--select" onClick={handleSelectDevice}>
<Text className="repair-page__field-value">{selectedDevice?.label || "请选择设备"}</Text>
<View className="repair-page__chevron" />
</View>
</View>
<View className="repair-page__field-row">
<Text className="repair-page__field-label"></Text>
<View className="repair-page__field-box repair-page__field-box--disabled">
<Text className="repair-page__field-value repair-page__field-value--muted">{selectedDevice?.params || "--"}</Text>
</View>
</View>
<View className="repair-page__textarea-wrap">
<Textarea
className="repair-page__textarea"
maxlength={REPAIR_DESCRIPTION_LIMIT}
placeholder="问题描述60个字以内"
placeholderClass="repair-page__placeholder"
value={description}
onInput={(event) => setDescription(clampDescription(event.detail.value))}
/>
<Text className="repair-page__textarea-count">
{description.length}/{REPAIR_DESCRIPTION_LIMIT}
</Text>
</View>
<View className="repair-page__upload-panel" onClick={handleChooseMedia}>
<View className="repair-page__camera">
<View className="repair-page__camera-top" />
<View className="repair-page__camera-body">
<View className="repair-page__camera-lens" />
</View>
</View>
<Text className="repair-page__upload-title">{uploadHint}</Text>
<Text className="repair-page__upload-subtitle">
{imageCount}/9 {videoCount}/1
</Text>
</View>
{attachments.length ? (
<View className="repair-page__attachment-grid">
{attachments.map((attachment) => (
<View
className={`repair-page__attachment ${attachment.kind === "video" ? "repair-page__attachment--video" : ""}`}
key={attachment.id}
onClick={() => handlePreviewAttachment(attachment)}
>
{attachment.kind === "image" ? (
<Image className="repair-page__attachment-image" mode="aspectFill" src={attachment.path} />
) : (
<View className="repair-page__video-cover">
{attachment.thumbPath ? (
<Image className="repair-page__attachment-image" mode="aspectFill" src={attachment.thumbPath} />
) : null}
<View className="repair-page__video-badge">VIDEO</View>
</View>
)}
<View className="repair-page__attachment-meta">
<Text className="repair-page__attachment-name">{attachment.name || "附件"}</Text>
<Text className="repair-page__attachment-size">
{attachment.kind === "video" ? `视频 ${formatAttachmentSize(attachment.size)}` : formatAttachmentSize(attachment.size)}
</Text>
</View>
<View className="repair-page__attachment-delete" onClick={(event) => handleDeleteAttachment(attachment.id, event)}>
<Text className="repair-page__attachment-delete-text">×</Text>
</View>
</View>
))}
</View>
) : null}
</View>
<View className="repair-page__add-card" onClick={handleChooseMedia}>
<View className="repair-page__plus">
<View className="repair-page__plus-line repair-page__plus-line--horizontal" />
<View className="repair-page__plus-line repair-page__plus-line--vertical" />
</View>
<Text className="repair-page__add-text"></Text>
</View>
<View className="repair-page__card repair-page__card--contact">
<View className="repair-page__field-row">
<Text className="repair-page__field-label"></Text>
<View className="repair-page__field-box">
<Input
className="repair-page__input"
maxlength={20}
placeholder="请输入联系人"
placeholderClass="repair-page__placeholder"
value={contactName}
onInput={(event) => setContactName(event.detail.value)}
/>
</View>
</View>
<View className="repair-page__field-row">
<Text className="repair-page__field-label"></Text>
<View className="repair-page__field-box">
<Input
className="repair-page__input"
maxlength={11}
placeholder="请输入手机号"
placeholderClass="repair-page__placeholder"
type="number"
value={phone}
onInput={(event) => setPhone(event.detail.value)}
/>
</View>
</View>
</View>
<Button className="repair-page__submit" disabled={submitting} loading={submitting} onClick={handleSubmit}>
</Button>
</View>
);
}

View File

@@ -0,0 +1,144 @@
export type RepairDeviceType = "monitor" | "camera";
export type RepairDeviceOption = {
id: string;
type: RepairDeviceType;
label: string;
params: string;
};
export type RepairAttachment = {
id: string;
kind: "image" | "video";
path: string;
size?: number;
duration?: number;
thumbPath?: string;
name?: string;
};
export type RepairFormInput = {
selectedDeviceId: string;
description: string;
contactName: string;
phone: string;
};
export type RepairSubmissionSnapshot = {
ticketNo: string;
deviceType: RepairDeviceType;
deviceTypeLabel: string;
deviceId: string;
deviceParams: string;
description: string;
attachments: RepairAttachment[];
contactName: string;
phone: string;
createdAt: string;
statusLabel: string;
};
type ChosenMediaFile = {
tempFilePath: string;
fileType?: "image" | "video";
size?: number;
duration?: number;
thumbTempFilePath?: string;
};
export const REPAIR_DESCRIPTION_LIMIT = 60;
export const REPAIR_DRAFT_STORAGE_KEY = "repair-request-draft";
export const REPAIR_DEVICE_TYPE_LABELS: Record<RepairDeviceType, string> = {
monitor: "体征监测设备",
camera: "AI摄像头"
};
export function clampDescription(value: string, limit = REPAIR_DESCRIPTION_LIMIT) {
return value.slice(0, limit);
}
export function isValidPhone(value: string) {
return /^1[3-9]\d{9}$/.test(value);
}
export function buildDeviceMap(devices: RepairDeviceOption[]) {
return devices.reduce<Record<RepairDeviceType, RepairDeviceOption[]>>(
(result, item) => {
result[item.type].push(item);
return result;
},
{
monitor: [],
camera: []
}
);
}
export function normalizeChosenFiles(files: ChosenMediaFile[]): RepairAttachment[] {
return files.map((item, index) => ({
id: `${item.tempFilePath}-${index}`,
kind: item.fileType === "video" ? "video" : "image",
path: item.tempFilePath,
size: item.size,
duration: item.duration,
thumbPath: item.thumbTempFilePath,
name: item.tempFilePath.split("/").pop() || item.tempFilePath
}));
}
export function appendRepairAttachments(existing: RepairAttachment[], incoming: RepairAttachment[]) {
const attachments = [...existing];
let errorMessage = "";
incoming.forEach((item) => {
if (item.kind === "video") {
const hasVideo = attachments.some((attachment) => attachment.kind === "video");
if (hasVideo) {
errorMessage = errorMessage || "最多上传1个视频";
return;
}
attachments.push(item);
return;
}
const imageCount = attachments.filter((attachment) => attachment.kind === "image").length;
if (imageCount >= 9) {
errorMessage = errorMessage || "最多上传9张图片";
return;
}
attachments.push(item);
});
return {
attachments,
errorMessage
};
}
export function validateRepairForm(input: RepairFormInput) {
if (!input.selectedDeviceId) {
return "请选择设备";
}
if (!input.description.trim()) {
return "请填写问题描述";
}
if (!input.contactName.trim()) {
return "请填写联系人";
}
if (!input.phone.trim()) {
return "请填写手机号";
}
if (!isValidPhone(input.phone)) {
return "请输入正确的手机号";
}
return "";
}

View File

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

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

@@ -0,0 +1,728 @@
.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: 0 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-page__meta-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
margin: 12rpx 0 24rpx;
}
.report-page__date-label {
flex: 1;
color: #91a3b8;
font-size: 24rpx;
line-height: 1.6;
}
.report-page__share-action {
flex-shrink: 0;
color: #36e4aa;
font-size: 24rpx;
}
.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;
}

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

@@ -0,0 +1,199 @@
import { ScrollView, Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro";
import { useMemo, useState } from "react";
import AppBar from "../../components/app-bar";
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 { ReportScoreOverview } from "../../components/report/score-overview";
import { ReportSummaryBlock } from "../../components/report/summary-block";
import { navigateBackWithFallback } from "../../utils/app-bar";
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);
};
return (
<ScrollView className="report-page" scrollY>
<View className="report-page__glow report-page__glow--one" />
<View className="report-page__glow report-page__glow--two" />
<View className="report-page__content">
<AppBar
title={currentRecord.babyName}
showBack
onBack={() => navigateBackWithFallback("/pages/index/index")}
/>
<View className="report-page__meta-bar">
<Text className="report-page__date-label">{currentRecord.dateLabel}</Text>
<Text
className="report-page__share-action"
onClick={() =>
Taro.showToast({
title: "分享功能待接入",
icon: "none"
})
}
>
</Text>
</View>
<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,38 @@
type AppBarMetricInput = {
menuButtonRect?: Partial<{
top: number;
left: number;
height: number;
}> | null;
statusBarHeight?: number;
windowWidth?: number;
};
export type AppBarMetrics = {
capsuleSafeWidth: number;
menuHeight: number;
menuTop: number;
topInset: number;
};
export function computeAppBarMetrics(input: AppBarMetricInput): AppBarMetrics {
const topInset = Math.max(input.statusBarHeight || 0, 0);
const menuHeight = input.menuButtonRect?.height && input.menuButtonRect.height > 0 ? input.menuButtonRect.height : 32;
const menuTop =
typeof input.menuButtonRect?.top === "number" ? input.menuButtonRect.top : Math.max(topInset + 6, topInset);
const capsuleSafeWidth =
typeof input.menuButtonRect?.left === "number" && input.windowWidth
? Math.max(input.windowWidth - input.menuButtonRect.left, 96)
: 96;
return {
topInset,
menuTop,
menuHeight,
capsuleSafeWidth
};
}
export function resolveBackNavigation(pageStackLength: number) {
return pageStackLength > 1 ? "navigateBack" : "redirectHome";
}

27
src/utils/app-bar.ts Normal file
View File

@@ -0,0 +1,27 @@
import Taro from "@tarojs/taro";
import { computeAppBarMetrics, resolveBackNavigation, type AppBarMetrics } from "./app-bar-metrics";
export { computeAppBarMetrics, resolveBackNavigation, type AppBarMetrics } from "./app-bar-metrics";
export function getAppBarMetrics() {
const windowInfo = typeof Taro.getWindowInfo === "function" ? Taro.getWindowInfo() : Taro.getSystemInfoSync();
const menuButtonRect =
typeof Taro.getMenuButtonBoundingClientRect === "function" ? Taro.getMenuButtonBoundingClientRect() : null;
return computeAppBarMetrics({
menuButtonRect,
statusBarHeight: windowInfo.statusBarHeight,
windowWidth: windowInfo.windowWidth
});
}
export function navigateBackWithFallback(fallbackUrl: string) {
const action = resolveBackNavigation(Taro.getCurrentPages().length);
if (action === "navigateBack") {
Taro.navigateBack({ delta: 1 });
return;
}
Taro.redirectTo({ url: fallbackUrl });
}

39
src/utils/main-tabbar.ts Normal file
View File

@@ -0,0 +1,39 @@
import Taro from "@tarojs/taro";
import type { TabbarItem } from "../components/bottom-tabbar";
type MainTabKey = "home" | "report" | "assistant" | "message" | "mine";
const mainTabRoutes: Partial<Record<MainTabKey, string>> = {
home: "/pages/index/index",
report: "/pages/report/index",
message: "/pages/message/index",
mine: "/pages/mine/index"
};
export function createMainTabItems(activeKey: MainTabKey): TabbarItem[] {
return [
{ key: "home", label: "首页", active: activeKey === "home" },
{ key: "report", label: "报告", active: activeKey === "report" },
{ key: "assistant", label: "小e", active: activeKey === "assistant" },
{ key: "message", label: "消息", active: activeKey === "message", badge: true },
{ key: "mine", label: "我的", active: activeKey === "mine" }
];
}
export function handleMainTabNavigation(item: TabbarItem, activeKey: MainTabKey) {
if (item.key === activeKey) {
return;
}
const route = mainTabRoutes[item.key as MainTabKey];
if (route) {
Taro.redirectTo({ url: route });
return;
}
Taro.showToast({
title: `${item.label}功能待接入`,
icon: "none"
});
}

View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "tmp/devices-tests",
"module": "CommonJS",
"target": "ES2020",
"declaration": false,
"types": []
},
"include": ["src/pages/devices/device-data.ts"]
}

View File

@@ -18,7 +18,7 @@
},
"types": [
"@tarojs/taro",
"wechat-miniprogram"
"miniprogram-api-typings"
]
},
"include": [

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", "src/pages/repair/repair-utils.ts"]
}

4
types/global.d.ts vendored
View File

@@ -1 +1,5 @@
declare module "*.scss";
declare module "*.svg" {
const src: string;
export default src;
}