chore(migration): 提交迁移收尾遗留——token 化补全、popup/tabs 组件删除、BLE 与配网页调整

This commit is contained in:
ozh 2026-09-09 10:33:16 +08:00
parent 0ca714bec5
commit 84942851cb
25 changed files with 792 additions and 439 deletions

1
.gitignore vendored
View File

@ -25,3 +25,4 @@ graphify-out/
# Project-specific
docs/superpowers/
.superpowers/

View File

@ -31,6 +31,13 @@ export default defineConfig(async (merge) => {
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
},
// css-loader v7 默认 exportLocalsConvention='as-is',kebab-case 类名
// (如 device-row)无法以 styles.deviceRow 访问,必须开启 camelCase 转换
cssLoaderOption: {
modules: {
exportLocalsConvention: 'camelCaseOnly'
}
}
}
}

View File

@ -1,9 +1,9 @@
// discovery.js(原 hooks/useBluetoothDiscovery.js)为保留的纯 JS 资产,
// 不做类型化;此处只声明 useBle 需要的最小工厂签名。
// 不做类型化;此处只声明 useBle 与页面需要用到的最小工厂签名。
export interface DiscoveryOptions {
onSearchingChange?: (searching: boolean) => void
onConnectedChange?: (connected: boolean) => void
onDeviceListChange?: (deviceList: Array<{ deviceId: string; name?: string; RSSI?: number }>) => void
onDeviceListChange?: (deviceList: Array<Record<string, unknown> & { deviceId: string }>) => void
onProfileChange?: (profileId: string) => void
onStageChange?: (stage: string) => void
onDeviceInfoChange?: (deviceInfo: Record<string, unknown>) => void
@ -12,17 +12,35 @@ export interface DiscoveryOptions {
onRadarData?: (data: unknown) => void
}
export interface DiscoveryState {
isSearching: boolean
isConnected: boolean
currentDeviceId: string
currentProfileId: string
deviceMap: Record<string, Record<string, unknown>>
deviceList: Array<Record<string, unknown> & { deviceId: string }>
}
export interface WifiConfigResult {
success: boolean
stateCode: number
}
export interface DiscoveryInstance {
startSearch: () => Promise<void>
stopSearch: () => Promise<void>
connectDevice: (deviceId: string) => Promise<void>
prepareWifiConfig: (deviceId: string, options?: Record<string, unknown>) => Promise<void>
stopSearch: (showStopToast?: boolean) => Promise<void>
connectDevice: (deviceId?: string) => Promise<unknown>
prepareWifiConfig: (deviceId: string, options?: Record<string, unknown>) => Promise<unknown>
disconnectDevice: () => Promise<void>
configureWifi: (ssid: string, password: string, options?: Record<string, unknown>) => Promise<void>
startRadar: () => Promise<void>
stopRadar: () => Promise<void>
configureWifi: (ssid: string, password: string, options?: Record<string, unknown>) => Promise<WifiConfigResult>
readDeviceInfo: () => Promise<unknown>
startRadar: () => Promise<unknown>
stopRadar: () => Promise<unknown>
setNarrowMode: (enabled: boolean) => Promise<unknown>
writeFallParams: (rawParams: unknown) => Promise<unknown>
sendRadarCommand: (commandByte: number) => Promise<unknown>
cleanupDiscovery: () => void
[key: string]: unknown
getState: () => DiscoveryState
}
export declare function createBluetoothDiscovery(options?: DiscoveryOptions): DiscoveryInstance

View File

@ -17,9 +17,9 @@ const DISCOVERY_POLL_INTERVAL_MS = 1200
const SCAN_DURATION_MS = 30000
const TARGET_NAME_KEYWORDS = Object.freeze(['ED713', '713WQ', 'ED719', 'EP832'])
function promisifyUniApi(apiName, params = {}) {
function promisifyTaroApi(apiName, params = {}) {
return new Promise((resolve, reject) => {
uni[apiName]({
Taro[apiName]({
...params,
success: (res) => resolve(res),
fail: (err) => reject(err)
@ -545,7 +545,7 @@ export function createBluetoothDiscovery(options = {}) {
}
try {
const result = await promisifyUniApi('getBluetoothDevices')
const result = await promisifyTaroApi('getBluetoothDevices')
const list = Array.isArray(result?.devices) ? result.devices : []
list.forEach((device) => {
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {

View File

@ -1,19 +1,36 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { createBluetoothDiscovery } from '../discovery'
import type { DiscoveryInstance, DiscoveryOptions } from '../discovery'
// 搜索结果设备项:discovery.upsertDevice 合入 showName/匹配标记后的产物
export type BleDevice = Record<string, unknown> & { deviceId: string }
// 工厂回调 → React state 的映射产物
export interface BleUiState {
searching: boolean
connected: boolean
deviceList: Array<{ deviceId: string; name?: string; RSSI?: number }>
deviceList: BleDevice[]
profileId: string
stage: string
deviceInfo: Record<string, unknown> | null
authResult: unknown
}
export function useBle() {
const [state, setState] = useState<BleUiState>({
interface BleSingleton {
discovery: DiscoveryInstance
state: BleUiState
listeners: Set<(s: BleUiState) => void>
}
// 模块级单例:add 页连接后 config 页必须共享同一 discovery 实例,
// 否则跨页后 connected=false、characteristics 丢失,配网流程断裂
let singleton: BleSingleton | null = null
function getSingleton(): BleSingleton {
if (singleton) return singleton
const listeners = new Set<(s: BleUiState) => void>()
const state: BleUiState = {
searching: false,
connected: false,
deviceList: [],
@ -21,34 +38,42 @@ export function useBle() {
stage: 'IDLE',
deviceInfo: null,
authResult: null
})
// discovery 实例在整个组件生命周期内只创建一次;
// 回调里读最新 state 用 setState 函数式更新,不闭包旧值
const discoveryRef = useRef<ReturnType<typeof createBluetoothDiscovery> | null>(null)
if (discoveryRef.current === null) {
discoveryRef.current = createBluetoothDiscovery({
onSearchingChange: (searching) => setState((s) => ({ ...s, searching })),
onConnectedChange: (connected) => setState((s) => ({ ...s, connected })),
onDeviceListChange: (deviceList) => setState((s) => ({ ...s, deviceList })),
onProfileChange: (profileId) => setState((s) => ({ ...s, profileId })),
onStageChange: (stage) => setState((s) => ({ ...s, stage })),
onDeviceInfoChange: (deviceInfo) => setState((s) => ({ ...s, deviceInfo })),
onAuthChange: (authResult) => setState((s) => ({ ...s, authResult }))
// onError / onRadarData / onProtocolState / onProtocolMsg 按页面需要接入
})
}
// 卸载时统一清理(停扫描、断连接、解绑事件)
useEffect(() => {
const discovery = discoveryRef.current
return () => {
discovery?.cleanupDiscovery()
discoveryRef.current = null
}
}, [])
const patch = (partial: Partial<BleUiState>) => {
Object.assign(state, partial)
listeners.forEach((fn) => fn({ ...state }))
}
const discovery = discoveryRef.current!
const discovery = createBluetoothDiscovery({
onSearchingChange: (searching) => patch({ searching }),
onConnectedChange: (connected) => patch({ connected }),
onDeviceListChange: (deviceList) => patch({ deviceList }),
onProfileChange: (profileId) => patch({ profileId }),
onStageChange: (stage) => patch({ stage }),
onDeviceInfoChange: (deviceInfo) => patch({ deviceInfo }),
onAuthChange: (authResult) => patch({ authResult })
// onError / onRadarData / onProtocolState / onProtocolMsg 按页面需要接入
} as DiscoveryOptions)
return { state, discovery }
singleton = { discovery, state, listeners }
return singleton
}
export function useBle() {
const { discovery, listeners, state } = getSingleton()
const [snapshot, setSnapshot] = useState<BleUiState>(() => ({ ...state }))
useEffect(() => {
const listener = (next: BleUiState) => setSnapshot(next)
listeners.add(listener)
// 挂载时若单例状态已更新过(如另一页已连接),立即对齐
setSnapshot({ ...state })
return () => {
listeners.delete(listener)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listeners])
return { state: snapshot, discovery }
}

View File

@ -13,5 +13,5 @@
.text {
font-size: 28px;
color: #9ea1a6;
color: $color-text-secondary;
}

View File

@ -6,16 +6,16 @@
}
.spinner {
border: 4px solid #eef0f4;
border-top-color: #2ccb98;
border-radius: 50%;
border: 4px solid $color-bg-disabled;
border-top-color: $color-primary;
border-radius: $radius-circle;
animation: spin 0.8s linear infinite;
}
.text {
margin-top: 20px;
font-size: 28px;
color: #9ea1a6;
color: $color-text-secondary;
}
@keyframes spin {

View File

@ -1,4 +1,5 @@
.navbar {
position: relative;
display: flex;
align-items: center;
height: 88px;
@ -13,20 +14,26 @@
align-items: center;
justify-content: center;
margin-left: -16px;
z-index: 1;
}
.backIcon {
font-size: 48px;
color: #2a2d33;
color: $color-text;
font-weight: 300;
line-height: 1;
}
// 绝对定位居中:与 back 按钮是否存在无关,免手动平衡宽度
.title {
flex: 1;
position: absolute;
left: 120px;
right: 120px;
text-align: center;
font-size: 32px;
font-weight: 600;
color: #2a2d33;
margin-right: 44px; // 平衡 back 按钮宽度
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -1,50 +0,0 @@
.mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 100;
}
.panel {
position: fixed;
background: #ffffff;
z-index: 101;
overflow-y: auto;
}
.pos-top {
top: 0;
left: 0;
right: 0;
border-bottom-left-radius: 24px;
border-bottom-right-radius: 24px;
}
.pos-center {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 24px;
min-width: 80vw;
}
.pos-bottom {
bottom: 0;
left: 0;
right: 0;
border-top-left-radius: 24px;
border-top-right-radius: 24px;
}
.close {
position: absolute;
top: 16px;
right: 16px;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
color: #9ea1a6;
}

View File

@ -1,38 +0,0 @@
import { View, Text } from '@tarojs/components'
import type { ReactNode } from 'react'
import styles from './index.module.scss'
interface Props {
visible: boolean
position?: 'top' | 'center' | 'bottom'
onClose: () => void
children?: ReactNode
maxHeight?: string // e.g. '60vh'
}
export function Popup({
visible,
position = 'bottom',
onClose,
children,
maxHeight = '60vh'
}: Props) {
if (!visible) return null
const positionClass = styles[`pos-${position}`]
return (
<>
<View className={styles.mask} onClick={onClose} />
<View
className={`${styles.panel} ${positionClass}`}
style={{ maxHeight }}
>
{children}
<View className={styles.close} onClick={onClose}>
<Text>×</Text>
</View>
</View>
</>
)
}

View File

@ -1,36 +0,0 @@
.tabs {
display: flex;
background: #ffffff;
border-bottom: 1px solid #f0f1f3;
}
.tab {
flex: 1;
padding: 28px 0;
display: flex;
align-items: center;
justify-content: center;
}
.label {
font-size: 30px;
color: #9ea1a6;
}
.active .label {
color: #2a2d33;
font-weight: 600;
position: relative;
}
.active .label::after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 56px;
height: 4px;
background: #2ccb98;
border-radius: 2px;
}

View File

@ -1,29 +0,0 @@
import { View, Text } from '@tarojs/components'
import styles from './index.module.scss'
export interface TabItem {
key: string
label: string
}
interface Props {
items: TabItem[]
activeKey: string
onChange: (key: string) => void
}
export function Tabs({ items, activeKey, onChange }: Props) {
return (
<View className={styles.tabs}>
{items.map((item) => (
<View
key={item.key}
className={`${styles.tab} ${item.key === activeKey ? styles.active : ''}`}
onClick={() => onChange(item.key)}
>
<Text className={styles.label}>{item.label}</Text>
</View>
))}
</View>
)
}

View File

@ -0,0 +1,28 @@
import type { BleDevice } from '@/ble/hooks/useBle'
// 设备型号 → 启用的按钮 key 列表。
// 后续如需让其他型号裁剪按钮,只需新增映射条目。
export const DEVICE_ACTION_KEYS: Record<string, readonly string[]> = Object.freeze({
EP832: ['WIFI'],
DEFAULT: ['WIFI', 'PARAMS', 'SERVER']
})
// 按钮注册表:label / 忙态文案。
// 未配置 handler 的 key 渲染为不可点击的占位项。
export const ACTION_REGISTRY = Object.freeze({
WIFI: {
label: '网络配置',
busyLabel: '连接中'
},
PARAMS: {
label: '参数配置'
},
SERVER: {
label: '服务器配置'
}
} as Record<string, { label: string; busyLabel?: string }>)
export function isEp832Device(device: BleDevice): boolean {
const name = String(device?.showName || device?.name || device?.localName || '').toUpperCase()
return name.includes('EP832')
}

View File

@ -1,55 +1,168 @@
.page { min-height: 100vh; background: #f1f1f4; }
.body { padding: 32px; }
.tip {
font-size: 28px;
color: #9ea1a6;
margin-bottom: 32px;
text-align: center;
.page {
min-height: 100vh;
background: #e9eaed;
}
.scanBtn {
height: 96px;
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
border-radius: 48px;
.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;
margin-bottom: 32px;
transition: transform 0.2s ease;
}
.scanBtnText {
color: #ffffff;
font-size: 32px;
.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;
}
.empty { padding: 80px 0; }
.list { display: flex; flex-direction: column; }
.item {
background: #ffffff;
border-radius: 16px;
padding: 32px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.name {
.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;
color: #2a2d33;
font-weight: 600;
max-width: 70%;
}
.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;
white-space: nowrap;
}
.rssi {
font-size: 24px;
color: #9ea1a6;
.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;
}

View File

@ -1,75 +1,142 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import Taro from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { Navbar } from '@/components/navbar'
import { Loading } from '@/components/loading'
import { useBle } from '@/ble/hooks/useBle'
import { useToast } from '@/hooks/useToast'
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 './add.module.scss'
export default function DeviceAdd() {
const toast = useToast()
const { state, discovery } = useBle()
const [connectingId, setConnectingId] = useState('')
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 {
// startSearch 内部自动完成:Android 定位权限检查 + 适配器初始化 +
// 服务 UUID 过滤扫描(00F4/F400/FFF4)+ 30s 自动停止
await discovery.startSearch()
} catch {
toast.error('蓝牙搜索启动失败')
// startSearch 内部已 toast 具体错误码,此处不再重复提示
}
}
// 选中设备 → 连接(prepareWifiConfig:连接 → 发现服务特征 → 订阅 F301,厂家确认无需鉴权)
const handleSelect = async (deviceId: string) => {
setConnectingId(deviceId)
// 点击"网络配置":连接 → 发现服务特征 → 订阅 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 {
toast.error('连接失败,请重试')
} catch (error) {
prepError = error
} finally {
setConnectingId('')
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 (
<View key={device.deviceId} className={styles.deviceCard}>
<View className={styles.deviceIcon} />
<View className={styles.deviceMain}>
<View className={styles.deviceNameRow}>
<Text className={styles.deviceName}>{String(device.showName ?? device.deviceId)}</Text>
{device.hasTargetService ? (
<Text className={styles.deviceTag}>00F4</Text>
) : device.matchedByName || device.matchedByPayload ? (
<Text className={`${styles.deviceTag} ${styles.nameTag}`}>名称匹配</Text>
) : null}
</View>
<Text className={styles.deviceIdText}>{String(device.deviceId)}</Text>
<View className={styles.deviceActions}>
{actions.map((action) => {
const busy = isActionBusy(action.key, device.deviceId)
return (
<View
key={action.key}
className={`${styles.actionBtn} ${busy ? styles.disabled : ''}`}
onClick={() => !busy && invokeAction(action.key, device.deviceId)}
>
<Text>{busy ? action.busyLabel : action.label}</Text>
</View>
)
})}
</View>
</View>
</View>
)
}
return (
<View className={styles.page}>
<Navbar title="添加设备" />
<View className={styles.body}>
<Text className={styles.tip}>请确保设备处于配网模式,靠近手机</Text>
<Navbar title="设备配置" />
{!state.searching ? (
<View className={styles.scanBtn} onClick={startScan}>
<Text className={styles.scanBtnText}>开始扫描</Text>
</View>
<View className={styles.searchWrap}>
<View
className={`${styles.searchBtn} ${state.searching ? styles.isSearching : ''}`}
onClick={startScan}
>
<Text>{state.searching ? '搜索中' : '搜索'}</Text>
</View>
</View>
<View className={styles.tipRow}>
<Text>*手机需开启蓝牙功能,Android手机还需开启定位功能</Text>
</View>
<View className={styles.listHeader}>
<Text className={styles.listTitle}>搜索到的设备列表</Text>
</View>
<View className={styles.listBody}>
{state.deviceList.length === 0 ? (
<View className={styles.emptyRow}><Text>暂无搜索结果</Text></View>
) : (
<>
<View className={styles.scanBtn} onClick={() => discovery.stopSearch()}>
<Text className={styles.scanBtnText}>停止扫描</Text>
</View>
{state.deviceList.length === 0 ? (
<View className={styles.empty}><Loading text="搜索中..." /></View>
) : (
<View className={styles.list}>
{state.deviceList.map((d) => (
<View
key={d.deviceId}
className={styles.item}
onClick={() => handleSelect(d.deviceId)}
>
<Text className={styles.name}>{d.name || d.deviceId}</Text>
<Text className={styles.rssi}>
{connectingId === d.deviceId ? '连接中...' : `RSSI ${d.RSSI ?? '-'}`}
</Text>
</View>
))}
</View>
)}
</>
state.deviceList.map(renderDevice)
)}
</View>
</View>

View File

@ -1,44 +1,123 @@
.page { min-height: 100vh; background: #f1f1f4; }
.page {
min-height: 100vh;
background: $color-bg;
}
.body { padding: 32px; }
.device-row {
min-height: 74px;
display: flex;
align-items: center;
gap: 14px;
padding: 0 30px;
border-bottom: 1px solid #f4f4f4;
background: $color-bg-white;
margin-top: 20px;
}
.label {
.device-label {
font-size: 28px;
color: #2a2d33;
margin-bottom: 16px;
margin-top: 24px;
display: block;
color: #8a8f98;
}
.input {
height: 96px;
background: #ffffff;
border-radius: 16px;
padding: 0 32px;
font-size: 30px;
margin-bottom: 16px;
.device-value {
flex: 1;
min-width: 0;
font-size: 27px;
color: #4a4f56;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hint {
.wifi-row {
min-height: 102px;
border-bottom: 1px solid #f2f2f2;
display: flex;
align-items: center;
padding: 0 30px;
gap: 20px;
background: $color-bg-white;
}
.wifi-label {
width: 170px;
font-size: 34px;
color: #363b45;
}
.required {
color: #ff4f4f;
margin-right: 8px;
}
.wifi-input {
flex: 1;
min-width: 0;
height: 80px;
font-size: 32px;
color: $color-text;
}
.wifi-input-ssid {
max-width: 260px;
}
.current-btn {
flex-shrink: 0;
min-width: 190px;
height: 62px;
border: 1px solid $color-primary-light-border;
border-radius: $radius-sm;
color: $color-primary-deep;
background: #effcf5;
text-align: center;
font-size: 24px;
color: #9ea1a6;
margin-top: 16px;
}
.submit {
margin-top: 48px;
height: 96px;
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
border-radius: 48px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 12px;
}
.submitDisabled { opacity: 0.5; }
.current-btn.disabled {
border-color: #b8d8ca;
color: #9bbbae;
background: #f3f6f4;
}
.submitText {
.import-row {
min-height: 100px;
border-bottom: 1px solid #f2f2f2;
font-size: 34px;
color: $color-primary;
display: flex;
align-items: center;
justify-content: center;
background: $color-bg-white;
}
.error-tip {
min-height: 80px;
padding: 16px 30px;
font-size: 26px;
color: $color-danger-deep;
background: $color-danger-light-bg;
line-height: 1.5;
word-break: break-all;
}
.submit-btn {
height: 100px;
background: $color-primary;
color: #ffffff;
font-size: 32px;
font-size: 36px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
margin: 40px 24px 0;
border-radius: 12px;
}
.submit-btn.disabled {
background: #8edcae;
}

View File

@ -1,72 +1,205 @@
import { useState } from 'react'
import Taro from '@tarojs/taro'
import { View, Text, Input } from '@tarojs/components'
import { Navbar } from '@/components/navbar'
import { useToast } from '@/hooks/useToast'
import { useBle } from '@/ble/hooks/useBle'
import { showToast } from '@/utils/toast'
import { getErrorMessage } from '@/utils/error'
import { navigateBack } from '@/utils/navigation'
import { useBle } from '@/ble/hooks/useBle'
import styles from './config.module.scss'
const WIFI_HISTORY_STORAGE_KEY = 'SMARTHOME_LAST_WIFI_CONFIG'
const WIFI_STATE_MESSAGES: Record<number, string> = Object.freeze({
1: 'WiFi 参数校验失败,请检查名称与密码',
2: '设备连接该 WiFi 失败,请确认密码是否正确',
3: 'WiFi 配置成功',
4: '设备正在连接 WiFi,请稍候'
})
function getWifiStateMessage(stateCode: number) {
return WIFI_STATE_MESSAGES[stateCode] || `WiFi 状态码异常: ${stateCode}`
}
export default function DeviceConfig() {
const toast = useToast()
const { state, discovery } = useBle()
const [ssid, setSsid] = useState('')
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [sending, setSending] = useState(false)
const [fetchingCurrentWifi, setFetchingCurrentWifi] = useState(false)
const [errorTip, setErrorTip] = useState('')
const selectedDevice = state.deviceList.find((d) => d.deviceId === discovery.getState().currentDeviceId)
const selectedDeviceName = (selectedDevice?.showName as string)
|| (selectedDevice?.deviceId as string)
|| '未选择设备'
// 启动 WiFi 模块并读取手机当前连接的 SSID(部分端必须先 startWifi)
const fetchCurrentWifiSsid = async () => {
if (fetchingCurrentWifi) return
setFetchingCurrentWifi(true)
const handleSubmit = async () => {
if (!state.connected) {
toast.error('设备未连接,请返回上一步重新连接')
return
}
if (!ssid || !password) {
toast.error('请填写 WiFi 信息')
return
}
setSubmitting(true)
try {
// 内部走 A7 分包写入 UUID_WIFI 特征;profile.wifi 校验 SSID/密码长度与非法字符
await discovery.configureWifi(ssid, password)
toast.success('配网指令已发送')
navigateBack()
} catch (e) {
const msg = e instanceof Error ? e.message : '配置失败'
toast.error(msg)
try {
await Taro.startWifi({})
} catch {
// 已启动等非致命错误忽略,继续尝试读取当前连接
}
const result = await Taro.getConnectedWifi({})
const info = (result as unknown as { wifi?: { SSID?: string; ssid?: string } }).wifi
const name = String(info?.SSID || info?.ssid || '').trim()
if (!name || name.toLowerCase().includes('unknown')) {
throw new Error('未获取到当前连接 WiFi 名称')
}
setSsid(name)
showToast('已填入当前连接WiFi')
} catch (error) {
showToast(getErrorMessage(error))
} finally {
setSubmitting(false)
setFetchingCurrentWifi(false)
}
}
const importLastWifiConfig = () => {
try {
const cached = Taro.getStorageSync(WIFI_HISTORY_STORAGE_KEY) as
| { ssid?: string; password?: string }
| ''
if (!cached || typeof cached !== 'object') {
showToast('暂无上次 WiFi 配置')
return
}
const s = String(cached.ssid || '').trim()
const p = String(cached.password || '').trim()
if (!s || !p) {
showToast('暂无可用的历史 WiFi 配置')
return
}
setSsid(s)
setPassword(p)
showToast('已导入上次 WiFi 配置')
} catch {
showToast('读取历史 WiFi 配置失败')
}
}
const cacheWifiConfig = (s: string, p: string) => {
try {
Taro.setStorageSync(WIFI_HISTORY_STORAGE_KEY, { ssid: s, password: p, updatedAt: Date.now() })
} catch {
// 缓存失败不阻断配网结果
}
}
const confirmWifiSettings = async () => {
if (sending) return
if (!state.connected) {
showToast('设备未连接,请重新配置')
return
}
const s = String(ssid || '').trim()
const p = String(password || '').trim()
if (!s) {
showToast('请输入 WiFi 名称')
return
}
if (!p) {
showToast('请输入 WiFi 密码')
return
}
setSending(true)
setErrorTip('')
let needAutoClose = false
let tipMessage = ''
try {
// 发送后等待设备经 UUID_STATE 返回最终态 1/2/3,才提示并自动返回
const result = await discovery.configureWifi(s, p, { timeout: 60000 })
tipMessage = getWifiStateMessage(result.stateCode)
needAutoClose = result.success
if (result.success) cacheWifiConfig(s, p)
} catch (error) {
// 超时/异常:错误持久展示在弹层内,避免瞬态 toast 被覆盖看不清
tipMessage = getErrorMessage(error)
setErrorTip(tipMessage)
} finally {
setSending(false)
if (tipMessage) showToast(tipMessage)
if (needAutoClose) navigateBack()
}
}
return (
<View className={styles.page}>
<Navbar title="设备配置" />
<View className={styles.body}>
<Text className={styles.label}>WiFi 名称</Text>
<Navbar title="设备网络设置" />
<View className={styles.deviceRow}>
<Text className={styles.deviceLabel}>设备</Text>
<Text className={styles.deviceValue}>{selectedDeviceName}</Text>
</View>
<View className={styles.wifiRow}>
<Text className={styles.wifiLabel}>
<Text className={styles.required}>*</Text>WiFi名称
</Text>
<Input
className={styles.input}
className={styles.wifiInputSsid}
placeholder="请输入 WiFi 名称"
maxlength={32}
confirmType="done"
value={ssid}
onInput={(e) => setSsid(e.detail.value)}
/>
<Text className={styles.label}>WiFi 密码</Text>
<View
className={`${styles.currentBtn} ${fetchingCurrentWifi ? styles.disabled : ''}`}
onClick={() => !fetchingCurrentWifi && fetchCurrentWifiSsid()}
>
<Text>{fetchingCurrentWifi ? '获取中...' : '一键获取当前连接WiFi'}</Text>
</View>
</View>
<View className={styles.wifiRow}>
<Text className={styles.wifiLabel}>
<Text className={styles.required}>*</Text>WiFi密码
</Text>
<Input
className={styles.input}
placeholder="请输入 WiFi 密码(8-63 位)"
className={styles.wifiInput}
placeholder="请输入密码"
password
maxlength={63}
confirmType="done"
value={password}
onInput={(e) => setPassword(e.detail.value)}
/>
<Text className={styles.hint}>
当前连接:{state.connected ? '已连接' : '未连接'}
{state.deviceInfo && state.profileId !== 'UNKNOWN' ? `(${state.profileId})` : ''}
</Text>
<View
className={`${styles.submit} ${submitting ? styles.submitDisabled : ''}`}
onClick={submitting ? undefined : handleSubmit}
>
<Text className={styles.submitText}>
{submitting ? '配置中...' : '开始配网'}
</Text>
</View>
<View className={styles.importRow} onClick={importLastWifiConfig}>
<Text>一键导入上次 WiFi 配置</Text>
</View>
{errorTip ? (
<View className={styles.errorTip}>
<Text>{errorTip}</Text>
</View>
) : null}
<View
className={`${styles.submitBtn} ${sending ? styles.disabled : ''}`}
onClick={() => !sending && confirmWifiSettings()}
>
<Text>{sending ? '发送中...' : '发送'}</Text>
</View>
</View>
)

View File

@ -1,3 +1,8 @@
.page {
min-height: 100vh;
background: $color-bg;
}
.stateWrap {
margin-top: 120px;
display: flex;
@ -9,7 +14,7 @@
margin-top: 40px;
width: 240px;
height: 80px;
background: #2ccb98;
background: $color-primary;
border-radius: 40px;
display: flex;
align-items: center;
@ -20,8 +25,9 @@
.heartCard {
margin: 24px;
padding: 32px;
background: #ffffff;
border-radius: 24px;
background: $color-bg-white;
border-radius: $radius-lg;
box-shadow: $shadow-card;
display: flex;
align-items: flex-start;
}
@ -33,46 +39,46 @@
min-width: 200px;
}
.heartIcon { font-size: 72px; color: #f5222d; line-height: 1; }
.heartIcon { font-size: 72px; color: $color-danger; line-height: 1; }
.heartValue {
margin-top: 16px;
font-size: 96px;
font-weight: 700;
color: #2a2d33;
color: $color-text;
line-height: 1.1;
}
.heartUnit { margin-top: 8px; font-size: 30px; color: #9ea1a6; }
.heartUnit { margin-top: 8px; font-size: 30px; color: $color-text-secondary; }
.badge {
margin-top: 20px;
display: flex;
align-items: center;
padding: 8px 24px;
border-radius: 999px;
border-radius: $radius-pill;
font-size: 24px;
}
.badgeOn { background: #f6ffed; color: #52c41a; }
.badgeOff { background: #f5f5f5; color: #999999; }
.badgeOn { background: $color-success-light-bg; color: $color-success; }
.badgeOff { background: $color-bg-disabled; color: $color-text-secondary; }
.badgeDot {
width: 14px;
height: 14px;
border-radius: 50%;
border-radius: $radius-circle;
margin-right: 10px;
}
.badgeDotOn { background: #52c41a; }
.badgeDotOff { background: #999999; }
.badgeDotOn { background: $color-success; }
.badgeDotOff { background: $color-text-secondary; }
.heartRight { flex: 1; min-width: 0; margin-left: 32px; }
.heartHealthLabel {
font-size: 30px;
font-weight: 500;
color: #2a2d33;
color: $color-text;
margin-bottom: 8px;
display: block;
}
@ -89,11 +95,12 @@
box-sizing: border-box;
margin-bottom: 20px;
padding: 24px;
background: #ffffff;
border-radius: 16px;
background: $color-bg-white;
border-radius: $radius-md;
box-shadow: $shadow-card;
}
.metricLabel { font-size: 26px; color: #9ea1a6; }
.metricLabel { font-size: 26px; color: $color-text-secondary; }
.metricRow {
margin-top: 12px;
@ -106,14 +113,15 @@
.metricUnit {
margin-left: 8px;
font-size: 24px;
color: #b5b9c0;
color: $color-text-tertiary;
}
.infoCard {
margin: 4px 24px 24px;
padding: 8px 32px;
background: #ffffff;
border-radius: 24px;
background: $color-bg-white;
border-radius: $radius-lg;
box-shadow: $shadow-card;
}
.infoRow {
@ -121,10 +129,10 @@
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f1f3;
border-bottom: 1px solid $color-border;
}
.infoRow:last-child { border-bottom: none; }
.infoLabel { font-size: 28px; color: #9ea1a6; }
.infoValue { font-size: 28px; color: #2a2d33; }
.infoLabel { font-size: 28px; color: $color-text-secondary; }
.infoValue { font-size: 28px; color: $color-text; }

View File

@ -5,13 +5,13 @@
width: 100%;
height: 66vh;
max-height: 100%;
background: #ffffff;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
background: $color-bg-white;
box-shadow: $shadow-popup;
z-index: 11;
display: flex;
flex-direction: column;
border-bottom-left-radius: 24px;
border-bottom-right-radius: 24px;
border-bottom-left-radius: $radius-lg;
border-bottom-right-radius: $radius-lg;
overflow: hidden;
}
@ -20,14 +20,14 @@
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f1f3;
border-bottom: 1px solid $color-border;
flex-shrink: 0;
}
.title {
font-size: 30px;
font-weight: 600;
color: #2a2d33;
color: $color-text;
}
.close {
@ -56,9 +56,9 @@
display: flex;
align-items: center;
justify-content: center;
background: #f5f6f8;
background: $color-bg-gray;
border: 1px solid transparent;
border-radius: 999px;
border-radius: $radius-pill;
font-size: 24px;
color: #606266;
@ -66,16 +66,16 @@
}
.quick-chip-active {
background: #e9fff3;
border-color: #2ccb98;
color: #12a37a;
background: $color-primary-light-bg;
border-color: $color-primary;
color: $color-primary-deep;
font-weight: 600;
}
.weekdays {
display: flex;
padding: 0 16px;
border-bottom: 1px solid #f0f1f3;
border-bottom: 1px solid $color-border;
flex-shrink: 0;
}
@ -103,7 +103,7 @@
justify-content: center;
font-size: 28px;
font-weight: 600;
color: #2a2d33;
color: $color-text;
}
.grid {
@ -132,7 +132,7 @@
}
.day-start,
.day-end { background: #2ccb98; }
.day-end { background: $color-primary; }
.day-start .day-num,
.day-end .day-num {
@ -141,35 +141,35 @@
}
.day-in {
background: #e9fff3;
background: $color-primary-light-bg;
border-radius: 0;
}
.day-in .day-num { color: #12a37a; }
.day-in .day-num { color: $color-primary-deep; }
.day-num {
font-size: 28px;
font-weight: 500;
color: #2a2d33;
color: $color-text;
line-height: 40px;
}
.day-tag {
font-size: 18px;
line-height: 24px;
font-size: 20px;
line-height: 26px;
color: rgba(255, 255, 255, 0.9);
}
.footer {
flex-shrink: 0;
padding: 16px 24px calc(16px + env(safe-area-inset-bottom));
border-top: 1px solid #f0f1f3;
background: #ffffff;
border-top: 1px solid $color-border;
background: $color-bg-white;
}
.confirm-btn {
height: 80px;
background: #2ccb98;
background: $color-primary;
border-radius: 40px;
display: flex;
align-items: center;

View File

@ -11,8 +11,8 @@
align-items: stretch;
min-height: 96px;
padding: 16px 24px;
background: #ffffff;
border-top: 1px solid #ededed;
background: $color-bg-white;
border-top: 1px solid $color-border;
}
.filter-cell {
@ -29,7 +29,7 @@
display: flex;
align-items: center;
justify-content: space-between;
background: #f5f6f8;
background: $color-bg-gray;
border: 1px solid transparent;
border-radius: 12px;
color: #606266;
@ -37,9 +37,9 @@
}
.filter-trigger-active {
background: #e9fff3;
border-color: #2ccb98;
color: #12a37a;
background: $color-primary-light-bg;
border-color: $color-primary;
color: $color-primary-deep;
}
.filter-text {
@ -65,7 +65,7 @@
margin-top: -40px;
text-align: center;
font-size: 24px;
color: #b5b9c0;
color: $color-text-tertiary;
}
/* 数据层:三抽屉的定位锚点 */
@ -78,7 +78,7 @@
.content-mask {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.35);
background: $color-mask;
z-index: 10;
}
@ -87,13 +87,13 @@
top: 0;
left: 0;
width: 100%;
background: #ffffff;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
background: $color-bg-white;
box-shadow: $shadow-popup;
z-index: 11;
display: flex;
flex-direction: column;
border-bottom-left-radius: 24px;
border-bottom-right-radius: 24px;
border-bottom-left-radius: $radius-lg;
border-bottom-right-radius: $radius-lg;
overflow: hidden;
max-height: 60vh;
}
@ -103,13 +103,13 @@
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f1f3;
border-bottom: 1px solid $color-border;
}
.drawer-title {
font-size: 32px;
font-weight: 600;
color: #2a2d33;
color: $color-text;
}
.drawer-close {
@ -137,22 +137,22 @@
justify-content: space-between;
border-radius: 14px;
&:active { background: #f7f8fa; }
&:active { background: $color-bg-press; }
}
.drawer-item-active { background: #e9fff3; }
.drawer-item-active { background: $color-primary-light-bg; }
.drawer-item-text {
font-size: 30px;
color: #2a2d33;
color: $color-text;
}
.drawer-item-active .drawer-item-text {
color: #2ccb98;
color: $color-primary;
font-weight: 600;
}
.check-icon {
font-size: 32px;
color: #2ccb98;
color: $color-primary;
}

View File

@ -7,7 +7,7 @@
.title {
font-size: 60px;
font-weight: 700;
color: #2a2d33;
color: $color-text;
text-align: center;
margin-bottom: 80px;
}
@ -16,8 +16,8 @@
.input {
height: 100px;
background: #ffffff;
border-radius: 16px;
background: $color-bg-white;
border-radius: $radius-md;
padding: 0 32px;
margin-bottom: 24px;
font-size: 30px;
@ -25,7 +25,7 @@
.submit {
height: 96px;
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
background: $color-primary-grad;
border-radius: 48px;
display: flex;
align-items: center;

View File

@ -1,6 +1,11 @@
.page {
min-height: 100vh;
background: $color-bg;
}
.header {
padding: 80px 32px;
background: #ffffff;
background: $color-bg-white;
display: flex;
align-items: center;
}
@ -8,8 +13,8 @@
.avatar {
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
border-radius: $radius-circle;
background: $color-primary-grad;
color: #ffffff;
font-size: 48px;
font-weight: 600;
@ -17,8 +22,6 @@
align-items: center;
justify-content: center;
margin-right: 32px;
text-align: center;
line-height: 120px;
}
.headerInfo { display: flex; flex-direction: column; }
@ -26,13 +29,13 @@
.name {
font-size: 36px;
font-weight: 600;
color: #2a2d33;
color: $color-text;
}
.uid {
margin-top: 8px;
font-size: 24px;
color: #9ea1a6;
color: $color-text-secondary;
}
.menu { margin-top: 20px; }
@ -40,16 +43,16 @@
.item {
height: 104px;
padding: 0 32px;
background: #ffffff;
border-bottom: 1px solid #f0f1f3;
background: $color-bg-white;
border-bottom: 1px solid $color-border;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 30px;
color: #2a2d33;
color: $color-text;
}
.arrow {
font-size: 36px;
color: #c0c4cc;
color: $color-text-placeholder;
}

View File

@ -1,3 +1,8 @@
.page {
min-height: 100vh;
background: $color-bg;
}
.stateWrap {
margin-top: 120px;
display: flex;
@ -9,7 +14,7 @@
margin-top: 40px;
width: 240px;
height: 80px;
background: #2ccb98;
background: $color-primary;
border-radius: 40px;
display: flex;
align-items: center;
@ -23,25 +28,25 @@
height: 104px;
padding: 0 32px;
margin-bottom: 2px;
background: #ffffff;
background: $color-bg-white;
display: flex;
align-items: center;
justify-content: space-between;
}
.itemActive { background: #e9fff3; }
.itemActive { background: $color-primary-light-bg; }
.name {
font-size: 32px;
color: #222;
color: $color-text;
font-weight: 600;
}
.itemActive .name { color: #26c996; }
.itemActive .name { color: $color-primary; }
.check {
font-size: 32px;
color: #c0c4cc;
color: $color-text-placeholder;
}
.itemActive .check { color: #26c996; }
.itemActive .check { color: $color-primary; }

View File

@ -1,10 +1,10 @@
@import './variables.scss';
// designWidth=750 下 1px=1rpx,正文默认字号取 28rpx(=14物理px)
page {
background-color: $color-bg;
color: $color-text;
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif;
font-size: 28px;
}
view, text {

12
src/utils/error.ts Normal file
View File

@ -0,0 +1,12 @@
export function getErrorMessage(error: unknown): string {
if (!error) {
return '操作失败,请重试'
}
if (typeof error === 'string') {
return error
}
const err = error as { message?: string; errMsg?: string }
return err.message || err.errMsg || '操作失败,请重试'
}