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)
+ )}
+
+
+ )
+}