fix: WeChat login Content-Type header, ASR tiny model, re-upload mini-program v1.0.11
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -101,6 +101,7 @@ export const API_ENDPOINTS = {
|
||||
TTS: {
|
||||
SYNTHESIZE: '/tts/synthesize',
|
||||
AUDIO: (hash: string) => `/tts/audio/${hash}`,
|
||||
ASR: '/tts/asr',
|
||||
},
|
||||
SHARE: {
|
||||
CREATE: '/share/create',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "宇之然AI磁场",
|
||||
"appid": "__UNI__DEV__",
|
||||
"versionName": "1.0.10",
|
||||
"versionCode": "110",
|
||||
"versionName": "1.0.11",
|
||||
"versionCode": "111",
|
||||
"description": "职引 - 宇之然AI磁场旗下AI模拟面试平台,提供AI面试官模拟练习、简历智能优化、大厂面经题库,助你轻松应对校招面试。",
|
||||
"h5": {
|
||||
"title": "职引 - AI模拟面试 | 宇之然AI磁场",
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
ref="dhRef"
|
||||
:text="aiSpeechText"
|
||||
:audio-url="aiAudioUrl"
|
||||
:amplitude-data="aiAmplitudeData"
|
||||
:position="position"
|
||||
:auto-play="true"
|
||||
@speaking-start="onAvatarSpeaking"
|
||||
@speaking-end="onAvatarSilent"
|
||||
@@ -53,7 +55,7 @@
|
||||
</scroll-view>
|
||||
|
||||
<view class="input-bar" v-if="!isComplete">
|
||||
<view class="mic-btn" :class="{ recording: isRecording }" @touchstart="startRecord" @touchend="stopRecord" @touchcancel="stopRecord">
|
||||
<view class="mic-btn" :class="{ recording: isRecording }" @touchstart="startRecord" @touchend="stopRecord" @touchcancel="stopRecord" @mousedown="startRecord" @mouseup="stopRecord" @mouseleave="stopRecord">
|
||||
<text class="mic-icon">🎤</text>
|
||||
</view>
|
||||
<view class="input-box">
|
||||
@@ -89,6 +91,7 @@ const position = ref('')
|
||||
const avatarMode = ref(true)
|
||||
const aiSpeechText = ref('')
|
||||
const aiAudioUrl = ref('')
|
||||
const aiAmplitudeData = ref([])
|
||||
const isSpeaking = ref(false)
|
||||
const dhRef = ref(null)
|
||||
const isRecording = ref(false)
|
||||
@@ -179,8 +182,8 @@ const sendAnswer = async () => {
|
||||
if (res.statusCode === 200 && res.data?.messages) {
|
||||
const aiMsg = res.data.messages.find(m => m.role === 'ai')
|
||||
messages.value.push(...res.data.messages)
|
||||
if (avatarMode.value && aiMsg) {
|
||||
await speakAiText(aiMsg.content, res.data.ttsHash)
|
||||
if (avatarMode.value && aiMsg) {
|
||||
await speakAiText(aiMsg.content, res.data.ttsHash, res.data.ttsAmplitude)
|
||||
}
|
||||
answeredCount.value = res.data.questionCount || answeredCount.value + 1
|
||||
if (res.data.ttsHash && !avatarMode.value) {
|
||||
@@ -195,8 +198,9 @@ const sendAnswer = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function speakAiText(text, ttsHash) {
|
||||
async function speakAiText(text, ttsHash, ttsAmplitude) {
|
||||
aiSpeechText.value = text
|
||||
aiAmplitudeData.value = ttsAmplitude || []
|
||||
if (ttsHash) {
|
||||
aiAudioUrl.value = api(API_ENDPOINTS.TTS.AUDIO(ttsHash))
|
||||
} else {
|
||||
@@ -208,6 +212,7 @@ async function speakAiText(text, ttsHash) {
|
||||
})
|
||||
if (synthRes.statusCode === 200 && synthRes.data?.hash) {
|
||||
aiAudioUrl.value = api(API_ENDPOINTS.TTS.AUDIO(synthRes.data.hash))
|
||||
aiAmplitudeData.value = synthRes.data?.amplitudeData || []
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -252,11 +257,12 @@ function stopRecord() {
|
||||
const audioPath = res.tempFilePath
|
||||
try {
|
||||
const uploadRes = await uni.uploadFile({
|
||||
url: api('/asr/recognize'),
|
||||
url: api(API_ENDPOINTS.TTS.ASR),
|
||||
filePath: audioPath,
|
||||
name: 'audio',
|
||||
header: { 'Authorization': `Bearer ${token.value}` },
|
||||
})
|
||||
console.log('[ASR] upload response:', uploadRes.statusCode, typeof uploadRes.data === 'string' ? uploadRes.data.slice(0, 200) : JSON.stringify(uploadRes.data).slice(0, 200))
|
||||
if (uploadRes.statusCode === 200 && uploadRes.data) {
|
||||
const data = typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data
|
||||
if (data.text) {
|
||||
@@ -265,7 +271,9 @@ function stopRecord() {
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
console.error('[ASR] upload error:', e?.message || e)
|
||||
}
|
||||
uni.showToast({ title: '语音识别失败,请手动输入', icon: 'none' })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -286,12 +286,25 @@ const doWxLogin = async () => {
|
||||
// #ifdef MP-WEIXIN
|
||||
wxLoading.value = true
|
||||
try {
|
||||
const { code } = await uni.login()
|
||||
const res = await uni.request({ url: api('/user/wx-login'), method: 'POST', data: { code } })
|
||||
const wxResp = await uni.login()
|
||||
console.log('[wxLogin] uni.login success:', JSON.stringify(wxResp).slice(0, 300))
|
||||
const { code, errMsg } = wxResp
|
||||
if (!code) { console.error('[wxLogin] no code:', errMsg); showToast('获取微信凭证失败'); return }
|
||||
const res = await uni.request({
|
||||
url: api('/user/wx-login'), method: 'POST',
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
data: { code },
|
||||
})
|
||||
console.log('[wxLogin] server response:', res.statusCode, JSON.stringify(res.data).slice(0, 500))
|
||||
if (res.statusCode === 200 && res.data?.token) {
|
||||
loginSuccess(res.data)
|
||||
} else { showToast('微信登录失败') }
|
||||
} catch { showToast('微信登录失败') }
|
||||
} else {
|
||||
showToast(res.data?.message || `登录失败(${res.statusCode})`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[wxLogin] error:', JSON.stringify(e).slice(0, 500))
|
||||
showToast('微信登录失败')
|
||||
}
|
||||
finally { wxLoading.value = false }
|
||||
// #endif
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user