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

877 lines
20 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="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="!fileUrl && uploadFile()">
<view v-if="!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', selectedNo ? '' : 'placeholder']">
{{ selectedNo || '请选择语音编号(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="remark"
placeholder="请输入备注信息"
maxlength="200"
:auto-height="true"
></textarea>
</view>
</view>
</scroll-view>
<view class="submit-btn" @click="submit">
<text>提交</text>
</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: selectedNo === 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="selectedNo === 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",
},
fileUrl: '',
fileName: '',
token: '',
upurl: '',
noOptions: [],
noIndex: -1,
selectedNo: '',
areaId: '',
remark: '',
showNoPicker: false,
configuredNoSet: {}, // 已配置编号集合key 为 label 字符串)
// 语音预览
previewAudio: null,
previewPlaying: false,
previewProgress: 0,
previewCurrentTime: 0,
previewDuration: 0,
previewSeekChanging: false
}
},
onUnload() {
this.destroyPreviewAudio()
},
onLoad() {
this.initNoOptions()
this.getUserAreaId()
this.getQiniuToken()
},
methods: {
// 初始化编号选项 11-40
initNoOptions() {
for (let i = 11; i <= 40; i++) {
this.noOptions.push({
value: i,
label: i.toString()
})
}
},
// 获取用户运营区ID
getUserAreaId() {
// 优先从存储中获取
if (uni.getStorageSync('adminAreaid')) {
this.areaId = uni.getStorageSync('adminAreaid')
this.loadConfiguredNos()
return
}
// 从用户信息中获取
this.$u.get("/getInfo").then(res => {
if (res.code == 200 && res.user && res.user.areaId) {
this.areaId = res.user.areaId
this.loadConfiguredNos()
}
})
},
// 获取七牛云token
getQiniuToken() {
this.$u.get("/common/qiniuToken").then((res) => {
if (res.code == 200) {
this.token = res.data
this.upurl = res.domain
}
})
},
// 拉取当前运营区已配置的语音编号,用于弹窗置灰
loadConfiguredNos() {
if (!this.areaId) return
this.$u.get('/bst/voice/list?areaId=' + this.areaId).then(res => {
if (res.code != 200) return
const rows = Array.isArray(res.data) ? res.data : (res.rows || [])
const set = {}
rows.forEach(item => {
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.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.selectedNo = this.noOptions[index].label
this.showNoPicker = false
},
// 上传文件
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'
// #ifdef H5
// 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.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
// #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.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
},
// 移除文件
removeFile() {
this.destroyPreviewAudio()
this.fileUrl = ''
this.fileName = ''
},
// 语音预览:播放/暂停
togglePreviewPlay() {
if (!this.fileUrl) return
if (this.previewPlaying) {
this.pausePreview()
} else {
this.playPreview()
}
},
playPreview() {
if (!this.fileUrl) return
if (this.previewAudio) {
this.previewAudio.play()
this.previewPlaying = true
return
}
const ctx = uni.createInnerAudioContext()
ctx.src = this.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(e) {
this.previewSeekChanging = true
},
onPreviewSeek(e) {
const p = Number(e.detail.value) || 0
this.previewSeekChanging = false
if (!this.previewAudio || !this.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
},
// 上传成功后预加载音频以获取时长,便于下方显示
preloadPreviewDuration() {
if (!this.fileUrl) return
const ctx = uni.createInnerAudioContext()
ctx.src = this.fileUrl
ctx.autoplay = false
let done = false
const setDuration = () => {
if (done || !ctx.duration || ctx.duration <= 0) return
done = true
this.previewDuration = ctx.duration
this.$forceUpdate()
setTimeout(() => {
ctx.destroy()
}, 100)
}
ctx.onCanplay(setDuration)
ctx.onTimeUpdate(() => setDuration())
ctx.onError(() => {
if (!done) ctx.destroy()
})
setTimeout(() => {
if (!done) {
done = true
ctx.destroy()
}
}, 5000)
},
// 提交
submit() {
if (!this.fileUrl) {
uni.showToast({
title: '请上传语音文件',
icon: 'none',
duration: 2000
})
return
}
if (!this.selectedNo) {
uni.showToast({
title: '请选择语音编号',
icon: 'none',
duration: 2000
})
return
}
if (!this.areaId) {
uni.showToast({
title: '未获取到运营区信息',
icon: 'none',
duration: 2000
})
return
}
const data = {
fileUrl: this.fileUrl,
no: parseInt(this.selectedNo),
areaId: this.areaId,
remark: this.remark || ''
}
uni.showLoading({
title: '提交中...',
mask: true
})
this.$u.post('/bst/voice', data).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
})
}
}).catch(err => {
uni.showToast({
title: '提交失败',
icon: 'none',
duration: 2000
})
}).finally(() => {
uni.hideLoading()
})
}
}
}
</script>
<style lang="scss">
page {
background: #F7F7F7;
}
.page {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 0 20rpx;
}
.form-container {
flex: 1;
padding-bottom: 140rpx;
}
.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;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
width: 6rpx;
height: 28rpx;
border-radius: 3rpx;
}
.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;
transition: all 0.3s ease;
&:active {
border-color: #3996FD;
background: #EFF6FF;
}
}
.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 {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 48rpx;
height: 48rpx;
}
.audio-play-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
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;
display: flex;
align-items: center;
gap: 8rpx;
margin-left: 50rpx;
}
.form-tip {
margin-top: 16rpx;
font-size: 24rpx;
color: #909399;
padding-left: 8rpx;
}
.picker-box {
width: 100%;
height: 96rpx;
background: #F5F7FA;
border: 2rpx solid transparent;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
transition: all 0.3s;
&:active {
background: #FFFFFF;
border-color: #3996FD;
box-shadow: 0 0 0 4rpx rgba(57, 150, 253, 0.12);
}
.picker-text {
font-size: 30rpx;
color: #333;
font-weight: 500;
&.placeholder {
color: #C0C4CC;
}
}
}
.form-textarea {
width: 100%;
min-height: 160rpx;
padding: 24rpx;
background: #F5F7FA;
border: 2rpx solid transparent;
border-radius: 20rpx;
font-size: 28rpx;
color: #333;
box-sizing: border-box;
transition: all 0.3s;
&:focus {
background: #FFFFFF;
border-color: #3996FD;
box-shadow: 0 0 0 4rpx rgba(57, 150, 253, 0.12);
}
}
.submit-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;
font-size: 34rpx;
font-weight: 600;
z-index: 100;
box-shadow: 0 -4rpx 20rpx rgba(57, 150, 253, 0.25);
&::after {
border: none;
}
&:active {
opacity: 0.9;
}
}
/* 语音编号选择弹窗居中可滚动点击选择 */
.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>