feat(voice): 入驻页录音 + 后台审核真实试听

- 入驻申请页新增「语音自我介绍(可选,≤60s)」录音/试听,申请成功拿到大神档案 id 后自动上传,失败不影响入驻结果

- 管理端审核「真人语音包核听」由 3 秒假播放改为按 voiceIntroUrl 真实播放,未上传显示灰色标记

- 清理小程序审核敏感词(语音房/连麦/开麦),入驻页身份改用 normalizeUserId 与 token 保持一致

- 新增 voice-intro-consistency.spec.ts(107 套件/709 用例全绿)
这个提交包含在:
xcbyy
2026-10-21 11:12:42 +08:00
父节点 7a1e32688c
当前提交 ae62f2b805
共有 6 个文件被更改,包括 228 次插入 和 18 次删除

查看文件

@@ -642,7 +642,7 @@ const posts = ref(syncInitialPostStates([
location: '海淀区 · 五道口',
distance: '3.5km',
time: '40分钟前',
content: '今晚王者荣耀荣耀王者冲分车队!目前缺一个听指挥的中单或者软辅妹子,全程开麦包赢不保段包赔,想上星的速速滴我!',
content: '今晚王者荣耀荣耀王者冲分车队!目前缺一个听指挥的中单或者软辅妹子,全程语音沟通包赢不保段包赔,想上星的速速滴我!',
topics: ['王者荣耀', '荣耀车队', '连胜冲分'],
images: [
'https://images.unsplash.com/photo-1542751371-adc38448a05e?w=500'

查看文件

@@ -179,6 +179,24 @@
</view>
</view>
</view>
<!-- 语音自我介绍(可选):设计文档「在线录制试听」落地 -->
<view class="form-item">
<view class="item-label-row">
<text class="item-label">语音自我介绍</text>
<text class="label-hint">(可选,≤60 秒;审核与买家试听用)</text>
</view>
<view class="voice-row">
<button class="voice-btn btn-active" :disabled="voiceUploading" @click="toggleRecord">
{{ voiceRecording ? '停止录音' : (voiceRecorded ? '重新录制' : '开始录音') }}
</button>
<button v-if="voiceRecorded" class="voice-btn btn-active" @click="playRecorded">
{{ voicePlaying ? '停止播放' : '试听' }}
</button>
<text class="voice-status">{{ voiceStatusText }}</text>
</view>
<text class="voice-tip">语音沟通在游戏内或微信进行,请勿在录音中透露联系方式。</text>
</view>
</view>
<!-- 协议勾选与服务承诺 -->
@@ -208,18 +226,89 @@
</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 { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
import { getExpertCertStatus, setExpertCertStatus, CERT_STATUS } from '@/common/role.js';
import { applyExpertCert, getPlatformConfigs, getUserProfile } from '@/common/api.js';
import { applyExpertCert, getPlatformConfigs, getUserProfile, uploadVoiceIntro, normalizeUserId } from '@/common/api.js';
const currentThemeKey = ref(getCurrentThemeKey());
const certStatus = ref(getExpertCertStatus());
const isSubmitting = ref(false);
const agreed = ref(true);
// 语音自我介绍(可选,≤60s)
const voiceRecording = ref(false);
const voiceRecorded = ref(false);
const voicePlaying = ref(false);
const voiceUploading = ref(false);
const voiceDuration = ref(0);
let voiceTempPath = '';
let recorderManager = null;
let voiceAudioCtx = null;
const voiceStatusText = computed(() => {
if (voiceUploading.value) return '上传中…';
if (voiceRecorded.value) return `已录制 ${voiceDuration.value} 秒(提交后自动上传)`;
return '未录制(可跳过)';
});
const ensureRecorder = () => {
if (recorderManager) return recorderManager;
recorderManager = uni.getRecorderManager();
recorderManager.onStop((res) => {
voiceRecording.value = false;
voiceTempPath = res.tempFilePath || '';
voiceDuration.value = Math.max(1, Math.round(Number(res.duration || 0) / 1000) || 1);
voiceRecorded.value = Boolean(voiceTempPath);
});
recorderManager.onError(() => {
voiceRecording.value = false;
uni.showToast({ title: '录音失败,请检查麦克风权限', icon: 'none' });
});
return recorderManager;
};
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 playRecorded = () => {
if (!voiceTempPath) 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 = voiceTempPath;
voiceAudioCtx.play();
voicePlaying.value = true;
};
onUnmounted(() => {
try { if (recorderManager && voiceRecording.value) recorderManager.stop(); } catch (_) {}
try { if (voiceAudioCtx) { voiceAudioCtx.destroy(); voiceAudioCtx = null; } } catch (_) {}
});
const TRACK_OPTIONS = [
'同城陪吃',
'KTV陪唱',
@@ -338,20 +427,22 @@ const submitApply = async () => {
isSubmitting.value = true;
uni.showLoading({ title: '正在提交入驻材料...' });
const uid = Number(uni.getStorageSync('userId')) || 1001;
// 身份与登录态保持一致:有 token 时由服务端按 token 识别(缺失 userId 传 0)
const uid = normalizeUserId();
const profileUid = uid || 1001;
try {
let avatarUrl = '';
let gender = '男';
try {
const userProfile = await getUserProfile(uid);
const userProfile = await getUserProfile(profileUid);
if (userProfile) {
avatarUrl = userProfile.avatar || '';
gender = userProfile.gender === 'female' ? '女' : '男';
}
} catch (_) {}
await applyExpertCert({
const applied = await applyExpertCert({
userId: uid,
name: applyForm.value.realName.trim(),
idCard: applyForm.value.idCard.trim(),
@@ -362,6 +453,21 @@ const submitApply = async () => {
gender: gender
});
// 已录制的语音介绍,在申请成功(拿到大神档案 id)后自动上传;失败不影响申请结果
if (voiceTempPath && applied && applied.id) {
voiceUploading.value = true;
try {
await uploadVoiceIntro(applied.id, voiceTempPath, voiceDuration.value);
voiceRecorded.value = false;
voiceTempPath = '';
} catch (e) {
console.warn('[Apply] 语音介绍上传失败:', e);
uni.showToast({ title: '入驻已提交,语音上传失败可在工作台重录', icon: 'none', duration: 2600 });
} finally {
voiceUploading.value = false;
}
}
setExpertCertStatus(CERT_STATUS.PENDING);
certStatus.value = CERT_STATUS.PENDING;
uni.hideLoading();
@@ -516,6 +622,35 @@ onShow(() => {
}
}
/* 语音自我介绍(可选) */
.voice-row {
display: flex;
align-items: center;
gap: 16rpx;
flex-wrap: wrap;
}
.voice-btn {
margin: 0;
padding: 0 28rpx;
height: 64rpx;
line-height: 64rpx;
border-radius: 32rpx;
font-size: 24rpx;
color: #FFFFFF;
background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%);
}
.voice-status {
font-size: 22rpx;
color: var(--text-muted, #94A3B8);
}
.voice-tip {
display: block;
margin-top: 12rpx;
font-size: 22rpx;
color: var(--text-muted, #64748B);
line-height: 1.6;
}
/* 主表单卡片 */
.apply-card {
background: #151D30;

查看文件

@@ -349,7 +349,7 @@ const nearbyOrders = ref([
id: 4,
categoryName: '线上王者冲分',
title: '王者荣耀 · 荣耀王者晋级双排急召',
requirement: '擅长打野/射手节奏位,全程语音开麦交流,保星连胜',
requirement: '擅长打野/射手节奏位,全程语音沟通,保星连胜',
reward: 70,
duration: '2局',
distance: '3.8km',

查看文件

@@ -214,7 +214,7 @@
{{ voiceUploading ? '上传中…' : (expert.voiceIntroUrl ? `已上传 · ${expert.voiceDuration || 0} 秒` : '暂未上传') }}
</text>
</view>
<text class="voice-tip">语音仅用于买家试听介绍,平台不提供实时语音房;实际语音沟通在游戏内或微信进行。</text>
<text class="voice-tip">语音仅用于买家试听介绍,实际语音沟通在游戏内或微信进行。</text>
</view>
<!-- 核心亮点功能:4 位数字门票现场核销中心 (严格依 UIUX 规范 4.2 落地) -->