diff --git a/src/app.config.ts b/src/app.config.ts index 3298b6f..2e718c3 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -8,6 +8,7 @@ export default { 'pages/device/detail', 'pages/device/add', 'pages/device/config', + 'pages/device/ble-config', 'pages/family/index', 'pages/family/detail', 'pages/family/contact/add', diff --git a/src/constants/routes.ts b/src/constants/routes.ts index eaf9d93..d8e64b7 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -5,6 +5,7 @@ export const ROUTES = Object.freeze({ MINE: '/pages/mine/index', DEVICE_LIST: '/pages/device/index', DEVICE_ADD: '/pages/device/add', + DEVICE_BLE_CONFIG: '/pages/device/ble-config', DEVICE_CONFIG: '/pages/device/config', DEVICE_DETAIL: '/pages/device/detail', FAMILY: '/pages/family/index', diff --git a/src/pages/device/ble-config.module.scss b/src/pages/device/ble-config.module.scss new file mode 100644 index 0000000..6ebd9cc --- /dev/null +++ b/src/pages/device/ble-config.module.scss @@ -0,0 +1,168 @@ +.page { + min-height: 100vh; + background: #e9eaed; +} + +.search-wrap { + height: 500px; + background: $color-bg-white; + display: flex; + justify-content: center; + align-items: center; +} + +.search-btn { + width: 260px; + height: 260px; + border-radius: 130px; + background: $color-primary; + color: #ffffff; + font-size: 56px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease; +} + +.search-btn:active { + transform: scale(0.97); +} + +.search-btn.is-searching { + animation: searchPulse 1.2s ease-in-out infinite; +} + +@keyframes searchPulse { + 0% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(67, 201, 150, 0.42); + } + + 70% { + transform: scale(1.04); + box-shadow: 0 0 0 30px rgba(67, 201, 150, 0); + } + + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(67, 201, 150, 0); + } +} + +.tip-row { + min-height: 78px; + padding: 12px 16px; + background: #efeff1; + color: $color-danger; + font-size: 34px; + font-weight: 600; + display: flex; + align-items: center; +} + +.list-header { + height: 116px; + background: $color-bg-white; + display: flex; + align-items: center; + padding: 0 38px; +} + +.list-title { + font-size: 52px; + color: #101217; + font-weight: 700; +} + +.list-body { + background: #e9eaed; + padding-bottom: 30px; +} + +.empty-row { + padding: 36px; + text-align: center; + color: #8c9097; + font-size: 30px; +} + +.device-card { + background: $color-bg-white; + margin-top: 12px; + padding: 24px; + display: flex; + gap: 20px; +} + +.device-icon { + width: 92px; + height: 92px; + border-radius: 46px; + border: 2px solid #d8d8d8; + box-shadow: inset 0 0 0 10px #f1f1f1; + flex-shrink: 0; +} + +.device-main { + flex: 1; + min-width: 0; +} + +.device-name-row { + display: flex; + align-items: center; + gap: 12px; +} + +.device-name { + flex: 1; + min-width: 0; + font-size: 38px; + color: #30343a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.device-tag { + font-size: 22px; + color: $color-primary-deep; + border: 1px solid $color-primary-deep; + border-radius: 20px; + padding: 2px 10px; +} + +.name-tag { + color: $color-info-light; + border-color: $color-info-light; +} + +.device-id-text { + display: block; + margin-top: 8px; + font-size: 30px; + color: #8a8f98; + word-break: break-all; +} + +.device-actions { + display: flex; + gap: 10px; + margin-top: 16px; +} + +.action-btn { + padding: 8px 16px; + border: 2px solid $color-primary; + color: $color-primary-deep; + font-size: 30px; + border-radius: 6px; + background: #f8fffc; +} + +.action-btn.disabled { + border-color: $color-primary-light-border; + color: #7db89e; + background: #f5faf7; +} diff --git a/src/pages/device/ble-config.tsx b/src/pages/device/ble-config.tsx new file mode 100644 index 0000000..a134a25 --- /dev/null +++ b/src/pages/device/ble-config.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from 'react' +import Taro from '@tarojs/taro' +import { View, Text } from '@tarojs/components' +import { Navbar } from '@/components/navbar' +import { useBle } from '@/ble/hooks/useBle' +import { navigateTo } from '@/utils/navigation' +import { ROUTES } from '@/constants/routes' +import { getErrorMessage } from '@/utils/error' +import { DEVICE_ACTION_KEYS, ACTION_REGISTRY, isEp832Device } from '@/constants/deviceActions' +import type { BleDevice } from '@/ble/hooks/useBle' +import styles from './ble-config.module.scss' + +export default function DeviceBleConfig() { + const { state, discovery } = useBle() + const [preparingId, setPreparingId] = useState('') + + // 页面隐藏/卸载时清理搜索(对齐旧版 onHide/onUnload),连接保持供配网页使用 + useEffect(() => { + return () => { + if (discovery.getState().isSearching) { + discovery.stopSearch(false) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const startScan = async () => { + if (state.searching) { + await discovery.stopSearch() + return + } + // startSearch 内部自动完成:Android 定位权限检查 + 适配器初始化 + + // 服务 UUID 过滤扫描(00F4/F400/FFF4)+ 4s 无结果兜底全扫 + 30s 自动停止 + try { + await discovery.startSearch() + } catch { + // startSearch 内部已 toast 具体错误码,此处不再重复提示 + } + } + + // 点击"网络配置":连接 → 发现服务特征 → 订阅 F301(厂家确认无需鉴权)→ 打开配网页 + const openWifiPanel = async (deviceId: string) => { + if (preparingId) return + setPreparingId(deviceId) + Taro.showLoading({ title: '连接设备中', mask: true }) + + let prepError: unknown = null + try { + await discovery.prepareWifiConfig(deviceId) + navigateTo(ROUTES.DEVICE_CONFIG) + } catch (error) { + prepError = error + } finally { + setPreparingId('') + // showLoading 与 showToast 共用同一遮罩层,必须先 hideLoading 再弹提示 + Taro.hideLoading() + } + + if (prepError) { + // 连接/订阅失败用模态弹窗,需用户主动确认,确保错误诊断信息可见可读 + Taro.showModal({ + title: '设备连接失败', + content: getErrorMessage(prepError), + showCancel: false, + confirmText: '知道了' + }) + } + } + + const invokeAction = (key: string, deviceId: string) => { + if (key === 'WIFI') openWifiPanel(deviceId) + // PARAMS/SERVER 仅占位,后续接入 + } + + const isActionBusy = (key: string, deviceId: string) => + key === 'WIFI' && preparingId === deviceId + + const renderDevice = (device: BleDevice) => { + const actions = (DEVICE_ACTION_KEYS[isEp832Device(device) ? 'EP832' : 'DEFAULT'] ?? DEVICE_ACTION_KEYS.DEFAULT) + .map((key) => ({ key, ...ACTION_REGISTRY[key] })) + + return ( + + + + + {String(device.showName ?? device.deviceId)} + {device.hasTargetService ? ( + 00F4 + ) : device.matchedByName || device.matchedByPayload ? ( + 名称匹配 + ) : null} + + {String(device.deviceId)} + + {actions.map((action) => { + const busy = isActionBusy(action.key, device.deviceId) + return ( + !busy && invokeAction(action.key, device.deviceId)} + > + {busy ? action.busyLabel : action.label} + + ) + })} + + + + ) + } + + return ( + + + + + + {state.searching ? '搜索中' : '搜索'} + + + + + *手机需开启蓝牙功能,Android手机还需开启定位功能 + + + + 搜索到的设备列表 + + + + {state.deviceList.length === 0 ? ( + 暂无搜索结果 + ) : ( + state.deviceList.map(renderDevice) + )} + + + ) +}