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 次删除

查看文件

@@ -64,16 +64,18 @@
<template #cell="{ record }">
<a-space>
<a-button
v-if="record.voiceIntroUrl"
size="mini"
type="outline"
shape="round"
@click="playVoice(record.id)"
@click="playVoice(record)"
>
<template #icon>
<icon-sound />
</template>
{{ playingId === record.id ? '播放中 16s...' : '试听语音包' }}
{{ playingId === record.id ? '播放中…' : '试听语音包' }}
</a-button>
<a-tag v-else color="gray">未上传</a-tag>
</a-space>
</template>
</a-table-column>
@@ -161,7 +163,7 @@
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import axios from 'axios';
import { Message } from '@arco-design/web-vue';
@@ -214,15 +216,39 @@
}
};
const playVoice = (id: number) => {
playingId.value = id;
setTimeout(() => {
if (playingId.value === id) {
playingId.value = null;
}
}, 3000);
let voiceAudio: HTMLAudioElement | null = null;
const playVoice = (record: any) => {
const url = String(record?.voiceIntroUrl || '');
if (!url) {
Message.info('该申请人未上传语音介绍');
return;
}
// 再次点击同一条 = 停止
if (playingId.value === record.id) {
try { voiceAudio?.pause(); } catch (_) {}
playingId.value = null;
return;
}
try { voiceAudio?.pause(); } catch (_) {}
voiceAudio = new Audio(url);
voiceAudio.onended = () => { playingId.value = null; };
voiceAudio.onerror = () => {
playingId.value = null;
Message.error('语音播放失败,请检查文件是否存在');
};
voiceAudio.play().catch(() => {
playingId.value = null;
Message.error('浏览器阻止了自动播放,请重试');
});
playingId.value = record.id;
};
onUnmounted(() => {
try { voiceAudio?.pause(); } catch (_) {}
voiceAudio = null;
playingId.value = null;
});
const handleAudit = async (id: number, action: 'approve' | 'reject' | 'revoke', reason?: string) => {
try {
await axios.post(`/api/companions/${id}/audit`, {

查看文件

@@ -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 落地) -->

查看文件

@@ -0,0 +1,49 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第九十一批:语音自我介绍三端一致性。
* 背景:管理端「真人语音包核听」曾是 3 秒假播放(文案写死 16s),
* 入驻申请页也没有录音入口,导致审核端永远听不到真人语音。
*/
describe('第九十一批语音介绍三端一致性闸门', () => {
const root = path.resolve(__dirname, '../../');
const audit = fs.readFileSync(
path.join(root, 'admin/arco-design-pro-vite/src/views/companion/audit/index.vue'),
'utf-8'
);
const apply = fs.readFileSync(path.join(root, 'pwuniapp/pages/expert/apply.vue'), 'utf-8');
it('审核页按真实 voiceIntroUrl 播放,未上传时禁用并标记', () => {
expect(audit).toContain('record.voiceIntroUrl');
expect(audit).toContain('new Audio(url)');
expect(audit).toContain('未上传');
expect(audit).not.toContain('播放中 16s...');
});
it('入驻申请页提供录音入口并在提交成功后上传语音', () => {
expect(apply).toContain('uni.getRecorderManager()');
expect(apply).toContain('uploadVoiceIntro');
expect(apply).toContain('applied.id');
expect(apply).toContain('duration: 60000');
});
const scanSensitive = (dir: string): string[] => {
const hits: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) hits.push(...scanSensitive(full));
else if (/\.(vue|js)$/.test(entry.name)) {
const text = fs.readFileSync(full, 'utf-8');
if (/语音房|连麦|开麦/.test(text)) hits.push(full);
}
}
return hits;
};
it('小程序页面文案不出现语音房/连麦/开麦等审核敏感词', () => {
const hits = scanSensitive(path.join(root, 'pwuniapp/pages'));
expect(hits).toEqual([]);
});
});