feat(voice): 入驻页录音 + 后台审核真实试听
- 入驻申请页新增「语音自我介绍(可选,≤60s)」录音/试听,申请成功拿到大神档案 id 后自动上传,失败不影响入驻结果 - 管理端审核「真人语音包核听」由 3 秒假播放改为按 voiceIntroUrl 真实播放,未上传显示灰色标记 - 清理小程序审核敏感词(语音房/连麦/开麦),入驻页身份改用 normalizeUserId 与 token 保持一致 - 新增 voice-intro-consistency.spec.ts(107 套件/709 用例全绿)
这个提交包含在:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户