update:调整文件夹

This commit is contained in:
hrz
2025-08-11 15:48:28 +08:00
parent 6e8f9ca22e
commit 8509e62114
138 changed files with 36 additions and 93 deletions
@@ -0,0 +1,684 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useToast } from 'wot-design-uni'
// 类型定义
interface WiFiNetwork {
ssid: string
rssi: number
authmode: number
channel: number
}
// Props
interface Props {
selectedNetwork: WiFiNetwork | null
password: string
}
const props = defineProps<Props>()
// Toast 实例
const toast = useToast()
// 响应式数据
const generating = ref(false)
const playing = ref(false)
const audioGenerated = ref(false)
const autoLoop = ref(true)
const audioFilePath = ref('')
const audioContext = ref<any>(null)
// AFSK调制参数 - 参考HTML文件
const MARK = 1800 // 二进制1的频率 (Hz)
const SPACE = 1500 // 二进制0的频率 (Hz)
const SAMPLE_RATE = 44100 // 采样率
const BIT_RATE = 100 // 比特率 (bps)
const START_BYTES = [0x01, 0x02] // 起始标记
const END_BYTES = [0x03, 0x04] // 结束标记
// 计算属性
const canGenerate = computed(() => {
if (!props.selectedNetwork)
return false
if (props.selectedNetwork.authmode > 0 && !props.password)
return false
return true
})
const audioLengthText = computed(() => {
if (!props.selectedNetwork)
return '0秒'
const dataStr = `${props.selectedNetwork.ssid}\n${props.password}`
const textBytes = stringToBytes(dataStr)
const totalBits = (START_BYTES.length + textBytes.length + 1 + END_BYTES.length) * 8
const duration = Math.ceil(totalBits / BIT_RATE)
return `${duration}`
})
// 字符串转字节数组 - uniapp兼容版本
function stringToBytes(str: string): number[] {
const bytes: number[] = []
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i)
if (code < 0x80) {
bytes.push(code)
}
else if (code < 0x800) {
bytes.push(0xC0 | (code >> 6))
bytes.push(0x80 | (code & 0x3F))
}
else if (code < 0xD800 || code >= 0xE000) {
bytes.push(0xE0 | (code >> 12))
bytes.push(0x80 | ((code >> 6) & 0x3F))
bytes.push(0x80 | (code & 0x3F))
}
else {
// 代理对处理
i++
const hi = code
const lo = str.charCodeAt(i)
const codePoint = 0x10000 + (((hi & 0x3FF) << 10) | (lo & 0x3FF))
bytes.push(0xF0 | (codePoint >> 18))
bytes.push(0x80 | ((codePoint >> 12) & 0x3F))
bytes.push(0x80 | ((codePoint >> 6) & 0x3F))
bytes.push(0x80 | (codePoint & 0x3F))
}
}
return bytes
}
// 校验和计算 - 参考HTML文件
function checksum(data: number[]): number {
return data.reduce((sum, b) => (sum + b) & 0xFF, 0)
}
// 字节转比特位 - 参考HTML文件
function toBits(byte: number): number[] {
const bits: number[] = []
for (let i = 7; i >= 0; i--) {
bits.push((byte >> i) & 1)
}
return bits
}
// AFSK调制 - 参考HTML文件算法
function afskModulate(bits: number[]): Float32Array {
const samplesPerBit = SAMPLE_RATE / BIT_RATE
const totalSamples = Math.floor(bits.length * samplesPerBit)
const buffer = new Float32Array(totalSamples)
for (let i = 0; i < bits.length; i++) {
const freq = bits[i] ? MARK : SPACE
for (let j = 0; j < samplesPerBit; j++) {
const t = (i * samplesPerBit + j) / SAMPLE_RATE
buffer[i * samplesPerBit + j] = Math.sin(2 * Math.PI * freq * t)
}
}
return buffer
}
// 浮点转16位PCM - 参考HTML文件
function floatTo16BitPCM(floatSamples: Float32Array): Uint8Array {
const buffer = new Uint8Array(floatSamples.length * 2)
for (let i = 0; i < floatSamples.length; i++) {
const s = Math.max(-1, Math.min(1, floatSamples[i]))
const val = s < 0 ? s * 0x8000 : s * 0x7FFF
buffer[i * 2] = val & 0xFF
buffer[i * 2 + 1] = (val >> 8) & 0xFF
}
return buffer
}
// base64编码表
const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
// 兼容的base64编码实现
function base64Encode(bytes: Uint8Array): string {
let result = ''
let i = 0
while (i < bytes.length) {
const a = bytes[i++]
const b = i < bytes.length ? bytes[i++] : 0
const c = i < bytes.length ? bytes[i++] : 0
const bitmap = (a << 16) | (b << 8) | c
result += base64Chars.charAt((bitmap >> 18) & 63)
result += base64Chars.charAt((bitmap >> 12) & 63)
result += i - 2 < bytes.length ? base64Chars.charAt((bitmap >> 6) & 63) : '='
result += i - 1 < bytes.length ? base64Chars.charAt(bitmap & 63) : '='
}
return result
}
// 数组转base64编码 - 兼容版本
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
// 尝试使用原生btoa,如果不存在则使用自定义实现
if (typeof btoa !== 'undefined') {
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
else {
return base64Encode(bytes)
}
}
// 构建WAV文件 - 返回ArrayBuffer而不是Blob
function buildWav(pcm: Uint8Array): ArrayBuffer {
const wavHeader = new Uint8Array(44)
const dataLen = pcm.length
const fileLen = 36 + dataLen
const writeStr = (offset: number, str: string) => {
for (let i = 0; i < str.length; i++) {
wavHeader[offset + i] = str.charCodeAt(i)
}
}
const write32 = (offset: number, value: number) => {
wavHeader[offset] = value & 0xFF
wavHeader[offset + 1] = (value >> 8) & 0xFF
wavHeader[offset + 2] = (value >> 16) & 0xFF
wavHeader[offset + 3] = (value >> 24) & 0xFF
}
const write16 = (offset: number, value: number) => {
wavHeader[offset] = value & 0xFF
wavHeader[offset + 1] = (value >> 8) & 0xFF
}
writeStr(0, 'RIFF')
write32(4, fileLen)
writeStr(8, 'WAVE')
writeStr(12, 'fmt ')
write32(16, 16)
write16(20, 1)
write16(22, 1)
write32(24, SAMPLE_RATE)
write32(28, SAMPLE_RATE * 2)
write16(32, 2)
write16(34, 16)
writeStr(36, 'data')
write32(40, dataLen)
// 合并header和数据
const result = new ArrayBuffer(44 + dataLen)
const resultView = new Uint8Array(result)
resultView.set(wavHeader)
resultView.set(pcm, 44)
return result
}
// 生成并播放声波 - 主要功能函数
async function generateAndPlay() {
if (!canGenerate.value || !props.selectedNetwork)
return
generating.value = true
try {
console.log('生成超声波配网音频...')
// 准备配网数据 - 参考HTML文件格式
const dataStr = `${props.selectedNetwork.ssid}\n${props.password}`
const textBytes = stringToBytes(dataStr)
const fullBytes = [...START_BYTES, ...textBytes, checksum(textBytes), ...END_BYTES]
console.log('配网数据:', { ssid: props.selectedNetwork.ssid, password: props.password })
console.log('数据字节长度:', textBytes.length)
// 转换为比特流
let bits: number[] = []
fullBytes.forEach((b) => {
bits = bits.concat(toBits(b))
})
console.log('比特流长度:', bits.length)
// AFSK调制 - 减少采样率降低文件大小
const reducedSampleRate = 22050 // 降低采样率
const samplesPerBit = reducedSampleRate / BIT_RATE
const totalSamples = Math.floor(bits.length * samplesPerBit)
const floatBuf = new Float32Array(totalSamples)
for (let i = 0; i < bits.length; i++) {
const freq = bits[i] ? MARK : SPACE
for (let j = 0; j < samplesPerBit; j++) {
const t = (i * samplesPerBit + j) / reducedSampleRate
floatBuf[i * samplesPerBit + j] = Math.sin(2 * Math.PI * freq * t) * 0.5 // 降低音量
}
}
const pcmBuf = floatTo16BitPCM(floatBuf)
// 生成WAV文件 - 使用降低的采样率
const wavBuffer = buildWavOptimized(pcmBuf, reducedSampleRate)
const base64 = arrayBufferToBase64(wavBuffer)
const dataUri = `data:audio/wav;base64,${base64}`
console.log('base64长度:', base64.length, '约', Math.round(base64.length / 1024), 'KB')
// 检查数据大小
if (base64.length > 1024 * 1024) { // 超过1MB
throw new Error('音频文件过大,请缩短SSID或密码长度')
}
audioFilePath.value = dataUri
audioGenerated.value = true
console.log('音频生成成功,比特流长度:', bits.length, '采样点数:', floatBuf.length)
toast.success('声波生成成功')
// 延迟播放
setTimeout(async () => {
await playAudio()
}, 800) // 增加延迟时间
}
catch (error) {
console.error('音频生成失败:', error)
toast.error(`声波生成失败: ${error.message || error}`)
}
finally {
generating.value = false
}
}
// 优化的WAV构建函数
function buildWavOptimized(pcm: Uint8Array, sampleRate: number): ArrayBuffer {
const wavHeader = new Uint8Array(44)
const dataLen = pcm.length
const fileLen = 36 + dataLen
const writeStr = (offset: number, str: string) => {
for (let i = 0; i < str.length; i++) {
wavHeader[offset + i] = str.charCodeAt(i)
}
}
const write32 = (offset: number, value: number) => {
wavHeader[offset] = value & 0xFF
wavHeader[offset + 1] = (value >> 8) & 0xFF
wavHeader[offset + 2] = (value >> 16) & 0xFF
wavHeader[offset + 3] = (value >> 24) & 0xFF
}
const write16 = (offset: number, value: number) => {
wavHeader[offset] = value & 0xFF
wavHeader[offset + 1] = (value >> 8) & 0xFF
}
writeStr(0, 'RIFF')
write32(4, fileLen)
writeStr(8, 'WAVE')
writeStr(12, 'fmt ')
write32(16, 16)
write16(20, 1)
write16(22, 1)
write32(24, sampleRate) // 使用传入的采样率
write32(28, sampleRate * 2)
write16(32, 2)
write16(34, 16)
writeStr(36, 'data')
write32(40, dataLen)
// 合并header和数据
const result = new ArrayBuffer(44 + dataLen)
const resultView = new Uint8Array(result)
resultView.set(wavHeader)
resultView.set(pcm, 44)
return result
}
// 播放音频
async function playAudio() {
if (!audioFilePath.value) {
toast.error('请先生成音频')
return
}
try {
// 强制清理所有旧的音频实例
await cleanupAudio()
// 等待一下确保清理完成
await new Promise(resolve => setTimeout(resolve, 200))
playing.value = true
console.log('开始播放超声波配网音频')
// 创建新的音频上下文
const innerAudioContext = uni.createInnerAudioContext()
audioContext.value = innerAudioContext
// 最简化的音频设置
innerAudioContext.src = audioFilePath.value
innerAudioContext.loop = autoLoop.value
innerAudioContext.volume = 0.8
innerAudioContext.autoplay = false
// 简化的事件监听
innerAudioContext.onPlay(() => {
console.log('超声波音频开始播放')
toast.success('开始播放配网声波')
})
innerAudioContext.onEnded(() => {
console.log('超声波音频播放结束')
if (!autoLoop.value) {
playing.value = false
cleanupAudio()
}
})
innerAudioContext.onError((error) => {
console.error('音频播放失败:', error)
playing.value = false
let errorMsg = '音频播放失败'
if (error.errCode === -99) {
errorMsg = '音频资源繁忙,请稍后重试'
}
else if (error.errCode === 10004) {
errorMsg = '音频格式不支持,可能是data URI问题'
}
else if (error.errCode === 10003) {
errorMsg = '音频文件错误'
}
toast.error(errorMsg)
cleanupAudio()
})
innerAudioContext.onStop(() => {
console.log('音频播放停止')
playing.value = false
})
// 延迟播放
setTimeout(() => {
if (audioContext.value) {
console.log('尝试播放音频,src长度:', audioFilePath.value.length)
audioContext.value.play()
}
}, 300)
}
catch (error) {
console.error('播放音频异常:', error)
playing.value = false
await cleanupAudio()
toast.error(`播放失败: ${error.message}`)
}
}
// 清理音频资源
async function cleanupAudio() {
if (audioContext.value) {
try {
audioContext.value.pause()
audioContext.value.destroy()
console.log('清理音频上下文')
}
catch (e) {
console.log('清理音频上下文失败:', e)
}
finally {
audioContext.value = null
}
}
}
// 停止播放
async function stopAudio() {
playing.value = false
await cleanupAudio()
console.log('停止播放超声波音频')
toast.success('已停止播放')
}
</script>
<template>
<view class="ultrasonic-config">
<!-- 选中的网络信息 -->
<view v-if="props.selectedNetwork" class="selected-network">
<view class="network-info">
<view class="network-name">
选中网络: {{ props.selectedNetwork.ssid }}
</view>
<view class="network-details">
<text class="network-signal">
信号: {{ props.selectedNetwork.rssi }}dBm
</text>
<text class="network-security">
{{ props.selectedNetwork.authmode === 0 ? '开放网络' : '加密网络' }}
</text>
</view>
<view v-if="props.password" class="network-password">
密码: {{ '*'.repeat(props.password.length) }}
</view>
</view>
</view>
<!-- 超声波配网操作 -->
<view class="submit-section">
<wd-button
type="primary"
size="large"
block
:loading="generating"
:disabled="!canGenerate"
@click="generateAndPlay"
>
{{ generating ? '生成中...' : '🎵 生成并播放声波' }}
</wd-button>
<wd-button
v-if="audioGenerated"
type="success"
size="large"
block
:loading="playing"
@click="playAudio"
>
{{ playing ? '播放中...' : '🔊 播放声波' }}
</wd-button>
<wd-button
v-if="playing"
type="warning"
size="large"
block
@click="stopAudio"
>
停止播放
</wd-button>
</view>
<!-- 音频控制选项 -->
<view v-if="audioGenerated" class="audio-options">
<view class="option-item">
<wd-checkbox v-model="autoLoop">
自动循环播放声波
</wd-checkbox>
</view>
</view>
<!-- 音频播放器 -->
<view v-if="audioGenerated" class="audio-player">
<view class="player-info">
<text class="audio-title">
配网音频文件
</text>
<text class="audio-duration">
时长: {{ audioLengthText }}
</text>
</view>
</view>
<!-- 使用说明 -->
<view class="help-section">
<view class="help-title">
超声波配网说明
</view>
<view class="help-content">
<text class="help-item">
1. 确保已选择WiFi网络并输入密码
</text>
<text class="help-item">
2. 点击生成并播放声波系统会将配网信息编码为音频
</text>
<text class="help-item">
3. 将手机靠近xiaozhi设备距离1-2
</text>
<text class="help-item">
4. 音频播放时xiaozhi会接收并解码配网信息
</text>
<text class="help-item">
5. 配网成功后设备会自动连接WiFi网络
</text>
<text class="help-tip">
使用AFSK调制技术通过1800Hz和1500Hz频率传输数据
</text>
<text class="help-tip">
请确保手机音量适中避免环境噪音干扰
</text>
</view>
</view>
</view>
</template>
<style scoped>
.ultrasonic-config {
padding: 20rpx 0;
}
.selected-network {
margin-bottom: 32rpx;
}
.network-info {
padding: 24rpx;
background-color: #f0f6ff;
border: 1rpx solid #336cff;
border-radius: 16rpx;
}
.network-name {
font-size: 28rpx;
font-weight: 600;
color: #232338;
margin-bottom: 8rpx;
}
.network-details {
display: flex;
gap: 24rpx;
margin-bottom: 8rpx;
}
.network-signal,
.network-security {
font-size: 24rpx;
color: #65686f;
}
.network-password {
font-size: 24rpx;
color: #65686f;
}
.submit-section {
margin-bottom: 32rpx;
}
.submit-section .wd-button {
margin-bottom: 16rpx;
}
.submit-section .wd-button:last-child {
margin-bottom: 0;
}
.audio-options {
margin-bottom: 32rpx;
padding: 24rpx;
background-color: #fbfbfb;
border-radius: 16rpx;
border: 1rpx solid #eeeeee;
}
.option-item {
font-size: 28rpx;
}
.audio-player {
margin-bottom: 32rpx;
padding: 24rpx;
background-color: #f0f6ff;
border: 1rpx solid #336cff;
border-radius: 16rpx;
}
.player-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.audio-title {
font-size: 28rpx;
font-weight: 600;
color: #232338;
}
.audio-duration {
font-size: 24rpx;
color: #65686f;
}
.help-section {
padding: 32rpx 24rpx;
background-color: #fbfbfb;
border-radius: 16rpx;
border: 1rpx solid #eeeeee;
}
.help-title {
font-size: 28rpx;
font-weight: 600;
color: #232338;
margin-bottom: 20rpx;
}
.help-content {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.help-item {
font-size: 24rpx;
color: #65686f;
line-height: 1.5;
}
.help-tip {
font-size: 24rpx;
color: #336cff;
font-weight: 500;
margin-top: 8rpx;
}
</style>
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useToast } from 'wot-design-uni'
// 类型定义
interface WiFiNetwork {
ssid: string
rssi: number
authmode: number
channel: number
}
// Props
interface Props {
selectedNetwork: WiFiNetwork | null
password: string
}
const props = defineProps<Props>()
// Toast 实例
const toast = useToast()
// 响应式数据
const configuring = ref(false)
// 计算属性
const canSubmit = computed(() => {
if (!props.selectedNetwork)
return false
if (props.selectedNetwork.authmode > 0 && !props.password)
return false
return true
})
// ESP32连接检查
async function checkESP32Connection() {
try {
const response = await uni.request({
url: 'http://192.168.4.1/scan',
method: 'GET',
timeout: 3000,
})
return response.statusCode === 200
}
catch (error) {
console.log('ESP32连接检查失败:', error)
return false
}
}
// 提交配网
async function submitConfig() {
if (!props.selectedNetwork)
return
// 检查ESP32连接
const connected = await checkESP32Connection()
if (!connected) {
toast.error('请先连接xiaozhi热点')
return
}
configuring.value = true
console.log('开始WiFi配网:', props.selectedNetwork.ssid)
try {
const response = await uni.request({
url: 'http://192.168.4.1/submit',
method: 'POST',
header: {
'Content-Type': 'application/json',
},
data: {
ssid: props.selectedNetwork.ssid,
password: props.selectedNetwork.authmode > 0 ? props.password : '',
},
timeout: 15000,
})
console.log('WiFi配网响应:', response)
if (response.statusCode === 200 && (response.data as any)?.success) {
toast.success(`配网成功!设备将连接到 ${props.selectedNetwork.ssid},设备会自动重启。请断开xiaozhi热点连接。`)
}
else {
const errorMsg = (response.data as any)?.error || '配网失败'
toast.error(errorMsg)
}
}
catch (error) {
console.error('WiFi配网失败:', error)
toast.error('配网失败,请检查网络连接')
}
finally {
configuring.value = false
}
}
</script>
<template>
<view class="wifi-config">
<!-- 选中的网络信息 -->
<view v-if="props.selectedNetwork" class="selected-network">
<view class="network-info">
<view class="network-name">
选中网络: {{ props.selectedNetwork.ssid }}
</view>
<view class="network-details">
<text class="network-signal">
信号: {{ props.selectedNetwork.rssi }}dBm
</text>
<text class="network-security">
{{ props.selectedNetwork.authmode === 0 ? '开放网络' : '加密网络' }}
</text>
</view>
</view>
</view>
<!-- 配网按钮 -->
<view class="submit-section">
<wd-button
type="primary"
size="large"
block
:loading="configuring"
:disabled="!canSubmit"
@click="submitConfig"
>
{{ configuring ? '配网中...' : '开始WiFi配网' }}
</wd-button>
</view>
<!-- 使用说明 -->
<view class="help-section">
<view class="help-title">
WiFi配网说明
</view>
<view class="help-content">
<text class="help-item">
1. 手机连接xiaozhi热点 (xiaozhi-XXXXXX)
</text>
<text class="help-item">
2. 选择要配网的目标WiFi网络
</text>
<text class="help-item">
3. 输入WiFi密码如果需要
</text>
<text class="help-item">
4. 点击开始配网等待设备连接
</text>
<text class="help-tip">
配网成功后设备会自动重启并连接目标WiFi
</text>
</view>
</view>
</view>
</template>
<style scoped>
.wifi-config {
padding: 20rpx 0;
}
.selected-network {
margin-bottom: 32rpx;
}
.network-info {
padding: 24rpx;
background-color: #f0f6ff;
border: 1rpx solid #336cff;
border-radius: 16rpx;
}
.network-name {
font-size: 28rpx;
font-weight: 600;
color: #232338;
margin-bottom: 8rpx;
}
.network-details {
display: flex;
gap: 24rpx;
}
.network-signal,
.network-security {
font-size: 24rpx;
color: #65686f;
}
.submit-section {
margin-bottom: 32rpx;
}
.help-section {
padding: 32rpx 24rpx;
background-color: #fbfbfb;
border-radius: 16rpx;
border: 1rpx solid #eeeeee;
}
.help-title {
font-size: 28rpx;
font-weight: 600;
color: #232338;
margin-bottom: 20rpx;
}
.help-content {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.help-item {
font-size: 24rpx;
color: #65686f;
line-height: 1.5;
}
.help-tip {
font-size: 24rpx;
color: #336cff;
font-weight: 500;
margin-top: 8rpx;
}
</style>
@@ -0,0 +1,556 @@
<script setup lang="ts">
import { computed, defineEmits, defineExpose, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
// 类型定义
interface WiFiNetwork {
ssid: string
rssi: number
authmode: number
channel: number
}
// Props
interface Props {
autoConnect?: boolean // 是否自动检测ESP32连接
}
const props = withDefaults(defineProps<Props>(), {
autoConnect: true,
})
// Emits
const emit = defineEmits<{
'network-selected': [network: WiFiNetwork | null, password: string]
'connection-status': [connected: boolean]
}>()
// Toast 实例
const toast = useToast()
// 响应式数据
const isConnectedToESP32 = ref(false)
const checkingConnection = ref(false)
const scanning = ref(false)
const wifiNetworks = ref<WiFiNetwork[]>([])
const selectedNetwork = ref<WiFiNetwork | null>(null)
const password = ref('')
const selectorExpanded = ref(false)
// 计算属性
const networkDisplayText = computed(() => {
if (!selectedNetwork.value)
return '请选择WiFi网络'
return selectedNetwork.value.ssid
})
// 检查xiaozhi连接状态
async function checkESP32Connection() {
checkingConnection.value = true
try {
const response = await uni.request({
url: 'http://192.168.4.1/scan',
method: 'GET',
timeout: 3000,
})
isConnectedToESP32.value = response.statusCode === 200
emit('connection-status', isConnectedToESP32.value)
console.log('xiaozhi连接状态:', isConnectedToESP32.value)
}
catch (error) {
isConnectedToESP32.value = false
emit('connection-status', false)
console.log('xiaozhi连接检查失败:', error)
}
finally {
checkingConnection.value = false
}
}
// 扫描WiFi网络
async function scanWifi() {
if (!isConnectedToESP32.value) {
toast.error('请先连接xiaozhi热点')
return
}
scanning.value = true
console.log('开始扫描WiFi网络')
try {
const response = await uni.request({
url: 'http://192.168.4.1/scan',
method: 'GET',
timeout: 10000,
})
console.log('WiFi扫描响应:', response)
if (response.statusCode === 200 && response.data) {
const data = response.data as any
if (data.success && Array.isArray(data.networks)) {
wifiNetworks.value = data.networks
console.log(`扫描成功,发现 ${data.networks.length} 个网络`)
}
else if (Array.isArray(response.data)) {
// 兼容旧格式
wifiNetworks.value = response.data.map((item: any) => ({
ssid: item.ssid,
rssi: item.rssi,
authmode: item.authmode,
channel: item.channel || 0,
}))
}
else {
throw new TypeError('扫描接口返回格式异常')
}
}
else {
throw new Error(`HTTP ${response.statusCode}`)
}
}
catch (error) {
console.error('WiFi扫描失败:', error)
toast.error('扫描失败,请检查xiaozhi连接')
}
finally {
scanning.value = false
}
}
// 显示网络选择器
async function showNetworkSelector() {
// 实时检测xiaozhi连接状态
await checkESP32Connection()
if (!isConnectedToESP32.value) {
toast.error('请先连接xiaozhi热点')
return
}
selectorExpanded.value = true
// 如果还没有网络列表,自动扫描
if (wifiNetworks.value.length === 0) {
scanWifi()
}
}
// 选择网络
function selectNetwork(network: WiFiNetwork) {
selectedNetwork.value = network
password.value = ''
selectorExpanded.value = false
console.log('选择网络:', network.ssid)
// 通知父组件
emit('network-selected', network, '')
}
// 密码变化时通知父组件
function onPasswordChange() {
emit('network-selected', selectedNetwork.value, password.value)
}
// 获取当前选择的网络和密码
function getSelectedNetworkInfo() {
return {
network: selectedNetwork.value,
password: password.value,
}
}
// 重置选择
function reset() {
selectedNetwork.value = null
password.value = ''
wifiNetworks.value = []
selectorExpanded.value = false
emit('network-selected', null, '')
}
// 获取信号强度描述
function getSignalStrength(rssi: number): string {
if (rssi >= -50)
return '信号强'
if (rssi >= -60)
return '信号良好'
if (rssi >= -70)
return '信号一般'
return '信号弱'
}
// 获取信号强度颜色
function getSignalColor(rssi: number): string {
if (rssi >= -50)
return '#52c41a'
if (rssi >= -60)
return '#73d13d'
if (rssi >= -70)
return '#faad14'
return '#ff4d4f'
}
// 暴露方法给父组件
defineExpose({
checkESP32Connection,
scanWifi,
getSelectedNetworkInfo,
reset,
})
// 生命周期
onMounted(() => {
if (props.autoConnect) {
checkESP32Connection()
}
})
</script>
<template>
<view class="wifi-selector">
<!-- Xiaozhi连接状态 -->
<view v-if="props.autoConnect" class="connection-status">
<view v-if="!isConnectedToESP32" class="status-warning">
<view class="status-content">
<text class="warning-text">
请先连接xiaozhi热点 (xiaozhi-XXXXXX)
</text>
<wd-button
size="small"
type="primary"
:loading="checkingConnection"
@click="checkESP32Connection"
>
{{ checkingConnection ? '检测中...' : '重新检测' }}
</wd-button>
</view>
</view>
<view v-else class="status-success">
<view class="status-content">
<text class="success-text">
已连接xiaozhi热点
</text>
<wd-button
size="small"
:loading="checkingConnection"
@click="checkESP32Connection"
>
{{ checkingConnection ? '检测中...' : '刷新状态' }}
</wd-button>
</view>
</view>
</view>
<!-- WiFi网络选择器 -->
<view class="network-selector">
<view class="selector-item" @click="showNetworkSelector">
<text class="selector-label">
WiFi网络
</text>
<text class="selector-value">
{{ networkDisplayText }}
</text>
<wd-icon name="arrow-right" custom-class="arrow-icon" />
</view>
</view>
<!-- 展开的网络列表 -->
<view v-if="selectorExpanded" class="network-list-overlay">
<view class="network-list-container">
<view class="list-header">
<text class="list-title">
选择WiFi网络
</text>
<view class="list-actions">
<wd-button
type="primary"
size="small"
:loading="scanning"
@click="scanWifi"
>
{{ scanning ? '扫描中...' : '刷新扫描' }}
</wd-button>
<wd-button
size="small"
@click="selectorExpanded = false"
>
取消
</wd-button>
</view>
</view>
<view class="network-list">
<view v-if="wifiNetworks.length === 0 && !scanning" class="empty-state">
<text class="empty-text">
暂无WiFi网络
</text>
<text class="empty-tip">
请点击刷新扫描
</text>
</view>
<view v-else class="wifi-list">
<view
v-for="network in wifiNetworks"
:key="network.ssid"
class="wifi-item"
@click="selectNetwork(network)"
>
<view class="wifi-info">
<view class="wifi-name">
{{ network.ssid }}
</view>
<view class="wifi-details">
<text class="wifi-signal">
信号: {{ network.rssi }}dBm
</text>
<text class="wifi-channel">
频道: {{ network.channel }}
</text>
</view>
</view>
<view class="wifi-security">
<text class="security-icon">
{{ network.authmode === 0 ? '开放' : '加密' }}
</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 密码输入 -->
<view v-if="selectedNetwork && selectedNetwork.authmode > 0" class="password-section">
<view class="password-item">
<text class="password-label">
网络密码
</text>
<wd-input
v-model="password"
placeholder="请输入WiFi密码"
show-password
clearable
@input="onPasswordChange"
/>
</view>
</view>
</view>
</template>
<style scoped>
.wifi-selector {
width: 100%;
}
.connection-status {
margin-bottom: 24rpx;
}
.status-warning {
padding: 24rpx;
background-color: #fff3cd;
border: 1rpx solid #ffeaa7;
border-radius: 16rpx;
}
.status-success {
padding: 24rpx;
background-color: #d4edda;
border: 1rpx solid #c3e6cb;
border-radius: 16rpx;
}
.status-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.warning-text {
color: #856404;
font-size: 28rpx;
}
.success-text {
color: #155724;
font-size: 28rpx;
}
.network-selector {
margin-bottom: 24rpx;
}
.selector-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx;
background: #f5f7fb;
border-radius: 12rpx;
border: 1rpx solid #eeeeee;
cursor: pointer;
transition: all 0.3s ease;
}
.selector-item:active {
background: #eef3ff;
border-color: #336cff;
}
.selector-label {
font-size: 28rpx;
color: #232338;
font-weight: 500;
}
.selector-value {
flex: 1;
text-align: right;
font-size: 26rpx;
color: #65686f;
margin: 0 16rpx;
}
:deep(.arrow-icon) {
font-size: 20rpx;
color: #9d9ea3;
}
.network-list-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: flex-end;
}
.network-list-container {
width: 100%;
max-height: 70vh;
background-color: #ffffff;
border-radius: 20rpx 20rpx 0 0;
padding: 32rpx;
box-sizing: border-box;
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #eeeeee;
}
.list-title {
font-size: 32rpx;
font-weight: 600;
color: #232338;
}
.list-actions {
display: flex;
gap: 16rpx;
}
.network-list {
max-height: 50vh;
overflow-y: auto;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 20rpx;
background-color: #fbfbfb;
border-radius: 16rpx;
border: 1rpx solid #eeeeee;
}
.empty-text {
font-size: 32rpx;
color: #65686f;
margin-bottom: 16rpx;
}
.empty-tip {
font-size: 24rpx;
color: #9d9ea3;
}
.wifi-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.wifi-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx 24rpx;
background-color: #fbfbfb;
border: 2rpx solid #eeeeee;
border-radius: 16rpx;
transition: all 0.3s ease;
cursor: pointer;
}
.wifi-item:active {
transform: scale(0.98);
background-color: #f0f6ff;
border-color: #336cff;
}
.wifi-info {
flex: 1;
}
.wifi-name {
font-size: 32rpx;
font-weight: 600;
color: #232338;
margin-bottom: 8rpx;
}
.wifi-details {
display: flex;
gap: 24rpx;
}
.wifi-signal,
.wifi-channel {
font-size: 24rpx;
color: #65686f;
}
.security-icon {
font-size: 24rpx;
color: #65686f;
margin-left: 20rpx;
}
.password-section {
margin-top: 24rpx;
}
.password-item {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.password-label {
font-size: 28rpx;
color: #232338;
font-weight: 500;
}
</style>
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { ref } from 'vue'
import UltrasonicConfig from './components/ultrasonic-config.vue'
import WifiConfig from './components/wifi-config.vue'
import WifiSelector from './components/wifi-selector.vue'
// 类型定义
interface WiFiNetwork {
ssid: string
rssi: number
authmode: number
channel: number
}
// 配网类型
const configType = ref<'wifi' | 'ultrasonic'>('wifi')
// 配网模式选择器状态
const configTypeSelectorShow = ref(false)
// WiFi选择器引用
const wifiSelectorRef = ref<InstanceType<typeof WifiSelector>>()
// 选择的WiFi网络信息
const selectedWifiInfo = ref<{
network: WiFiNetwork | null
password: string
}>({
network: null,
password: '',
})
// 配网模式选项
const configTypeOptions = [
{
name: 'WiFi配网',
value: 'wifi' as const,
},
// {
// name: '超声波配网',
// value: 'ultrasonic' as const,
// },
]
// 显示配网模式选择器
function showConfigTypeSelector() {
configTypeSelectorShow.value = true
}
// 配网模式选择器确认
function onConfigTypeConfirm(item: { name: string, value: 'wifi' | 'ultrasonic' }) {
configType.value = item.value
configTypeSelectorShow.value = false
}
// 配网模式选择器取消
function onConfigTypeCancel() {
configTypeSelectorShow.value = false
}
// WiFi网络选择事件
function onNetworkSelected(network: WiFiNetwork | null, password: string) {
selectedWifiInfo.value = { network, password }
}
// ESP32连接状态变化事件
function onConnectionStatusChange(connected: boolean) {
console.log('ESP32连接状态:', connected)
}
</script>
<template>
<view class="min-h-screen bg-[#f5f7fb]">
<wd-navbar title="设备配网" safe-area-inset-top />
<view class="box-border px-[20rpx]">
<!-- 配网方式选择 -->
<view class="pb-[20rpx] first:pt-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold">
配网方式
</text>
</view>
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:border-[#336cff] active:bg-[#eef3ff]" @click="showConfigTypeSelector">
<text class="text-[28rpx] text-[#232338] font-medium">
配网方式
</text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ configType === 'wifi' ? 'WiFi配网' : '超声波配网' }}
</text>
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
</view>
</view>
<!-- WiFi网络选择 -->
<view class="pb-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold">
网络配置
</text>
</view>
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
<wifi-selector
ref="wifiSelectorRef"
@network-selected="onNetworkSelected"
@connection-status="onConnectionStatusChange"
/>
</view>
<!-- 配网操作 -->
<view v-if="selectedWifiInfo.network" class="flex-1">
<!-- WiFi配网组件 -->
<wifi-config
v-if="configType === 'wifi'"
:selected-network="selectedWifiInfo.network"
:password="selectedWifiInfo.password"
/>
<!-- 超声波配网组件 -->
<ultrasonic-config
v-else-if="configType === 'ultrasonic'"
:selected-network="selectedWifiInfo.network"
:password="selectedWifiInfo.password"
/>
</view>
</view>
<!-- 配网模式选择器 -->
<wd-action-sheet
v-model="configTypeSelectorShow"
:actions="configTypeOptions.map(item => ({ name: item.name, value: item.value }))"
@close="onConfigTypeCancel"
@select="({ item }) => onConfigTypeConfirm(item)"
/>
</view>
</template>
<route lang="jsonc" type="page">
{
"style": {
"navigationBarTitleText": "设备配网",
"navigationStyle": "custom"
}
}
</route>