280 lines
7.1 KiB
Vue
280 lines
7.1 KiB
Vue
<template>
|
|
<view class="digital-human">
|
|
<view class="avatar-stage">
|
|
<view class="avatar-body" :class="{ speaking: isSpeaking }">
|
|
<image class="avatar-img" :src="avatarSrc" mode="aspectFill" @error="avatarError = true" />
|
|
<view class="mouth-overlay" :style="mouthStyle"></view>
|
|
</view>
|
|
<image src="/static/avatar-default.png" style="display:none" />
|
|
<image src="/static/avatar-software.png" style="display:none" />
|
|
<image src="/static/avatar-frontend.png" style="display:none" />
|
|
<image src="/static/avatar-backend.png" style="display:none" />
|
|
<image src="/static/avatar-algo.png" style="display:none" />
|
|
<image src="/static/avatar-pm.png" style="display:none" />
|
|
<image src="/static/avatar-data.png" style="display:none" />
|
|
<image src="/static/avatar-marketing.png" style="display:none" />
|
|
<image src="/static/avatar-ops.png" style="display:none" />
|
|
<image src="/static/avatar-ui.png" style="display:none" />
|
|
<view class="status-dot" :class="{ active: isSpeaking }"></view>
|
|
<text class="role-label">{{ positionLabel }}</text>
|
|
</view>
|
|
<view class="speech-area" v-if="currentText">
|
|
<view class="speech-bubble">
|
|
<text class="speech-text">{{ currentText }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, watch, computed, onBeforeUnmount, nextTick } from 'vue'
|
|
|
|
const props = defineProps({
|
|
text: { type: String, default: '' },
|
|
audioUrl: { type: String, default: '' },
|
|
amplitudeData: { type: Array, default: () => [] },
|
|
avatarUrl: { type: String, default: '' },
|
|
position: { type: String, default: '' },
|
|
autoPlay: { type: Boolean, default: true },
|
|
})
|
|
|
|
const emit = defineEmits(['speaking-start', 'speaking-end'])
|
|
|
|
const isSpeaking = ref(false)
|
|
const currentText = ref('')
|
|
const mouthOpenness = ref(0)
|
|
const avatarError = ref(false)
|
|
|
|
let audioEl = null
|
|
let animFrame = null
|
|
|
|
const allAvatars = [
|
|
'/static/avatar-default.png',
|
|
'/static/avatar-software.png',
|
|
'/static/avatar-frontend.png',
|
|
'/static/avatar-backend.png',
|
|
'/static/avatar-algo.png',
|
|
'/static/avatar-pm.png',
|
|
'/static/avatar-data.png',
|
|
'/static/avatar-marketing.png',
|
|
'/static/avatar-ops.png',
|
|
'/static/avatar-ui.png',
|
|
]
|
|
|
|
const avatarMap = {
|
|
'软件开发': '/static/avatar-software.png',
|
|
'前端开发': '/static/avatar-frontend.png',
|
|
'后端开发': '/static/avatar-backend.png',
|
|
'算法工程师': '/static/avatar-algo.png',
|
|
'产品经理': '/static/avatar-pm.png',
|
|
'数据分析': '/static/avatar-data.png',
|
|
'市场营销': '/static/avatar-marketing.png',
|
|
'运营': '/static/avatar-ops.png',
|
|
'UI设计': '/static/avatar-ui.png',
|
|
}
|
|
|
|
const avatarSrc = computed(() => {
|
|
if (avatarError.value) return '/static/avatar-default.png'
|
|
if (props.avatarUrl) return props.avatarUrl
|
|
const key = Object.keys(avatarMap).find(k => props.position.includes(k))
|
|
return key ? avatarMap[key] : '/static/avatar-default.png'
|
|
})
|
|
|
|
const positionLabel = computed(() => {
|
|
return props.position ? `${props.position} 面试官` : 'AI 面试官'
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
stopAudio()
|
|
})
|
|
|
|
watch(() => props.audioUrl, (url) => {
|
|
if (url && props.autoPlay) {
|
|
nextTick(() => playAudio(url))
|
|
}
|
|
})
|
|
|
|
watch(() => props.text, (txt) => {
|
|
currentText.value = txt
|
|
})
|
|
|
|
const mouthStyle = computed(() => {
|
|
const o = mouthOpenness.value
|
|
const h = 3 + o * o * 22
|
|
const w = 18 + o * 8
|
|
return {
|
|
height: h + 'rpx',
|
|
width: w + 'rpx',
|
|
borderRadius: o > 0.3 ? '50%' : '4rpx',
|
|
background: o > 0.5 ? 'linear-gradient(180deg, #C97B84, #A85562)' : '#C97B84',
|
|
opacity: o > 0.01 ? 1 : 0.3,
|
|
}
|
|
})
|
|
|
|
function getAmplitude(positionMs) {
|
|
const amp = props.amplitudeData
|
|
if (!amp || amp.length === 0) return -1
|
|
const idx = Math.floor(positionMs / 50)
|
|
if (idx >= amp.length) return -1
|
|
return amp[idx]
|
|
}
|
|
|
|
function tickMouth() {
|
|
if (!audioEl) return
|
|
const currentTime = audioEl.currentTime * 1000 || 0
|
|
const amp = getAmplitude(currentTime)
|
|
if (amp >= 0) {
|
|
const openness = Math.pow(Math.min(1, amp * 2.5), 0.7)
|
|
mouthOpenness.value = openness
|
|
} else {
|
|
const t = Date.now() / 1000
|
|
const idle = (Math.sin(t * 4) + 1) / 2 * 0.15
|
|
mouthOpenness.value = Math.max(0.03, idle)
|
|
}
|
|
animFrame = setTimeout(tickMouth, 50)
|
|
}
|
|
|
|
function playAudio(url) {
|
|
stopAudio()
|
|
isSpeaking.value = true
|
|
emit('speaking-start')
|
|
tickMouth()
|
|
|
|
try {
|
|
const innerAudio = uni.createInnerAudioContext()
|
|
audioEl = innerAudio
|
|
innerAudio.src = url
|
|
innerAudio.autoplay = true
|
|
innerAudio.onEnded(() => finishSpeaking())
|
|
innerAudio.onError(() => finishSpeaking())
|
|
innerAudio.onStop(() => finishSpeaking())
|
|
} catch (e) {
|
|
finishSpeaking()
|
|
}
|
|
}
|
|
|
|
function finishSpeaking() {
|
|
isSpeaking.value = false
|
|
mouthOpenness.value = 0
|
|
cleanupTimers()
|
|
cleanupAudio()
|
|
emit('speaking-end')
|
|
}
|
|
|
|
function cleanupTimers() {
|
|
if (animFrame) { clearTimeout(animFrame); animFrame = null }
|
|
}
|
|
|
|
function cleanupAudio() {
|
|
if (audioEl) {
|
|
try { audioEl.stop(); audioEl.destroy() } catch {}
|
|
audioEl = null
|
|
}
|
|
}
|
|
|
|
function stopAudio() {
|
|
if (audioEl) { try { audioEl.stop(); audioEl.destroy() } catch {} }
|
|
finishSpeaking()
|
|
}
|
|
|
|
defineExpose({ play: playAudio, stop: stopAudio })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.digital-human {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 10rpx 0;
|
|
}
|
|
.avatar-stage {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 10rpx;
|
|
position: relative;
|
|
}
|
|
.avatar-body {
|
|
width: 200rpx;
|
|
height: 260rpx;
|
|
border-radius: 20rpx;
|
|
border: 4rpx solid #E5E7EB;
|
|
overflow: hidden;
|
|
position: relative;
|
|
transition: border-color 0.3s, box-shadow 0.3s;
|
|
animation: breathing 4s ease-in-out infinite;
|
|
}
|
|
.avatar-body.speaking {
|
|
border-color: #6366F1;
|
|
box-shadow: 0 0 40rpx rgba(99, 102, 241, 0.4);
|
|
animation: speakPulse 1.5s ease-in-out infinite;
|
|
}
|
|
@keyframes speakPulse {
|
|
0%, 100% { box-shadow: 0 0 20rpx rgba(99, 102, 241, 0.3); }
|
|
50% { box-shadow: 0 0 50rpx rgba(99, 102, 241, 0.6); }
|
|
}
|
|
@keyframes breathing {
|
|
0%, 100% { transform: scale(1); }
|
|
50% { transform: scale(1.012); }
|
|
}
|
|
.avatar-img {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.mouth-overlay {
|
|
position: absolute;
|
|
top: 36.8%;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
transition: all 0.08s cubic-bezier(0.25, 0.1, 0.25, 1);
|
|
}
|
|
.status-dot {
|
|
width: 14rpx;
|
|
height: 14rpx;
|
|
border-radius: 50%;
|
|
background: #9CA3AF;
|
|
position: absolute;
|
|
top: 8rpx;
|
|
right: 8rpx;
|
|
transition: background 0.3s;
|
|
}
|
|
.status-dot.active {
|
|
background: #10B981;
|
|
box-shadow: 0 0 12rpx rgba(16, 185, 129, 0.6);
|
|
}
|
|
.role-label {
|
|
font-size: 22rpx;
|
|
color: #6B7280;
|
|
background: #F3F4F6;
|
|
padding: 4rpx 20rpx;
|
|
border-radius: 20rpx;
|
|
}
|
|
.speech-area {
|
|
margin-top: 12rpx;
|
|
padding: 0 40rpx;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
.speech-bubble {
|
|
background: #FFFFFF;
|
|
border-radius: 16rpx;
|
|
padding: 20rpx 24rpx;
|
|
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
|
|
position: relative;
|
|
}
|
|
.speech-bubble::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: -12rpx;
|
|
left: 60rpx;
|
|
border: 12rpx solid transparent;
|
|
border-bottom-color: #FFFFFF;
|
|
border-top: none;
|
|
}
|
|
.speech-text {
|
|
font-size: 26rpx;
|
|
line-height: 1.6;
|
|
color: #1F2937;
|
|
}
|
|
</style>
|