feat(voice): 大神语音介绍真实落地(录制/上传/试听)
- DB: companions.voice_intro_url;仓储 updateCompanionVoice;详情与 /api/expert/me 返回 voiceIntroUrl/voiceDuration - 后端: POST /api/companions/:id/voice(音频白名单 mp3/m4a/aac/wav/ogg、5MB、≤60s、限流、仅本人或管理员) - 小程序: api.uploadVoiceIntro(uni.uploadFile + 用户令牌);工作台新增「语音介绍」卡片(录制/重录/试听,60s mp3);详情页改为 uni.createInnerAudioContext 真实播放,卸载销毁 - 修复 uploadImage 未携带用户令牌(生产 fail-closed 后会导致上传失败) - 新增 voice-intro.spec.ts(104 套件/701 用例全绿);冒烟 7/7
这个提交包含在:
@@ -872,6 +872,18 @@ export async function applyPostParty(postId, applyData = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户会话 token 请求头(上传类接口需要)
|
||||
*/
|
||||
function authHeader() {
|
||||
try {
|
||||
const t = uni.getStorageSync('userToken');
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实上传本地图片至服务端持久化静态存储
|
||||
* @param {string} tempFilePath 本地图片临时路径 (如 wxfile://tmp_xxx 或 blob:xxx)
|
||||
@@ -917,6 +929,7 @@ export function uploadImage(tempFilePath) {
|
||||
url: uploadUrl,
|
||||
filePath: tempFilePath,
|
||||
name: 'file',
|
||||
header: authHeader(),
|
||||
success: (uploadRes) => {
|
||||
try {
|
||||
const data = JSON.parse(uploadRes.data);
|
||||
@@ -1097,6 +1110,51 @@ export async function deleteExpertService(expertId, serviceId) {
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传大神语音介绍(录音文件),返回可直接播放的公网地址
|
||||
* @param {number} expertId 大神ID
|
||||
* @param {string} tempFilePath 录音临时文件路径
|
||||
* @param {number} duration 时长(秒,≤60)
|
||||
*/
|
||||
export function uploadVoiceIntro(expertId, tempFilePath, duration) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!tempFilePath) return reject(new Error('语音文件路径为空'));
|
||||
// #ifdef H5
|
||||
const uploadUrl = `/api/companions/${Number(expertId)}/voice`;
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
const uploadUrl = `https://peiww.gynhc.com/api/companions/${Number(expertId)}/voice`;
|
||||
// #endif
|
||||
uni.uploadFile({
|
||||
url: uploadUrl,
|
||||
filePath: tempFilePath,
|
||||
name: 'file',
|
||||
header: authHeader(),
|
||||
formData: {
|
||||
duration: String(Math.min(60, Math.max(1, Math.round(Number(duration) || 15)))),
|
||||
userId: String(normalizeUserId()),
|
||||
},
|
||||
success: (uploadRes) => {
|
||||
try {
|
||||
const data = JSON.parse(uploadRes.data);
|
||||
if (data && data.code === 20000 && data.data && data.data.voiceIntroUrl) {
|
||||
let finalUrl = data.data.voiceIntroUrl;
|
||||
// #ifndef H5
|
||||
if (String(finalUrl).startsWith('/uploads/')) finalUrl = `https://peiww.gynhc.com${finalUrl}`;
|
||||
// #endif
|
||||
resolve({ url: finalUrl, duration: data.data.voiceDuration });
|
||||
} else {
|
||||
reject(new Error((data && data.message) || '语音上传失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error('语音上传返回格式错误'));
|
||||
}
|
||||
},
|
||||
fail: (err) => reject(new Error(err.errMsg || '语音上传网络异常')),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户收藏的陪玩列表
|
||||
*/
|
||||
|
||||
@@ -52,14 +52,14 @@
|
||||
<image class="play-svg" :src="isPlayingVoice ? ICONS.pause : ICONS.play" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="voice-info">
|
||||
<text class="voice-title">{{ isPlayingVoice ? '正在播放打招呼原声...' : '点击试听打招呼原声' }}</text>
|
||||
<text class="voice-title">{{ isPlayingVoice ? '正在播放语音介绍...' : (expert.voiceIntroUrl ? '点击试听语音介绍' : '暂未上传语音介绍') }}</text>
|
||||
<view class="voice-wave" v-if="isPlayingVoice">
|
||||
<view class="bar b1"></view>
|
||||
<view class="bar b2"></view>
|
||||
<view class="bar b3"></view>
|
||||
<view class="bar b4"></view>
|
||||
</view>
|
||||
<text class="voice-dur" v-else>{{ expert.voiceDuration }}s 真实原声</text>
|
||||
<text class="voice-dur" v-else>{{ expert.voiceIntroUrl ? expert.voiceDuration + 's 真实录音' : '暂无可试听内容' }}</text>
|
||||
</view>
|
||||
<text class="listen-tag">试听</text>
|
||||
</view>
|
||||
@@ -280,6 +280,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
uni.$off('theme-change', onThemeChanged);
|
||||
try { if (voiceAudio) { voiceAudio.destroy(); voiceAudio = null; } } catch (_) {}
|
||||
});
|
||||
|
||||
const stopPropagation = (e) => {
|
||||
@@ -335,6 +336,7 @@ const expert = ref({
|
||||
const isCompanion = computed(() => expert.value.isCompanion !== false);
|
||||
const selectedServiceId = ref('srv_1');
|
||||
const isPlayingVoice = ref(false);
|
||||
let voiceAudio = null;
|
||||
let currentExpertTargetId = null;
|
||||
|
||||
const refreshFavoriteState = async () => {
|
||||
@@ -526,13 +528,27 @@ const selectService = (srv) => {
|
||||
};
|
||||
|
||||
const toggleVoice = () => {
|
||||
isPlayingVoice.value = !isPlayingVoice.value;
|
||||
if (isPlayingVoice.value) {
|
||||
uni.showToast({ title: '正在播放大神打招呼原声', icon: 'none' });
|
||||
setTimeout(() => {
|
||||
isPlayingVoice.value = false;
|
||||
}, 4000);
|
||||
const url = String((expert.value && expert.value.voiceIntroUrl) || '');
|
||||
if (!url) {
|
||||
uni.showToast({ title: '该大神暂未上传语音介绍', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (isPlayingVoice.value) {
|
||||
try { voiceAudio && voiceAudio.pause(); } catch (_) {}
|
||||
isPlayingVoice.value = false;
|
||||
return;
|
||||
}
|
||||
if (!voiceAudio) {
|
||||
voiceAudio = uni.createInnerAudioContext();
|
||||
voiceAudio.onEnded(() => { isPlayingVoice.value = false; });
|
||||
voiceAudio.onError(() => {
|
||||
isPlayingVoice.value = false;
|
||||
uni.showToast({ title: '语音播放失败,请稍后重试', icon: 'none' });
|
||||
});
|
||||
}
|
||||
voiceAudio.src = url;
|
||||
voiceAudio.play();
|
||||
isPlayingVoice.value = true;
|
||||
};
|
||||
|
||||
const chatOnline = () => {
|
||||
|
||||
@@ -194,6 +194,29 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 语音介绍(录音试听):仅上传自己的录音,不做实时语音 -->
|
||||
<view class="section-card voice-intro-card">
|
||||
<view class="sec-head">
|
||||
<view class="title-wrap">
|
||||
<image class="sec-svg" :src="ICONS.mic" mode="aspectFit" />
|
||||
<text class="sec-title">语音介绍(试听)</text>
|
||||
</view>
|
||||
<text class="sec-sub">≤60 秒 · 仅本人录音</text>
|
||||
</view>
|
||||
<view class="voice-row">
|
||||
<button class="btn-wd-records btn-active" :disabled="voiceUploading" @click="toggleRecord">
|
||||
{{ voiceRecording ? '停止录音' : (expert.voiceIntroUrl ? '重新录制' : '录制语音') }}
|
||||
</button>
|
||||
<button v-if="expert.voiceIntroUrl" class="btn-wd-records btn-active" @click="playVoice">
|
||||
{{ voicePlaying ? '停止播放' : '播放试听' }}
|
||||
</button>
|
||||
<text class="voice-meta">
|
||||
{{ voiceUploading ? '上传中…' : (expert.voiceIntroUrl ? `已上传 · ${expert.voiceDuration || 0} 秒` : '暂未上传') }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="voice-tip">语音仅用于买家试听介绍,平台不提供实时语音房;实际语音沟通在游戏内或微信进行。</text>
|
||||
</view>
|
||||
|
||||
<!-- 核心亮点功能:4 位数字门票现场核销中心 (严格依 UIUX 规范 4.2 落地) -->
|
||||
<view class="verify-section-card">
|
||||
<view class="sec-head">
|
||||
@@ -443,7 +466,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { ICONS } from '@/common/icons.js';
|
||||
import { isExpertCertified } from '@/common/role.js';
|
||||
@@ -466,6 +489,7 @@ import {
|
||||
createExpertService,
|
||||
updateExpertService,
|
||||
deleteExpertService,
|
||||
uploadVoiceIntro,
|
||||
getUserWithdrawals
|
||||
} from '@/common/api.js';
|
||||
|
||||
@@ -562,7 +586,9 @@ const loadExpertProfile = async () => {
|
||||
nickname: profile.name || expert.value.nickname,
|
||||
avatar: profile.avatar || expert.value.avatar,
|
||||
track: profile.track || '',
|
||||
hourlyRate: Number(profile.hourlyRate) || expert.value.hourlyRate
|
||||
hourlyRate: Number(profile.hourlyRate) || expert.value.hourlyRate,
|
||||
voiceIntroUrl: profile.voiceIntroUrl || '',
|
||||
voiceDuration: Number(profile.voiceDuration) || 0
|
||||
};
|
||||
}
|
||||
} catch (_) {}
|
||||
@@ -577,6 +603,83 @@ const loadExpertServices = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 语音介绍(录音试听)状态与处理
|
||||
const voiceRecording = ref(false);
|
||||
const voiceUploading = ref(false);
|
||||
const voicePlaying = ref(false);
|
||||
let recorderManager = null;
|
||||
let voiceAudioCtx = null;
|
||||
|
||||
const ensureRecorder = () => {
|
||||
if (recorderManager) return recorderManager;
|
||||
recorderManager = uni.getRecorderManager();
|
||||
recorderManager.onStop((res) => {
|
||||
voiceRecording.value = false;
|
||||
const duration = Math.max(1, Math.round(Number(res.duration || 0) / 1000) || 1);
|
||||
uploadRecordedVoice(res.tempFilePath, duration);
|
||||
});
|
||||
recorderManager.onError(() => {
|
||||
voiceRecording.value = false;
|
||||
uni.showToast({ title: '录音失败,请检查麦克风权限', icon: 'none' });
|
||||
});
|
||||
return recorderManager;
|
||||
};
|
||||
|
||||
const uploadRecordedVoice = async (filePath, duration) => {
|
||||
if (!filePath) return;
|
||||
voiceUploading.value = true;
|
||||
try {
|
||||
const r = await uploadVoiceIntro(currentExpertId(), filePath, duration);
|
||||
expert.value = { ...expert.value, voiceIntroUrl: r.url, voiceDuration: r.duration };
|
||||
uni.showToast({ title: '语音介绍已更新', icon: 'none' });
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '语音上传失败', icon: 'none' });
|
||||
} finally {
|
||||
voiceUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRecord = () => {
|
||||
const rm = ensureRecorder();
|
||||
if (voiceRecording.value) {
|
||||
rm.stop();
|
||||
return;
|
||||
}
|
||||
const start = () => {
|
||||
rm.start({ duration: 60000, format: 'mp3', sampleRate: 16000, numberOfChannels: 1, encodeBitRate: 48000 });
|
||||
voiceRecording.value = true;
|
||||
uni.showToast({ title: '开始录音,最长 60 秒', icon: 'none' });
|
||||
};
|
||||
uni.authorize({
|
||||
scope: 'scope.record',
|
||||
success: start,
|
||||
fail: () => uni.showToast({ title: '请先允许使用麦克风', icon: 'none' })
|
||||
});
|
||||
};
|
||||
|
||||
const playVoice = () => {
|
||||
const url = expert.value.voiceIntroUrl;
|
||||
if (!url) return;
|
||||
if (voicePlaying.value) {
|
||||
try { voiceAudioCtx && voiceAudioCtx.stop(); } catch (_) {}
|
||||
voicePlaying.value = false;
|
||||
return;
|
||||
}
|
||||
if (!voiceAudioCtx) {
|
||||
voiceAudioCtx = uni.createInnerAudioContext();
|
||||
voiceAudioCtx.onEnded(() => { voicePlaying.value = false; });
|
||||
voiceAudioCtx.onError(() => { voicePlaying.value = false; });
|
||||
}
|
||||
voiceAudioCtx.src = url;
|
||||
voiceAudioCtx.play();
|
||||
voicePlaying.value = true;
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
try { if (recorderManager && voiceRecording.value) recorderManager.stop(); } catch (_) {}
|
||||
try { if (voiceAudioCtx) { voiceAudioCtx.destroy(); voiceAudioCtx = null; } } catch (_) {}
|
||||
});
|
||||
|
||||
const openServiceForm = (srv) => {
|
||||
if (srv) {
|
||||
serviceForm.value = {
|
||||
@@ -2180,6 +2283,28 @@ const switchBackToPlayer = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 语音介绍卡片 */
|
||||
.voice-intro-card {
|
||||
.voice-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
.voice-meta {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.voice-tip {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* 服务包操作与表单弹窗 */
|
||||
.srv-ops {
|
||||
display: flex;
|
||||
|
||||
在新工单中引用
屏蔽一个用户