diff --git a/README.md b/README.md index f0617c8..f58a044 100644 --- a/README.md +++ b/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. 上传、提交审核和发布 diff --git a/docs/superpowers/plans/2026-05-07-device-binding-home.md b/docs/superpowers/plans/2026-05-07-device-binding-home.md new file mode 100644 index 0000000..ad802c7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-device-binding-home.md @@ -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` +- [ ] 确认构建通过且没有新增依赖问题 diff --git a/docs/superpowers/specs/2026-05-07-device-binding-home-design.md b/docs/superpowers/specs/2026-05-07-device-binding-home-design.md new file mode 100644 index 0000000..096160a --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-device-binding-home-design.md @@ -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. 点击设备项后执行绑定成功流程 + +异常处理: + +- 未开启定位或授权失败:提示开启定位权限 +- 蓝牙不可用:提示开启系统蓝牙 +- 未发现设备:展示空状态提示 + +## 实现边界 + +- 只修改首页和文档 +- 不新增全局状态管理 +- 不新增依赖 +- 不删除任何目录或批量删除任何文件 diff --git a/src/pages/index/index.scss b/src/pages/index/index.scss index 9e92554..92ba0f8 100644 --- a/src/pages/index/index.scss +++ b/src/pages/index/index.scss @@ -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; } diff --git a/src/pages/index/index.tsx b/src/pages/index/index.tsx index b7ebf73..76f5c6c 100644 --- a/src/pages/index/index.tsx +++ b/src/pages/index/index.tsx @@ -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 = { + 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("idle"); + const [bluetoothDevices, setBluetoothDevices] = useState([]); + const [recentDeviceName, setRecentDeviceName] = useState(""); + const [statusHint, setStatusHint] = useState("可通过扫码或蓝牙搜索完成设备绑定。"); + const discoveryTimerRef = useRef | 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 ( - - + const bindDevice = async (name: string, sourceLabel: string) => { + setStatusHint(`正在连接 ${name}`); + showToast("设备配对连接中"); - - 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(); + + 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 ( + + + + + + 登录 + + + + + - handleClick("设备列表页待接入")}> - + + 已关联体征监测设备 - 0 + {deviceCount} > - - {quickActions.map((item) => ( - handleClick(item.toast)}> - - - - {item.label} - - ))} - - - - - - 尊敬的用户您好!绑定前请注意以下几点。 + + + + 扫码 添加新设备 - + + + 蓝牙搜附近的设备 + + + + + + 绑定进度 + + {bluetoothStatus === "searching" ? "搜索中" : bluetoothStatus === "empty" ? "无设备" : bluetoothStatus === "success" ? "成功" : "待开始"} + + + + {statusHint} + {bluetoothStateText[bluetoothStatus]} + + {bluetoothDevices.length > 0 ? ( + + {bluetoothDevices.map((item) => ( + handleDeviceSelect(item)}> + + {item.name} + + {item.source === "ble" ? "附近蓝牙设备" : "开发占位设备"} + + + 绑定 + + ))} + + ) : null} + + {recentDeviceName ? ( + + 最近绑定 + {recentDeviceName} + + ) : null} + + + + + + 绑定前请注意以下几点: + + + {notices.map((item) => ( - + {item} ))} - + {navItems.map((item) => ( - handleClick(`${item.label}功能待接入`)}> - - {item.badge ? : null} + handleTabClick(item.label)}> + + {item.badge ? : null} - {item.label} + {item.label} ))}