396 lines
15 KiB
Vue
396 lines
15 KiB
Vue
<template>
|
||
<view class="page">
|
||
<view class="topbar">
|
||
<view class="topbar-inner">
|
||
<view class="back-btn" @click="confirmExit"><text class="back-arrow">‹</text></view>
|
||
<view class="topbar-center">
|
||
<view class="topbar-pos-row">
|
||
<text class="topbar-position">{{ position || 'AI面试' }}</text>
|
||
<text class="topbar-status">面试中</text>
|
||
</view>
|
||
<view class="progress-track" v-if="interviewId">
|
||
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
|
||
</view>
|
||
<text class="topbar-timer">⏱ {{ formatTime }}</text>
|
||
</view>
|
||
<view class="topbar-right">
|
||
<text class="avatar-toggle" @click="avatarMode = !avatarMode">
|
||
{{ avatarMode ? '💬' : '👤' }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Avatar mode -->
|
||
<view class="avatar-section" v-if="avatarMode">
|
||
<digital-human
|
||
ref="dhRef"
|
||
:text="aiSpeechText"
|
||
:audio-url="aiAudioUrl"
|
||
:amplitude-data="aiAmplitudeData"
|
||
:position="position"
|
||
: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>
|
||
|
||
<view class="msg-row ai" v-if="aiLoading">
|
||
<view class="typing">
|
||
<view class="typing-dot"></view>
|
||
<view class="typing-dot"></view>
|
||
<view class="typing-dot"></view>
|
||
</view>
|
||
</view>
|
||
|
||
<view id="msg-bottom" style="height: 16rpx;"></view>
|
||
</scroll-view>
|
||
|
||
<view class="input-bar" v-if="!isComplete">
|
||
<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">
|
||
<textarea class="input-area" v-model="inputText" placeholder="输入你的回答..." :auto-height="true" :maxlength="2000" :disabled="aiLoading" @confirm="sendAnswer" />
|
||
</view>
|
||
<view class="send-btn" :class="{ disabled: (!inputText.trim() && !isRecording) || aiLoading }" @click="sendAnswer">
|
||
<text class="send-icon">{{ isRecording ? '◉' : '➤' }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="disclaimer-bar" v-if="!isComplete">AI 生成内容仅供参考,请核实重要信息</view>
|
||
|
||
<view class="complete-bar" v-else>
|
||
<button class="cta-btn" @click="goResult">查看面试报告</button>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import { api, API_ENDPOINTS } from '../../config'
|
||
import DigitalHuman from '../../components/digital-human.vue'
|
||
|
||
const messages = ref([{ role: 'ai', content: '你好!我是 AI 面试官,请选择岗位开始模拟面试!' }])
|
||
const inputText = ref('')
|
||
const aiLoading = ref(false)
|
||
const interviewId = ref('')
|
||
const answeredCount = ref(0)
|
||
const isComplete = ref(false)
|
||
const scrollToId = ref('')
|
||
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)
|
||
let recorder = null
|
||
|
||
let timerSeconds = 0
|
||
let timerInterval = null
|
||
|
||
let MAX_QUESTIONS = 10
|
||
const progressPercent = computed(() => Math.min((answeredCount.value / MAX_QUESTIONS) * 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')}`
|
||
})
|
||
const token = () => uni.getStorageSync('token') || ''
|
||
|
||
onLoad((options) => {
|
||
if (options?.position) {
|
||
const pos = decodeURIComponent(options.position)
|
||
position.value = pos
|
||
messages.value = [{ role: 'ai', content: `你好!我是你的专属 ${pos} 面试官,准备好了就开始吧!` }]
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
timerInterval = setInterval(() => timerSeconds++, 1000)
|
||
if (token()) startInterview()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
clearInterval(timerInterval)
|
||
})
|
||
|
||
const checkLogin = () => {
|
||
if (!token()) {
|
||
uni.showModal({
|
||
title: '请先登录', content: '登录后即可开始 AI 模拟面试', confirmText: '去登录',
|
||
success: (r) => { if (r.confirm) uni.navigateTo({ url: '/pages/login/login' }) },
|
||
})
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
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()}`, '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
|
||
if (res.data.totalQuestions) MAX_QUESTIONS = res.data.totalQuestions
|
||
// 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)
|
||
}
|
||
} else {
|
||
const msg = res.data?.message || '创建面试失败'
|
||
messages.value.push({ role: 'ai', content: msg })
|
||
}
|
||
} catch {
|
||
messages.value.push({ role: 'ai', content: '创建面试失败,请重试' })
|
||
} finally {
|
||
aiLoading.value = false
|
||
scrollToBottom()
|
||
}
|
||
}
|
||
|
||
const sendAnswer = async () => {
|
||
if (!inputText.value.trim() || aiLoading.value || isComplete.value) return
|
||
if (!token()) { checkLogin(); return }
|
||
if (!interviewId.value) {
|
||
await startInterview()
|
||
if (!interviewId.value) return // creation failed, don't discard answer
|
||
}
|
||
|
||
const answer = inputText.value.trim()
|
||
messages.value.push({ role: 'user', content: answer })
|
||
inputText.value = ''
|
||
scrollToBottom()
|
||
aiLoading.value = true
|
||
|
||
try {
|
||
const res = await uni.request({
|
||
url: api(`/interview/${interviewId.value}/answer`), method: 'POST',
|
||
header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' },
|
||
data: avatarMode.value ? { answer, avatar: true } : { answer },
|
||
})
|
||
if (res.statusCode === 200 && res.data?.messages) {
|
||
const aiMsg = res.data.messages.find(m => m.role === 'ai')
|
||
// Only push AI messages from response to avoid duplicating the user message already added above
|
||
const newAiMessages = res.data.messages.filter(m => m.role === 'ai')
|
||
if (newAiMessages.length > 0) messages.value.push(...newAiMessages)
|
||
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.totalQuestions) MAX_QUESTIONS = res.data.totalQuestions
|
||
} else if (res.statusCode === 403) {
|
||
messages.value.push({ role: 'ai', content: res.data?.message || '面试次数已用完' })
|
||
isComplete.value = true
|
||
} else {
|
||
messages.value.push({ role: 'ai', content: res.data?.message || '回答提交失败' })
|
||
}
|
||
} catch {
|
||
messages.value.push({ role: 'ai', content: '回答提交失败,请重试' })
|
||
} finally {
|
||
aiLoading.value = false
|
||
scrollToBottom()
|
||
}
|
||
}
|
||
|
||
async function speakAiText(text, ttsHash, ttsAmplitude) {
|
||
aiSpeechText.value = text
|
||
aiAmplitudeData.value = ttsAmplitude || []
|
||
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', 'Authorization': `Bearer ${token()}` },
|
||
data: { text },
|
||
})
|
||
if (synthRes.statusCode === 200 && synthRes.data?.hash) {
|
||
aiAudioUrl.value = api(API_ENDPOINTS.TTS.AUDIO(synthRes.data.hash))
|
||
aiAmplitudeData.value = synthRes.data?.amplitudeData || []
|
||
}
|
||
} 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 confirmExit = () => {
|
||
uni.showModal({
|
||
title: '退出面试', content: interviewId.value ? '确定退出吗?当前进度将不会保存。' : '确定退出?',
|
||
success: (r) => { if (r.confirm) uni.navigateBack() },
|
||
})
|
||
}
|
||
|
||
function startRecord() {
|
||
if (aiLoading.value || isComplete.value) return
|
||
// #ifdef MP-WEIXIN
|
||
isRecording.value = true
|
||
recorder = uni.getRecorderManager()
|
||
recorder.onStart(() => {})
|
||
recorder.onError(() => { isRecording.value = false })
|
||
recorder.start({ format: 'mp3' })
|
||
uni.vibrateShort({ type: 'medium' })
|
||
// #endif
|
||
// #ifndef MP-WEIXIN
|
||
uni.showToast({ title: '语音输入仅支持小程序', icon: 'none' })
|
||
// #endif
|
||
}
|
||
|
||
function stopRecord() {
|
||
if (!recorder || !isRecording.value) return
|
||
isRecording.value = false
|
||
recorder.stop()
|
||
recorder.onStop(async (res) => {
|
||
if (!res.tempFilePath) return
|
||
const audioPath = res.tempFilePath
|
||
try {
|
||
const uploadRes = await uni.uploadFile({
|
||
url: api(API_ENDPOINTS.TTS.ASR),
|
||
filePath: audioPath,
|
||
name: 'audio',
|
||
header: { 'Authorization': `Bearer ${token()}` },
|
||
})
|
||
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) {
|
||
inputText.value = data.text
|
||
uni.vibrateShort({ type: 'light' })
|
||
return
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('[ASR] upload error:', e?.message || e)
|
||
}
|
||
uni.showToast({ title: '语音识别失败,请手动输入', icon: 'none' })
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page { height: 100vh; display: flex; flex-direction: column; background: #F8F9FC; }
|
||
|
||
.topbar {
|
||
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
|
||
padding-top: 20rpx; flex-shrink: 0;
|
||
}
|
||
.topbar-inner {
|
||
display: flex; align-items: center; padding: 16rpx 24rpx 20rpx; gap: 16rpx;
|
||
}
|
||
.back-btn {
|
||
width: 60rpx; height: 60rpx; background: rgba(255,255,255,0.15);
|
||
border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||
}
|
||
.back-arrow { font-size: 36rpx; color: #FFFFFF; font-weight: 300; line-height: 1; }
|
||
.topbar-center { flex: 1; display: flex; flex-direction: column; gap: 8rpx; }
|
||
.topbar-pos-row { display: flex; align-items: center; gap: 10rpx; }
|
||
.topbar-position { font-size: 26rpx; color: #FFFFFF; font-weight: 600; }
|
||
.topbar-status { font-size: 18rpx; color: #FFFFFF; background: rgba(255,255,255,0.2); padding: 2rpx 12rpx; border-radius: 20rpx; }
|
||
.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; text-align: center; }
|
||
.avatar-toggle { font-size: 36rpx; cursor: pointer; }
|
||
|
||
/* 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; }
|
||
|
||
.msg-bubble { max-width: 560rpx; padding: 20rpx 24rpx; line-height: 1.7; font-size: 26rpx; }
|
||
.msg-bubble.ai {
|
||
background: #FFFFFF; color: var(--color-text);
|
||
border-radius: 0 var(--radius-lg) var(--radius-lg) var(--radius-lg);
|
||
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
|
||
}
|
||
.msg-bubble.user {
|
||
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
|
||
color: #FFFFFF;
|
||
border-radius: var(--radius-lg) 0 var(--radius-lg) var(--radius-lg);
|
||
}
|
||
|
||
/* Typing */
|
||
.typing {
|
||
background: #FFFFFF; padding: 20rpx 28rpx;
|
||
border-radius: 0 var(--radius-lg) var(--radius-lg) var(--radius-lg);
|
||
display: flex; gap: 8rpx; align-items: center;
|
||
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
|
||
}
|
||
.typing-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #D1D5DB; animation: blink 1.4s infinite; }
|
||
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
||
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
||
@keyframes blink { 0%,80%,100% { opacity: 0.3 } 40% { opacity: 1 } }
|
||
|
||
/* Input */
|
||
.input-bar {
|
||
background: #FFFFFF; padding: 16rpx 20rpx;
|
||
padding-bottom: calc(16rpx + var(--safe-bottom));
|
||
display: flex; align-items: flex-end; gap: 12rpx;
|
||
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.04); flex-shrink: 0;
|
||
}
|
||
.input-box { flex: 1; background: var(--color-bg); border-radius: var(--radius-md); padding: 12rpx 20rpx; }
|
||
.input-area { width: 100%; font-size: 26rpx; color: var(--color-text); max-height: 160rpx; line-height: 1.5; }
|
||
.mic-btn {
|
||
width: 64rpx; height: 64rpx; border-radius: 50%; background: #F3F4F6;
|
||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||
transition: all 0.2s;
|
||
}
|
||
.mic-btn:active { transform: scale(0.9); }
|
||
.mic-btn.recording { background: #FEE2E2; animation: mic-pulse 1s infinite; }
|
||
.mic-icon { font-size: 28rpx; }
|
||
@keyframes mic-pulse {
|
||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||
50% { box-shadow: 0 0 0 16rpx rgba(239, 68, 68, 0); }
|
||
}
|
||
.send-btn {
|
||
width: 80rpx; height: 80rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
|
||
border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||
transition: all 0.2s ease;
|
||
}
|
||
.send-btn:active { transform: scale(0.9); }
|
||
.send-btn.disabled { background: var(--color-border); }
|
||
.send-icon { font-size: 32rpx; color: #FFFFFF; transform: translateY(2rpx); }
|
||
|
||
.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); }
|
||
</style>
|