Compare commits
16 Commits
6ff9070eca
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afd86d9a56 | ||
|
|
89a1cebea8 | ||
|
|
26a7411504 | ||
|
|
42fde98037 | ||
|
|
b6a3719d2b | ||
|
|
a3227ba519 | ||
|
|
b7fa2fce9d | ||
|
|
ef70bfc71c | ||
|
|
f3eb25b035 | ||
|
|
f8238694ae | ||
|
|
6d93440334 | ||
|
|
c42344e38a | ||
|
|
9a2f382dc9 | ||
|
|
9885f131da | ||
|
|
a6382d669b | ||
|
|
054d8e3519 |
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
0
.idea/.gitignore
generated
vendored
Normal file
0
.idea/.gitignore
generated
vendored
Normal file
9
.idea/New project 3.iml
generated
Normal file
9
.idea/New project 3.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/misc.xml
generated
Normal file
6
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/New project 3.iml" filepath="$PROJECT_DIR$/.idea/New project 3.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
71
.idea/workspace.xml
generated
Normal file
71
.idea/workspace.xml
generated
Normal file
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="a812a826-f373-4202-9fd1-557f469f1478" name="更改" comment="">
|
||||
<change beforePath="$PROJECT_DIR$/project.config.json" beforeDir="false" afterPath="$PROJECT_DIR$/project.config.json" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/scripts/tests/app-bar-source.test.cjs" beforeDir="false" afterPath="$PROJECT_DIR$/scripts/tests/app-bar-source.test.cjs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/scripts/tests/report-utils.test.cjs" beforeDir="false" afterPath="$PROJECT_DIR$/scripts/tests/report-utils.test.cjs" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/secondary-page/index.scss" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/secondary-page/index.scss" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/devices/index.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/devices/index.config.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/feedback/index.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/feedback/index.config.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/follow-us/index.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/follow-us/index.config.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/index/index.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/index/index.config.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/repair-detail/index.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/repair-detail/index.config.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/repair/index.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/repair/index.config.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/settings/index.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/settings/index.config.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/support/index.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/support/index.config.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/videos/index.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/videos/index.config.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/tsconfig.report-tests.json" beforeDir="false" afterPath="$PROJECT_DIR$/tsconfig.report-tests.json" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||
</component>
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="MarkdownSettingsMigration">
|
||||
<option name="stateVersion" value="1" />
|
||||
</component>
|
||||
<component name="ProjectId" id="3DTzLiED7L7Nx6z6Fi1yC0zRw9x" />
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"WebServerToolWindowFactoryState": "false",
|
||||
"last_opened_file_path": "C:/Users/a/Documents/New project 3",
|
||||
"nodejs_package_manager_path": "npm"
|
||||
}
|
||||
}]]></component>
|
||||
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="应用程序级" UseSingleDictionary="true" transferred="true" />
|
||||
<component name="TaskManager">
|
||||
<task active="true" id="Default" summary="默认任务">
|
||||
<changelist id="a812a826-f373-4202-9fd1-557f469f1478" name="更改" comment="" />
|
||||
<created>1778320793548</created>
|
||||
<option name="number" value="Default" />
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1778320793548</updated>
|
||||
<workItem from="1778320795766" duration="654000" />
|
||||
</task>
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
<component name="Vcs.Log.Tabs.Properties">
|
||||
<option name="TAB_STATES">
|
||||
<map>
|
||||
<entry key="MAIN">
|
||||
<value>
|
||||
<State />
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
19
AGENTS.md
19
AGENTS.md
@@ -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
130
README.md
@@ -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. 建议你下一步怎么做
|
||||
|
||||
如果你是零基础,推荐按这个顺序继续:
|
||||
|
||||
658
docs/superpowers/plans/2026-05-08-sleep-report-page.md
Normal file
658
docs/superpowers/plans/2026-05-08-sleep-report-page.md
Normal 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">></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` 这些命名,避免偏离计划。
|
||||
467
docs/superpowers/specs/2026-05-08-sleep-report-design.md
Normal file
467
docs/superpowers/specs/2026-05-08-sleep-report-design.md
Normal 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
14
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"disableSWC": true
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "3.6.3",
|
||||
"libVersion": "3.15.2",
|
||||
"appid": "wx2e6e4000b0ef29d9",
|
||||
"miniprogramRoot": "dist/",
|
||||
"projectname": "wechat-miniapp-starter",
|
||||
|
||||
50
scripts/tests/app-bar-source.test.cjs
Normal file
50
scripts/tests/app-bar-source.test.cjs
Normal file
@@ -0,0 +1,50 @@
|
||||
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, /<|>\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 exposes fixed layout modes instead of scattered slot props", () => {
|
||||
assert.match(appBarSource, /type AppBarMode = "spacer" \| "back-title" \| "back-title-actions-below" \| "title-actions-below"/);
|
||||
assert.match(appBarSource, /mode\?: AppBarMode/);
|
||||
assert.match(appBarSource, /actions\?: ReactNode/);
|
||||
assert.doesNotMatch(appBarSource, /subtitle\?:|eyebrow\?:|align\?:|rightText\?:|onRightAction\?:|leftSlot\?:|rightSlot\?:|bottomSlot\?:/);
|
||||
});
|
||||
|
||||
run("AppBar includes dedicated rows for title and below-line actions", () => {
|
||||
assert.match(appBarSource, /app-bar__title-row/);
|
||||
assert.match(appBarSource, /app-bar__actions-row/);
|
||||
assert.match(appBarStyles, /\.app-bar__actions-row\s*\{/);
|
||||
assert.match(appBarStyles, /\.app-bar__title-row\s*\{/);
|
||||
});
|
||||
30
scripts/tests/devices-page.test.cjs
Normal file
30
scripts/tests/devices-page.test.cjs
Normal 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 台设备在线", "权限待确认"]
|
||||
);
|
||||
});
|
||||
45
scripts/tests/message-page-source.test.cjs
Normal file
45
scripts/tests/message-page-source.test.cjs
Normal 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">/
|
||||
);
|
||||
});
|
||||
85
scripts/tests/repair-utils.test.cjs
Normal file
85
scripts/tests/repair-utils.test.cjs
Normal 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);
|
||||
});
|
||||
79
scripts/tests/report-utils.test.cjs
Normal file
79
scripts/tests/report-utils.test.cjs
Normal file
@@ -0,0 +1,79 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
getSleepLevel,
|
||||
getStatusTone,
|
||||
pickReportRecord
|
||||
} = require("../../tmp/report-tests/report-utils.js");
|
||||
const { resolveReportBackAction } = require("../../tmp/report-tests/navigation.js");
|
||||
const { computeAppBarMetrics, resolveBackNavigation } = require("../../tmp/report-tests/utils/app-bar-metrics.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);
|
||||
});
|
||||
|
||||
run("resolveReportBackAction returns navigateBack when there is history", () => {
|
||||
assert.equal(resolveReportBackAction(2), "navigateBack");
|
||||
});
|
||||
|
||||
run("resolveReportBackAction falls back to home when report page has no history", () => {
|
||||
assert.equal(resolveReportBackAction(1), "redirectHome");
|
||||
});
|
||||
|
||||
run("computeAppBarMetrics uses menu button metrics when available", () => {
|
||||
assert.deepEqual(
|
||||
computeAppBarMetrics({
|
||||
statusBarHeight: 24,
|
||||
windowWidth: 390,
|
||||
menuButtonRect: {
|
||||
top: 30,
|
||||
left: 300,
|
||||
height: 32
|
||||
}
|
||||
}),
|
||||
{
|
||||
topInset: 24,
|
||||
menuTop: 30,
|
||||
menuHeight: 32,
|
||||
capsuleSafeWidth: 96
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
run("resolveBackNavigation falls back when stack has no previous page", () => {
|
||||
assert.equal(resolveBackNavigation(1), "redirectHome");
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
11
src/assets/svg/add.svg
Normal 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
6
src/assets/svg/back.svg
Normal 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 |
65
src/components/app-bar/index.scss
Normal file
65
src/components/app-bar/index.scss
Normal 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;
|
||||
}
|
||||
62
src/components/app-bar/index.tsx
Normal file
62
src/components/app-bar/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/report/chart-card.tsx
Normal file
66
src/components/report/chart-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/report/chip-group.tsx
Normal file
29
src/components/report/chip-group.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/report/daytime-prediction.tsx
Normal file
28
src/components/report/daytime-prediction.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/report/distribution-card.tsx
Normal file
35
src/components/report/distribution-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/report/insight-list.tsx
Normal file
28
src/components/report/insight-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/report/kv-list.tsx
Normal file
29
src/components/report/kv-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/report/metric-grid.tsx
Normal file
28
src/components/report/metric-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/components/report/page-header.tsx
Normal file
25
src/components/report/page-header.tsx
Normal 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"><</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>
|
||||
);
|
||||
}
|
||||
63
src/components/report/score-overview.tsx
Normal file
63
src/components/report/score-overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/report/section-card.tsx
Normal file
24
src/components/report/section-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/report/summary-block.tsx
Normal file
33
src/components/report/summary-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
.secondary-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
padding: 32rpx 24rpx 48rpx;
|
||||
padding: 0 24rpx 48rpx;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(180deg, #1e2432 0%, #171d29 100%);
|
||||
overflow: hidden;
|
||||
@@ -35,7 +35,7 @@
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 28rpx;
|
||||
padding: 32rpx 30rpx;
|
||||
padding: 28rpx 30rpx;
|
||||
border-radius: 28rpx;
|
||||
background: rgba(39, 46, 64, 0.95);
|
||||
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.03);
|
||||
@@ -49,16 +49,9 @@
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.secondary-page__title {
|
||||
display: block;
|
||||
color: #f6f8fb;
|
||||
font-size: 42rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.secondary-page__description {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
margin-top: 10rpx;
|
||||
color: #9aa5bb;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
49
src/pages/devices/device-data.ts
Normal file
49
src/pages/devices/device-data.ts
Normal 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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: "custom",
|
||||
navigationBarTitleText: "我的设备"
|
||||
});
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: "custom",
|
||||
navigationBarTitleText: "问题反馈"
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: "custom",
|
||||
navigationBarTitleText: "关注我们"
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: "custom",
|
||||
navigationBarTitleText: "首页"
|
||||
navigationBarTitleText: ""
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">></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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: "custom",
|
||||
navigationBarTitleText: "个人信息"
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">></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">></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">></Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
7
src/pages/repair-detail/index.config.ts
Normal file
7
src/pages/repair-detail/index.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: "custom",
|
||||
navigationBarTitleText: "报修详情",
|
||||
navigationBarBackgroundColor: "#0B1220",
|
||||
navigationBarTextStyle: "white",
|
||||
backgroundColor: "#0B1220"
|
||||
});
|
||||
166
src/pages/repair-detail/index.scss
Normal file
166
src/pages/repair-detail/index.scss
Normal 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;
|
||||
}
|
||||
104
src/pages/repair-detail/index.tsx
Normal file
104
src/pages/repair-detail/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: "设备报修"
|
||||
navigationStyle: "custom",
|
||||
navigationBarTitleText: "申请报修",
|
||||
navigationBarBackgroundColor: "#0B1220",
|
||||
navigationBarTextStyle: "white",
|
||||
backgroundColor: "#0B1220"
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
144
src/pages/repair/repair-utils.ts
Normal file
144
src/pages/repair/repair-utils.ts
Normal 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 "";
|
||||
}
|
||||
3
src/pages/report/index.config.ts
Normal file
3
src/pages/report/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: "custom"
|
||||
});
|
||||
728
src/pages/report/index.scss
Normal file
728
src/pages/report/index.scss
Normal 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
199
src/pages/report/index.tsx
Normal 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
865
src/pages/report/mock.ts
Normal 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: "波动可控" }
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
3
src/pages/report/navigation.ts
Normal file
3
src/pages/report/navigation.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function resolveReportBackAction(pageStackLength: number) {
|
||||
return pageStackLength > 1 ? "navigateBack" : "redirectHome";
|
||||
}
|
||||
55
src/pages/report/report-utils.ts
Normal file
55
src/pages/report/report-utils.ts
Normal 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
107
src/pages/report/types.ts
Normal 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[];
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: "custom",
|
||||
navigationBarTitleText: "设置"
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: "custom",
|
||||
navigationBarTitleText: "帮助与客服"
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: "custom",
|
||||
navigationBarTitleText: "教学视频"
|
||||
});
|
||||
|
||||
38
src/utils/app-bar-metrics.ts
Normal file
38
src/utils/app-bar-metrics.ts
Normal 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
27
src/utils/app-bar.ts
Normal 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
39
src/utils/main-tabbar.ts
Normal 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"
|
||||
});
|
||||
}
|
||||
12
tsconfig.devices-tests.json
Normal file
12
tsconfig.devices-tests.json
Normal 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"]
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"types": [
|
||||
"@tarojs/taro",
|
||||
"wechat-miniprogram"
|
||||
"miniprogram-api-typings"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
|
||||
12
tsconfig.report-tests.json
Normal file
12
tsconfig.report-tests.json
Normal 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/report/navigation.ts", "src/pages/repair/repair-utils.ts", "src/utils/app-bar.ts", "src/utils/app-bar-metrics.ts"]
|
||||
}
|
||||
4
types/global.d.ts
vendored
4
types/global.d.ts
vendored
@@ -1 +1,5 @@
|
||||
declare module "*.scss";
|
||||
declare module "*.svg" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user