2025-07-08 10:38:19 +08:00

1771 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="voice-control">
<!-- 设备状态卡片 -->
<view class="card">
<view class="status-titletop">{{ title }}</view>
<view style="padding:20rpx;">
<u--form labelPosition="left" labelWidth="100"
:labelStyle="{ marginRight: '16px', lineHeight: '32px', width: '50px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', color: '#000000' }">
<view class="version-wrap">
<u-form-item :label="$tt('status.deviceVersion')">
<u-row>
<u-col span="8">
<u--text :text="'Version ' + device.firmwareVersion"></u--text>
</u-col>
</u-row>
</u-form-item>
</view>
</u--form>
</view>
</view>
<!-- <button @click="print()">测试打印</button -->
<!-- 基础信息 -->
<view class="card basic-info">
<view class="section-title">基础信息</view>
<view class="info-content">
<!-- 音量控制 -->
<view class="volume-slider">
<view class="volume-icon">
<image src="https://xaznkj.cn/doc/photo/voice.svg" mode="aspectFit" class="volume-svg">
</image>
</view>
<view class="slider-container">
<u-slider v-model="volume" :min="0" :max="100" :step="1" @change="volumeChange"
:disabled="device.status !== 3" height="4" activeColor="#2979ff" blockSize="18"
:showValue="false">
</u-slider>
<view class="volume-marks">
<text>0</text>
<text>50</text>
<text>100</text>
</view>
</view>
<view class="volume-value">{{ volume }}%</view>
</view>
<!-- 音频开关 -->
<view class="audio-switch">
<text>音频开关</text>
<u-switch v-model="audioEnabled" @change="audioSwitchChange" :disabled="device.status !== 3"
size="22"></u-switch>
</view>
</view>
</view>
<!-- 音频列表 -->
<view class="card audio-list">
<view class="section-title">
<text>音频列表</text>
<image src="https://xaznkj.cn/doc/photo/add.svg" mode="aspectFit" class="add-icon"
@click="showAddAudioModal"></image>
</view>
<view class="list-container">
<view class="empty-tip" v-if="audioList.length === 0">
<u-icon name="music" size="50" color="#c0c4cc"></u-icon>
<text>暂无音频文件</text>
</view>
<view class="audio-item" v-for="(item, index) in audioList" :key="index">
<view class="audio-info">
<u-icon name="play-right" size="18" color="#2979ff"></u-icon>
<text class="audio-name">{{ item.name }}</text>
</view>
<view class="audio-actions">
<text class="audio-duration">{{ item.duration }}</text>
<u-icon name="trash" size="18" color="#ff4d4f" @click="deleteAudio(index)"></u-icon>
</view>
</view>
</view>
</view>
<!-- 默认音频列表 -->
<view class="card default-list">
<view class="section-title">
<text>播放列表</text>
<image src="https://xaznkj.cn/doc/photo/add.svg" mode="aspectFit" class="add-icon"
@click="showAddDefaultModal"></image>
</view>
<view class="list-container">
<view class="empty-tip" v-if="defaultList.length === 0">
<u-icon name="star" size="50" color="#c0c4cc"></u-icon>
<text>暂无默认音频</text>
</view>
<view class="audio-item" v-for="(item, index) in defaultList" :key="index">
<view class="audio-info">
<u-icon :name="item.status === '启用' ? 'star-fill' : 'star'" size="18"
:color="item.status === '启用' ? '#ff9900' : '#c0c4cc'"></u-icon>
<view class="audio-details">
<text class="audio-name">{{ item.name }}</text>
<view class="audio-meta">
<text class="time-info">{{ item.playTime }}</text>
<text class="week-info">{{ item.weekdays }}</text>
<text v-if="item.radarEnabled" class="radar-info">{{ item.radarSpeed }}</text>
</view>
</view>
</view>
<view class="audio-actions">
<u-switch v-model="item.status" :active-value="'启用'" :inactive-value="'禁用'"
@change="(value) => handleStatusChange(index, value)" size="22" :disabled="device.status !== 3"></u-switch>
<u-icon name="edit-pen" size="18" color="#2979ff" v-if="device.status === 3" @click="editDefault(index)"></u-icon>
<u-icon name="edit-pen" size="18" color="#ccc" v-else></u-icon>
<u-icon name="trash" size="18" color="#ff4d4f" v-if="device.status === 3" @click="deleteDefault(index)"></u-icon>
<u-icon name="trash" size="18" color="#ccc" v-else></u-icon>
</view>
</view>
</view>
</view>
<!-- 远程喊话 -->
<view class="card remote-talk">
<view class="talk-container">
<!-- 录音状态显示 -->
<view class="recorder-status">
<view class="status-indicator" :class="{ recording: isRecording }">
<image
:src="isRecording ? 'https://xaznkj.cn/doc/photo/recording.png' : 'https://xaznkj.cn/doc/photo/record.png'"
class="mic-img" mode="aspectFit" />
</view>
<text class="status-text">{{ recordingStatus }}</text>
</view>
<!-- 录音时长显示 -->
<view class="timer-display" v-if="isRecording">
{{ formatTime(recordingTime) }}
</view>
<!-- 录音控制按钮 -->
<view class="control-buttons">
<view class="talk-button" :class="{ recording: isRecording }" @touchstart="startRecording"
@touchend="stopRecording" @touchcancel="cancelRecording">
<image
:src="isRecording ? 'https://xaznkj.cn/doc/photo/micred.png' : 'https://xaznkj.cn/doc/photo/mic.png'"
class="mic-img" mode="aspectFit" />
<text>{{ isRecording ? '录音中...' : '按住说话' }}</text>
</view>
</view>
<!-- 录音状态提示 -->
<view class="recording-tips" v-if="!hasRecording && !isRecording">
<text class="tip-text">按住按钮开始录音松开自动结束并上传</text>
</view>
<!-- 录音预览仅在上传失败时显示 -->
<!-- <view class="recording-preview" v-if="hasRecording && uploadFailed">
<view class="preview-title">录音预览</view>
<view class="audio-player">
<audio :src="audioUrl" controls style="width: 100%; height: 40px;"></audio>
<view class="preview-controls">
<u-button type="primary" size="small" @click="uploadRecording">
重新上传
</u-button>
<u-button type="text" size="small" @click="reRecord">
重新录制
</u-button>
</view>
</view>
</view> -->
<!-- 最近录音列表 -->
<!-- <view class="recording-list" v-if="recordings.length > 0">
<view class="list-title">最近录音</view>
<scroll-view scroll-y style="height: 200px;">
<view v-for="(recording, index) in recordings" :key="index" class="recording-item">
<view class="recording-info">
<text class="recording-name">{{ recording.name }}</text>
<text class="recording-time">{{ recording.time }}</text>
</view>
<u-icon name="trash" size="18" color="#ff4d4f" @click="deleteRecording(index)"></u-icon>
</view>
</scroll-view>
</view> -->
</view>
</view>
<!-- 添加音频弹窗 -->
<u-popup :show="showAddAudio" mode="center" @close="showAddAudio = false" round="8">
<view class="add-audio-modal">
<view class="modal-title">添加音频</view>
<view class="modal-content">
<u-form :model="newAudio" ref="audioForm">
<u-form-item label="备注名" prop="name" borderBottom>
<u-input v-model="newAudio.name" placeholder="请输入备注名"></u-input>
</u-form-item>
<u-form-item label="主持人" prop="per" borderBottom>
<u-radio-group v-model="newAudio.per">
<u-radio label="男声1" name="0"></u-radio>
<u-radio label="男声2" name="1"></u-radio>
<u-radio label="女声1" name="3"></u-radio>
<u-radio label="女声2" name="4"></u-radio>
</u-radio-group>
</u-form-item>
<u-form-item label="合成语速" prop="spd" borderBottom>
<view class="slider-with-value">
<u-slider v-model="newAudio.spd" :min="0" :max="15" :step="1" :showValue="true"
class="custom-slider" height="12" blockSize="28" style="width: 100%;" />
</view>
</u-form-item>
<u-form-item label="合成音调" prop="pit" borderBottom>
<view class="slider-with-value">
<u-slider v-model="newAudio.pit" :min="0" :max="15" :step="1" :showValue="true"
class="custom-slider" height="12" blockSize="28" style="width: 100%;" />
</view>
</u-form-item>
<u-form-item label="合成音量" prop="vol" borderBottom>
<view class="slider-with-value">
<u-slider v-model="newAudio.vol" :min="0" :max="15" :step="1" :showValue="true"
class="custom-slider" height="12" blockSize="28" style="width: 100%;" />
</view>
</u-form-item>
<u-form-item label="合成文本" prop="text" borderBottom>
<u-input v-model="newAudio.text" type="textarea" placeholder="最多输入30个字"
height="100"></u-input>
</u-form-item>
</u-form>
</view>
<view class="modal-footer">
<u-button @click="showAddAudio = false" plain size="small">取消</u-button>
<u-button type="primary" @click="confirmAddAudio" size="small">确定</u-button>
</view>
</view>
</u-popup>
<!-- 添加默认音频弹窗 -->
<u-popup :show="showAddDefault" mode="center" @close="showAddDefault = false" round="8">
<view class="add-audio-modal">
<view class="modal-title">添加播放列表</view>
<view class="modal-content">
<u-form :model="newDefault" ref="defaultForm">
<u-form-item label="开始时间" prop="startTime" borderBottom>
<picker mode="time" :value="newDefault.startTime" start="00:00" end="23:59"
@change="startTimeChange">
<view class="picker-value">{{ newDefault.startTime || '请选择开始时间' }}</view>
</picker>
</u-form-item>
<u-form-item label="结束时间" prop="endTime" borderBottom>
<picker mode="time" :value="newDefault.endTime" start="00:00" end="23:59"
@change="endTimeChange">
<view class="picker-value">{{ newDefault.endTime || '请选择结束时间' }}</view>
</picker>
</u-form-item>
<u-form-item label="重复" prop="repeat" borderBottom>
<view class="week-picker">
<view class="week-item" v-for="(day, index) in weekDays" :key="index"
:class="{ active: newDefault.repeatDays.includes(index) }"
@click="toggleWeekDay(index)">
{{ day }}
</view>
</view>
</u-form-item>
<u-form-item label="雷达开关" prop="radarEnabled" borderBottom>
<u-switch v-model="newDefault.radarEnabled" @change="radarSwitchChange"></u-switch>
</u-form-item>
<template v-if="newDefault.radarEnabled">
<u-form-item label="速度范围" prop="speedRange" borderBottom>
<view class="speed-range">
<u-input v-model="newDefault.minSpeed" type="number" placeholder="最小速度"></u-input>
<text class="separator">-</text>
<u-input v-model="newDefault.maxSpeed" type="number" placeholder="最大速度"></u-input>
<text class="unit">km/h</text>
</view>
</u-form-item>
</template>
<u-form-item label="选择音频" prop="audioFile" borderBottom>
<picker :range="audioList" range-key="name" :value="audioIndex" @change="audioChange">
<view class="picker-value">{{ selectedAudioName || '请选择音频' }}</view>
</picker>
</u-form-item>
</u-form>
</view>
<view class="modal-footer">
<u-button @click="showAddDefault = false" plain size="small">取消</u-button>
<u-button type="primary" @click="confirmAddDefault" size="small">确定</u-button>
</view>
</view>
</u-popup>
</view>
</template>
<script>
import {
serviceInvoke
} from '@/apis/modules/runtime.js';
export default {
name: 'VoiceControl',
props: {
device: {
type: Object,
required: true
}
},
watch: {
device: function(newVal, oldVal) {
console.log("newVal", newVal)
if (newVal.deviceName !== '') {
this.deviceInfo = newVal;
if (this.deviceInfo.deviceType != 3) {
this.operateList = this.deviceInfo.thingsModels && this.deviceInfo.thingsModels.filter((
item) => item.isReadonly == '0');
this.attributeList = this.deviceInfo.thingsModels && this.deviceInfo.thingsModels.filter((
item) => item.isReadonly == '1');
this.attributeList = this.attributeList.sort((a, b) => b.order - a.order);
this.operateList = this.operateList.sort((a, b) => b.order - a.order);
}
this.updateDeviceStatus(this.deviceInfo);
this.updateBasicSettings();
}
}
},
data() {
return {
title: '设备离线',
volume: 50,
audioEnabled: true,
isRecording: false,
recorderManager: null,
recordingTime: 0,
recordingTimer: null,
tempFilePath: '',
hasRecording: false,
audioUrl: '',
uploadFailed: false,
recordings: [],
recordingStatus: '准备录音',
showAddAudio: false,
showAddDefault: false,
showStartTimePicker: false,
showEndTimePicker: false,
showAudioPicker: false,
weekDays: ['日', '一', '二', '三', '四', '五', '六'],
audioIndex: -1,
newAudio: {
name: '',
per: '0',
spd: '5',
pit: '5',
vol: '5',
text: '',
file: null
},
deviceInfo: {
chartList: [],
},
newDefault: {
startTime: '',
endTime: '',
repeatDays: [],
radarEnabled: false,
minSpeed: '',
maxSpeed: '',
audioFile: null
},
audioFiles: [],
audioList: [],
defaultList: [],
audioUrl: '',
uploadFailed: false,
isEditDefault: false,
editDefaultIndex: null
};
},
created() {
if (this.device !== null && Object.keys(this.device).length !== 0) {
this.deviceInfo = this.device;
this.updateDeviceStatus(this.deviceInfo);
};
this.mqttCallback();
this.recorderManager = uni.getRecorderManager();
this.initRecorder();
this.updateBasicSettings();
},
beforeDestroy() {
if (this.recordingTimer) {
clearInterval(this.recordingTimer);
this.recordingTimer = null;
}
},
methods: {
print() {
console.log("测试打印", JSON.stringify(this.deviceInfo.thingsModels))
},
checkOnline(callback, ...args) {
if (this.device.status !== 3) {
uni.showToast({
title: '设备离线,无法操作',
icon: 'none'
});
return false;
}
if (typeof callback === 'function') {
return callback.apply(this, args);
}
return true;
},
async uploadRecording() {
if (!this.tempFilePath) return;
try {
this.recordingStatus = '上传中...';
// 在 uni-app 中直接使用 uni.uploadFile不需要 FormData
const uploadUrl = 'https://xaznkj.cn/common/upload/audio';
const token =
'Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImU3MWM2OTg4LTNlMzMtNDYyMy05M2M3LWE4YzZmMTNlMjZkZSJ9.wgsL8b3WDmyuesG8JTA3LcNFp2FigkB90h6Inwxt7OFadH6rc5np5TjAyU1pzU2_b5cmG8BYXMEdAqEdJzoDcA';
// 使用 uni.uploadFile 上传录音文件
const [error, res] = await new Promise((resolve) => {
uni.uploadFile({
url: uploadUrl,
filePath: this.tempFilePath,
name: 'file',
header: {
'Authorization': token
},
success: (res) => resolve([null, res]),
fail: (err) => resolve([err, null])
});
});
if (error) throw error;
if (res.statusCode === 200) {
const result = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
if (result.code === 200) {
this.recordingStatus = '上传成功';
this.uploadFailed = false;
uni.showToast({
title: '上传成功',
icon: 'success'
});
// 提取返回的 URL 并通过 MQTT 下发到 103#onlinePlay
if (result.url || result.resourcePath) {
const onlinePlayModel = this.deviceInfo.thingsModels?.find(model => model.id ===
'103#onlinePlay');
if (onlinePlayModel) {
// 构建包含 interrupt 参数的 JSON 数据
const playData = 'JSON=' + JSON.stringify({
online_play: {
url: "http://1.94.62.14:8080" + (result.resourcePath || result
.url),
},
interrupt: 98
});
onlinePlayModel.shadow = playData;
await this.mqttPublish(this.deviceInfo, onlinePlayModel);
uni.showToast({
title: '音频已下发到设备',
icon: 'success'
});
} else {
uni.showToast({
title: '未找到 103#onlinePlay 物模型',
icon: 'none'
});
}
}
} else {
throw new Error(result.msg || '上传失败');
}
} else {
throw new Error('上传失败,状态码: ' + res.statusCode);
}
} catch (error) {
console.error('上传失败:', error);
this.recordingStatus = '上传失败';
this.uploadFailed = true;
let errorMsg = '上传失败';
if (error.message && error.message.includes('Mixed Content')) {
errorMsg = '由于安全策略,无法在 HTTPS 页面访问 HTTP 接口。请联系管理员配置接口支持 HTTPS。';
}
uni.showToast({
title: errorMsg || error.message || '上传失败',
icon: 'none'
});
}
},
reRecord() {
this.hasRecording = false;
this.uploadFailed = false;
this.recordingStatus = '准备录音';
},
async volumeChange(value) {
if (!this.checkOnline()) return;
try {
const volumeModel = this.device.thingsModels.find(item => item.id === '103#volume');
if (volumeModel) {
volumeModel.shadow = value.toString();
await this.mqttPublish(this.device, volumeModel);
}
} catch (error) {
console.error('调整音量失败:', error);
uni.showToast({
title: '操作失败: ' + error.message,
icon: 'none'
});
}
},
mqttCallback() {
this.$mqttTool.client.on('message', (topic, message, buffer) => {
let topics = topic.split('/');
let productId = topics[1];
let deviceNum = topics[2];
message = JSON.parse(message.toString());
// 只处理当前设备
if (this.deviceInfo.serialNumber !== deviceNum) return;
if (topics[3] == 'status') {
this.deviceInfo.status = message.status;
this.deviceInfo.isShadow = message.isShadow;
this.deviceInfo.rssi = message.rssi;
this.deviceInfo = Object.assign({}, this.deviceInfo);
this.updateDeviceStatus(this.deviceInfo);
this.updateBasicSettings();
this.$forceUpdate();
}
if (topics[4] == 'reply') {
uni.showToast({
icon: 'none',
title: message,
})
}
if (topics[3] == 'property' || topics[3] == 'function' || topic.endsWith('ws/service')) {
if (Array.isArray(message.message)) {
let mp3ListChanged = false;
let playListChanged = false;
message.message.forEach(item => {
if (item.id === '103#mp3List') mp3ListChanged = true;
if (item.id === '103#playList') playListChanged = true;
// 你可以在这里补充其他针对性属性的处理
});
if (mp3ListChanged) {
const mp3ListModel = this.deviceInfo.thingsModels.find(model => model.id === '103#mp3List');
if (mp3ListModel && mp3ListModel.shadow) {
try {
const jsonStr = mp3ListModel.shadow.replace('JSON=', '');
const data = JSON.parse(jsonStr);
if (data && data.mp3_list) {
this.audioList = data.mp3_list.map((item, index) => {
const [id, ...nameArr] = item.split('_');
const name = nameArr.join('_') || item;
return {
id: Number(id),
name: name,
filename: item
};
});
this.audioList = [...this.audioList];
}
} catch (error) {
console.error('解析音频列表失败:', error);
}
}
}
if (playListChanged) {
const playListModel = this.deviceInfo.thingsModels.find(model => model.id === '103#playList');
if (playListModel && playListModel.shadow) {
try {
const jsonStr = playListModel.shadow.replace('JSON=', '');
const data = JSON.parse(jsonStr);
if (data && data.play_list) {
this.defaultList = data.play_list.map((item, index) => {
const beginTime = this.formatSecondsToTime(item.time.begin);
const endTime = this.formatSecondsToTime(item.time.end);
const weekdays = this.convertWeekToArray(item.time.week);
return {
id: index + 1,
name: item.play.filename,
playTime: `${beginTime} - ${endTime}`,
weekdays: weekdays.join(', '),
radarEnabled: item.speed.en === 1,
status: item.play.en === 1 ? '启用' : '禁用',
radarSpeed: item.speed.en === 1 ? `${item.speed.min}-${item.speed.max}km/h` : ''
};
});
this.defaultList = [...this.defaultList];
}
} catch (error) {
console.error('解析播放列表失败:', error);
}
}
}
}
}
});
},
async mqttPublish(device, model) {
const command = {};
command[model.id] = model.shadow;
const data = {
serialNumber: device.serialNumber,
productId: device.productId,
remoteCommand: command,
identifier: model.id,
modelName: model.name,
isShadow: device.status != 3,
type: model.type
};
serviceInvoke(data).then(response => {
if (response.code === 200) {
uni.showToast({
icon: 'none',
title: this.$tt('status.service')
});
}
});
},
updateDeviceStatus(device) {
if (device.status === 3) {
this.title = this.$tt('status.online');
} else {
this.title = device.isShadow === 1 ? this.$tt('status.shadow') : this.$tt('status.deviceOffline');
}
},
initRecorder() {
if (!this.recorderManager) return;
this.recorderManager.onStart(() => {
this.recordingStatus = '正在录音...';
});
this.recorderManager.onStop((res) => {
this.tempFilePath = res.tempFilePath;
this.audioUrl = res.tempFilePath;
this.hasRecording = true;
this.recordingStatus = '录音完成,正在上传...';
this.recordings.unshift({
name: `录音_${this.formatTime(res.duration ? Math.floor(res.duration/1000) : this.recordingTime)}`,
time: new Date().toLocaleString(),
path: res.tempFilePath,
duration: res.duration ? Math.floor(res.duration / 1000) : this.recordingTime
});
this.uploadRecording();
});
this.recorderManager.onError((res) => {
this.isRecording = false;
this.recordingStatus = '录音失败';
uni.showToast({
title: res.errMsg || '录音失败',
icon: 'none',
});
if (this.recordingTimer) {
clearInterval(this.recordingTimer);
this.recordingTimer = null;
}
});
},
startRecording() {
if (!this.checkOnline() || !this.recorderManager) return;
try {
this.isRecording = true;
this.recordingStatus = '正在录音...';
this.recordingTime = 0;
this.recordingTimer = setInterval(() => {
this.recordingTime++;
}, 1000);
this.recorderManager.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 48000,
format: 'mp3',
frameSize: 1,
});
} catch (e) {
this.isRecording = false;
this.recordingStatus = '录音启动失败';
uni.showToast({
title: '录音启动失败,请检查权限',
icon: 'none',
});
}
},
stopRecording() {
if (!this.isRecording || !this.recorderManager) return;
this.isRecording = false;
this.recordingStatus = '录音完成,正在上传...';
if (this.recordingTimer) {
clearInterval(this.recordingTimer);
this.recordingTimer = null;
}
this.recorderManager.stop();
},
cancelRecording() {
if (!this.isRecording || !this.recorderManager) return;
this.isRecording = false;
this.recordingStatus = '准备录音';
if (this.recordingTimer) {
clearInterval(this.recordingTimer);
this.recordingTimer = null;
}
this.recordingTime = 0;
this.recorderManager.stop();
uni.showToast({
title: '已取消录音',
icon: 'none',
});
},
showAddAudioModal() {
this.showAddAudio = true;
},
showAddDefaultModal() {
this.showAddDefault = true;
},
async confirmAddAudio() {
if (!this.checkOnline()) return;
try {
const mp3ListModel = this.deviceInfo.thingsModels.find(model => model.id === '103#mp3List');
if (!mp3ListModel) {
throw new Error('未找到 mp3_list 模型');
}
const jsonStr = mp3ListModel.shadow.replace('JSON=', '');
const data = JSON.parse(jsonStr);
let maxId = 0;
console.log("data", JSON.stringify(data))
if (data.mp3_list) {
data.mp3_list.forEach(item => {
const id = parseInt(item.split('_')[0]);
if (!isNaN(id) && id > maxId) {
maxId = id;
}
});
}
const newId = maxId + 1;
const ttsData = {
JSON_id: 1,
TTS: {
per: parseInt(this.newAudio.per),
spd: parseInt(this.newAudio.spd),
pit: parseInt(this.newAudio.pit),
vol: parseInt(this.newAudio.vol),
tex_utf8: this.newAudio.text,
filename: `${newId}_${this.newAudio.name}`
}
};
console.log("103#mp3List", JSON.stringify(ttsData))
mp3ListModel.shadow = 'JSON=' + JSON.stringify(ttsData);
await this.mqttPublish(this.device, mp3ListModel);
this.newAudio = {
name: '',
per: '0',
spd: '5',
pit: '5',
vol: '5',
text: '',
file: null
};
this.showAddAudio = false;
uni.showToast({
title: '添加成功',
icon: 'success'
});
} catch (error) {
console.error('添加音频失败:', error);
uni.showToast({
title: '添加失败: ' + error.message,
icon: 'none'
});
}
},
async confirmAddDefault() {
if (!this.checkOnline()) return;
if (!this.newDefault.startTime || !this.newDefault.endTime || !this.newDefault.audioFile) {
uni.showToast({
title: '请填写完整信息',
icon: 'none'
});
return;
}
if (this.newDefault.radarEnabled) {
if (!this.newDefault.minSpeed || !this.newDefault.maxSpeed) {
uni.showToast({
title: '请填写速度范围',
icon: 'none'
});
return;
}
if (parseInt(this.newDefault.minSpeed) >= parseInt(this.newDefault.maxSpeed)) {
uni.showToast({
title: '最小速度必须小于最大速度',
icon: 'none'
});
return;
}
}
const playListModel = this.deviceInfo.thingsModels.find(model => model.id === '103#playList');
if (playListModel) {
try {
const jsonStr = playListModel.shadow.replace('JSON=', '');
const data = JSON.parse(jsonStr);
if (!data) {
data = {};
}
if (!data.play_list) {
data.play_list = [];
}
let maxNum = 0;
if (data.play_list.length > 0) {
maxNum = Math.max(...data.play_list.map(item => item.play.num || 0));
}
const [startHour, startMinute] = this.newDefault.startTime.split(':').map(Number);
const [endHour, endMinute] = this.newDefault.endTime.split(':').map(Number);
const startSeconds = startHour * 3600 + startMinute * 60;
const endSeconds = endHour * 3600 + endMinute * 60;
let weekValue = 0;
this.newDefault.repeatDays.forEach(day => {
weekValue |= (1 << day);
});
const audioIndex = this.audioList.findIndex(a => a.name === this.newDefault.audioFile.name);
const filename = this.newDefault.audioFile.filename;
const newPlayItem = {
play: {
num: this.isEditDefault && this.editDefaultIndex !== null && data.play_list[this
.editDefaultIndex] ? data.play_list[this.editDefaultIndex].play.num :
maxNum + 1,
filename: filename,
en: 1
},
time: {
begin: startSeconds,
end: endSeconds,
week: weekValue
},
speed: {
en: this.newDefault.radarEnabled ? 1 : 0,
min: this.newDefault.radarEnabled ? parseInt(this.newDefault.minSpeed) : 0,
max: this.newDefault.radarEnabled ? parseInt(this.newDefault.maxSpeed) : 0
}
};
if (this.isEditDefault && this.editDefaultIndex !== null) {
data.play_list[this.editDefaultIndex] = newPlayItem;
} else {
data.play_list.push(newPlayItem);
}
console.log(JSON.stringify(data))
playListModel.shadow = 'JSON=' + JSON.stringify(data);
try {
await this.mqttPublish(this.device, playListModel);
uni.showToast({
title: '添加成功',
icon: 'success'
});
this.showAddDefault = false;
this.newDefault = {
startTime: '',
endTime: '',
repeatDays: [],
radarEnabled: false,
minSpeed: '',
maxSpeed: '',
audioFile: null
};
this.isEditDefault = false;
this.editDefaultIndex = null;
} catch (error) {
console.error('发送添加命令失败:', error);
uni.showToast({
title: '添加失败',
icon: 'none'
});
}
} catch (error) {
console.error('解析或更新播放列表失败:', error);
uni.showToast({
title: '添加失败',
icon: 'none'
});
}
}
},
async deleteAudio(index) {
if (!this.checkOnline()) return;
try {
uni.showModal({
title: '提示',
content: '确定要删除该音频吗?',
cancelText: '取消',
confirmText: '确定',
success: async (res) => {
if (res.confirm) {
const mp3ListModel = this.deviceInfo.thingsModels.find(model => model
.id === '103#mp3List');
if (mp3ListModel && mp3ListModel.shadow) {
try {
const jsonStr = mp3ListModel.shadow.replace('JSON=', '');
const data = JSON.parse(jsonStr);
if (data.mp3_list) {
data.mp3_list.splice(index, 1);
mp3ListModel.shadow = 'JSON=' + JSON.stringify(data);
await this.mqttPublish(this.device, mp3ListModel);
this.audioList.splice(index, 1);
uni.showToast({
title: '删除成功',
icon: 'success'
});
}
} catch (error) {
console.error('删除音频失败:', error);
uni.showToast({
title: '删除失败: ' + error.message,
icon: 'none'
});
}
}
}
}
});
} catch (error) {
console.error('删除音频失败:', error);
uni.showToast({
title: '删除失败: ' + error.message,
icon: 'none'
});
}
},
deleteDefault(index) {
if (!this.checkOnline()) return;
uni.showModal({
title: '提示',
content: '确认删除该播放项吗?',
cancelText: '取消',
confirmText: '确定',
success: async (res) => {
if (res.confirm) {
const playListModel = this.deviceInfo.thingsModels.find(model => model.id ===
'103#playList');
if (playListModel) {
try {
const jsonStr = playListModel.shadow.replace('JSON=', '');
const data = JSON.parse(jsonStr);
if (data.play_list) {
data.play_list.splice(index, 1);
data.play_list.forEach((item, index) => {
item.play.num = index + 1;
});
playListModel.shadow = 'JSON=' + JSON.stringify(data);
try {
await this.mqttPublish(this.device, playListModel);
uni.showToast({
title: '删除成功',
icon: 'success'
});
} catch (error) {
console.error('发送删除命令失败:', error);
uni.showToast({
title: '删除失败',
icon: 'none'
});
}
}
} catch (error) {
console.error('解析或更新播放列表失败:', error);
uni.showToast({
title: '删除失败',
icon: 'none'
});
}
}
}
}
});
},
afterAudioRead(file) {
this.audioFiles.push(file);
},
deleteAudioFile(index) {
this.audioFiles.splice(index, 1);
},
beforeAudioUpload(file) {
const isValidType = file.name.toLowerCase().endsWith('.mp3');
const isValidSize = file.size / 1024 / 1024 < 10;
if (!isValidType) {
uni.showToast({
title: '只能上传MP3格式',
icon: 'none'
});
return false;
}
if (!isValidSize) {
uni.showToast({
title: '文件大小不能超过10MB',
icon: 'none'
});
return false;
}
return true;
},
async audioSwitchChange() {
if (!this.checkOnline()) return;
try {
const playEnModel = this.device.thingsModels.find(item => item.id === '103#playEn');
if (playEnModel) {
playEnModel.shadow = this.audioEnabled ? '1' : '0';
await this.mqttPublish(this.device, playEnModel);
}
} catch (error) {
console.error('切换音频开关失败:', error);
uni.showToast({
title: '操作失败: ' + error.message,
icon: 'none'
});
}
},
formatTime(seconds) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
},
startTimeChange(e) {
this.newDefault.startTime = e.detail.value;
},
endTimeChange(e) {
this.newDefault.endTime = e.detail.value;
},
toggleWeekDay(index) {
const position = this.newDefault.repeatDays.indexOf(index);
if (position === -1) {
this.newDefault.repeatDays.push(index);
} else {
this.newDefault.repeatDays.splice(position, 1);
}
},
radarSwitchChange(value) {
this.newDefault.radarEnabled = value;
if (!value) {
this.newDefault.minSpeed = '';
this.newDefault.maxSpeed = '';
}
},
audioChange(e) {
this.audioIndex = e.detail.value;
this.newDefault.audioFile = this.audioList[this.audioIndex];
},
updateBasicSettings() {
if (!this.deviceInfo.thingsModels) return;
const playEnModel = this.deviceInfo.thingsModels.find(model => model.id === '103#playEn');
if (playEnModel) {
this.audioEnabled = playEnModel.shadow === '1';
}
const volumeModel = this.deviceInfo.thingsModels.find(model => model.id === '103#volume');
if (volumeModel) {
this.volume = parseInt(volumeModel.shadow) || 50;
}
const mp3ListModel = this.deviceInfo.thingsModels.find(model => model.id === '103#mp3List');
if (mp3ListModel && mp3ListModel.shadow) {
try {
const jsonStr = mp3ListModel.shadow.replace('JSON=', '');
const data = JSON.parse(jsonStr);
if (data && data.mp3_list) {
this.audioList = data.mp3_list.map((item, index) => {
const [id, ...nameArr] = item.split('_');
const name = nameArr.join('_') || item;
return {
id: Number(id),
name: name,
filename: item
};
});
// 强制整体赋值,保证小程序端响应
this.audioList = [...this.audioList];
}
} catch (error) {
console.error('解析音频列表失败:', error);
}
}
const playListModel = this.deviceInfo.thingsModels.find(model => model.id === '103#playList');
if (playListModel && playListModel.shadow) {
try {
const jsonStr = playListModel.shadow.replace('JSON=', '');
const data = JSON.parse(jsonStr);
if (data && data.play_list) {
this.defaultList = data.play_list.map((item, index) => {
const beginTime = this.formatSecondsToTime(item.time.begin);
const endTime = this.formatSecondsToTime(item.time.end);
const weekdays = this.convertWeekToArray(item.time.week);
return {
id: index + 1,
name: item.play.filename,
playTime: `${beginTime} - ${endTime}`,
weekdays: weekdays.join(', '),
radarEnabled: item.speed.en === 1,
status: item.play.en === 1 ? '启用' : '禁用',
radarSpeed: item.speed.en === 1 ? `${item.speed.min}-${item.speed.max}km/h` :
''
};
});
// 强制整体赋值,保证小程序端响应
this.defaultList = [...this.defaultList];
}
} catch (error) {
console.error('解析播放列表失败:', error);
}
}
},
formatSecondsToTime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
},
convertWeekToArray(week) {
const weekdays = [];
for (let i = 0; i < 7; i++) {
if (week & (1 << i)) {
weekdays.push(this.weekDays[i]);
}
}
return weekdays;
},
async handleStatusChange(index, value) {
if (!this.checkOnline()) return;
const playListModel = this.deviceInfo.thingsModels.find(model => model.id === '103#playList');
if (playListModel) {
try {
const jsonStr = playListModel.shadow.replace('JSON=', '');
const data = JSON.parse(jsonStr);
if (data.play_list) {
data.play_list[index].play.en = value === '启用' ? 1 : 0;
playListModel.shadow = 'JSON=' + JSON.stringify(data);
try {
await this.mqttPublish(this.device, playListModel);
uni.showToast({
title: '更新成功',
icon: 'success'
});
} catch (error) {
console.error('发送状态更新命令失败:', error);
uni.showToast({
title: '更新失败',
icon: 'none'
});
this.defaultList[index].status = value === '启用' ? '禁用' : '启用';
}
}
} catch (error) {
console.error('解析或更新播放列表失败:', error);
uni.showToast({
title: '更新失败',
icon: 'none'
});
this.defaultList[index].status = value === '启用' ? '禁用' : '启用';
}
}
},
deleteRecording(index) {
uni.showModal({
title: '提示',
content: '确定要删除该录音吗?',
cancelText: '取消',
confirmText: '确定',
success: (res) => {
if (res.confirm) {
this.recordings.splice(index, 1);
uni.showToast({
title: '删除成功',
icon: 'success'
});
}
}
});
},
editDefault(index) {
if (!this.checkOnline()) return;
const item = this.defaultList[index];
const audioFile = this.audioList.find(a => a.filename === item.name || a.name === item.name);
const audioIndex = this.audioList.findIndex(a => a.filename === item.name || a.name === item.name);
this.newDefault = {
startTime: item.playTime.split(' - ')[0],
endTime: item.playTime.split(' - ')[1],
repeatDays: this.weekDays.map((d, i) => item.weekdays.includes(d) ? i : -1).filter(i => i !== -1),
radarEnabled: item.radarEnabled,
minSpeed: item.radarEnabled && item.radarSpeed ? item.radarSpeed.split('-')[0] : '',
maxSpeed: item.radarEnabled && item.radarSpeed ? item.radarSpeed.split('-')[1].replace('km/h',
'') : '',
audioFile: audioFile
};
this.audioIndex = audioIndex;
this.isEditDefault = true;
this.editDefaultIndex = index;
this.showAddDefault = true;
}
},
computed: {
startTimeLabel() {
return this.newDefault.startTime ? this.formatTime(this.newDefault.startTime) : '请选择开始时间';
},
endTimeLabel() {
return this.newDefault.endTime ? this.formatTime(this.newDefault.endTime) : '请选择结束时间';
},
selectedAudioName() {
return this.audioIndex >= 0 ? this.audioList[this.audioIndex].name : '';
}
}
};
</script>
<style lang="scss" scoped>
// @import "@/uni_modules/uview-ui/scss/index.scss";
.voice-control {
padding: 20rpx;
background-color: #f7f8fa;
min-height: 100vh;
.card {
background-color: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.status-titletop {
font-weight: bold;
font-size: 15px;
color: #333333;
line-height: 42rpx;
text-align: left;
padding: 15rpx 28rpx;
background-color: #eef6ff;
border-bottom: 1rpx solid #dceaff;
}
.version-wrap {
background-color: #F7F7F7;
border-radius: 10rpx;
padding: 0 42rpx;
font-size: 24rpx;
color: #000000;
line-height: 42rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
padding: 24rpx;
border-bottom: 1rpx solid #f5f5f5;
display: flex;
align-items: center;
.add-icon {
width: 40rpx;
height: 40rpx;
margin-left: auto;
}
}
.info-content {
padding: 16rpx 24rpx 24rpx;
}
.volume-slider {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 20rpx;
padding: 16rpx 0;
background-color: #f9f9f9;
border-radius: 16rpx;
.volume-icon {
width: 40rpx;
height: 40rpx;
display: flex;
justify-content: center;
align-items: center;
.volume-svg {
width: 40rpx;
height: 40rpx;
}
}
.slider-container {
flex: 1;
position: relative;
.volume-marks {
display: flex;
justify-content: space-between;
margin-top: 8rpx;
padding: 0 8rpx;
text {
font-size: 22rpx;
color: #999;
}
}
}
.volume-value {
min-width: 72rpx;
text-align: right;
color: #2979ff;
font-size: 26rpx;
font-weight: 500;
}
}
.audio-switch {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
text {
font-size: 28rpx;
color: #333;
}
}
.audio-list,
.default-list {
.list-container {
padding: 0 24rpx 16rpx;
}
.empty-tip {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 0;
color: #c0c4cc;
font-size: 28rpx;
gap: 20rpx;
opacity: 0.8;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
opacity: 0.6;
}
}
.audio-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.audio-info {
display: flex;
align-items: flex-start;
gap: 16rpx;
flex: 1;
overflow: hidden;
.audio-details {
flex: 1;
overflow: hidden;
.audio-name {
font-size: 26rpx;
color: #333;
margin-bottom: 8rpx;
display: block;
}
.audio-meta {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
font-size: 22rpx;
color: #666;
.time-info,
.week-info,
.radar-info {
background-color: #f5f5f5;
padding: 4rpx 12rpx;
border-radius: 4rpx;
}
}
}
}
.audio-actions {
display: flex;
align-items: center;
gap: 24rpx;
margin-left: 16rpx;
}
}
}
.remote-talk {
.talk-container {
padding: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
.recorder-status {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20rpx;
.status-indicator {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background: #f4f4f5;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10rpx;
transition: all 0.3s;
&.recording {
background: #fef0f0;
animation: pulse 1.5s infinite;
}
}
.status-text {
font-size: 26rpx;
color: #606266;
}
}
.timer-display {
font-size: 48rpx;
font-weight: bold;
color: #303133;
margin-bottom: 20rpx;
}
.control-buttons {
display: flex;
justify-content: center;
gap: 20rpx;
margin-bottom: 20rpx;
.talk-button {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background-color: #f0f6ff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10rpx;
transition: all 0.3s ease;
border: 2rpx solid #e1e8ff;
box-shadow: 0 4rpx 12rpx rgba(41, 121, 255, 0.2);
&.recording {
background-color: #fff1f0;
border-color: #ff4d4f;
transform: scale(1.05);
box-shadow: 0 6rpx 16rpx rgba(245, 77, 79, 0.3);
}
&:active {
transform: scale(0.95);
}
text {
font-size: 24rpx;
color: #666;
font-weight: 500;
}
}
}
.recording-tips {
margin-top: 20rpx;
padding: 24rpx;
background: #f8f9fa;
border-radius: 16rpx;
width: 100%;
.tip-text {
font-size: 28rpx;
color: #606266;
text-align: center;
}
}
.recording-preview {
margin-top: 20rpx;
padding: 24rpx;
background: #f8f9fa;
border-radius: 16rpx;
width: 100%;
.preview-title {
font-size: 28rpx;
color: #606266;
margin-bottom: 20rpx;
text-align: left;
}
.audio-player {
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
audio {
width: 100%;
height: 80rpx;
}
.preview-controls {
display: flex;
justify-content: center;
margin-top: 10rpx;
}
}
}
.recording-list {
text-align: left;
border-top: 1rpx solid #ebeef5;
padding-top: 20rpx;
width: 100%;
.list-title {
font-size: 28rpx;
color: #606266;
margin-bottom: 20rpx;
}
.recording-item {
display: flex;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #ebeef5;
.recording-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
.recording-name {
font-size: 26rpx;
color: #303133;
}
.recording-time {
font-size: 22rpx;
color: #909399;
}
}
}
}
}
}
@keyframes pulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.4);
}
70% {
transform: scale(1.1);
box-shadow: 0 0 0 10rpx rgba(245, 108, 108, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0);
}
}
.add-audio-modal {
width: 80vw;
max-width: 600rpx;
padding: 0;
border-radius: 16rpx;
background-color: #fff;
overflow: hidden;
.modal-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
padding: 28rpx 28rpx 20rpx;
text-align: center;
border-bottom: 1rpx solid #f5f5f5;
}
.modal-content {
padding: 0 28rpx;
margin: 20rpx 0;
max-height: 60vh;
overflow-y: auto;
.u-form-item {
padding: 20rpx 0;
:deep(.u-form-item__body) {
padding: 0;
}
:deep(.u-form-item__body__left) {
width: 160rpx;
}
:deep(.u-form-item__body__right) {
flex: 1;
}
}
.slider-with-value {
width: 100% !important;
display: block !important;
.custom-slider {
width: 100% !important;
min-width: 0 !important;
max-width: 100% !important;
flex: 1 !important;
display: block !important;
margin-right: 0 !important;
}
}
:deep(.u-radio-group) {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
}
.modal-footer {
display: flex;
justify-content: space-between;
padding: 16rpx 28rpx 28rpx;
border-top: 1rpx solid #f5f5f5;
.u-button {
width: 48%;
height: 72rpx;
font-size: 26rpx;
border-radius: 40rpx;
}
}
}
.week-picker {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
padding: 10rpx 0;
.week-item {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 30rpx;
background-color: #f5f5f5;
font-size: 24rpx;
color: #666;
transition: all 0.3s ease;
&.active {
background-color: #2979ff;
color: #fff;
}
}
}
.speed-range {
display: flex;
align-items: center;
gap: 10rpx;
.u-input {
flex: 1;
}
.separator {
color: #666;
padding: 0 10rpx;
}
.unit {
color: #666;
font-size: 24rpx;
margin-left: 10rpx;
}
}
.picker-value {
height: 80rpx;
line-height: 80rpx;
font-size: 28rpx;
color: #333;
padding: 0 20rpx;
background-color: #f9f9f9;
border-radius: 8rpx;
}
.mic-img {
width: 56rpx;
height: 56rpx;
margin-bottom: 8rpx;
}
}
</style>