feat: add device binding home interactions

This commit is contained in:
czz
2026-05-07 15:52:12 +08:00
parent a212e9f412
commit 19ea7c89d0
5 changed files with 662 additions and 157 deletions

View File

@@ -2,12 +2,12 @@
这是一个适合零基础开发者的 `Taro + React + TypeScript` 微信小程序项目模板。你后续会用 React 组件方式开发页面,再编译成微信小程序代码进行预览和发布。 这是一个适合零基础开发者的 `Taro + React + TypeScript` 微信小程序项目模板。你后续会用 React 组件方式开发页面,再编译成微信小程序代码进行预览和发布。
当前首页已经改成了一版“无数据状态”业务样式,方便你直接在这个基础上继续接真实接口和页面跳转。 当前首页已经改成了一版“设备绑定首页(无设备状态”业务样式,方便你直接在这个基础上继续接真实接口和页面跳转。
## 1. 目前已经包含什么 ## 1. 目前已经包含什么
- `Taro + React + TypeScript` 项目骨架 - `Taro + React + TypeScript` 项目骨架
- 首页无数据状态业务示例页面 - 首页设备绑定业务示例页面
- 小程序 `AppID` 配置 - 小程序 `AppID` 配置
- `AGENTS.md` 协作规则文件 - `AGENTS.md` 协作规则文件
- 从开发到发布的中文说明 - 从开发到发布的中文说明
@@ -158,20 +158,29 @@ npm run dev:weapp
## 12. 当前首页做了什么 ## 12. 当前首页做了什么
现在首页已经不是默认演示页,而是一个更接近正式项目的静态业务页,包含: 现在首页已经不是默认演示页,而是一个“设备绑定首页(无设备状态)”业务页,包含:
- 顶部登录入口 - 顶部登录入口
- 已关联设备数量展示 - 已关联设备数量展示
- 两个主操作按钮 - 扫码添加设备按钮
- 蓝牙搜索附近设备按钮
- 绑定状态展示区
- 绑定前提示卡片 - 绑定前提示卡片
- 底部导航视觉样式 - 底部导航视觉样式
这些内容当前是静态演示结构,点击后会先弹出提示,方便你后续继续接 当前首页已经接入了部分小程序能力
- 扫码按钮会调用微信小程序扫码能力
- 蓝牙按钮会先检查定位权限,再尝试打开蓝牙搜索
- 如果开发环境中暂时搜不到真实设备,页面会用前端占位设备演示完整绑定流程
这些内容当前仍然属于前端演示和占位实现,方便你后续继续接:
- 登录页 - 登录页
- 扫码添加设备 - 扫码解析接口
- 真实蓝牙设备筛选与配对
- 设备列表 - 设备列表
- 商城、我的等页面 - 报告、消息、我的等页面
## 13. 你接下来最常做的开发动作 ## 13. 你接下来最常做的开发动作
@@ -208,6 +217,7 @@ src
- 保持 `npm run dev:weapp` 在运行 - 保持 `npm run dev:weapp` 在运行
- 在微信开发者工具里查看编译后的模拟器效果 - 在微信开发者工具里查看编译后的模拟器效果
- 扫码和蓝牙相关能力更推荐使用真机调试,因为开发者工具里不一定能完整模拟真实权限和设备搜索环境
### 真机预览 ### 真机预览
@@ -215,6 +225,7 @@ src
2. 点击开发者工具中的“预览” 2. 点击开发者工具中的“预览”
3. 使用管理员或绑定开发者微信扫码 3. 使用管理员或绑定开发者微信扫码
4. 在手机里查看效果 4. 在手机里查看效果
5. 如果要测试蓝牙绑定,请同时确认手机蓝牙和定位权限已经打开
## 15. 上传、提交审核和发布 ## 15. 上传、提交审核和发布

View 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`
- [ ] 确认构建通过且没有新增依赖问题

View File

@@ -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. 点击设备项后执行绑定成功流程
异常处理:
- 未开启定位或授权失败:提示开启定位权限
- 蓝牙不可用:提示开启系统蓝牙
- 未发现设备:展示空状态提示
## 实现边界
- 只修改首页和文档
- 不新增全局状态管理
- 不新增依赖
- 不删除任何目录或批量删除任何文件

View File

@@ -1,43 +1,67 @@
.home-page { .device-page {
position: relative; position: relative;
min-height: 100vh; min-height: 100vh;
padding: 24rpx 24rpx 148rpx; padding: 24rpx 24rpx 156rpx;
box-sizing: border-box; box-sizing: border-box;
background: #1b2130; background: linear-gradient(180deg, #1e2432 0%, #191f2c 100%);
overflow: hidden; overflow: hidden;
} }
.hero-bg { .device-page__halo {
position: absolute; position: absolute;
top: -54rpx;
right: -36rpx;
width: 340rpx;
height: 340rpx;
border-radius: 50%; border-radius: 50%;
background: pointer-events: none;
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%);
} }
.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; position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
margin-bottom: 26rpx; margin-bottom: 26rpx;
} }
.login-pill { .device-header__login {
min-width: 94rpx; min-width: 92rpx;
height: 48rpx; height: 48rpx;
padding: 0 24rpx; padding: 0 24rpx;
border-radius: 999rpx; border-radius: 999rpx;
background: linear-gradient(90deg, #34e5b4 0%, #18c9b7 100%); background: linear-gradient(90deg, #35e5b3 0%, #20c9bf 100%);
color: #ffffff; color: #ffffff;
font-size: 22rpx; font-size: 22rpx;
line-height: 48rpx; line-height: 48rpx;
text-align: center; 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 { .device-summary {
@@ -47,140 +71,245 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 22rpx; margin-bottom: 22rpx;
color: #ffffff;
} }
.device-summary__content { .device-summary__title-wrap {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 10rpx; gap: 10rpx;
} }
.device-summary__title { .device-summary__title {
font-size: 26rpx; color: #f3f7ff;
font-size: 28rpx;
font-weight: 600; font-weight: 600;
color: #eff4ff;
} }
.device-summary__count { .device-summary__count {
color: #ff964f;
font-size: 26rpx; font-size: 26rpx;
font-weight: 700; font-weight: 700;
color: #ff9d57;
} }
.device-summary__arrow { .device-summary__arrow {
color: #8a93a7;
font-size: 28rpx; font-size: 28rpx;
color: #7f889f;
} }
.action-card { .device-actions-card,
.device-status-card {
position: relative; position: relative;
z-index: 1; z-index: 1;
padding: 38rpx 30rpx 34rpx;
border-radius: 24rpx; border-radius: 24rpx;
background: rgba(43, 49, 67, 0.94); background: rgba(42, 48, 66, 0.96);
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.02); 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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 74rpx; height: 74rpx;
border-radius: 999rpx; 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); box-shadow: 0 14rpx 26rpx rgba(20, 184, 166, 0.18);
} }
.action-button + .action-button { .device-action--secondary {
margin-top: 34rpx; margin-top: 34rpx;
} }
.action-button__icon { .device-action__icon {
position: relative; position: relative;
width: 34rpx; width: 32rpx;
height: 34rpx; height: 32rpx;
margin-right: 16rpx; margin-right: 16rpx;
} }
.action-button__icon-core { .device-action__icon::before,
position: absolute; .device-action__icon::after {
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 {
position: absolute; position: absolute;
content: ""; content: "";
} }
.action-button__icon--scan .action-button__icon-core::before { .device-action__icon--scan::before {
left: 6rpx; inset: 4rpx;
right: 6rpx; border: 2rpx solid #ffffff;
top: 14rpx; border-radius: 8rpx;
}
.device-action__icon--scan::after {
left: 8rpx;
right: 8rpx;
top: 15rpx;
height: 2rpx; height: 2rpx;
background: #ffffff; background: #ffffff;
} }
.action-button__icon--scan .action-button__icon-core::after { .device-action__icon--bluetooth::before {
top: 6rpx;
bottom: 6rpx;
left: 14rpx; left: 14rpx;
top: 2rpx;
width: 2rpx; width: 2rpx;
height: 28rpx;
background: #ffffff; background: #ffffff;
} }
.action-button__icon--tag .action-button__icon-core { .device-action__icon--bluetooth::after {
transform: rotate(45deg) scale(0.8); left: 8rpx;
border-radius: 6rpx; 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 { .device-action__label {
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 {
color: #ffffff; color: #ffffff;
font-size: 28rpx; font-size: 28rpx;
font-weight: 600; font-weight: 600;
letter-spacing: 1rpx; 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; position: relative;
z-index: 1; z-index: 1;
margin-top: 22rpx; margin-top: 22rpx;
padding: 22rpx 24rpx 24rpx; padding: 22rpx 24rpx 24rpx;
border-radius: 22rpx; border-radius: 22rpx;
background: #fbefc7; background: #fbefc7;
color: #977244;
box-shadow: 0 12rpx 24rpx rgba(6, 10, 22, 0.14); box-shadow: 0 12rpx 24rpx rgba(6, 10, 22, 0.14);
} }
.notice-card__title-row { .device-notice-card__title-row {
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 10rpx; margin-bottom: 10rpx;
} }
.notice-card__speaker { .device-notice-card__horn {
position: relative; position: relative;
width: 20rpx; width: 20rpx;
height: 16rpx; height: 16rpx;
@@ -189,13 +318,13 @@
background: #ff9d57; background: #ff9d57;
} }
.notice-card__speaker::before, .device-notice-card__horn::before,
.notice-card__speaker::after { .device-notice-card__horn::after {
position: absolute; position: absolute;
content: ""; content: "";
} }
.notice-card__speaker::before { .device-notice-card__horn::before {
right: -8rpx; right: -8rpx;
top: 2rpx; top: 2rpx;
width: 0; width: 0;
@@ -205,7 +334,7 @@
border-left: 8rpx solid #ff9d57; border-left: 8rpx solid #ff9d57;
} }
.notice-card__speaker::after { .device-notice-card__horn::after {
right: -16rpx; right: -16rpx;
top: 2rpx; top: 2rpx;
width: 8rpx; width: 8rpx;
@@ -216,25 +345,25 @@
transform: rotate(45deg); transform: rotate(45deg);
} }
.notice-card__title { .device-notice-card__title {
color: #af7e42;
font-size: 22rpx; font-size: 22rpx;
font-weight: 600; font-weight: 600;
color: #af7e42;
} }
.notice-list { .device-notice-card__list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6rpx; gap: 6rpx;
} }
.notice-item { .device-notice-card__item {
color: #9f7a4c;
font-size: 22rpx; font-size: 22rpx;
line-height: 1.7; line-height: 1.7;
color: #9f7a4c;
} }
.tab-bar { .device-tabbar {
position: fixed; position: fixed;
left: 0; left: 0;
right: 0; right: 0;
@@ -244,11 +373,11 @@
align-items: flex-start; align-items: flex-start;
justify-content: space-around; justify-content: space-around;
padding: 16rpx 24rpx calc(20rpx + env(safe-area-inset-bottom)); 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); box-shadow: 0 -8rpx 24rpx rgba(4, 8, 20, 0.34);
} }
.tab-item { .device-tabbar__item {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -256,7 +385,7 @@
min-width: 88rpx; min-width: 88rpx;
} }
.tab-item__icon { .device-tabbar__icon {
position: relative; position: relative;
width: 34rpx; width: 34rpx;
height: 34rpx; height: 34rpx;
@@ -266,13 +395,13 @@
opacity: 0.86; opacity: 0.86;
} }
.tab-item__icon::before, .device-tabbar__icon::before,
.tab-item__icon::after { .device-tabbar__icon::after {
position: absolute; position: absolute;
content: ""; content: "";
} }
.tab-item__icon::before { .device-tabbar__icon::before {
left: 8rpx; left: 8rpx;
right: 8rpx; right: 8rpx;
top: 10rpx; top: 10rpx;
@@ -281,7 +410,7 @@
color: #8f97a9; color: #8f97a9;
} }
.tab-item__icon::after { .device-tabbar__icon::after {
left: 8rpx; left: 8rpx;
right: 8rpx; right: 8rpx;
bottom: 10rpx; bottom: 10rpx;
@@ -290,17 +419,17 @@
color: #8f97a9; color: #8f97a9;
} }
.tab-item__icon--active { .device-tabbar__icon--active {
border-color: #36e4aa; border-color: #36e4aa;
background: rgba(54, 228, 170, 0.12); background: rgba(54, 228, 170, 0.12);
} }
.tab-item__icon--active::before, .device-tabbar__icon--active::before,
.tab-item__icon--active::after { .device-tabbar__icon--active::after {
color: #36e4aa; color: #36e4aa;
} }
.tab-item__badge { .device-tabbar__badge {
position: absolute; position: absolute;
top: -4rpx; top: -4rpx;
right: -4rpx; right: -4rpx;
@@ -308,15 +437,15 @@
height: 10rpx; height: 10rpx;
border-radius: 50%; border-radius: 50%;
background: #ff4d4f; 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 { .device-tabbar__label {
font-size: 20rpx;
color: #b0b6c4; color: #b0b6c4;
font-size: 20rpx;
line-height: 1.2; line-height: 1.2;
} }
.tab-item__label--active { .device-tabbar__label--active {
color: #36e4aa; color: #36e4aa;
} }

View File

@@ -1,95 +1,346 @@
import { Text, View } from "@tarojs/components"; import { Text, View } from "@tarojs/components";
import Taro from "@tarojs/taro"; import Taro from "@tarojs/taro";
import { useEffect, useRef, useState } from "react";
import "./index.scss"; import "./index.scss";
const quickActions = [ type BluetoothStatus = "idle" | "searching" | "empty" | "success";
{
key: "scan", type DeviceCandidate = {
icon: "scan", id: string;
label: "扫一扫 添加如新设备", name: string;
toast: "后续可以在这里接入扫码添加设备" source: "ble" | "mock";
}, };
{
key: "tag",
icon: "service",
label: "暂无捐赠所得的设备",
toast: "后续可以在这里展示捐赠设备"
}
];
const notices = [ const notices = [
"1. 传感器是否上电成功,控制盒三绿灯状态。", "1. 请确保设备已开机",
"2. 对APP进行蓝牙和位置定位服务授权。", "2. 请开启蓝牙与定位权限",
"3. 若使用扫一扫功能,请对摄像头进行授权。" "3. 扫码功能需开启相机权限"
]; ];
const navItems = [ const navItems = [
{ key: "home", label: "首页", active: true }, { key: "home", label: "首页", active: true },
{ key: "report", label: "报告", active: false }, { key: "report", label: "报告", active: false },
{ key: "service", label: "小e", active: false }, { key: "assistant", label: "小e", active: false },
{ key: "mall", label: "商城", active: false, badge: true }, { key: "message", label: "消息", active: false, badge: true },
{ key: "mine", label: "我的", active: false } { 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() { 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({ Taro.showToast({
title, title,
icon: "none" icon: "none"
}); });
}; };
return ( const bindDevice = async (name: string, sourceLabel: string) => {
<View className="home-page"> setStatusHint(`正在连接 ${name}`);
<View className="hero-bg" /> showToast("设备配对连接中");
<View className="home-header"> await new Promise((resolve) => {
<View className="login-pill" onClick={() => handleClick("登录功能待接入")}> 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>
<View className="device-header__add" onClick={handleAdd}>
+
</View>
</View> </View>
<View className="device-summary" onClick={() => handleClick("设备列表页待接入")}> <View className="device-summary" onClick={handleDeviceList}>
<View className="device-summary__content"> <View className="device-summary__title-wrap">
<Text className="device-summary__title"></Text> <Text className="device-summary__title"></Text>
<Text className="device-summary__count">0</Text> <Text className="device-summary__count">{deviceCount}</Text>
</View> </View>
<Text className="device-summary__arrow">&gt;</Text> <Text className="device-summary__arrow">&gt;</Text>
</View> </View>
<View className="action-card"> <View className="device-actions-card">
{quickActions.map((item) => ( <View className="device-action" onClick={handleScanBind}>
<View className="action-button" key={item.key} onClick={() => handleClick(item.toast)}> <View className="device-action__icon device-action__icon--scan" />
<View className={`action-button__icon action-button__icon--${item.key}`}> <Text className="device-action__label"> </Text>
<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> </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) => ( {notices.map((item) => (
<Text className="notice-item" key={item}> <Text className="device-notice-card__item" key={item}>
{item} {item}
</Text> </Text>
))} ))}
</View> </View>
</View> </View>
<View className="tab-bar"> <View className="device-tabbar">
{navItems.map((item) => ( {navItems.map((item) => (
<View className="tab-item" key={item.key} onClick={() => handleClick(`${item.label}功能待接入`)}> <View className="device-tabbar__item" key={item.key} onClick={() => handleTabClick(item.label)}>
<View className={`tab-item__icon ${item.active ? "tab-item__icon--active" : ""}`}> <View className={`device-tabbar__icon ${item.active ? "device-tabbar__icon--active" : ""}`}>
{item.badge ? <View className="tab-item__badge" /> : null} {item.badge ? <View className="device-tabbar__badge" /> : null}
</View> </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>
))} ))}
</View> </View>