feat: add device binding home interactions
This commit is contained in:
25
README.md
25
README.md
@@ -2,12 +2,12 @@
|
||||
|
||||
这是一个适合零基础开发者的 `Taro + React + TypeScript` 微信小程序项目模板。你后续会用 React 组件方式开发页面,再编译成微信小程序代码进行预览和发布。
|
||||
|
||||
当前首页已经改成了一版“无数据状态”业务样式,方便你直接在这个基础上继续接真实接口和页面跳转。
|
||||
当前首页已经改成了一版“设备绑定首页(无设备状态)”业务样式,方便你直接在这个基础上继续接真实接口和页面跳转。
|
||||
|
||||
## 1. 目前已经包含什么
|
||||
|
||||
- `Taro + React + TypeScript` 项目骨架
|
||||
- 首页无数据状态业务示例页面
|
||||
- 首页设备绑定业务示例页面
|
||||
- 小程序 `AppID` 配置
|
||||
- `AGENTS.md` 协作规则文件
|
||||
- 从开发到发布的中文说明
|
||||
@@ -158,20 +158,29 @@ npm run dev:weapp
|
||||
|
||||
## 12. 当前首页做了什么
|
||||
|
||||
现在首页已经不是默认演示页,而是一个更接近正式项目的静态业务首页,包含:
|
||||
现在首页已经不是默认演示页,而是一个“设备绑定首页(无设备状态)”业务页,包含:
|
||||
|
||||
- 顶部登录入口
|
||||
- 已关联设备数量展示
|
||||
- 两个主操作按钮
|
||||
- 扫码添加设备按钮
|
||||
- 蓝牙搜索附近设备按钮
|
||||
- 绑定状态展示区
|
||||
- 绑定前提示卡片
|
||||
- 底部导航视觉样式
|
||||
|
||||
这些内容当前是静态演示结构,点击后会先弹出提示,方便你后续继续接:
|
||||
当前首页已经接入了部分小程序能力:
|
||||
|
||||
- 扫码按钮会调用微信小程序扫码能力
|
||||
- 蓝牙按钮会先检查定位权限,再尝试打开蓝牙搜索
|
||||
- 如果开发环境中暂时搜不到真实设备,页面会用前端占位设备演示完整绑定流程
|
||||
|
||||
这些内容当前仍然属于前端演示和占位实现,方便你后续继续接:
|
||||
|
||||
- 登录页
|
||||
- 扫码添加设备
|
||||
- 扫码解析接口
|
||||
- 真实蓝牙设备筛选与配对
|
||||
- 设备列表
|
||||
- 商城、我的等页面
|
||||
- 报告、消息、我的等页面
|
||||
|
||||
## 13. 你接下来最常做的开发动作
|
||||
|
||||
@@ -208,6 +217,7 @@ src
|
||||
|
||||
- 保持 `npm run dev:weapp` 在运行
|
||||
- 在微信开发者工具里查看编译后的模拟器效果
|
||||
- 扫码和蓝牙相关能力更推荐使用真机调试,因为开发者工具里不一定能完整模拟真实权限和设备搜索环境
|
||||
|
||||
### 真机预览
|
||||
|
||||
@@ -215,6 +225,7 @@ src
|
||||
2. 点击开发者工具中的“预览”
|
||||
3. 使用管理员或绑定开发者微信扫码
|
||||
4. 在手机里查看效果
|
||||
5. 如果要测试蓝牙绑定,请同时确认手机蓝牙和定位权限已经打开
|
||||
|
||||
## 15. 上传、提交审核和发布
|
||||
|
||||
|
||||
50
docs/superpowers/plans/2026-05-07-device-binding-home.md
Normal file
50
docs/superpowers/plans/2026-05-07-device-binding-home.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Device Binding Home 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:** 保持单页实现,使用本地 `useState` 管理设备数量、蓝牙搜索状态、搜索结果和绑定结果。扫码和蓝牙优先调用 Taro 小程序能力,在无法完成真实设备搜索时使用轻量前端兜底数据维持流程闭环。
|
||||
|
||||
**Tech Stack:** Taro 4、React 18、TypeScript、SCSS
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 更新首页交互逻辑
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/index/index.tsx`
|
||||
|
||||
- [ ] 定义设备绑定页所需的本地状态:设备数量、蓝牙状态、搜索结果、最近绑定设备
|
||||
- [ ] 实现扫码按钮逻辑,调用 `Taro.scanCode`
|
||||
- [ ] 实现蓝牙搜索流程,串联授权、蓝牙适配器、设备发现和占位兜底
|
||||
- [ ] 实现设备列表点击绑定逻辑
|
||||
- [ ] 为未完成的登录、设备列表、底部导航保留 Toast 占位
|
||||
|
||||
### Task 2: 更新首页视觉样式
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/index/index.scss`
|
||||
|
||||
- [ ] 调整顶部区域、功能按钮、权限卡片和底部导航样式
|
||||
- [ ] 新增蓝牙状态区、设备结果列表、成功提示区样式
|
||||
- [ ] 保持深色背景与参考图一致的视觉方向
|
||||
|
||||
### Task 3: 更新项目说明
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] 更新首页说明为设备绑定页
|
||||
- [ ] 补充扫码和蓝牙交互当前已实现的范围
|
||||
- [ ] 说明蓝牙功能更适合真机调试
|
||||
|
||||
### Task 4: 验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/index/index.tsx`
|
||||
- Modify: `src/pages/index/index.scss`
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] 运行 `npm run build:weapp`
|
||||
- [ ] 确认构建通过且没有新增依赖问题
|
||||
@@ -0,0 +1,64 @@
|
||||
# 设备绑定首页设计说明
|
||||
|
||||
**日期:** 2026-05-07
|
||||
|
||||
## 目标
|
||||
|
||||
把首页调整为“设备绑定首页(无设备状态)”,并在不接入真实后端接口的前提下,补齐扫码绑定、蓝牙搜索、权限提示和底部导航的前端交互骨架。
|
||||
|
||||
## 范围
|
||||
|
||||
- 保留当前单页结构,不新增独立页面
|
||||
- 使用 `Taro + React + TypeScript`
|
||||
- 扫码使用 `Taro.scanCode`
|
||||
- 蓝牙流程优先调用小程序蓝牙相关 API
|
||||
- 蓝牙搜索结果允许使用前端占位数据兜底,方便开发阶段演示流程
|
||||
- 不接入真实后端接口,不保存真实绑定关系
|
||||
|
||||
## 页面结构
|
||||
|
||||
- 顶部区:登录按钮、设备标题、设备数量、添加入口
|
||||
- 功能区:扫码添加设备、蓝牙搜索设备
|
||||
- 状态区:蓝牙搜索状态、搜索结果列表、绑定成功提示
|
||||
- 权限提示区:展示绑定前注意事项
|
||||
- 底部导航:首页、报告、小e、消息、我的
|
||||
|
||||
## 交互设计
|
||||
|
||||
### 扫码绑定
|
||||
|
||||
1. 点击“扫码 添加新设备”
|
||||
2. 调用扫码能力
|
||||
3. 从扫码结果中提取设备编码
|
||||
4. 弹出“查询中”提示
|
||||
5. 用本地状态模拟绑定成功,并把设备数量更新为 `1`
|
||||
|
||||
异常处理:
|
||||
|
||||
- 用户取消扫码:提示已取消
|
||||
- 扫码失败:提示二维码无法识别或扫码失败
|
||||
- 权限不足:提示前往系统设置开启相机权限
|
||||
|
||||
### 蓝牙绑定
|
||||
|
||||
1. 点击“蓝牙搜附近的设备”
|
||||
2. 检查定位授权状态
|
||||
3. 尝试申请定位授权
|
||||
4. 打开蓝牙适配器
|
||||
5. 开始搜索附近蓝牙设备
|
||||
6. 展示“搜索中”状态
|
||||
7. 收集搜索结果;如果短时间内没有结果,显示前端占位设备列表
|
||||
8. 点击设备项后执行绑定成功流程
|
||||
|
||||
异常处理:
|
||||
|
||||
- 未开启定位或授权失败:提示开启定位权限
|
||||
- 蓝牙不可用:提示开启系统蓝牙
|
||||
- 未发现设备:展示空状态提示
|
||||
|
||||
## 实现边界
|
||||
|
||||
- 只修改首页和文档
|
||||
- 不新增全局状态管理
|
||||
- 不新增依赖
|
||||
- 不删除任何目录或批量删除任何文件
|
||||
@@ -1,43 +1,67 @@
|
||||
.home-page {
|
||||
.device-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
padding: 24rpx 24rpx 148rpx;
|
||||
padding: 24rpx 24rpx 156rpx;
|
||||
box-sizing: border-box;
|
||||
background: #1b2130;
|
||||
background: linear-gradient(180deg, #1e2432 0%, #191f2c 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
.device-page__halo {
|
||||
position: absolute;
|
||||
top: -54rpx;
|
||||
right: -36rpx;
|
||||
width: 340rpx;
|
||||
height: 340rpx;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle, rgba(67, 78, 109, 0.18) 0 30%, transparent 31% 47%, rgba(67, 78, 109, 0.12) 48% 66%, transparent 67%),
|
||||
radial-gradient(circle at center, rgba(87, 212, 184, 0.12), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-header {
|
||||
.device-page__halo--large {
|
||||
top: -72rpx;
|
||||
right: -42rpx;
|
||||
width: 360rpx;
|
||||
height: 360rpx;
|
||||
background:
|
||||
radial-gradient(circle, rgba(86, 212, 185, 0.06) 0 24%, transparent 25% 42%, rgba(103, 117, 155, 0.12) 43% 62%, transparent 63%),
|
||||
radial-gradient(circle at center, rgba(255, 255, 255, 0.04), transparent 72%);
|
||||
}
|
||||
|
||||
.device-page__halo--small {
|
||||
top: 44rpx;
|
||||
right: 92rpx;
|
||||
width: 160rpx;
|
||||
height: 120rpx;
|
||||
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.05), transparent 70%);
|
||||
}
|
||||
|
||||
.device-header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 26rpx;
|
||||
}
|
||||
|
||||
.login-pill {
|
||||
min-width: 94rpx;
|
||||
.device-header__login {
|
||||
min-width: 92rpx;
|
||||
height: 48rpx;
|
||||
padding: 0 24rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, #34e5b4 0%, #18c9b7 100%);
|
||||
background: linear-gradient(90deg, #35e5b3 0%, #20c9bf 100%);
|
||||
color: #ffffff;
|
||||
font-size: 22rpx;
|
||||
line-height: 48rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 10rpx 24rpx rgba(32, 214, 181, 0.24);
|
||||
box-shadow: 0 12rpx 24rpx rgba(32, 214, 181, 0.22);
|
||||
}
|
||||
|
||||
.device-header__add {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.86);
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
line-height: 30rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.device-summary {
|
||||
@@ -47,140 +71,245 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 22rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.device-summary__content {
|
||||
.device-summary__title-wrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.device-summary__title {
|
||||
font-size: 26rpx;
|
||||
color: #f3f7ff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #eff4ff;
|
||||
}
|
||||
|
||||
.device-summary__count {
|
||||
color: #ff964f;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #ff9d57;
|
||||
}
|
||||
|
||||
.device-summary__arrow {
|
||||
color: #8a93a7;
|
||||
font-size: 28rpx;
|
||||
color: #7f889f;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
.device-actions-card,
|
||||
.device-status-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 38rpx 30rpx 34rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(43, 49, 67, 0.94);
|
||||
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.02);
|
||||
background: rgba(42, 48, 66, 0.96);
|
||||
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.action-button {
|
||||
.device-actions-card {
|
||||
padding: 34rpx 30rpx;
|
||||
}
|
||||
|
||||
.device-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 74rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, #39e7aa 0%, #1cc9c1 100%);
|
||||
background: linear-gradient(90deg, #39e6ad 0%, #1fc9c1 100%);
|
||||
box-shadow: 0 14rpx 26rpx rgba(20, 184, 166, 0.18);
|
||||
}
|
||||
|
||||
.action-button + .action-button {
|
||||
.device-action--secondary {
|
||||
margin-top: 34rpx;
|
||||
}
|
||||
|
||||
.action-button__icon {
|
||||
.device-action__icon {
|
||||
position: relative;
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.action-button__icon-core {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.95);
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.action-button__icon--scan .action-button__icon-core::before,
|
||||
.action-button__icon--scan .action-button__icon-core::after,
|
||||
.action-button__icon--tag .action-button__icon-core::before,
|
||||
.action-button__icon--tag .action-button__icon-core::after {
|
||||
.device-action__icon::before,
|
||||
.device-action__icon::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.action-button__icon--scan .action-button__icon-core::before {
|
||||
left: 6rpx;
|
||||
right: 6rpx;
|
||||
top: 14rpx;
|
||||
.device-action__icon--scan::before {
|
||||
inset: 4rpx;
|
||||
border: 2rpx solid #ffffff;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.device-action__icon--scan::after {
|
||||
left: 8rpx;
|
||||
right: 8rpx;
|
||||
top: 15rpx;
|
||||
height: 2rpx;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.action-button__icon--scan .action-button__icon-core::after {
|
||||
top: 6rpx;
|
||||
bottom: 6rpx;
|
||||
.device-action__icon--bluetooth::before {
|
||||
left: 14rpx;
|
||||
top: 2rpx;
|
||||
width: 2rpx;
|
||||
height: 28rpx;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.action-button__icon--tag .action-button__icon-core {
|
||||
transform: rotate(45deg) scale(0.8);
|
||||
border-radius: 6rpx;
|
||||
.device-action__icon--bluetooth::after {
|
||||
left: 8rpx;
|
||||
top: 6rpx;
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-top: 2rpx solid #ffffff;
|
||||
border-right: 2rpx solid #ffffff;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.action-button__icon--tag .action-button__icon-core::before {
|
||||
top: 9rpx;
|
||||
left: 9rpx;
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.action-button__icon--tag .action-button__icon-core::after {
|
||||
right: -7rpx;
|
||||
top: 14rpx;
|
||||
width: 10rpx;
|
||||
height: 2rpx;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.action-button__label {
|
||||
.device-action__label {
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.notice-card {
|
||||
.device-status-card {
|
||||
margin-top: 22rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.device-status-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.device-status-card__title {
|
||||
color: #edf3ff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.device-status-card__tag {
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.device-status-card__tag--idle {
|
||||
background: rgba(125, 136, 164, 0.18);
|
||||
color: #b8c0d5;
|
||||
}
|
||||
|
||||
.device-status-card__tag--searching {
|
||||
background: rgba(54, 228, 170, 0.14);
|
||||
color: #63f0c0;
|
||||
}
|
||||
|
||||
.device-status-card__tag--empty {
|
||||
background: rgba(255, 157, 87, 0.14);
|
||||
color: #ffb173;
|
||||
}
|
||||
|
||||
.device-status-card__tag--success {
|
||||
background: rgba(72, 214, 165, 0.18);
|
||||
color: #7cf0c4;
|
||||
}
|
||||
|
||||
.device-status-card__hint {
|
||||
display: block;
|
||||
color: #f1f5ff;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.device-status-card__subhint {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
color: #8f98ad;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.device-list__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 22rpx;
|
||||
border-radius: 18rpx;
|
||||
background: rgba(25, 31, 44, 0.88);
|
||||
}
|
||||
|
||||
.device-list__item + .device-list__item {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.device-list__name {
|
||||
display: block;
|
||||
color: #f3f7ff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.device-list__meta {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
color: #8992a8;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.device-list__action {
|
||||
color: #3de5af;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.device-success {
|
||||
margin-top: 18rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
border-radius: 18rpx;
|
||||
background: rgba(61, 229, 175, 0.1);
|
||||
}
|
||||
|
||||
.device-success__title {
|
||||
display: block;
|
||||
color: #88f5cc;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.device-success__name {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
color: #f3f7ff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.device-notice-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 22rpx;
|
||||
padding: 22rpx 24rpx 24rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #fbefc7;
|
||||
color: #977244;
|
||||
box-shadow: 0 12rpx 24rpx rgba(6, 10, 22, 0.14);
|
||||
}
|
||||
|
||||
.notice-card__title-row {
|
||||
.device-notice-card__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.notice-card__speaker {
|
||||
.device-notice-card__horn {
|
||||
position: relative;
|
||||
width: 20rpx;
|
||||
height: 16rpx;
|
||||
@@ -189,13 +318,13 @@
|
||||
background: #ff9d57;
|
||||
}
|
||||
|
||||
.notice-card__speaker::before,
|
||||
.notice-card__speaker::after {
|
||||
.device-notice-card__horn::before,
|
||||
.device-notice-card__horn::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.notice-card__speaker::before {
|
||||
.device-notice-card__horn::before {
|
||||
right: -8rpx;
|
||||
top: 2rpx;
|
||||
width: 0;
|
||||
@@ -205,7 +334,7 @@
|
||||
border-left: 8rpx solid #ff9d57;
|
||||
}
|
||||
|
||||
.notice-card__speaker::after {
|
||||
.device-notice-card__horn::after {
|
||||
right: -16rpx;
|
||||
top: 2rpx;
|
||||
width: 8rpx;
|
||||
@@ -216,25 +345,25 @@
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.notice-card__title {
|
||||
.device-notice-card__title {
|
||||
color: #af7e42;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: #af7e42;
|
||||
}
|
||||
|
||||
.notice-list {
|
||||
.device-notice-card__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.notice-item {
|
||||
.device-notice-card__item {
|
||||
color: #9f7a4c;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.7;
|
||||
color: #9f7a4c;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
.device-tabbar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
@@ -244,11 +373,11 @@
|
||||
align-items: flex-start;
|
||||
justify-content: space-around;
|
||||
padding: 16rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
|
||||
background: rgba(31, 36, 49, 0.98);
|
||||
background: rgba(30, 35, 48, 0.98);
|
||||
box-shadow: 0 -8rpx 24rpx rgba(4, 8, 20, 0.34);
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
.device-tabbar__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -256,7 +385,7 @@
|
||||
min-width: 88rpx;
|
||||
}
|
||||
|
||||
.tab-item__icon {
|
||||
.device-tabbar__icon {
|
||||
position: relative;
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
@@ -266,13 +395,13 @@
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.tab-item__icon::before,
|
||||
.tab-item__icon::after {
|
||||
.device-tabbar__icon::before,
|
||||
.device-tabbar__icon::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.tab-item__icon::before {
|
||||
.device-tabbar__icon::before {
|
||||
left: 8rpx;
|
||||
right: 8rpx;
|
||||
top: 10rpx;
|
||||
@@ -281,7 +410,7 @@
|
||||
color: #8f97a9;
|
||||
}
|
||||
|
||||
.tab-item__icon::after {
|
||||
.device-tabbar__icon::after {
|
||||
left: 8rpx;
|
||||
right: 8rpx;
|
||||
bottom: 10rpx;
|
||||
@@ -290,17 +419,17 @@
|
||||
color: #8f97a9;
|
||||
}
|
||||
|
||||
.tab-item__icon--active {
|
||||
.device-tabbar__icon--active {
|
||||
border-color: #36e4aa;
|
||||
background: rgba(54, 228, 170, 0.12);
|
||||
}
|
||||
|
||||
.tab-item__icon--active::before,
|
||||
.tab-item__icon--active::after {
|
||||
.device-tabbar__icon--active::before,
|
||||
.device-tabbar__icon--active::after {
|
||||
color: #36e4aa;
|
||||
}
|
||||
|
||||
.tab-item__badge {
|
||||
.device-tabbar__badge {
|
||||
position: absolute;
|
||||
top: -4rpx;
|
||||
right: -4rpx;
|
||||
@@ -308,15 +437,15 @@
|
||||
height: 10rpx;
|
||||
border-radius: 50%;
|
||||
background: #ff4d4f;
|
||||
box-shadow: 0 0 0 4rpx rgba(31, 36, 49, 0.98);
|
||||
box-shadow: 0 0 0 4rpx rgba(30, 35, 48, 0.98);
|
||||
}
|
||||
|
||||
.tab-item__label {
|
||||
font-size: 20rpx;
|
||||
.device-tabbar__label {
|
||||
color: #b0b6c4;
|
||||
font-size: 20rpx;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tab-item__label--active {
|
||||
.device-tabbar__label--active {
|
||||
color: #36e4aa;
|
||||
}
|
||||
|
||||
@@ -1,95 +1,346 @@
|
||||
import { Text, View } from "@tarojs/components";
|
||||
import Taro from "@tarojs/taro";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import "./index.scss";
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
key: "scan",
|
||||
icon: "scan",
|
||||
label: "扫一扫 添加如新设备",
|
||||
toast: "后续可以在这里接入扫码添加设备"
|
||||
},
|
||||
{
|
||||
key: "tag",
|
||||
icon: "service",
|
||||
label: "暂无捐赠所得的设备",
|
||||
toast: "后续可以在这里展示捐赠设备"
|
||||
}
|
||||
];
|
||||
type BluetoothStatus = "idle" | "searching" | "empty" | "success";
|
||||
|
||||
type DeviceCandidate = {
|
||||
id: string;
|
||||
name: string;
|
||||
source: "ble" | "mock";
|
||||
};
|
||||
|
||||
const notices = [
|
||||
"1. 传感器是否上电成功,控制盒三绿灯状态。",
|
||||
"2. 对APP进行蓝牙和位置(定位服务)授权。",
|
||||
"3. 若使用扫一扫功能,请对摄像头进行授权。"
|
||||
"1. 请确保设备已开机",
|
||||
"2. 请开启蓝牙与定位权限",
|
||||
"3. 扫码功能需开启相机权限"
|
||||
];
|
||||
|
||||
const navItems = [
|
||||
{ key: "home", label: "首页", active: true },
|
||||
{ key: "report", label: "报告", active: false },
|
||||
{ key: "service", label: "小e", active: false },
|
||||
{ key: "mall", label: "商城", active: false, badge: true },
|
||||
{ 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" }
|
||||
];
|
||||
|
||||
const bluetoothStateText: Record<BluetoothStatus, string> = {
|
||||
idle: "点击蓝牙按钮后,将在这里显示附近设备搜索状态。",
|
||||
searching: "正在搜索附近设备,请保持设备开机并靠近手机。",
|
||||
empty: "未发现可连接设备,请确认设备已开机并已开启蓝牙与定位。",
|
||||
success: "设备绑定成功,后续可在这里继续展示同步状态。"
|
||||
};
|
||||
|
||||
function parseDeviceCode(result?: string) {
|
||||
if (!result) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = result.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as { code?: string; device_sn?: string };
|
||||
return parsed.code || parsed.device_sn || trimmed;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
const handleClick = (title: string) => {
|
||||
const [deviceCount, setDeviceCount] = useState(0);
|
||||
const [bluetoothStatus, setBluetoothStatus] = useState<BluetoothStatus>("idle");
|
||||
const [bluetoothDevices, setBluetoothDevices] = useState<DeviceCandidate[]>([]);
|
||||
const [recentDeviceName, setRecentDeviceName] = useState("");
|
||||
const [statusHint, setStatusHint] = useState("可通过扫码或蓝牙搜索完成设备绑定。");
|
||||
const discoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (discoveryTimerRef.current) {
|
||||
clearTimeout(discoveryTimerRef.current);
|
||||
}
|
||||
|
||||
Taro.stopBluetoothDevicesDiscovery().catch(() => undefined);
|
||||
Taro.closeBluetoothAdapter().catch(() => undefined);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showToast = (title: string) => {
|
||||
Taro.showToast({
|
||||
title,
|
||||
icon: "none"
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="home-page">
|
||||
<View className="hero-bg" />
|
||||
const bindDevice = async (name: string, sourceLabel: string) => {
|
||||
setStatusHint(`正在连接 ${name}`);
|
||||
showToast("设备配对连接中");
|
||||
|
||||
<View className="home-header">
|
||||
<View className="login-pill" onClick={() => handleClick("登录功能待接入")}>
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 600);
|
||||
});
|
||||
|
||||
setBluetoothStatus("success");
|
||||
setRecentDeviceName(name);
|
||||
setDeviceCount(1);
|
||||
setStatusHint(`${sourceLabel}绑定成功,已可开始同步健康数据。`);
|
||||
showToast("设备绑定成功");
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
showToast("登录功能待接入");
|
||||
};
|
||||
|
||||
const handleDeviceList = () => {
|
||||
showToast("设备列表页待接入");
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
showToast("后续可从这里进入设备管理页");
|
||||
};
|
||||
|
||||
const handleTabClick = (label: string) => {
|
||||
showToast(`${label}功能待接入`);
|
||||
};
|
||||
|
||||
const handleScanBind = async () => {
|
||||
try {
|
||||
setStatusHint("等待扫码识别设备编码。");
|
||||
|
||||
const result = await Taro.scanCode({
|
||||
scanType: ["qrCode", "barCode"]
|
||||
});
|
||||
|
||||
const deviceCode = parseDeviceCode(result.result);
|
||||
|
||||
if (!deviceCode) {
|
||||
showToast("二维码无法识别");
|
||||
setStatusHint("扫码成功,但未识别出可绑定的设备编码。");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatusHint(`正在查询设备 ${deviceCode}`);
|
||||
showToast("正在查询设备");
|
||||
await bindDevice(`扫码设备 ${deviceCode}`, "扫码设备");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
|
||||
if (message.includes("cancel")) {
|
||||
showToast("已取消扫码");
|
||||
setStatusHint("你取消了本次扫码绑定。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.includes("auth deny") || message.includes("authorize")) {
|
||||
showToast("请开启相机权限");
|
||||
setStatusHint("扫码功能需要相机权限,请前往系统设置开启。");
|
||||
return;
|
||||
}
|
||||
|
||||
showToast("扫码失败,请重试");
|
||||
setStatusHint("扫码失败,可能是权限不足或二维码无法识别。");
|
||||
}
|
||||
};
|
||||
|
||||
const requestLocationPermission = async () => {
|
||||
const setting = await Taro.getSetting();
|
||||
const locationAuthorized = setting.authSetting["scope.userLocation"];
|
||||
|
||||
if (locationAuthorized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await Taro.authorize({ scope: "scope.userLocation" });
|
||||
return true;
|
||||
} catch {
|
||||
showToast("请开启定位权限");
|
||||
setStatusHint("蓝牙搜索依赖定位权限,请先授权。");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBluetoothBind = async () => {
|
||||
if (discoveryTimerRef.current) {
|
||||
clearTimeout(discoveryTimerRef.current);
|
||||
discoveryTimerRef.current = null;
|
||||
}
|
||||
|
||||
const hasPermission = await requestLocationPermission();
|
||||
|
||||
if (!hasPermission) {
|
||||
setBluetoothStatus("idle");
|
||||
setBluetoothDevices([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Taro.openBluetoothAdapter();
|
||||
setBluetoothStatus("searching");
|
||||
setBluetoothDevices([]);
|
||||
setStatusHint("正在搜索附近设备。");
|
||||
showToast("正在搜索附近设备");
|
||||
|
||||
const foundDevices = new Map<string, DeviceCandidate>();
|
||||
|
||||
Taro.onBluetoothDeviceFound((result) => {
|
||||
result.devices.forEach((item) => {
|
||||
const name = item.name || item.localName;
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
foundDevices.set(item.deviceId, {
|
||||
id: item.deviceId,
|
||||
name,
|
||||
source: "ble"
|
||||
});
|
||||
});
|
||||
|
||||
setBluetoothDevices(Array.from(foundDevices.values()).slice(0, 6));
|
||||
});
|
||||
|
||||
await Taro.startBluetoothDevicesDiscovery({
|
||||
allowDuplicatesKey: false
|
||||
});
|
||||
|
||||
discoveryTimerRef.current = setTimeout(async () => {
|
||||
const nextDevices = foundDevices.size > 0 ? Array.from(foundDevices.values()) : mockBluetoothDevices;
|
||||
|
||||
setBluetoothDevices(nextDevices);
|
||||
|
||||
if (nextDevices.length > 0) {
|
||||
setStatusHint("已找到附近设备,请点击列表完成绑定。");
|
||||
} else {
|
||||
setBluetoothStatus("empty");
|
||||
setStatusHint(bluetoothStateText.empty);
|
||||
}
|
||||
|
||||
await Taro.stopBluetoothDevicesDiscovery().catch(() => undefined);
|
||||
}, 2200);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
|
||||
if (message.includes("not available") || message.includes("10001")) {
|
||||
showToast("请先开启系统蓝牙");
|
||||
setStatusHint("系统蓝牙未开启,暂时无法搜索设备。");
|
||||
} else {
|
||||
showToast("蓝牙搜索失败");
|
||||
setStatusHint("蓝牙搜索未成功启动,请稍后重试。");
|
||||
}
|
||||
|
||||
setBluetoothStatus("idle");
|
||||
setBluetoothDevices([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeviceSelect = async (device: DeviceCandidate) => {
|
||||
await bindDevice(device.name, device.source === "ble" ? "蓝牙设备" : "演示设备");
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<View className="device-header__login" onClick={handleLogin}>
|
||||
登录
|
||||
</View>
|
||||
|
||||
<View className="device-header__add" onClick={handleAdd}>
|
||||
+
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="device-summary" onClick={() => handleClick("设备列表页待接入")}>
|
||||
<View className="device-summary__content">
|
||||
<View className="device-summary" onClick={handleDeviceList}>
|
||||
<View className="device-summary__title-wrap">
|
||||
<Text className="device-summary__title">已关联体征监测设备</Text>
|
||||
<Text className="device-summary__count">0</Text>
|
||||
<Text className="device-summary__count">{deviceCount}</Text>
|
||||
</View>
|
||||
<Text className="device-summary__arrow">></Text>
|
||||
</View>
|
||||
|
||||
<View className="action-card">
|
||||
{quickActions.map((item) => (
|
||||
<View className="action-button" key={item.key} onClick={() => handleClick(item.toast)}>
|
||||
<View className={`action-button__icon action-button__icon--${item.key}`}>
|
||||
<View className="action-button__icon-core" />
|
||||
</View>
|
||||
<Text className="action-button__label">{item.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className="notice-card">
|
||||
<View className="notice-card__title-row">
|
||||
<View className="notice-card__speaker" />
|
||||
<Text className="notice-card__title">尊敬的用户您好!绑定前请注意以下几点。</Text>
|
||||
<View className="device-actions-card">
|
||||
<View className="device-action" onClick={handleScanBind}>
|
||||
<View className="device-action__icon device-action__icon--scan" />
|
||||
<Text className="device-action__label">扫码 添加新设备</Text>
|
||||
</View>
|
||||
|
||||
<View className="notice-list">
|
||||
<View className="device-action device-action--secondary" onClick={handleBluetoothBind}>
|
||||
<View className="device-action__icon device-action__icon--bluetooth" />
|
||||
<Text className="device-action__label">蓝牙搜附近的设备</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="device-status-card">
|
||||
<View className="device-status-card__header">
|
||||
<Text className="device-status-card__title">绑定进度</Text>
|
||||
<Text className={`device-status-card__tag device-status-card__tag--${bluetoothStatus}`}>
|
||||
{bluetoothStatus === "searching" ? "搜索中" : bluetoothStatus === "empty" ? "无设备" : bluetoothStatus === "success" ? "成功" : "待开始"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className="device-status-card__hint">{statusHint}</Text>
|
||||
<Text className="device-status-card__subhint">{bluetoothStateText[bluetoothStatus]}</Text>
|
||||
|
||||
{bluetoothDevices.length > 0 ? (
|
||||
<View className="device-list">
|
||||
{bluetoothDevices.map((item) => (
|
||||
<View className="device-list__item" key={item.id} onClick={() => handleDeviceSelect(item)}>
|
||||
<View>
|
||||
<Text className="device-list__name">{item.name}</Text>
|
||||
<Text className="device-list__meta">
|
||||
{item.source === "ble" ? "附近蓝牙设备" : "开发占位设备"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="device-list__action">绑定</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{recentDeviceName ? (
|
||||
<View className="device-success">
|
||||
<Text className="device-success__title">最近绑定</Text>
|
||||
<Text className="device-success__name">{recentDeviceName}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className="device-notice-card">
|
||||
<View className="device-notice-card__title-row">
|
||||
<View className="device-notice-card__horn" />
|
||||
<Text className="device-notice-card__title">绑定前请注意以下几点:</Text>
|
||||
</View>
|
||||
|
||||
<View className="device-notice-card__list">
|
||||
{notices.map((item) => (
|
||||
<Text className="notice-item" key={item}>
|
||||
<Text className="device-notice-card__item" key={item}>
|
||||
{item}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="tab-bar">
|
||||
<View className="device-tabbar">
|
||||
{navItems.map((item) => (
|
||||
<View className="tab-item" key={item.key} onClick={() => handleClick(`${item.label}功能待接入`)}>
|
||||
<View className={`tab-item__icon ${item.active ? "tab-item__icon--active" : ""}`}>
|
||||
{item.badge ? <View className="tab-item__badge" /> : null}
|
||||
<View className="device-tabbar__item" key={item.key} onClick={() => handleTabClick(item.label)}>
|
||||
<View className={`device-tabbar__icon ${item.active ? "device-tabbar__icon--active" : ""}`}>
|
||||
{item.badge ? <View className="device-tabbar__badge" /> : null}
|
||||
</View>
|
||||
<Text className={`tab-item__label ${item.active ? "tab-item__label--active" : ""}`}>{item.label}</Text>
|
||||
<Text className={`device-tabbar__label ${item.active ? "device-tabbar__label--active" : ""}`}>{item.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user