feat: TTS服务 + 数字人面试组件 (P1)

This commit is contained in:
yuzhiran
2026-06-12 09:42:06 +08:00
parent 065fe7a186
commit a55cb56be2
11 changed files with 553 additions and 32 deletions
+125 -32
View File
@@ -1,6 +1,5 @@
<template>
<view class="page">
<!-- Top bar -->
<view class="topbar">
<view class="topbar-inner">
<view class="back-btn" @click="confirmExit"><text class="back-arrow"></text></view>
@@ -14,19 +13,34 @@
</view>
<text class="topbar-timer"> {{ formatTime }}</text>
</view>
<view class="topbar-right"></view>
<view class="topbar-right">
<text class="avatar-toggle" @click="avatarMode = !avatarMode">
{{ avatarMode ? '💬' : '👤' }}
</text>
</view>
</view>
</view>
<!-- Chat area -->
<scroll-view class="chat-area" scroll-y :scroll-into-view="scrollToId" :scroll-with-animation="true">
<!-- Avatar mode -->
<view class="avatar-section" v-if="avatarMode">
<digital-human
ref="dhRef"
:text="aiSpeechText"
:audio-url="aiAudioUrl"
:auto-play="true"
@speaking-start="onAvatarSpeaking"
@speaking-end="onAvatarSilent"
/>
</view>
<!-- Chat area (both modes) -->
<scroll-view class="chat-area" scroll-y :scroll-into-view="scrollToId" :scroll-with-animation="true" :class="{ 'chat-compact': avatarMode }">
<view v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="msg-row" :class="msg.role">
<view class="msg-bubble" :class="msg.role">
<text>{{ msg.content }}</text>
</view>
</view>
<!-- Typing indicator -->
<view class="msg-row ai" v-if="aiLoading">
<view class="typing">
<view class="typing-dot"></view>
@@ -38,7 +52,6 @@
<view id="msg-bottom" style="height: 16rpx;"></view>
</scroll-view>
<!-- Input bar -->
<view class="input-bar" v-if="!isComplete">
<view class="input-box">
<textarea class="input-area" v-model="inputText" placeholder="输入你的回答..." :auto-height="true" :maxlength="2000" :disabled="aiLoading" @confirm="sendAnswer" />
@@ -47,10 +60,9 @@
<text class="send-icon"></text>
</view>
</view>
<!-- AI 免责提示 -->
<view class="disclaimer-bar" v-if="!isComplete">AI 生成内容仅供参考请核实重要信息</view>
<!-- Complete -->
<view class="complete-bar" v-else>
<button class="cta-btn" @click="goResult">查看面试报告</button>
</view>
@@ -60,7 +72,8 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { api } from '../../config'
import { api, API_ENDPOINTS } from '../../config'
import DigitalHuman from '../../components/digital-human.vue'
const messages = ref([{ role: 'ai', content: '你好!我是 AI 面试官,请选择岗位开始模拟面试!' }])
const inputText = ref('')
@@ -70,13 +83,19 @@ const answeredCount = ref(0)
const isComplete = ref(false)
const scrollToId = ref('')
const position = ref('')
const avatarMode = ref(false)
const aiSpeechText = ref('')
const aiAudioUrl = ref('')
const isSpeaking = ref(false)
const dhRef = ref(null)
let timerSeconds = 0
let timerInterval = null
const progressPercent = computed(() => Math.min((answeredCount.value / 5) * 100, 100))
const formatTime = computed(() => {
const m = Math.floor(timerSeconds / 60); const s = timerSeconds % 60
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
})
const token = computed(() => uni.getStorageSync('token') || '')
@@ -88,13 +107,21 @@ onLoad((options) => {
}
})
onMounted(() => { timerInterval = setInterval(() => timerSeconds++, 1000); if (token.value) startInterview() })
onBeforeUnmount(() => clearInterval(timerInterval))
onMounted(() => {
timerInterval = setInterval(() => timerSeconds++, 1000)
if (token.value) startInterview()
})
onBeforeUnmount(() => {
clearInterval(timerInterval)
})
const checkLogin = () => {
if (!token.value) {
uni.showModal({ title: '请先登录', content: '登录后即可开始 AI 模拟面试', confirmText: '去登录',
success: (r) => { if (r.confirm) uni.navigateTo({ url: '/pages/login/login' }) } })
uni.showModal({
title: '请先登录', content: '登录后即可开始 AI 模拟面试', confirmText: '去登录',
success: (r) => { if (r.confirm) uni.navigateTo({ url: '/pages/login/login' }) },
})
return false
}
return true
@@ -104,15 +131,27 @@ const startInterview = async () => {
if (!checkLogin()) return
aiLoading.value = true
try {
const res = await uni.request({ url: api('/interview/create'), method: 'POST',
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' }, data: { position: position.value } })
const res = await uni.request({
url: api('/interview/create'), method: 'POST',
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' },
data: { position: position.value },
})
if (res.statusCode === 200 && res.data) {
interviewId.value = res.data.id
messages.value = res.data.messages || messages.value
answeredCount.value = res.data.questionCount || 0
// Speak first question in avatar mode
if (avatarMode.value && res.data.messages?.length) {
const last = res.data.messages[res.data.messages.length - 1]
if (last?.role === 'ai') await speakAiText(last.content)
}
}
} catch { messages.value.push({ role: 'ai', content: '创建面试失败,请重试' }) }
finally { aiLoading.value = false; scrollToBottom() }
} catch {
messages.value.push({ role: 'ai', content: '创建面试失败,请重试' })
} finally {
aiLoading.value = false
scrollToBottom()
}
}
const sendAnswer = async () => {
@@ -122,32 +161,79 @@ const sendAnswer = async () => {
const answer = inputText.value.trim()
messages.value.push({ role: 'user', content: answer })
inputText.value = ''; scrollToBottom()
inputText.value = ''
scrollToBottom()
aiLoading.value = true
try {
const res = await uni.request({ url: api(`/interview/${interviewId.value}/answer`), method: 'POST',
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' }, data: { answer } })
const res = await uni.request({
url: api(`/interview/${interviewId.value}/answer`), method: 'POST',
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' },
data: avatarMode.value ? { answer, avatar: true } : { answer },
})
if (res.statusCode === 200 && res.data?.messages) {
messages.value.push(...res.data.messages)
const aiMsg = res.data.messages.find(m => m.role === 'ai')
if (avatarMode.value && aiMsg) {
// In avatar mode, only show avatar speaking, don't add to chat
await speakAiText(aiMsg.content, res.data.ttsHash)
} else {
messages.value.push(...res.data.messages)
}
answeredCount.value = res.data.questionCount || answeredCount.value + 1
if (res.data.ttsHash && !avatarMode.value) {
// Still got TTS but not in avatar mode, just show text
}
}
} catch { messages.value.push({ role: 'ai', content: '回答提交失败,请重试' }) }
finally { aiLoading.value = false; scrollToBottom() }
} catch {
messages.value.push({ role: 'ai', content: '回答提交失败,请重试' })
} finally {
aiLoading.value = false
scrollToBottom()
}
}
async function speakAiText(text, ttsHash) {
aiSpeechText.value = text
if (ttsHash) {
aiAudioUrl.value = api(API_ENDPOINTS.TTS.AUDIO(ttsHash))
} else {
try {
const synthRes = await uni.request({
url: api('/tts/synthesize'), method: 'POST',
header: { 'Content-Type': 'application/json' },
data: { text },
})
if (synthRes.statusCode === 200 && synthRes.data?.hash) {
aiAudioUrl.value = api(API_ENDPOINTS.TTS.AUDIO(synthRes.data.hash))
}
} catch {}
}
}
function onAvatarSpeaking() {
isSpeaking.value = true
}
function onAvatarSilent() {
isSpeaking.value = false
}
const goResult = () => uni.navigateTo({ url: `/pages/report/report?interviewId=${interviewId.value}` })
const scrollToBottom = () => { nextTick(() => { scrollToId.value = ''; setTimeout(() => { scrollToId.value = 'msg-bottom' }, 100) }) }
const scrollToBottom = () => {
nextTick(() => { scrollToId.value = ''; setTimeout(() => { scrollToId.value = 'msg-bottom' }, 100) })
}
const confirmExit = () => {
uni.showModal({ title: '退出面试', content: interviewId.value ? '确定退出吗?当前进度将不会保存。' : '确定退出?',
success: (r) => { if (r.confirm) uni.navigateBack() } })
uni.showModal({
title: '退出面试', content: interviewId.value ? '确定退出吗?当前进度将不会保存。' : '确定退出?',
success: (r) => { if (r.confirm) uni.navigateBack() },
})
}
</script>
<style scoped>
.page { height: 100vh; display: flex; flex-direction: column; background: #F8F9FC; }
/* ===== Top Bar ===== */
.topbar {
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
padding-top: 20rpx; flex-shrink: 0;
@@ -167,10 +253,18 @@ const confirmExit = () => {
.progress-track { height: 6rpx; background: rgba(255,255,255,0.2); border-radius: 3rpx; overflow: hidden; }
.progress-fill { height: 100%; background: #FFFFFF; border-radius: 3rpx; transition: width 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); }
.topbar-timer { font-size: 20rpx; color: rgba(255,255,255,0.7); font-variant-numeric: tabular-nums; }
.topbar-right { width: 60rpx; flex-shrink: 0; }
.topbar-right { width: 60rpx; flex-shrink: 0; text-align: center; }
.avatar-toggle { font-size: 36rpx; cursor: pointer; }
/* ===== Chat ===== */
/* Avatar section */
.avatar-section {
background: linear-gradient(180deg, #EEF2FF 0%, #F8F9FC 100%);
flex-shrink: 0;
}
/* Chat */
.chat-area { flex: 1; padding: 24rpx 20rpx; overflow-y: auto; }
.chat-compact { max-height: 40vh; }
.msg-row { display: flex; margin-bottom: 24rpx; }
.msg-row.ai { justify-content: flex-start; }
.msg-row.user { justify-content: flex-end; }
@@ -199,7 +293,7 @@ const confirmExit = () => {
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink { 0%,80%,100% { opacity: 0.3 } 40% { opacity: 1 } }
/* ===== Input ===== */
/* Input */
.input-bar {
background: #FFFFFF; padding: 16rpx 20rpx;
padding-bottom: calc(16rpx + var(--safe-bottom));
@@ -217,7 +311,6 @@ const confirmExit = () => {
.send-btn.disabled { background: var(--color-border); }
.send-icon { font-size: 32rpx; color: #FFFFFF; transform: translateY(2rpx); }
/* Complete */
.complete-bar { background: #FFFFFF; padding: 20rpx 24rpx; padding-bottom: calc(20rpx + var(--safe-bottom)); }
.cta-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFFFFF; border-radius: var(--radius-md); font-size: 28rpx; font-weight: 600; border: none; }
.disclaimer-bar { text-align: center; font-size: 20rpx; color: var(--color-text-tertiary); padding: 8rpx 24rpx; background: #FFFFFF; border-top: 1rpx solid var(--color-border); }