chuangte_bike_newxcx/page_shanghu/gongzuotai/yuyin/config.vue
2026-02-06 15:28:01 +08:00

946 lines
24 KiB
Vue
Raw Permalink 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="page">
<u-navbar :title="voiceName || '语音配置'" :border-bottom="false" :background="bgc" back-icon-color="#262B37" title-color='#262B37'
title-size='36' height='36' id="navbar">
</u-navbar>
<scroll-view class="content-container" scroll-y>
<!-- 选择车辆区域 -->
<view class="section">
<view class="section-title">选择车辆</view>
<view class="select-device-btn" @click.stop="openDeviceModal">
<u-icon name="plus" color="#3996FD" size="24"></u-icon>
<text>选择车辆</text>
</view>
</view>
<!-- 已选车辆列表始终展示该区域空时显示提示 -->
<view class="section section-selected">
<view class="section-title">已选车辆 ({{ selectedDevices.length }})</view>
<view class="device-list" v-if="selectedDevices.length > 0">
<view class="device-item" v-for="(device, index) in selectedDevices" :key="getDeviceKeyForCompare(device) || index">
<view class="device-info">
<view class="device-name">SN: {{ device.sn || device.deviceSn || '--' }}</view>
<view class="device-meta">车牌: {{ device.vehicleNum || '--' }}</view>
<!-- 失败提示:放在车牌下方 -->
<view class="device-error" v-if="device.syncStatus === 'failed' && device.errorMsg">
{{ device.errorMsg }}
</view>
</view>
<view class="device-status">
<view class="status-info" v-if="device.syncStatus">
<u-icon
:name="device.syncStatus === 'success' ? 'checkmark-circle' : device.syncStatus === 'loading' ? 'loading' : 'close-circle'"
:color="device.syncStatus === 'success' ? '#64B6A7' : device.syncStatus === 'loading' ? '#3996FD' : '#ff4444'"
size="32"
></u-icon>
<text class="status-text" :class="device.syncStatus">
{{ device.syncStatus === 'success' ? '成功' : device.syncStatus === 'loading' ? '同步中...' : '失败' }}
</text>
</view>
<!-- 重试按钮:右上角(成功/失败都展示;同步中置灰不可点) -->
<view
class="retry-btn"
:class="{ disabled: device.syncStatus === 'loading' }"
@click.stop="device.syncStatus === 'loading' ? null : retrySync(device, index)"
v-if="device.syncStatus"
>
重试
</view>
<view class="delete-btn" @click.stop="removeDevice(index)" v-if="!device.syncStatus || device.syncStatus !== 'loading'">
<u-icon name="close" color="#999" size="20"></u-icon>
</view>
</view>
</view>
</view>
<view class="selected-empty" v-else>
<text>暂无已选车辆,请点击上方「选择车辆」添加</text>
</view>
</view>
</scroll-view>
<!-- 底部同步按钮 -->
<view class="sync-btn" :class="{ disabled: selectedDevices.length === 0 || isSyncing }" @click="syncVoice" v-if="!showDeviceModal">
<u-icon name="reload" color="#fff" size="24" :class="{ rotating: isSyncing }"></u-icon>
<text>{{ isSyncing ? '同步中...' : '同步语音' }}</text>
</view>
<!-- 车辆选择弹窗遮罩 -->
<view class="modal-mask" v-if="showDeviceModal" @tap.stop="closeDeviceModal"></view>
<!-- 车辆选择弹窗 -->
<view class="device-modal" v-if="showDeviceModal" @tap.stop="preventClose">
<view class="modal-header">
<text class="modal-title">选择车辆</text>
<view class="close-btn" @tap="closeDeviceModal">
<u-icon name="close" color="#999" size="32"></u-icon>
</view>
</view>
<view class="modal-content">
<!-- 搜索框 -->
<view class="search-box">
<input
class="search-input"
v-model="searchKeyword"
placeholder="搜索SN或车牌号"
@input="searchDevices"
/>
<u-icon name="search" color="#999" size="20" class="search-icon"></u-icon>
</view>
<!-- 车辆列表:全部展示,点击选中/取消,选中项高亮 -->
<scroll-view class="modal-device-list" scroll-y :show-scrollbar="true">
<view
class="modal-device-item"
:class="{ 'selected': isDeviceSelected(device) }"
v-for="(device, index) in filteredDeviceList"
:key="getDeviceKey(device, index)"
@click.stop="onSelectDeviceByIndex(index)"
>
<view class="device-checkbox">
<u-icon
:name="isDeviceSelected(device) ? 'checkbox-mark' : 'checkbox'"
:color="isDeviceSelected(device) ? '#3996FD' : '#ddd'"
size="40"
></u-icon>
</view>
<view class="modal-device-info">
<view class="modal-device-name">SN: {{ device.sn || device.deviceSn || '--' }}</view>
<view class="modal-device-meta">车牌: {{ device.vehicleNum || '--' }}</view>
</view>
</view>
<view class="empty-tip" v-if="filteredDeviceList.length === 0 && deviceList.length === 0">
暂无车辆数据
</view>
<view class="empty-tip" v-if="filteredDeviceList.length === 0 && deviceList.length > 0">
未搜索到匹配的车辆
</view>
</scroll-view>
<!-- 已选车辆数量 + 全选复选框(方框+勾,一眼看出是复选框) -->
<view class="selected-count">
<text>已选择 {{ tempSelectedDevices.length }} 辆</text>
<view class="select-all-wrap" @tap.stop="toggleSelectAll" v-if="filteredDeviceList.length > 0">
<view class="checkbox-box" :class="{ checked: isAllSelected }">
<text class="checkbox-tick" v-if="isAllSelected">✓</text>
</view>
<text class="select-all-label">全选</text>
</view>
</view>
</view>
<view class="modal-footer">
<view class="modal-btn cancel-btn" @tap="closeDeviceModal">取消</view>
<view class="modal-btn confirm-btn" @tap="confirmSelectDevices">确定</view>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
bgc: {
backgroundColor: "#fff",
},
voiceId: '',
voiceName: '',
areaId: '',
selectedDevices: [],
deviceList: [],
filteredDeviceList: [],
showDeviceModal: false,
searchKeyword: '',
tempSelectedDevices: [],
tempSelectedKeys: [], // 选中项的 key 列表,用于高亮(用字符串保证响应式)
isSyncing: false,
currentSyncIndex: -1
}
},
computed: {
// 当前列表是否已全选(复选框为 true
isAllSelected() {
const list = this.filteredDeviceList || []
if (list.length === 0) return false
return list.every(device => this.isDeviceSelected(device))
}
},
onLoad(options) {
if (options.voiceId) {
this.voiceId = options.voiceId
}
if (options.voiceName) {
this.voiceName = decodeURIComponent(options.voiceName)
}
// 获取当前用户的运营区ID
this.getUserArea()
},
methods: {
preventClose() {},
closeDeviceModal() {
this.showDeviceModal = false
},
// 打开设备选择弹窗
openDeviceModal() {
if (!this.areaId) {
uni.showToast({
title: '未获取到运营区信息',
icon: 'none',
duration: 2000
})
return
}
if (!this.deviceList || this.deviceList.length === 0) {
this.getDeviceList().then(() => {
this.showDeviceModal = true
this.initTempSelected()
}).catch(() => {})
} else {
this.showDeviceModal = true
this.initTempSelected()
}
},
// 打开弹窗时初始化临时选中(与当前已选车辆一致)
initTempSelected() {
const list = this.selectedDevices || []
this.tempSelectedDevices = list.map(d => ({ ...d }))
this.tempSelectedKeys = list.map(d => this.getDeviceKeyForCompare(d))
},
// 获取用户运营区
getUserArea() {
// 优先从存储中获取
if (uni.getStorageSync('adminAreaid')) {
this.areaId = uni.getStorageSync('adminAreaid')
this.getDeviceList()
return
}
// 从用户信息中获取
this.$u.get("/getInfo").then(res => {
if (res.code == 200 && res.user && res.user.areaId) {
this.areaId = res.user.areaId
this.getDeviceList()
} else {
uni.showToast({
title: '未获取到运营区信息',
icon: 'none',
duration: 2000
})
}
}).catch(err => {
uni.showToast({
title: '获取运营区信息失败',
icon: 'none',
duration: 2000
})
})
},
// 获取设备列表
getDeviceList() {
return new Promise((resolve, reject) => {
if (!this.areaId) {
uni.showToast({
title: '未获取到运营区信息',
icon: 'none',
duration: 2000
})
reject('未获取到运营区信息')
return
}
uni.showLoading({
title: '加载车辆中...',
mask: true
})
this.$u.get(`/bst/device/all?areaId=${this.areaId}&supportLocation=true`).then((res) => {
console.log('设备列表接口返回:', res)
if (res.code == 200) {
this.deviceList = res.data || []
this.filteredDeviceList = this.deviceList
console.log('设备列表加载成功,数量:', this.deviceList.length)
console.log('设备列表数据:', this.deviceList)
resolve(this.deviceList)
} else {
console.log('获取设备列表失败:', res.msg)
uni.showToast({
title: res.msg || '获取车辆列表失败',
icon: 'none',
duration: 2000
})
reject(res.msg || '获取车辆列表失败')
}
}).catch(err => {
console.log('获取车辆列表异常:', err)
uni.showToast({
title: '获取车辆列表失败',
icon: 'none',
duration: 2000
})
reject(err)
}).finally(() => {
uni.hideLoading()
})
})
},
// 搜索车辆(按 SN 或 车牌)
searchDevices() {
if (!this.searchKeyword) {
this.filteredDeviceList = this.deviceList
return
}
const keyword = this.searchKeyword.trim().toLowerCase()
this.filteredDeviceList = this.deviceList.filter(device => {
const sn = (device.sn || device.deviceSn || '').toLowerCase()
const plate = (device.vehicleNum || '').toLowerCase()
const name = (device.deviceName || '').toLowerCase()
return sn.includes(keyword) || plate.includes(keyword) || name.includes(keyword)
})
},
// 列表项 :key 用(带 index 保证唯一)
getDeviceKey(device, index) {
if (!device) return 'dev-' + index
const k = this.getDeviceKeyForCompare(device)
return k || 'idx-' + index
},
// 设备唯一标识(不含 index用于选中状态和高亮
getDeviceKeyForCompare(device) {
if (!device) return ''
if (device.id != null && device.id !== '') return 'id-' + device.id
if (device.deviceId != null && device.deviceId !== '') return 'did-' + device.deviceId
if (device.sn) return 'sn-' + String(device.sn).trim()
if (device.deviceSn) return 'dsn-' + String(device.deviceSn).trim()
return ''
},
// 判断是否为同一设备(兼容 id / deviceId / sn / deviceSn
isSameDevice(a, b) {
if (!a || !b) return false
if (a.id != null && b.id != null && String(a.id) === String(b.id)) return true
if (a.deviceId != null && b.deviceId != null && String(a.deviceId) === String(b.deviceId)) return true
if (a.sn && b.sn && String(a.sn).trim() === String(b.sn).trim()) return true
if (a.deviceSn && b.deviceSn && String(a.deviceSn).trim() === String(b.deviceSn).trim()) return true
return false
},
// 是否已选中(用 key 列表判断,保证高亮一定更新)
isDeviceSelected(device) {
const key = this.getDeviceKeyForCompare(device)
return key ? this.tempSelectedKeys.indexOf(key) !== -1 : false
},
// 弹窗内点击:用索引从列表取设备,避免 scroll-view 下 device 闭包为 undefined
onSelectDeviceByIndex(index) {
const list = this.filteredDeviceList || []
const device = list[index]
if (!device) return
const key = this.getDeviceKeyForCompare(device)
if (!key) return
const keyIdx = this.tempSelectedKeys.indexOf(key)
if (keyIdx >= 0) {
this.tempSelectedKeys = this.tempSelectedKeys.filter((_, i) => i !== keyIdx)
this.tempSelectedDevices = this.tempSelectedDevices.filter((_, i) => i !== keyIdx)
} else {
this.tempSelectedKeys = [...this.tempSelectedKeys, key]
this.tempSelectedDevices = [...this.tempSelectedDevices, { ...device }]
}
this.$forceUpdate()
},
// 全选/全不选切换:复选框 true 时点击则全不选false 时点击则全选
toggleSelectAll() {
if (this.isAllSelected) {
this.clearAllDevices()
} else {
this.selectAllDevices()
}
},
// 全选当前列表filteredDeviceList 中未选中的全部加入)
selectAllDevices() {
const list = this.filteredDeviceList || []
const keys = [...this.tempSelectedKeys]
const devices = [...this.tempSelectedDevices]
list.forEach(device => {
const key = this.getDeviceKeyForCompare(device)
if (!key || keys.indexOf(key) !== -1) return
keys.push(key)
devices.push({ ...device })
})
this.tempSelectedKeys = keys
this.tempSelectedDevices = devices
this.$forceUpdate()
},
// 全不选:清空已选
clearAllDevices() {
this.tempSelectedKeys = []
this.tempSelectedDevices = []
this.$forceUpdate()
},
// 确认选择:写入已选列表并关闭弹窗
confirmSelectDevices() {
this.selectedDevices = this.tempSelectedDevices.map(d => ({ ...d }))
this.showDeviceModal = false
this.searchKeyword = ''
this.filteredDeviceList = this.deviceList
this.$forceUpdate()
},
// 移除设备
removeDevice(index) {
this.selectedDevices.splice(index, 1)
},
// 同步语音
async syncVoice() {
if (this.selectedDevices.length === 0 || this.isSyncing) {
return
}
this.isSyncing = true
this.currentSyncIndex = 0
// 重置所有设备的同步状态
this.selectedDevices.forEach(device => {
device.syncStatus = null
device.errorMsg = ''
})
// 逐个同步设备
for (let i = 0; i < this.selectedDevices.length; i++) {
this.currentSyncIndex = i
const device = this.selectedDevices[i]
// 设置加载状态
device.syncStatus = 'loading'
try {
// 构建请求参数
const params = {
voiceId: this.voiceId
}
// 只传 sn 或 id按后端要求
if (device.sn) {
params.sn = device.sn
} else if (device.deviceSn) {
params.sn = device.deviceSn
} else if (device.id != null && device.id !== '') {
params.id = device.id
} else if (device.deviceId != null && device.deviceId !== '') {
params.id = device.deviceId
} else {
device.syncStatus = 'failed'
device.errorMsg = '设备信息不完整'
continue
}
// 调用同步接口
const res = await this.$u.put('/bst/device/iot/syncVoice', params)
if (res.code == 200) {
device.syncStatus = 'success'
device.errorMsg = ''
} else {
device.syncStatus = 'failed'
device.errorMsg = res.msg || '同步失败'
}
} catch (error) {
device.syncStatus = 'failed'
device.errorMsg = error.message || '请求失败'
}
// 等待一小段时间再同步下一个设备,避免请求过快
if (i < this.selectedDevices.length - 1) {
await new Promise(resolve => setTimeout(resolve, 300))
}
}
this.isSyncing = false
this.currentSyncIndex = -1
// 统计同步结果
const successCount = this.selectedDevices.filter(d => d.syncStatus === 'success').length
const failCount = this.selectedDevices.filter(d => d.syncStatus === 'failed').length
uni.showToast({
title: `同步完成:成功${successCount}个,失败${failCount}个`,
icon: 'none',
duration: 3000
})
},
// 重试同步
async retrySync(device, index) {
if (this.isSyncing) {
return
}
device.syncStatus = 'loading'
device.errorMsg = ''
try {
const params = {
voiceId: this.voiceId
}
// 只传 sn 或 id按后端要求
if (device.sn) {
params.sn = device.sn
} else if (device.deviceSn) {
params.sn = device.deviceSn
} else if (device.id != null && device.id !== '') {
params.id = device.id
} else if (device.deviceId != null && device.deviceId !== '') {
params.id = device.deviceId
} else {
device.syncStatus = 'failed'
device.errorMsg = '设备信息不完整'
return
}
const res = await this.$u.put('/bst/device/iot/syncVoice', params)
if (res.code == 200) {
device.syncStatus = 'success'
device.errorMsg = ''
uni.showToast({
title: '同步成功',
icon: 'success',
duration: 2000
})
} else {
device.syncStatus = 'failed'
device.errorMsg = res.msg || '同步失败'
uni.showToast({
title: res.msg || '同步失败',
icon: 'none',
duration: 2000
})
}
} catch (error) {
device.syncStatus = 'failed'
device.errorMsg = error.message || '请求失败'
uni.showToast({
title: '请求失败',
icon: 'none',
duration: 2000
})
}
}
}
}
</script>
<style lang="scss">
page {
background: #F7F7F7;
}
.page {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 20rpx;
overflow: hidden;
padding-bottom: 100rpx;
}
.content-container {
flex: 1;
}
.section {
background: #fff;
border-radius: 20rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
left: 0;
top: 28rpx;
bottom: 28rpx;
width: 6rpx;
border-radius: 3rpx;
}
}
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #262B37;
margin-bottom: 20rpx;
padding-left: 8rpx;
}
.select-device-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
height: 88rpx;
border: 2rpx dashed #3996FD;
border-radius: 16rpx;
color: #3996FD;
font-size: 28rpx;
font-weight: 500;
background: #F8FBFF;
&:active {
background: #EFF6FF;
}
}
.device-list {
display: flex;
flex-direction: column;
}
.selected-empty {
padding: 40rpx 24rpx;
text-align: center;
font-size: 28rpx;
color: #999;
}
.device-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 20rpx;
background: #fff;
border: 2rpx solid #E5E7EB;
border-radius: 12rpx;
margin-bottom: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
position: relative;
}
.device-info {
flex: 1;
}
.device-name {
font-size: 30rpx;
font-weight: 600;
color: #262B37;
margin-bottom: 8rpx;
}
.device-meta {
font-size: 24rpx;
color: #999;
}
.device-error {
margin-top: 8rpx;
font-size: 22rpx;
color: #ff4444;
word-break: break-all;
}
.device-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12rpx;
min-width: 120rpx;
/* 预留右上角重试按钮空间避免与状态文案重叠 */
padding-top: 56rpx;
}
.status-info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4rpx;
}
.status-text {
font-size: 24rpx;
&.success {
color: #64B6A7;
}
&.loading {
color: #3996FD;
}
&.failed {
color: #ff4444;
}
}
.error-msg {
font-size: 22rpx;
color: #ff4444;
max-width: 200rpx;
text-align: right;
word-break: break-all;
}
.retry-btn {
position: absolute;
top: 18rpx;
right: 18rpx;
padding: 8rpx 18rpx;
background: #3996FD;
color: #fff;
border-radius: 6rpx;
font-size: 24rpx;
line-height: 1;
}
.retry-btn.disabled {
background: #C7CDD3;
}
.delete-btn {
padding: 8rpx;
}
.sync-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 100rpx;
background: linear-gradient(135deg, #3996FD 0%, #64B6A7 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: 32rpx;
font-weight: 600;
z-index: 100;
box-shadow: 0 -4rpx 20rpx rgba(57, 150, 253, 0.25);
&.disabled {
background: #ccc;
box-shadow: none;
}
.rotating {
animation: rotate 1s linear infinite;
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 9998;
}
.device-modal {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
max-height: 80vh;
display: flex;
flex-direction: column;
z-index: 9999;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.1);
pointer-events: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.modal-title {
font-size: 34rpx;
font-weight: 600;
color: #262B37;
}
.close-btn {
padding: 8rpx;
}
.modal-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.search-box {
position: relative;
padding: 20rpx;
}
.search-input {
width: 630rpx;
height: 70rpx;
padding: 0 60rpx 0 20rpx;
background: #f5f5f5;
border-radius: 8rpx;
font-size: 28rpx;
}
.search-icon {
position: absolute;
right: 40rpx;
top: 50%;
transform: translateY(-50%);
}
.modal-device-list {
// flex: 1;
// min-height: 0;
// overflow-y: auto;
max-height: 54vh;
overflow: scroll;
}
.modal-device-item {
display: flex;
align-items: center;
padding: 24rpx;
margin: 0 20rpx;
margin-bottom: 12rpx;
border-radius: 12rpx;
border: 2rpx solid #E5E7EB;
background: #fff;
transition: all 0.2s;
&:active {
background-color: #f0f0f0;
}
&.selected {
background: #E3F2FD;
border-color: #3996FD;
border-width: 3rpx;
box-shadow: 0 0 0 2rpx rgba(57, 150, 253, 0.2);
.modal-device-name {
color: #1976D2;
font-weight: 700;
}
.modal-device-meta {
color: #3996FD;
font-weight: 500;
}
}
}
.device-checkbox {
margin-right: 20rpx;
flex-shrink: 0;
}
.modal-device-info {
flex: 1;
}
.modal-device-name {
font-size: 30rpx;
color: #262B37;
margin-bottom: 8rpx;
}
.modal-device-meta {
font-size: 24rpx;
color: #999;
}
.empty-tip {
text-align: center;
padding: 40rpx;
color: #999;
font-size: 28rpx;
}
.selected-count {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
font-size: 28rpx;
color: #3996FD;
font-weight: 600;
background: #EFF6FF;
border-top: 1rpx solid #f0f0f0;
}
.select-all-wrap {
display: flex;
align-items: center;
gap: 12rpx;
}
.checkbox-box {
width: 40rpx;
height: 40rpx;
border: 2rpx solid #ddd;
border-radius: 6rpx;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.checkbox-box.checked {
background: #3996FD;
border-color: #3996FD;
}
.checkbox-tick {
color: #fff;
font-size: 28rpx;
font-weight: bold;
line-height: 1;
}
.select-all-label {
font-size: 28rpx;
color: #3996FD;
font-weight: 500;
}
.modal-footer {
display: flex;
gap: 20rpx;
padding: 30rpx;
border-top: 1rpx solid #f0f0f0;
}
.modal-btn {
flex: 1;
height: 80rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 600;
}
.cancel-btn {
background: #f5f5f5;
color: #666;
}
.confirm-btn {
background: linear-gradient(135deg, #3996FD 0%, #64B6A7 100%);
color: #fff;
box-shadow: 0 4rpx 12rpx rgba(57, 150, 253, 0.25);
}
</style>