feat: TTS服务 + 数字人面试组件 (P1)
This commit is contained in:
@@ -23,6 +23,7 @@ import { ProgressModule } from './modules/progress/progress.module'
|
|||||||
import { ContributionModule } from './modules/contribution/contribution.module'
|
import { ContributionModule } from './modules/contribution/contribution.module'
|
||||||
import { DailyQuestionModule } from './modules/daily-question/daily-question.module'
|
import { DailyQuestionModule } from './modules/daily-question/daily-question.module'
|
||||||
import { ScheduleModule } from './modules/schedule/schedule.module'
|
import { ScheduleModule } from './modules/schedule/schedule.module'
|
||||||
|
import { TtsModule } from './modules/tts/tts.module'
|
||||||
|
|
||||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin'
|
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin'
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin
|
|||||||
ContributionModule,
|
ContributionModule,
|
||||||
DailyQuestionModule,
|
DailyQuestionModule,
|
||||||
ScheduleModule,
|
ScheduleModule,
|
||||||
|
TtsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ export class InterviewController {
|
|||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@CurrentUser('userId') userId: string,
|
@CurrentUser('userId') userId: string,
|
||||||
@Body('answer') answer: string,
|
@Body('answer') answer: string,
|
||||||
|
@Body('avatar') avatar?: boolean,
|
||||||
) {
|
) {
|
||||||
|
if (avatar) return this.interviewService.answerWithAvatar(id, userId, answer)
|
||||||
return this.interviewService.answer(id, userId, answer)
|
return this.interviewService.answer(id, userId, answer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { InterviewService } from './interview.service'
|
|||||||
import { Interview, InterviewSchema } from './interview.schema'
|
import { Interview, InterviewSchema } from './interview.schema'
|
||||||
import { Progress, ProgressSchema } from '../schemas/progress.schema'
|
import { Progress, ProgressSchema } from '../schemas/progress.schema'
|
||||||
import { UserModule } from '../user/user.module'
|
import { UserModule } from '../user/user.module'
|
||||||
|
import { TtsModule } from '../tts/tts.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -13,6 +14,7 @@ import { UserModule } from '../user/user.module'
|
|||||||
{ name: Progress.name, schema: ProgressSchema },
|
{ name: Progress.name, schema: ProgressSchema },
|
||||||
]),
|
]),
|
||||||
UserModule,
|
UserModule,
|
||||||
|
TtsModule,
|
||||||
],
|
],
|
||||||
controllers: [InterviewController],
|
controllers: [InterviewController],
|
||||||
providers: [InterviewService],
|
providers: [InterviewService],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Progress, ProgressDocument } from '../schemas/progress.schema'
|
|||||||
import { AiService } from '../ai/ai.service'
|
import { AiService } from '../ai/ai.service'
|
||||||
import { UserService } from '../user/user.service'
|
import { UserService } from '../user/user.service'
|
||||||
import { QuotaService } from '../user/quota.service'
|
import { QuotaService } from '../user/quota.service'
|
||||||
|
import { TtsService } from '../tts/tts.service'
|
||||||
import { analyzeSpeech } from '../../common/utils/filler-words'
|
import { analyzeSpeech } from '../../common/utils/filler-words'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -16,6 +17,7 @@ export class InterviewService {
|
|||||||
private aiService: AiService,
|
private aiService: AiService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private quotaService: QuotaService,
|
private quotaService: QuotaService,
|
||||||
|
private ttsService: TtsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(userId: string, position: string) {
|
async create(userId: string, position: string) {
|
||||||
@@ -99,6 +101,20 @@ ${conversationHistory}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async answerWithAvatar(interviewId: string, userId: string, answer: string) {
|
||||||
|
const base = await this.answer(interviewId, userId, answer)
|
||||||
|
const aiMsg = base.messages?.find(m => m.role === 'ai')
|
||||||
|
if (aiMsg?.content) {
|
||||||
|
try {
|
||||||
|
const tts = await this.ttsService.synthesize(aiMsg.content)
|
||||||
|
return { ...base, ttsHash: tts.hash, ttsDurationMs: tts.durationMs }
|
||||||
|
} catch {
|
||||||
|
// TTS failure is non-critical, return without audio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
async complete(interviewId: string, userId: string) {
|
async complete(interviewId: string, userId: string) {
|
||||||
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
|
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
|
||||||
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
|
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Controller, Get, Post, Body, Param, Res, HttpException, HttpStatus } from '@nestjs/common'
|
||||||
|
import { Response } from 'express'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import { TtsService } from './tts.service'
|
||||||
|
import { Public } from '../../common/decorators/public.decorator'
|
||||||
|
|
||||||
|
@Controller('tts')
|
||||||
|
export class TtsController {
|
||||||
|
constructor(private ttsService: TtsService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('synthesize')
|
||||||
|
async synthesize(@Body('text') text: string, @Body('voice') voice?: string) {
|
||||||
|
if (!text || text.length > 500) {
|
||||||
|
throw new HttpException('文本不能为空且不超过500字', HttpStatus.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
const result = await this.ttsService.synthesize(text, voice)
|
||||||
|
return { hash: result.hash, durationMs: result.durationMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('audio/:hash')
|
||||||
|
async getAudio(@Param('hash') hash: string, @Res() res: Response) {
|
||||||
|
const filePath = this.ttsService.getCachedPath(hash)
|
||||||
|
if (!filePath) {
|
||||||
|
throw new HttpException('音频不存在', HttpStatus.NOT_FOUND)
|
||||||
|
}
|
||||||
|
const stream = fs.createReadStream(filePath)
|
||||||
|
res.setHeader('Content-Type', 'audio/mpeg')
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000')
|
||||||
|
stream.pipe(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { TtsController } from './tts.controller'
|
||||||
|
import { TtsService } from './tts.service'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [TtsController],
|
||||||
|
providers: [TtsService],
|
||||||
|
exports: [TtsService],
|
||||||
|
})
|
||||||
|
export class TtsModule {}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import * as crypto from 'crypto'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import { execSync } from 'child_process'
|
||||||
|
import { Injectable, Logger } from '@nestjs/common'
|
||||||
|
|
||||||
|
const CACHE_DIR = '/tmp/tts-cache'
|
||||||
|
|
||||||
|
interface TtsResult {
|
||||||
|
hash: string
|
||||||
|
filePath: string
|
||||||
|
durationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TtsService {
|
||||||
|
private readonly logger = new Logger(TtsService.name)
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (!fs.existsSync(CACHE_DIR)) {
|
||||||
|
fs.mkdirSync(CACHE_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesize(text: string, voice: string = 'zh-CN-XiaoxiaoNeural'): Promise<TtsResult> {
|
||||||
|
const hash = crypto.createHash('md5').update(text + voice).digest('hex')
|
||||||
|
const filePath = path.join(CACHE_DIR, `${hash}.mp3`)
|
||||||
|
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const durationMs = await this.getDuration(filePath)
|
||||||
|
return { hash, filePath, durationMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`edge-tts --voice "${voice}" --text "${this.escapeText(text)}" --write-media "${filePath}"`,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
)
|
||||||
|
const durationMs = await this.getDuration(filePath)
|
||||||
|
this.logger.log(`TTS generated: hash=${hash} text="${text.slice(0, 40)}..." duration=${durationMs}ms`)
|
||||||
|
return { hash, filePath, durationMs }
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`TTS failed: ${e.message}`)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCachedPath(hash: string): string | null {
|
||||||
|
const filePath = path.join(CACHE_DIR, `${hash}.mp3`)
|
||||||
|
return fs.existsSync(filePath) ? filePath : null
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeText(text: string): string {
|
||||||
|
return text.replace(/"/g, '\\"').replace(/\n/g, ' ').replace(/\r/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDuration(filePath: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
// Estimate duration from file size (~16kbps for mp3 at 22050Hz)
|
||||||
|
const stat = fs.statSync(filePath)
|
||||||
|
const bytesPerMs = 16 * 1024 / 8 / 1000 // 16kbps → bytes per ms
|
||||||
|
return Math.round(stat.size / bytesPerMs)
|
||||||
|
} catch {
|
||||||
|
return 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<template>
|
||||||
|
<view class="digital-human">
|
||||||
|
<view class="avatar-stage">
|
||||||
|
<view class="avatar-ring" :class="{ speaking: isSpeaking }">
|
||||||
|
<!-- Default CSS avatar if image fails -->
|
||||||
|
<view class="avatar-default" v-if="imgFailed">
|
||||||
|
<text class="avatar-initials">AI</text>
|
||||||
|
</view>
|
||||||
|
<image class="avatar-img" :src="avatarSrc" mode="aspectFill" @error="imgFailed = true" v-else />
|
||||||
|
<canvas
|
||||||
|
v-if="isH5"
|
||||||
|
id="dh-mouth"
|
||||||
|
class="mouth-canvas"
|
||||||
|
></canvas>
|
||||||
|
</view>
|
||||||
|
<text class="role-label">AI 面试官</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, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
text: { type: String, default: '' },
|
||||||
|
audioUrl: { type: String, default: '' },
|
||||||
|
avatarUrl: { type: String, default: '' },
|
||||||
|
autoPlay: { type: Boolean, default: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['speaking-start', 'speaking-end'])
|
||||||
|
|
||||||
|
const isH5 = ref(false)
|
||||||
|
const isSpeaking = ref(false)
|
||||||
|
const currentText = ref('')
|
||||||
|
const imgFailed = ref(false)
|
||||||
|
let audioEl = null
|
||||||
|
let audioCtx = null
|
||||||
|
let analyser = null
|
||||||
|
let animFrameId = null
|
||||||
|
let mouthScale = 0
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
isH5.value = typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||||
|
initCanvas()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopAudio()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.audioUrl, (url) => {
|
||||||
|
if (url && props.autoPlay) {
|
||||||
|
nextTick(() => playAudio(url))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.text, (txt) => {
|
||||||
|
currentText.value = txt
|
||||||
|
})
|
||||||
|
|
||||||
|
const avatarSrc = computed(() => {
|
||||||
|
return props.avatarUrl || '/static/default-avatar.png'
|
||||||
|
})
|
||||||
|
|
||||||
|
function initCanvas() {
|
||||||
|
if (!isH5.value) return
|
||||||
|
const canvas = document.getElementById('dh-mouth')
|
||||||
|
if (!canvas) return
|
||||||
|
// Size the canvas to match mouth area (~40% width, ~15% height, centered bottom)
|
||||||
|
const parent = canvas.parentElement
|
||||||
|
if (!parent) return
|
||||||
|
const rect = parent.getBoundingClientRect()
|
||||||
|
canvas.width = rect.width * 0.4
|
||||||
|
canvas.height = rect.height * 0.15
|
||||||
|
canvas.style.width = canvas.width + 'px'
|
||||||
|
canvas.style.height = canvas.height + 'px'
|
||||||
|
canvas.style.position = 'absolute'
|
||||||
|
canvas.style.bottom = '18%'
|
||||||
|
canvas.style.left = '30%'
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMouth(openRatio) {
|
||||||
|
if (!isH5.value) return
|
||||||
|
const canvas = document.getElementById('dh-mouth')
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const w = canvas.width
|
||||||
|
const h = canvas.height
|
||||||
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
|
||||||
|
const mouthH = Math.max(2, h * openRatio)
|
||||||
|
const mouthW = w * 0.8
|
||||||
|
|
||||||
|
ctx.fillStyle = '#C97B84'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.ellipse(w / 2, h / 2 + (h - mouthH) / 2, mouthW / 2, mouthH / 2, 0, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
if (openRatio > 0.1) {
|
||||||
|
ctx.fillStyle = '#2D1B1E'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.ellipse(w / 2, h / 2 + (h - mouthH) / 2 + 1, mouthW / 4, mouthH / 4, 0, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playAudio(url) {
|
||||||
|
stopAudio()
|
||||||
|
isSpeaking.value = true
|
||||||
|
emit('speaking-start')
|
||||||
|
|
||||||
|
try {
|
||||||
|
audioEl = new Audio(url)
|
||||||
|
|
||||||
|
if (isH5.value) {
|
||||||
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)()
|
||||||
|
const source = audioCtx.createMediaElementSource(audioEl)
|
||||||
|
analyser = audioCtx.createAnalyser()
|
||||||
|
analyser.fftSize = 256
|
||||||
|
source.connect(analyser)
|
||||||
|
analyser.connect(audioCtx.destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
audioEl.onended = () => {
|
||||||
|
finishSpeaking()
|
||||||
|
}
|
||||||
|
|
||||||
|
audioEl.onerror = () => {
|
||||||
|
finishSpeaking()
|
||||||
|
}
|
||||||
|
|
||||||
|
await audioEl.play()
|
||||||
|
|
||||||
|
if (analyser) {
|
||||||
|
animateMouth()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
finishSpeaking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateMouth() {
|
||||||
|
if (!analyser) return
|
||||||
|
const dataArray = new Uint8Array(analyser.frequencyBinCount)
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
if (!isSpeaking.value) return
|
||||||
|
analyser.getByteFrequencyData(dataArray)
|
||||||
|
const sum = dataArray.reduce((a, b) => a + b, 0)
|
||||||
|
const avg = sum / dataArray.length
|
||||||
|
mouthScale = Math.min(1, avg / 128)
|
||||||
|
// Smooth
|
||||||
|
mouthScale = Math.max(0.05, mouthScale)
|
||||||
|
drawMouth(mouthScale)
|
||||||
|
animFrameId = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
tick()
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishSpeaking() {
|
||||||
|
isSpeaking.value = false
|
||||||
|
emit('speaking-end')
|
||||||
|
if (analyser) {
|
||||||
|
mouthScale = 0
|
||||||
|
drawMouth(0)
|
||||||
|
}
|
||||||
|
cleanupAudio()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupAudio() {
|
||||||
|
if (animFrameId) { cancelAnimationFrame(animFrameId); animFrameId = null }
|
||||||
|
if (audioCtx) { audioCtx.close().catch(() => {}); audioCtx = null }
|
||||||
|
analyser = null
|
||||||
|
audioEl = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAudio() {
|
||||||
|
if (audioEl) {
|
||||||
|
try { audioEl.pause(); audioEl.src = '' } catch {}
|
||||||
|
}
|
||||||
|
finishSpeaking()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ play: playAudio, stop: stopAudio })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.digital-human {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar stage */
|
||||||
|
.avatar-stage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-ring {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4rpx solid #E5E7EB;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: border-color 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-ring.speaking {
|
||||||
|
border-color: #6366F1;
|
||||||
|
box-shadow: 0 0 30rpx rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-default {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #6366F1, #8B5CF6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-initials {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouth-canvas {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #6B7280;
|
||||||
|
background: #F3F4F6;
|
||||||
|
padding: 4rpx 20rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Speech bubble */
|
||||||
|
.speech-area {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
padding: 0 40rpx;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-bubble {
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
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: 28rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #1F2937;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -86,6 +86,10 @@ export const API_ENDPOINTS = {
|
|||||||
CHECK: (outTradeNo: string) => `/payment/check/${outTradeNo}`,
|
CHECK: (outTradeNo: string) => `/payment/check/${outTradeNo}`,
|
||||||
ACTIVATE: '/payment/activate',
|
ACTIVATE: '/payment/activate',
|
||||||
},
|
},
|
||||||
|
TTS: {
|
||||||
|
SYNTHESIZE: '/tts/synthesize',
|
||||||
|
AUDIO: (hash: string) => `/tts/audio/${hash}`,
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const PROD_API_HOST = import.meta.env.VITE_PROD_API_HOST || 'https://zhiyinwx.yzrcloud.cn'
|
const PROD_API_HOST = import.meta.env.VITE_PROD_API_HOST || 'https://zhiyinwx.yzrcloud.cn'
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page">
|
||||||
<!-- Top bar -->
|
|
||||||
<view class="topbar">
|
<view class="topbar">
|
||||||
<view class="topbar-inner">
|
<view class="topbar-inner">
|
||||||
<view class="back-btn" @click="confirmExit"><text class="back-arrow">‹</text></view>
|
<view class="back-btn" @click="confirmExit"><text class="back-arrow">‹</text></view>
|
||||||
@@ -14,19 +13,34 @@
|
|||||||
</view>
|
</view>
|
||||||
<text class="topbar-timer">⏱ {{ formatTime }}</text>
|
<text class="topbar-timer">⏱ {{ formatTime }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="topbar-right"></view>
|
<view class="topbar-right">
|
||||||
|
<text class="avatar-toggle" @click="avatarMode = !avatarMode">
|
||||||
|
{{ avatarMode ? '💬' : '👤' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Chat area -->
|
<!-- Avatar mode -->
|
||||||
<scroll-view class="chat-area" scroll-y :scroll-into-view="scrollToId" :scroll-with-animation="true">
|
<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 v-for="(msg, idx) in messages" :key="idx" :id="'msg-' + idx" class="msg-row" :class="msg.role">
|
||||||
<view class="msg-bubble" :class="msg.role">
|
<view class="msg-bubble" :class="msg.role">
|
||||||
<text>{{ msg.content }}</text>
|
<text>{{ msg.content }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Typing indicator -->
|
|
||||||
<view class="msg-row ai" v-if="aiLoading">
|
<view class="msg-row ai" v-if="aiLoading">
|
||||||
<view class="typing">
|
<view class="typing">
|
||||||
<view class="typing-dot"></view>
|
<view class="typing-dot"></view>
|
||||||
@@ -38,7 +52,6 @@
|
|||||||
<view id="msg-bottom" style="height: 16rpx;"></view>
|
<view id="msg-bottom" style="height: 16rpx;"></view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<!-- Input bar -->
|
|
||||||
<view class="input-bar" v-if="!isComplete">
|
<view class="input-bar" v-if="!isComplete">
|
||||||
<view class="input-box">
|
<view class="input-box">
|
||||||
<textarea class="input-area" v-model="inputText" placeholder="输入你的回答..." :auto-height="true" :maxlength="2000" :disabled="aiLoading" @confirm="sendAnswer" />
|
<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>
|
<text class="send-icon">➤</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- AI 免责提示 -->
|
|
||||||
<view class="disclaimer-bar" v-if="!isComplete">AI 生成内容仅供参考,请核实重要信息</view>
|
<view class="disclaimer-bar" v-if="!isComplete">AI 生成内容仅供参考,请核实重要信息</view>
|
||||||
|
|
||||||
<!-- Complete -->
|
|
||||||
<view class="complete-bar" v-else>
|
<view class="complete-bar" v-else>
|
||||||
<button class="cta-btn" @click="goResult">查看面试报告</button>
|
<button class="cta-btn" @click="goResult">查看面试报告</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -60,7 +72,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
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 messages = ref([{ role: 'ai', content: '你好!我是 AI 面试官,请选择岗位开始模拟面试!' }])
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
@@ -70,13 +83,19 @@ const answeredCount = ref(0)
|
|||||||
const isComplete = ref(false)
|
const isComplete = ref(false)
|
||||||
const scrollToId = ref('')
|
const scrollToId = ref('')
|
||||||
const position = 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 timerSeconds = 0
|
||||||
let timerInterval = null
|
let timerInterval = null
|
||||||
|
|
||||||
const progressPercent = computed(() => Math.min((answeredCount.value / 5) * 100, 100))
|
const progressPercent = computed(() => Math.min((answeredCount.value / 5) * 100, 100))
|
||||||
const formatTime = computed(() => {
|
const formatTime = computed(() => {
|
||||||
const m = Math.floor(timerSeconds / 60); const s = timerSeconds % 60
|
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') || '')
|
const token = computed(() => uni.getStorageSync('token') || '')
|
||||||
|
|
||||||
@@ -88,13 +107,21 @@ onLoad((options) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => { timerInterval = setInterval(() => timerSeconds++, 1000); if (token.value) startInterview() })
|
onMounted(() => {
|
||||||
onBeforeUnmount(() => clearInterval(timerInterval))
|
timerInterval = setInterval(() => timerSeconds++, 1000)
|
||||||
|
if (token.value) startInterview()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(timerInterval)
|
||||||
|
})
|
||||||
|
|
||||||
const checkLogin = () => {
|
const checkLogin = () => {
|
||||||
if (!token.value) {
|
if (!token.value) {
|
||||||
uni.showModal({ title: '请先登录', content: '登录后即可开始 AI 模拟面试', confirmText: '去登录',
|
uni.showModal({
|
||||||
success: (r) => { if (r.confirm) uni.navigateTo({ url: '/pages/login/login' }) } })
|
title: '请先登录', content: '登录后即可开始 AI 模拟面试', confirmText: '去登录',
|
||||||
|
success: (r) => { if (r.confirm) uni.navigateTo({ url: '/pages/login/login' }) },
|
||||||
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -104,15 +131,27 @@ const startInterview = async () => {
|
|||||||
if (!checkLogin()) return
|
if (!checkLogin()) return
|
||||||
aiLoading.value = true
|
aiLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await uni.request({ url: api('/interview/create'), method: 'POST',
|
const res = await uni.request({
|
||||||
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' }, data: { position: position.value } })
|
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) {
|
if (res.statusCode === 200 && res.data) {
|
||||||
interviewId.value = res.data.id
|
interviewId.value = res.data.id
|
||||||
messages.value = res.data.messages || messages.value
|
messages.value = res.data.messages || messages.value
|
||||||
answeredCount.value = res.data.questionCount || 0
|
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 () => {
|
const sendAnswer = async () => {
|
||||||
@@ -122,32 +161,79 @@ const sendAnswer = async () => {
|
|||||||
|
|
||||||
const answer = inputText.value.trim()
|
const answer = inputText.value.trim()
|
||||||
messages.value.push({ role: 'user', content: answer })
|
messages.value.push({ role: 'user', content: answer })
|
||||||
inputText.value = ''; scrollToBottom()
|
inputText.value = ''
|
||||||
|
scrollToBottom()
|
||||||
aiLoading.value = true
|
aiLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await uni.request({ url: api(`/interview/${interviewId.value}/answer`), method: 'POST',
|
const res = await uni.request({
|
||||||
header: { 'Authorization': `Bearer ${token.value}`, 'Content-Type': 'application/json' }, data: { answer } })
|
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) {
|
if (res.statusCode === 200 && 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)
|
messages.value.push(...res.data.messages)
|
||||||
answeredCount.value = res.data.questionCount || answeredCount.value + 1
|
|
||||||
}
|
}
|
||||||
} catch { messages.value.push({ role: 'ai', content: '回答提交失败,请重试' }) }
|
answeredCount.value = res.data.questionCount || answeredCount.value + 1
|
||||||
finally { aiLoading.value = false; scrollToBottom() }
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 = () => {
|
const confirmExit = () => {
|
||||||
uni.showModal({ title: '退出面试', content: interviewId.value ? '确定退出吗?当前进度将不会保存。' : '确定退出?',
|
uni.showModal({
|
||||||
success: (r) => { if (r.confirm) uni.navigateBack() } })
|
title: '退出面试', content: interviewId.value ? '确定退出吗?当前进度将不会保存。' : '确定退出?',
|
||||||
|
success: (r) => { if (r.confirm) uni.navigateBack() },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page { height: 100vh; display: flex; flex-direction: column; background: #F8F9FC; }
|
.page { height: 100vh; display: flex; flex-direction: column; background: #F8F9FC; }
|
||||||
|
|
||||||
/* ===== Top Bar ===== */
|
|
||||||
.topbar {
|
.topbar {
|
||||||
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
|
background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid));
|
||||||
padding-top: 20rpx; flex-shrink: 0;
|
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-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); }
|
.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-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-area { flex: 1; padding: 24rpx 20rpx; overflow-y: auto; }
|
||||||
|
.chat-compact { max-height: 40vh; }
|
||||||
.msg-row { display: flex; margin-bottom: 24rpx; }
|
.msg-row { display: flex; margin-bottom: 24rpx; }
|
||||||
.msg-row.ai { justify-content: flex-start; }
|
.msg-row.ai { justify-content: flex-start; }
|
||||||
.msg-row.user { justify-content: flex-end; }
|
.msg-row.user { justify-content: flex-end; }
|
||||||
@@ -199,7 +293,7 @@ const confirmExit = () => {
|
|||||||
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
@keyframes blink { 0%,80%,100% { opacity: 0.3 } 40% { opacity: 1 } }
|
@keyframes blink { 0%,80%,100% { opacity: 0.3 } 40% { opacity: 1 } }
|
||||||
|
|
||||||
/* ===== Input ===== */
|
/* Input */
|
||||||
.input-bar {
|
.input-bar {
|
||||||
background: #FFFFFF; padding: 16rpx 20rpx;
|
background: #FFFFFF; padding: 16rpx 20rpx;
|
||||||
padding-bottom: calc(16rpx + var(--safe-bottom));
|
padding-bottom: calc(16rpx + var(--safe-bottom));
|
||||||
@@ -217,7 +311,6 @@ const confirmExit = () => {
|
|||||||
.send-btn.disabled { background: var(--color-border); }
|
.send-btn.disabled { background: var(--color-border); }
|
||||||
.send-icon { font-size: 32rpx; color: #FFFFFF; transform: translateY(2rpx); }
|
.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)); }
|
.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; }
|
.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); }
|
.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); }
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
placeholder - will use inline SVG fallback in component
|
||||||
Reference in New Issue
Block a user