Files
012-kaopeilian/frontend/src/views/trainee/ai-practice-coze.vue
111 998211c483 feat: 初始化考培练系统项目
- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
2026-01-24 19:33:28 +08:00

755 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="ai-practice-container">
<!-- 页面头部 -->
<div class="practice-header">
<el-page-header @back="goBack">
<template #content>
<span class="page-title">AI陪练会话</span>
</template>
<template #extra>
<el-tag v-if="cozeSession" type="success">会话已连接</el-tag>
<el-tag v-else type="info">未连接</el-tag>
</template>
</el-page-header>
</div>
<!-- 主要内容区 -->
<div class="practice-main">
<!-- 左侧陪练模式选择 -->
<div class="practice-sidebar">
<div class="mode-selector">
<h3>陪练模式</h3>
<el-radio-group v-model="practiceMode" @change="handleModeChange">
<el-radio-button value="text">文本对话</el-radio-button>
<el-radio-button value="voice">语音对话</el-radio-button>
</el-radio-group>
</div>
<div class="topic-selector">
<h3>陪练主题</h3>
<el-input
v-model="trainingTopic"
placeholder="请输入陪练主题(可选)"
:disabled="!!cozeSession"
/>
<el-button
type="primary"
@click="startSession"
:loading="isCreatingSession"
:disabled="!!cozeSession"
style="margin-top: 12px; width: 100%"
>
{{ cozeSession ? '会话进行中' : '开始陪练' }}
</el-button>
<el-button
v-if="cozeSession"
@click="endSession"
style="margin-top: 8px; width: 100%"
>
结束陪练
</el-button>
</div>
<div class="practice-tips">
<h3>陪练提示</h3>
<ul>
<li>保持专注认真倾听</li>
<li>积极思考主动表达</li>
<li>不要害怕犯错</li>
<li>及时总结经验</li>
</ul>
</div>
</div>
<!-- 右侧对话区域 -->
<div class="practice-content">
<!-- 文本对话模式 -->
<div v-if="practiceMode === 'text'" class="text-chat-area">
<!-- 消息列表 -->
<div class="message-list" ref="messageContainer">
<div v-if="messages.length === 0" class="empty-state">
<el-empty description="点击开始陪练开始您的AI陪练之旅">
<el-icon :size="64"><ChatDotRound /></el-icon>
</el-empty>
</div>
<div
v-for="(message, index) in messages"
:key="index"
class="message-item"
:class="message.role"
>
<div class="message-avatar">
<el-avatar :size="36">
{{ message.role === 'user' ? '我' : 'AI' }}
</el-avatar>
</div>
<div class="message-content">
<div class="message-text" v-if="!message.loading">
{{ message.content }}
</div>
<div class="message-loading" v-else>
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
<div class="message-time">
{{ formatTime(message.timestamp) }}
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-area" v-if="cozeSession">
<el-input
v-model="inputMessage"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
placeholder="输入您的消息..."
@keyup.enter.ctrl="sendTextMessage"
:disabled="isLoading"
/>
<el-button
type="primary"
@click="sendTextMessage"
:loading="isLoading"
:disabled="!inputMessage.trim()"
style="margin-top: 10px"
>
发送
</el-button>
<el-button
v-if="isLoading"
@click="abortChat"
style="margin-top: 10px"
>
中断
</el-button>
</div>
</div>
<!-- 语音对话模式 -->
<div v-else class="voice-chat-area">
<div class="voice-visualization">
<div class="voice-status">
<el-icon :size="120" :class="{ recording: isRecording }">
<Microphone />
</el-icon>
<p class="status-text">{{ voiceStatusText }}</p>
</div>
<div class="voice-controls" v-if="cozeSession">
<el-button
type="primary"
size="large"
circle
@click="toggleRecording"
:disabled="isProcessing"
>
<el-icon :size="32">
<component :is="isRecording ? 'VideoPause' : 'VideoPlay'" />
</el-icon>
</el-button>
</div>
</div>
<!-- 语音对话历史 -->
<div class="voice-history">
<div
v-for="(message, index) in messages"
:key="index"
class="voice-message"
:class="message.role"
>
<span class="speaker">{{ message.role === 'user' ? '您' : 'AI陪练' }}</span>
<span class="content">{{ message.content }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createTrainingSession,
endTrainingSession,
sendMessage as sendCozeMessage,
type CozeSession,
type StreamEvent
} from '@/api/coze'
const router = useRouter()
// 陪练模式
const practiceMode = ref<'text' | 'voice'>('text')
const trainingTopic = ref('')
const cozeSession = ref<CozeSession | null>(null)
const isCreatingSession = ref(false)
// 消息相关
const messages = ref<any[]>([])
const inputMessage = ref('')
const isLoading = ref(false)
const abortController = ref<AbortController | null>(null)
// 语音相关
const isRecording = ref(false)
const isProcessing = ref(false)
const voiceStatusText = ref('点击开始按钮进行语音陪练')
const mediaRecorder = ref<MediaRecorder | null>(null)
const audioChunks = ref<Blob[]>([])
// DOM引用
const messageContainer = ref<HTMLElement>()
/**
* 返回上一页
*/
const goBack = () => {
if (cozeSession.value) {
ElMessageBox.confirm('确定要离开吗?这将结束当前陪练会话。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
endSession()
router.back()
})
} else {
router.back()
}
}
/**
* 切换模式
*/
const handleModeChange = (_mode: string) => {
if (cozeSession.value) {
ElMessage.warning('请先结束当前会话再切换模式')
practiceMode.value = practiceMode.value === 'text' ? 'voice' : 'text'
}
}
/**
* 开始会话
*/
const startSession = async () => {
isCreatingSession.value = true
try {
const response = await createTrainingSession(trainingTopic.value || undefined)
if (response.data) {
cozeSession.value = response.data as unknown as CozeSession
ElMessage.success('陪练会话已创建')
// 添加欢迎消息
messages.value.push({
role: 'assistant',
content: '您好我是您的AI陪练助手。让我们开始今天的练习吧',
timestamp: new Date()
})
}
} catch (error: any) {
ElMessage.error('创建会话失败:' + (error.message || '未知错误'))
} finally {
isCreatingSession.value = false
}
}
/**
* 结束会话
*/
const endSession = async () => {
if (!cozeSession.value) return
try {
await endTrainingSession(cozeSession.value.sessionId, {
reason: '用户主动结束',
feedback: {
rating: 5,
comment: '陪练体验良好'
}
})
cozeSession.value = null
messages.value = []
inputMessage.value = ''
trainingTopic.value = ''
ElMessage.success('陪练会话已结束')
} catch (error: any) {
ElMessage.error('结束会话失败:' + (error.message || '未知错误'))
}
}
/**
* 发送文本消息
*/
const sendTextMessage = async () => {
const content = inputMessage.value.trim()
if (!content || !cozeSession.value) return
// 添加用户消息
messages.value.push({
role: 'user',
content,
timestamp: new Date()
})
// 清空输入
inputMessage.value = ''
// 添加AI加载消息
const aiMessageIndex = messages.value.length
messages.value.push({
role: 'assistant',
content: '',
loading: true,
timestamp: new Date()
})
// 滚动到底部
await scrollToBottom()
// 发送消息
isLoading.value = true
let fullContent = ''
try {
abortController.value = new AbortController()
await sendCozeMessage(
cozeSession.value.sessionId,
content,
true,
(event: StreamEvent) => {
if (event.event === 'message_delta') {
fullContent += event.data.content || ''
messages.value[aiMessageIndex] = {
role: 'assistant',
content: fullContent,
loading: false,
timestamp: new Date()
}
scrollToBottom()
} else if (event.event === 'message_completed') {
isLoading.value = false
} else if (event.event === 'error') {
throw new Error(event.data.error || '发送消息失败')
}
}
)
} catch (error: any) {
isLoading.value = false
messages.value[aiMessageIndex] = {
role: 'assistant',
content: '抱歉,发送消息时出现错误。请稍后重试。',
loading: false,
timestamp: new Date(),
isError: true
}
ElMessage.error(error.message || '发送消息失败')
}
}
/**
* 中断对话
*/
const abortChat = () => {
if (abortController.value) {
abortController.value.abort()
abortController.value = null
isLoading.value = false
ElMessage.info('对话已中断')
}
}
/**
* 切换录音状态
*/
const toggleRecording = async () => {
if (isRecording.value) {
stopRecording()
} else {
await startRecording()
}
}
/**
* 开始录音
*/
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder.value = new MediaRecorder(stream)
audioChunks.value = []
mediaRecorder.value.ondataavailable = (event) => {
audioChunks.value.push(event.data)
}
mediaRecorder.value.onstop = async () => {
const audioBlob = new Blob(audioChunks.value, { type: 'audio/wav' })
await processAudio(audioBlob)
}
mediaRecorder.value.start()
isRecording.value = true
voiceStatusText.value = '正在录音...'
} catch (error) {
ElMessage.error('无法访问麦克风')
}
}
/**
* 停止录音
*/
const stopRecording = () => {
if (mediaRecorder.value && isRecording.value) {
mediaRecorder.value.stop()
mediaRecorder.value.stream.getTracks().forEach(track => track.stop())
isRecording.value = false
voiceStatusText.value = '正在处理...'
isProcessing.value = true
}
}
/**
* 处理录音
*/
const processAudio = async (_audioBlob: Blob) => {
// TODO: 实现音频转文本和发送逻辑
isProcessing.value = false
voiceStatusText.value = '点击开始按钮进行语音陪练'
ElMessage.info('语音功能正在开发中')
}
/**
* 滚动到底部
*/
const scrollToBottom = async () => {
await nextTick()
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
}
}
/**
* 格式化时间
*/
const formatTime = (date: Date) => {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
// 清理
onUnmounted(() => {
if (abortController.value) {
abortController.value.abort()
}
if (mediaRecorder.value && isRecording.value) {
stopRecording()
}
})
</script>
<style lang="scss" scoped>
.ai-practice-container {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f7fa;
.practice-header {
background: white;
padding: 16px 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
.page-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
}
.practice-main {
flex: 1;
display: flex;
overflow: hidden;
.practice-sidebar {
width: 300px;
background: white;
border-right: 1px solid #ebeef5;
padding: 24px;
overflow-y: auto;
h3 {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.mode-selector {
margin-bottom: 32px;
.el-radio-group {
width: 100%;
display: flex;
.el-radio-button {
flex: 1;
:deep(.el-radio-button__inner) {
width: 100%;
}
}
}
}
.topic-selector {
margin-bottom: 32px;
}
.practice-tips {
ul {
list-style: none;
padding: 0;
margin: 0;
li {
padding: 8px 0;
color: #666;
font-size: 14px;
display: flex;
align-items: center;
&::before {
content: '•';
margin-right: 8px;
color: #667eea;
font-weight: bold;
}
}
}
}
}
.practice-content {
flex: 1;
display: flex;
flex-direction: column;
background: white;
.text-chat-area {
height: 100%;
display: flex;
flex-direction: column;
.message-list {
flex: 1;
overflow-y: auto;
padding: 24px;
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.el-empty {
padding: 40px;
}
}
.message-item {
display: flex;
gap: 12px;
margin-bottom: 24px;
&.user {
flex-direction: row-reverse;
.message-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
.message-time {
color: rgba(255, 255, 255, 0.8);
}
}
}
&.assistant {
.message-content {
background: #f5f7fa;
color: #333;
}
}
.message-content {
max-width: 70%;
padding: 12px 16px;
border-radius: 12px;
.message-text {
font-size: 15px;
line-height: 1.6;
white-space: pre-wrap;
}
.message-loading {
display: flex;
gap: 4px;
padding: 8px 0;
.dot {
width: 8px;
height: 8px;
background: #909399;
border-radius: 50%;
animation: loading 1.4s ease-in-out infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
.message-time {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
}
}
}
.input-area {
border-top: 1px solid #ebeef5;
padding: 16px 24px;
background: white;
}
}
.voice-chat-area {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
.voice-visualization {
text-align: center;
.voice-status {
margin-bottom: 48px;
.el-icon {
color: #909399;
transition: all 0.3s;
&.recording {
color: #f56c6c;
animation: pulse 1.5s ease-in-out infinite;
}
}
.status-text {
font-size: 18px;
color: #666;
margin-top: 24px;
}
}
}
.voice-history {
width: 100%;
max-width: 600px;
max-height: 300px;
overflow-y: auto;
margin-top: 48px;
padding: 24px;
background: #f5f7fa;
border-radius: 12px;
.voice-message {
margin-bottom: 16px;
font-size: 14px;
line-height: 1.6;
.speaker {
font-weight: 600;
color: #333;
}
.content {
color: #666;
}
&.user .speaker {
color: #667eea;
}
&.assistant .speaker {
color: #764ba2;
}
}
}
}
}
}
}
@keyframes loading {
0%, 60%, 100% {
transform: scale(1);
opacity: 1;
}
30% {
transform: scale(1.3);
opacity: 0.5;
}
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
// 响应式
@media (max-width: 768px) {
.ai-practice-container {
.practice-main {
flex-direction: column;
.practice-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid #ebeef5;
padding: 16px;
}
}
}
}
</style>