mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-30 02:43:56 +08:00
feat: 初始化移动端项目
This commit is contained in:
@@ -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,154 @@
|
||||
<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)
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="min-h-screen bg-[#f5f7fb]">
|
||||
<wd-navbar title="设备配网" safe-area-inset-top>
|
||||
<template #left>
|
||||
<wd-icon name="arrow-left" size="18" @click="goBack" />
|
||||
</template>
|
||||
</wd-navbar>
|
||||
|
||||
<view class="box-border px-[20rpx]">
|
||||
<!-- 配网方式选择 -->
|
||||
<view class="pb-[20rpx] first:pt-[20rpx]">
|
||||
<text class="text-[36rpx] 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-[36rpx] 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>
|
||||
Reference in New Issue
Block a user