From 84942851cb071a05a771fbe594b4e6b72d78f33d Mon Sep 17 00:00:00 2001 From: ozh Date: Wed, 9 Sep 2026 10:33:16 +0800 Subject: [PATCH] =?UTF-8?q?chore(migration):=20=E6=8F=90=E4=BA=A4=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E6=94=B6=E5=B0=BE=E9=81=97=E7=95=99=E2=80=94=E2=80=94?= =?UTF-8?q?token=20=E5=8C=96=E8=A1=A5=E5=85=A8=E3=80=81popup/tabs=20?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=88=A0=E9=99=A4=E3=80=81BLE=20=E4=B8=8E?= =?UTF-8?q?=E9=85=8D=E7=BD=91=E9=A1=B5=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + config/index.ts | 7 + src/ble/discovery.d.ts | 36 +++- src/ble/discovery.js | 6 +- src/ble/hooks/useBle.ts | 85 +++++--- src/components/empty/index.module.scss | 2 +- src/components/loading/index.module.scss | 8 +- src/components/navbar/index.module.scss | 15 +- src/components/popup/index.module.scss | 50 ----- src/components/popup/index.tsx | 38 ---- src/components/tabs/index.module.scss | 36 ---- src/components/tabs/index.tsx | 29 --- src/constants/deviceActions.ts | 28 +++ src/pages/device/add.module.scss | 187 +++++++++++++---- src/pages/device/add.tsx | 155 ++++++++++---- src/pages/device/config.module.scss | 133 +++++++++--- src/pages/device/config.tsx | 213 ++++++++++++++++---- src/pages/device/detail.module.scss | 52 +++-- src/pages/event/calendar-drawer.module.scss | 44 ++-- src/pages/event/index.module.scss | 38 ++-- src/pages/login/index.module.scss | 8 +- src/pages/mine/index.module.scss | 25 ++- src/pages/scene/select.module.scss | 19 +- src/styles/reset.scss | 4 +- src/utils/error.ts | 12 ++ 25 files changed, 792 insertions(+), 439 deletions(-) delete mode 100644 src/components/popup/index.module.scss delete mode 100644 src/components/popup/index.tsx delete mode 100644 src/components/tabs/index.module.scss delete mode 100644 src/components/tabs/index.tsx create mode 100644 src/constants/deviceActions.ts create mode 100644 src/utils/error.ts diff --git a/.gitignore b/.gitignore index 7c2ac24..bc7bb5b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ graphify-out/ # Project-specific docs/superpowers/ +.superpowers/ diff --git a/config/index.ts b/config/index.ts index b0d5f72..28d3d7d 100644 --- a/config/index.ts +++ b/config/index.ts @@ -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' + } } } } diff --git a/src/ble/discovery.d.ts b/src/ble/discovery.d.ts index 0135367..a601802 100644 --- a/src/ble/discovery.d.ts +++ b/src/ble/discovery.d.ts @@ -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 & { deviceId: string }>) => void onProfileChange?: (profileId: string) => void onStageChange?: (stage: string) => void onDeviceInfoChange?: (deviceInfo: Record) => 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> + deviceList: Array & { deviceId: string }> +} + +export interface WifiConfigResult { + success: boolean + stateCode: number +} + export interface DiscoveryInstance { startSearch: () => Promise - stopSearch: () => Promise - connectDevice: (deviceId: string) => Promise - prepareWifiConfig: (deviceId: string, options?: Record) => Promise + stopSearch: (showStopToast?: boolean) => Promise + connectDevice: (deviceId?: string) => Promise + prepareWifiConfig: (deviceId: string, options?: Record) => Promise disconnectDevice: () => Promise - configureWifi: (ssid: string, password: string, options?: Record) => Promise - startRadar: () => Promise - stopRadar: () => Promise + configureWifi: (ssid: string, password: string, options?: Record) => Promise + readDeviceInfo: () => Promise + startRadar: () => Promise + stopRadar: () => Promise + setNarrowMode: (enabled: boolean) => Promise + writeFallParams: (rawParams: unknown) => Promise + sendRadarCommand: (commandByte: number) => Promise cleanupDiscovery: () => void - [key: string]: unknown + getState: () => DiscoveryState } export declare function createBluetoothDiscovery(options?: DiscoveryOptions): DiscoveryInstance diff --git a/src/ble/discovery.js b/src/ble/discovery.js index 0914de3..56d595d 100644 --- a/src/ble/discovery.js +++ b/src/ble/discovery.js @@ -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) { diff --git a/src/ble/hooks/useBle.ts b/src/ble/hooks/useBle.ts index 2f57560..d9e0c4e 100644 --- a/src/ble/hooks/useBle.ts +++ b/src/ble/hooks/useBle.ts @@ -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 & { 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 | null authResult: unknown } -export function useBle() { - const [state, setState] = useState({ +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 | 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) => { + 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(() => ({ ...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 } } diff --git a/src/components/empty/index.module.scss b/src/components/empty/index.module.scss index d229d1b..f34903b 100644 --- a/src/components/empty/index.module.scss +++ b/src/components/empty/index.module.scss @@ -13,5 +13,5 @@ .text { font-size: 28px; - color: #9ea1a6; + color: $color-text-secondary; } diff --git a/src/components/loading/index.module.scss b/src/components/loading/index.module.scss index 216a408..c82a2e6 100644 --- a/src/components/loading/index.module.scss +++ b/src/components/loading/index.module.scss @@ -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 { diff --git a/src/components/navbar/index.module.scss b/src/components/navbar/index.module.scss index 121b768..6c95b62 100644 --- a/src/components/navbar/index.module.scss +++ b/src/components/navbar/index.module.scss @@ -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; } diff --git a/src/components/popup/index.module.scss b/src/components/popup/index.module.scss deleted file mode 100644 index 04f926a..0000000 --- a/src/components/popup/index.module.scss +++ /dev/null @@ -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; -} diff --git a/src/components/popup/index.tsx b/src/components/popup/index.tsx deleted file mode 100644 index 0318288..0000000 --- a/src/components/popup/index.tsx +++ /dev/null @@ -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 ( - <> - - - {children} - - × - - - - ) -} diff --git a/src/components/tabs/index.module.scss b/src/components/tabs/index.module.scss deleted file mode 100644 index 51fbd00..0000000 --- a/src/components/tabs/index.module.scss +++ /dev/null @@ -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; -} diff --git a/src/components/tabs/index.tsx b/src/components/tabs/index.tsx deleted file mode 100644 index 752add8..0000000 --- a/src/components/tabs/index.tsx +++ /dev/null @@ -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 ( - - {items.map((item) => ( - onChange(item.key)} - > - {item.label} - - ))} - - ) -} diff --git a/src/constants/deviceActions.ts b/src/constants/deviceActions.ts new file mode 100644 index 0000000..2bce82c --- /dev/null +++ b/src/constants/deviceActions.ts @@ -0,0 +1,28 @@ +import type { BleDevice } from '@/ble/hooks/useBle' + +// 设备型号 → 启用的按钮 key 列表。 +// 后续如需让其他型号裁剪按钮,只需新增映射条目。 +export const DEVICE_ACTION_KEYS: Record = 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) + +export function isEp832Device(device: BleDevice): boolean { + const name = String(device?.showName || device?.name || device?.localName || '').toUpperCase() + return name.includes('EP832') +} diff --git a/src/pages/device/add.module.scss b/src/pages/device/add.module.scss index 6fccc68..6ebd9cc 100644 --- a/src/pages/device/add.module.scss +++ b/src/pages/device/add.module.scss @@ -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; } diff --git a/src/pages/device/add.tsx b/src/pages/device/add.tsx index b862823..415833b 100644 --- a/src/pages/device/add.tsx +++ b/src/pages/device/add.tsx @@ -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 ( + + + + + {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 ? ( - - 开始扫描 - + + + {state.searching ? '搜索中' : '搜索'} + + + + + *手机需开启蓝牙功能,Android手机还需开启定位功能 + + + + 搜索到的设备列表 + + + + {state.deviceList.length === 0 ? ( + 暂无搜索结果 ) : ( - <> - discovery.stopSearch()}> - 停止扫描 - - {state.deviceList.length === 0 ? ( - - ) : ( - - {state.deviceList.map((d) => ( - handleSelect(d.deviceId)} - > - {d.name || d.deviceId} - - {connectingId === d.deviceId ? '连接中...' : `RSSI ${d.RSSI ?? '-'}`} - - - ))} - - )} - + state.deviceList.map(renderDevice) )} diff --git a/src/pages/device/config.module.scss b/src/pages/device/config.module.scss index 890c090..dce57da 100644 --- a/src/pages/device/config.module.scss +++ b/src/pages/device/config.module.scss @@ -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; } diff --git a/src/pages/device/config.tsx b/src/pages/device/config.tsx index 2e21858..73a7c30 100644 --- a/src/pages/device/config.tsx +++ b/src/pages/device/config.tsx @@ -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 = 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 ( - - - WiFi 名称 + + + + 设备 + {selectedDeviceName} + + + + + *WiFi名称 + setSsid(e.detail.value)} /> - WiFi 密码 + !fetchingCurrentWifi && fetchCurrentWifiSsid()} + > + {fetchingCurrentWifi ? '获取中...' : '一键获取当前连接WiFi'} + + + + + + *WiFi密码 + setPassword(e.detail.value)} /> - - 当前连接:{state.connected ? '已连接' : '未连接'} - {state.deviceInfo && state.profileId !== 'UNKNOWN' ? `(${state.profileId})` : ''} - - - - {submitting ? '配置中...' : '开始配网'} - + + + + 一键导入上次 WiFi 配置 + + + {errorTip ? ( + + {errorTip} + ) : null} + + !sending && confirmWifiSettings()} + > + {sending ? '发送中...' : '发送'} ) diff --git a/src/pages/device/detail.module.scss b/src/pages/device/detail.module.scss index e481eb8..2e4b301 100644 --- a/src/pages/device/detail.module.scss +++ b/src/pages/device/detail.module.scss @@ -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; } diff --git a/src/pages/event/calendar-drawer.module.scss b/src/pages/event/calendar-drawer.module.scss index dc7c524..6eaba0b 100644 --- a/src/pages/event/calendar-drawer.module.scss +++ b/src/pages/event/calendar-drawer.module.scss @@ -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; diff --git a/src/pages/event/index.module.scss b/src/pages/event/index.module.scss index 5084a5b..9c14066 100644 --- a/src/pages/event/index.module.scss +++ b/src/pages/event/index.module.scss @@ -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; } diff --git a/src/pages/login/index.module.scss b/src/pages/login/index.module.scss index 22bec5e..00bd251 100644 --- a/src/pages/login/index.module.scss +++ b/src/pages/login/index.module.scss @@ -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; diff --git a/src/pages/mine/index.module.scss b/src/pages/mine/index.module.scss index e6e3fe2..9629afe 100644 --- a/src/pages/mine/index.module.scss +++ b/src/pages/mine/index.module.scss @@ -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; } diff --git a/src/pages/scene/select.module.scss b/src/pages/scene/select.module.scss index a6c7a59..92ca186 100644 --- a/src/pages/scene/select.module.scss +++ b/src/pages/scene/select.module.scss @@ -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; } diff --git a/src/styles/reset.scss b/src/styles/reset.scss index e1c1299..732f7a0 100644 --- a/src/styles/reset.scss +++ b/src/styles/reset.scss @@ -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 { diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000..40aa5ba --- /dev/null +++ b/src/utils/error.ts @@ -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 || '操作失败,请重试' +}