chuangte_bike_newxcx/page_shanghu/gongzuotai/yuyin/detail.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="语音详情" :border-bottom="false" :background="bgc" back-icon-color="#262B37" title-color='#262B37'
title-size='36' height='36' id="navbar">
</u-navbar>
<!-- <scroll-view class="detail-container" scroll-y v-if="!isEdit">
<view class="detail-section">
<view class="detail-item">
<view class="detail-label">语音编号</view>
<view class="detail-value">{{ voiceDetail.no || '-' }}</view>
</view>
<view class="detail-item">
<view class="detail-label">运营区</view>
<view class="detail-value">{{ voiceDetail.areaName || '-' }}</view>
</view>
<view class="detail-item" v-if="voiceDetail.remark">
<view class="detail-label">备注</view>
<view class="detail-value">{{ voiceDetail.remark }}</view>
</view>
<view class="detail-item" v-if="voiceDetail.fileUrl">
<view class="detail-label">语音文件</view>
<view class="detail-value file-url" @click="playAudio">
<u-icon name="play-circle" color="#3996FD" size="32"></u-icon>
<text>点击播放</text>
</view>
</view>
</view>
<view class="action-section">
<view class="action-btn edit-btn" @click="toEdit">
修改
</view>
<view class="action-btn delete-btn" @click="deleteVoice">
删除
</view>
</view>
</scroll-view> -->
<!-- 编辑表单与新增页一致无语音名称/运营区编号弹窗+语音预览 -->
<scroll-view class="form-container" scroll-y>
<view class="form-card">
<view class="form-item">
<view class="form-label">
<text class="label-text">语音文件</text>
<text class="required">*</text>
</view>
<view class="upload-box" @click="!editForm.fileUrl && uploadFile()">
<view v-if="!editForm.fileUrl" class="upload-placeholder">
<u-icon name="plus-circle" color="#3996FD" size="64"></u-icon>
<text class="upload-tip">点击上传MP3文件</text>
</view>
<view v-else class="audio-bar">
<view class="audio-remove" @click.stop="removeFile">
<u-icon name="close-circle-fill" color="#ff4444" size="36"></u-icon>
</view>
<view class="audio-play-btn" @click="togglePreviewPlay">
<u-icon
:name="previewPlaying ? 'pause-circle-fill' : 'play-circle-fill'"
:color="previewPlaying ? '#64B6A7' : '#3996FD'"
size="52"
></u-icon>
</view>
<view class="audio-progress-wrap">
<slider
class="audio-slider"
:value="previewProgress"
:min="0"
:max="100"
:step="0.1"
activeColor="#3996FD"
backgroundColor="#E5E7EB"
@change="onPreviewSeek"
@changing="onPreviewSeekChanging"
/>
<view class="audio-time">
<text>{{ formatTime(previewCurrentTime) }}</text>
<text>/ {{ formatTime(previewDuration) }}</text>
</view>
</view>
</view>
</view>
<view class="form-tip">仅支持MP3格式文件</view>
</view>
</view>
<view class="form-card">
<view class="form-item">
<view class="form-label">
<text class="label-text">语音编号</text>
<text class="required">*</text>
</view>
<view class="picker-box" @click="openNoPicker">
<text :class="['picker-text', editForm.no ? '' : 'placeholder']">
{{ editForm.no || '请选择语音编号(11-40)' }}
</text>
<u-icon name="arrow-right" color="#C0C4CC" size="20"></u-icon>
</view>
</view>
</view>
<view class="form-card">
<view class="form-item">
<view class="form-label">
<text class="label-text">备注</text>
<text class="label-tip">(可选)</text>
</view>
<textarea
class="form-textarea"
v-model="editForm.remark"
placeholder="请输入备注信息"
maxlength="200"
:auto-height="true"
></textarea>
</view>
</view>
<view class="form-actions">
<view class="form-btn cancel-btn" style="background: red;color: #fff;" @click="deleteVoice">删除</view>
<view class="form-btn submit-btn" @click="submitEdit">保存</view>
</view>
</scroll-view>
<!-- 语音编号选择弹窗:与新增页一致 -->
<view class="no-picker-mask" v-if="showNoPicker" @click="closeNoPicker"></view>
<view class="no-picker-modal" v-if="showNoPicker" @click.stop="">
<view class="no-picker-title">选择语音编号</view>
<scroll-view class="no-picker-list" scroll-y>
<view
class="no-picker-item"
:class="{ active: editForm.no === item.label, disabled: isNoConfigured(item.label) }"
v-for="(item, idx) in noOptions"
:key="idx"
@click="selectNo(idx)"
>
<text>{{ item.label }}</text>
<view class="no-picker-right">
<text class="configured-tag" v-if="isNoConfigured(item.label)">已配置</text>
<u-icon v-else-if="editForm.no === item.label" name="checkmark" color="#3996FD" size="20"></u-icon>
</view>
</view>
</scroll-view>
<view class="no-picker-cancel" @click="closeNoPicker">取消</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
bgc: {
backgroundColor: "#fff",
},
voiceId: '',
voiceDetail: {},
isEdit: false,
noOptions: [],
noIndex: -1,
editForm: {
fileUrl: '',
no: '',
areaId: '',
remark: ''
},
fileName: '',
token: '',
upurl: '',
showNoPicker: false,
configuredNoSet: {},
previewAudio: null,
previewPlaying: false,
previewProgress: 0,
previewCurrentTime: 0,
previewDuration: 0,
previewSeekChanging: false
}
},
onLoad(options) {
if (options.id) {
this.voiceId = options.id
this.initNoOptions()
this.getQiniuToken()
this.getDetail()
}
},
onUnload() {
this.destroyPreviewAudio()
},
methods: {
// 初始化编号选项
initNoOptions() {
for (let i = 11; i <= 40; i++) {
this.noOptions.push({
value: i,
label: i.toString()
})
}
},
// 获取详情
getDetail() {
uni.showLoading({
title: '加载中...',
mask: true
})
this.$u.get(`/bst/voice/${this.voiceId}`).then((res) => {
if (res.code == 200) {
this.voiceDetail = res.data || {}
this.editForm = {
fileUrl: this.voiceDetail.fileUrl || '',
no: this.voiceDetail.no != null ? this.voiceDetail.no.toString() : '',
areaId: this.voiceDetail.areaId || '',
remark: this.voiceDetail.remark || ''
}
this.fileName = this.voiceDetail.fileUrl ? 'audio.mp3' : ''
const noIdx = this.noOptions.findIndex(item => item.label === this.editForm.no)
if (noIdx >= 0) this.noIndex = noIdx
this.loadConfiguredNos()
} else {
uni.showToast({
title: res.msg || '获取详情失败',
icon: 'none',
duration: 2000
})
}
}).finally(() => {
uni.hideLoading()
})
},
// 拉取当前运营区已配置编号,编辑时排除当前语音的 no
loadConfiguredNos() {
const areaId = this.voiceDetail.areaId || this.editForm.areaId
if (!areaId) return
this.$u.get('/bst/voice/list?areaId=' + areaId).then(res => {
if (res.code != 200) return
const rows = Array.isArray(res.data) ? res.data : (res.rows || [])
const set = {}
rows.forEach(item => {
if (item && item.id == this.voiceId) return
const no = item && (item.no != null ? String(item.no) : '')
if (no) set[no] = true
})
this.configuredNoSet = set
this.$forceUpdate()
}).catch(() => {})
},
isNoConfigured(label) {
return !!(this.configuredNoSet && this.configuredNoSet[String(label)])
},
openNoPicker() {
if (this.voiceDetail.areaId && (!this.configuredNoSet || Object.keys(this.configuredNoSet).length === 0)) {
this.loadConfiguredNos()
}
this.showNoPicker = true
},
closeNoPicker() {
this.showNoPicker = false
},
selectNo(index) {
const item = this.noOptions[index]
if (item && this.isNoConfigured(item.label)) {
uni.showToast({ title: '该编号已配置', icon: 'none' })
return
}
this.noIndex = index
this.editForm.no = this.noOptions[index].label
this.showNoPicker = false
},
// 获取七牛云token
getQiniuToken() {
this.$u.get("/common/qiniuToken").then((res) => {
if (res.code == 200) {
this.token = res.data
this.upurl = res.domain
}
})
},
// 播放音频
playAudio() {
if (this.voiceDetail.fileUrl) {
// #ifdef H5
const audio = new Audio(this.voiceDetail.fileUrl)
audio.play()
// #endif
// #ifndef H5
uni.downloadFile({
url: this.voiceDetail.fileUrl,
success: (res) => {
uni.openDocument({
filePath: res.tempFilePath,
success: () => {
console.log('打开文档成功')
}
})
}
})
// #endif
}
},
// 进入编辑模式
toEdit() {
this.isEdit = true
this.$nextTick(() => {
if (this.editForm.fileUrl) this.preloadPreviewDuration()
})
},
// 取消编辑
cancelEdit() {
this.destroyPreviewAudio()
this.isEdit = false
this.getDetail()
},
// 上传文件
uploadFile() {
if (!this.token) {
uni.showToast({
title: '上传token获取失败请重试',
icon: 'none',
duration: 2000
})
return
}
// #ifdef H5
// H5使用input选择文件
const input = document.createElement('input')
input.type = 'file'
input.accept = 'audio/mp3,audio/mpeg'
input.onchange = (e) => {
const file = e.target.files[0]
if (file) {
if (!file.name.toLowerCase().endsWith('.mp3')) {
uni.showToast({
title: '仅支持MP3格式文件',
icon: 'none',
duration: 2000
})
return
}
this.uploadToQiniu(file)
}
}
input.click()
// #endif
// #ifdef MP-WEIXIN
// 微信小程序使用wx.chooseMessageFile选择文件
wx.chooseMessageFile({
count: 1,
type: 'file',
success: (res) => {
const file = res.tempFiles[0]
if (file) {
// 检查文件类型
if (!file.name.toLowerCase().endsWith('.mp3')) {
uni.showToast({
title: '仅支持MP3格式文件',
icon: 'none',
duration: 2000
})
return
}
this.uploadToQiniu(file.path)
}
},
fail: (err) => {
console.log('选择文件失败', err)
uni.showToast({
title: '选择文件失败,请从聊天中选择文件',
icon: 'none',
duration: 2000
})
}
})
// #endif
// #ifndef H5 || MP-WEIXIN
// 其他平台如APP使用uni.chooseFile
// #ifdef APP-PLUS
uni.chooseFile({
count: 1,
type: 'file',
extension: ['.mp3'],
success: (res) => {
const file = res.tempFiles[0]
if (file) {
this.uploadToQiniu(file.path)
}
},
fail: (err) => {
console.log('选择文件失败', err)
uni.showToast({
title: '选择文件失败',
icon: 'none',
duration: 2000
})
}
})
// #endif
// #endif
},
// 上传到七牛云
uploadToQiniu(filePath) {
uni.showLoading({
title: '上传中...',
mask: true
})
let _this = this
let math = 'static/' + _this.$u.guid(20) + '.mp3'
// #ifndef H5
wx.uploadFile({
url: 'https://up-z2.qiniup.com',
name: 'file',
filePath: filePath,
formData: {
token: _this.token,
key: 'bike/audio/' + math
},
success: function(res) {
try {
let str = JSON.parse(res.data)
_this.destroyPreviewAudio()
_this.editForm.fileUrl = _this.upurl + '/' + str.key
_this.fileName = 'audio.mp3'
_this.preloadPreviewDuration()
uni.hideLoading()
uni.showToast({
title: '上传成功',
icon: 'success',
duration: 2000
})
} catch(e) {
uni.hideLoading()
uni.showToast({
title: '上传失败',
icon: 'none',
duration: 2000
})
}
},
fail: function() {
uni.hideLoading()
uni.showToast({
title: '上传失败',
icon: 'none',
duration: 2000
})
}
})
// #endif
// #ifdef H5
const reader = new FileReader()
reader.onload = (e) => {
const blob = e.target.result
const formData = new FormData()
formData.append('file', blob)
formData.append('token', _this.token)
formData.append('key', 'bike/audio/' + math)
fetch('https://up-z2.qiniup.com', {
method: 'POST',
body: formData
}).then(res => res.json()).then(data => {
_this.destroyPreviewAudio()
_this.editForm.fileUrl = _this.upurl + '/' + data.key
_this.fileName = filePath.name || 'audio.mp3'
_this.preloadPreviewDuration()
uni.hideLoading()
uni.showToast({
title: '上传成功',
icon: 'success',
duration: 2000
})
}).catch(err => {
uni.hideLoading()
uni.showToast({
title: '上传失败',
icon: 'none',
duration: 2000
})
})
}
reader.readAsArrayBuffer(filePath)
// #endif
},
// 移除文件
removeFile() {
this.destroyPreviewAudio()
this.editForm.fileUrl = ''
this.fileName = ''
},
togglePreviewPlay() {
if (!this.editForm.fileUrl) return
if (this.previewPlaying) this.pausePreview()
else this.playPreview()
},
playPreview() {
if (!this.editForm.fileUrl) return
if (this.previewAudio) {
this.previewAudio.play()
this.previewPlaying = true
return
}
const ctx = uni.createInnerAudioContext()
ctx.src = this.editForm.fileUrl
ctx.autoplay = false
ctx.onTimeUpdate(() => {
if (this.previewSeekChanging) return
this.previewCurrentTime = ctx.currentTime
this.previewDuration = ctx.duration || 0
if (ctx.duration > 0) this.previewProgress = (ctx.currentTime / ctx.duration) * 100
})
ctx.onEnded(() => {
this.previewPlaying = false
this.previewProgress = 0
this.previewCurrentTime = 0
})
ctx.onError(() => {
this.previewPlaying = false
uni.showToast({ title: '播放失败', icon: 'none' })
})
ctx.onCanplay(() => {
if (ctx.duration > 0) this.previewDuration = ctx.duration
})
ctx.play()
this.previewAudio = ctx
this.previewPlaying = true
},
pausePreview() {
if (this.previewAudio) {
this.previewAudio.pause()
this.previewPlaying = false
}
},
onPreviewSeekChanging() {
this.previewSeekChanging = true
},
onPreviewSeek(e) {
const p = Number(e.detail.value) || 0
this.previewSeekChanging = false
if (!this.previewAudio || !this.editForm.fileUrl) return
const duration = this.previewAudio.duration || this.previewDuration
if (duration <= 0) return
const seekTo = (p / 100) * duration
if (typeof this.previewAudio.seek === 'function') this.previewAudio.seek(seekTo)
this.previewCurrentTime = seekTo
this.previewProgress = p
},
formatTime(seconds) {
if (seconds == null || isNaN(seconds) || seconds < 0) return '00:00'
const s = Math.floor(seconds)
const m = Math.floor(s / 60)
const sec = s % 60
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
},
destroyPreviewAudio() {
if (this.previewAudio) {
this.previewAudio.stop()
this.previewAudio.destroy()
this.previewAudio = null
}
this.previewPlaying = false
this.previewProgress = 0
this.previewCurrentTime = 0
this.previewDuration = 0
},
// 与语音列表 index 预加载时长一致:只创建上下文设 src不 play在 onCanplay/onTimeUpdate 取 duration
preloadPreviewDuration() {
if (!this.editForm.fileUrl) return
const audioContext = uni.createInnerAudioContext()
audioContext.src = this.editForm.fileUrl
audioContext.autoplay = false
let hasGotDuration = false
const trySetDuration = () => {
if (hasGotDuration) return
if (audioContext.duration > 0) {
hasGotDuration = true
this.previewDuration = audioContext.duration
this.$forceUpdate()
setTimeout(() => {
try { audioContext.destroy() } catch (e) {}
}, 100)
}
}
audioContext.onCanplay(trySetDuration)
audioContext.onTimeUpdate(trySetDuration)
audioContext.onError(() => {
try { audioContext.destroy() } catch (e) {}
})
setTimeout(() => {
if (!hasGotDuration) {
try { audioContext.destroy() } catch (e) {}
}
}, 3000)
},
// 提交修改
submitEdit() {
if (!this.editForm.fileUrl) {
uni.showToast({ title: '请上传语音文件', icon: 'none', duration: 2000 })
return
}
if (!this.editForm.no) {
uni.showToast({ title: '请选择语音编号', icon: 'none', duration: 2000 })
return
}
const areaId = this.editForm.areaId || this.voiceDetail.areaId
if (!areaId) {
uni.showToast({ title: '未获取到运营区信息', icon: 'none', duration: 2000 })
return
}
const data = {
id: this.voiceId,
fileUrl: this.editForm.fileUrl,
no: parseInt(this.editForm.no),
areaId: areaId,
remark: this.editForm.remark || ''
}
uni.showLoading({
title: '保存中...',
mask: true
})
this.$u.post('/bst/voice', data).then((res) => {
if (res.code == 200) {
uni.showToast({
title: '修改成功',
icon: 'success',
duration: 3000
})
setTimeout(()=>{
uni.navigateBack()
},1000)
} else {
uni.showToast({
title: res.msg || '修改失败',
icon: 'none',
duration: 5000
})
}
}).finally(() => {
uni.hideLoading()
})
},
// 删除语音
deleteVoice() {
uni.showModal({
title: '提示',
content: '确定要删除这条语音吗?',
success: (res) => {
if (res.confirm) {
uni.showLoading({
title: '删除中...',
mask: true
})
this.$u.delete(`/bst/voice/${this.voiceId}`).then((res) => {
if (res.code == 200) {
uni.showToast({
title: '删除成功',
icon: 'success',
duration: 2000
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
uni.showToast({
title: res.msg || '删除失败',
icon: 'none',
duration: 2000
})
}
}).finally(() => {
uni.hideLoading()
})
}
}
})
}
}
}
</script>
<style lang="scss">
page {
background: #F7F7F7;
}
.page {
width: 100%;
min-height: 100vh;
padding: 20rpx;
}
.detail-container {
height: 100vh;
}
.detail-section {
padding: 20rpx;
background: #fff;
border-radius: 20rpx;
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;
}
}
.detail-item {
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.detail-label {
font-size: 26rpx;
color: #999;
margin-bottom: 12rpx;
}
.detail-value {
font-size: 30rpx;
color: #262B37;
font-weight: 500;
}
.file-url {
display: flex;
align-items: center;
gap: 12rpx;
color: #3996FD;
}
.action-section {
display: flex;
gap: 20rpx;
padding: 20rpx 0;
}
.action-btn {
flex: 1;
height: 88rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 600;
}
.edit-btn {
background: linear-gradient(135deg, #3996FD 0%, #64B6A7 100%);
color: #fff;
box-shadow: 0 4rpx 12rpx rgba(57, 150, 253, 0.25);
}
.delete-btn {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%);
color: #fff;
box-shadow: 0 4rpx 12rpx rgba(255, 68, 68, 0.2);
}
.form-container {
height: 100vh;
padding-bottom: 40rpx;
}
.form-card {
background: #FFFFFF;
border-radius: 24rpx;
padding: 40rpx 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.form-item {
.form-label {
display: flex;
align-items: center;
margin-bottom: 24rpx;
padding-left: 8rpx;
.label-text { font-size: 30rpx; color: #333; font-weight: 600; }
.required { color: #FF4444; margin-left: 8rpx; font-size: 28rpx; }
.label-tip { font-size: 24rpx; color: #909399; margin-left: 8rpx; font-weight: 400; }
}
}
.upload-box {
width: 100%;
min-height: 200rpx;
background: #F5F7FA;
border: 3rpx dashed #D3D4D6;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20rpx;
padding: 40rpx;
.upload-tip { font-size: 28rpx; color: #666; font-weight: 500; }
}
.audio-bar {
width: 100%;
display: flex;
align-items: flex-end;
gap: 20rpx;
padding: 28rpx 24rpx;
}
.audio-remove, .audio-play-btn { flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
.audio-play-btn { width: 52rpx; height: 52rpx; margin-right: 50rpx; }
.audio-progress-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: center; }
.audio-slider { width: 100%; margin: 0; }
.audio-time { font-size: 22rpx; color: #909399; margin-top: 6rpx; margin-left: 50rpx; }
.picker-box {
width: 100%;
height: 96rpx;
background: #F5F7FA;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
.picker-text { font-size: 30rpx; color: #333; font-weight: 500; &.placeholder { color: #C0C4CC; } }
}
.form-tip {
margin-top: 16rpx;
font-size: 24rpx;
color: #909399;
padding-left: 8rpx;
}
.form-textarea {
width: 100%;
min-height: 160rpx;
padding: 24rpx;
background: #F5F7FA;
border-radius: 20rpx;
font-size: 28rpx;
color: #333;
box-sizing: border-box;
}
.form-actions {
display: flex;
gap: 20rpx;
margin-top: 40rpx;
}
.form-btn {
flex: 1;
height: 88rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 600;
}
.cancel-btn {
background: #f0f0f0;
color: #666;
}
.submit-btn {
background: linear-gradient(135deg, #3996FD 0%, #64B6A7 100%);
color: #fff;
box-shadow: 0 4rpx 12rpx rgba(57, 150, 253, 0.25);
}
.no-picker-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9998;
}
.no-picker-modal {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 560rpx;
max-height: 70vh;
background: #fff;
border-radius: 24rpx;
overflow: hidden;
z-index: 9999;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.15);
}
.no-picker-title {
text-align: center;
font-size: 32rpx;
font-weight: 600;
color: #262B37;
padding: 32rpx;
}
.no-picker-list { max-height: 480rpx; }
.no-picker-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx;
font-size: 30rpx;
color: #333;
border-top: 1px solid #f3f1f1;
&.active { color: #3996FD; font-weight: 600; background: #EFF6FF; }
&.disabled { color: #B0B0B0; background: #F7F7F7; }
}
.no-picker-right { display: flex; align-items: center; }
.configured-tag {
font-size: 22rpx;
color: #999;
padding: 6rpx 12rpx;
background: #EFEFEF;
border-radius: 999rpx;
}
.no-picker-cancel {
text-align: center;
font-size: 30rpx;
color: #999;
padding: 24rpx;
border-top: 1rpx solid #f0f0f0;
}
</style>