fix: WeChat login Content-Type header, ASR tiny model, re-upload mini-program v1.0.11

This commit is contained in:
yuzhiran
2026-06-15 10:00:02 +08:00
parent 4fa620f0a2
commit 18c50726cd
22 changed files with 311 additions and 128 deletions
+108 -48
View File
@@ -1,12 +1,22 @@
<template>
<view class="digital-human">
<view class="avatar-stage">
<view class="avatar-ring" :class="{ speaking: isSpeaking }">
<image class="face-img" src="/static/ai-face.png" mode="aspectFill" />
<view class="mouth-overlay" :class="{ open: isSpeaking }" :style="{ height: mouthHeight + 'rpx' }"></view>
<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">AI 面试官</text>
<text class="role-label">{{ positionLabel }}</text>
</view>
<view class="speech-area" v-if="currentText">
<view class="speech-bubble">
@@ -17,12 +27,14 @@
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
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 },
})
@@ -30,19 +42,50 @@ const emit = defineEmits(['speaking-start', 'speaking-end'])
const isSpeaking = ref(false)
const currentText = ref('')
const mouthHeight = ref(4)
const mouthOpenness = ref(0)
const avatarError = ref(false)
let audioEl = null
let mouthTimer = null
let blinkTimer = null
let animFrame = null
onMounted(() => {
scheduleBlink()
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()
if (blinkTimer) clearTimeout(blinkTimer)
})
watch(() => props.audioUrl, (url) => {
@@ -55,22 +98,47 @@ watch(() => props.text, (txt) => {
currentText.value = txt
})
function scheduleBlink() {
const delay = 2500 + Math.random() * 3500
blinkTimer = setTimeout(() => {
if (isSpeaking.value) {
mouthHeight.value = 14
setTimeout(() => { mouthHeight.value = 4 }, 100)
}
scheduleBlink()
}, delay)
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')
startMouthAnim()
tickMouth()
try {
const innerAudio = uni.createInnerAudioContext()
@@ -85,25 +153,18 @@ function playAudio(url) {
}
}
function startMouthAnim() {
let dir = 1
let h = 4
mouthTimer = setInterval(() => {
h += dir * 2
if (h >= 16) dir = -1
else if (h <= 4) dir = 1
mouthHeight.value = h
}, 100)
}
function finishSpeaking() {
isSpeaking.value = false
mouthHeight.value = 4
if (mouthTimer) { clearInterval(mouthTimer); mouthTimer = null }
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 {}
@@ -133,16 +194,17 @@ defineExpose({ play: playAudio, stop: stopAudio })
gap: 10rpx;
position: relative;
}
.avatar-ring {
width: 220rpx;
height: 220rpx;
border-radius: 50%;
.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-ring.speaking {
.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;
@@ -151,22 +213,20 @@ defineExpose({ play: playAudio, stop: stopAudio })
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); }
}
.face-img {
@keyframes breathing {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.012); }
}
.avatar-img {
width: 100%;
height: 100%;
}
.mouth-overlay {
position: absolute;
bottom: 28%;
top: 36.8%;
left: 50%;
transform: translateX(-50%);
width: 22%;
background: #C97B84;
border-radius: 50%;
transition: height 0.1s;
}
.mouth-overlay.open {
background: #A85562;
transition: all 0.08s cubic-bezier(0.25, 0.1, 0.25, 1);
}
.status-dot {
width: 14rpx;