- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
755 lines
18 KiB
Vue
755 lines
18 KiB
Vue
<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>
|