- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
458 lines
9.9 KiB
Vue
458 lines
9.9 KiB
Vue
<template>
|
||
<div class="text-chat-wrapper">
|
||
<!-- 场景背景卡片(可折叠) -->
|
||
<el-card v-if="currentScene" class="scene-context" shadow="never">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>场景背景与目标</span>
|
||
<el-button
|
||
text
|
||
:icon="showContext ? ArrowUp : ArrowDown"
|
||
@click="showContext = !showContext"
|
||
>
|
||
{{ showContext ? '收起' : '展开' }}
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
<div v-show="showContext" class="context-content">
|
||
<div class="context-section">
|
||
<h4>场景背景</h4>
|
||
<p>{{ currentScene.background }}</p>
|
||
</div>
|
||
<div class="context-section">
|
||
<h4>练习目标</h4>
|
||
<ul>
|
||
<li v-for="(obj, index) in currentScene.objectives" :key="index">
|
||
{{ obj }}
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<div v-if="currentScene.keywords && currentScene.keywords.length > 0" class="context-section">
|
||
<h4>关键词</h4>
|
||
<div class="keywords">
|
||
<el-tag
|
||
v-for="keyword in currentScene.keywords"
|
||
:key="keyword"
|
||
type="info"
|
||
size="small"
|
||
effect="plain"
|
||
>
|
||
{{ keyword }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 消息列表 -->
|
||
<div class="message-container" ref="messageContainerRef">
|
||
<div
|
||
v-for="message in messageList"
|
||
:key="message.id"
|
||
:class="['message-item', message.role]"
|
||
>
|
||
<div class="message-avatar">
|
||
<el-avatar
|
||
:src="message.role === 'user' ? userAvatar : botAvatar"
|
||
:size="36"
|
||
>
|
||
<el-icon v-if="message.role === 'user'"><User /></el-icon>
|
||
<el-icon v-else><Service /></el-icon>
|
||
</el-avatar>
|
||
</div>
|
||
<div class="message-bubble">
|
||
<div class="message-content" v-html="formatContent(message.content)"></div>
|
||
<div v-if="message.loading" class="typing-indicator">
|
||
<span></span>
|
||
<span></span>
|
||
<span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div v-if="messageList.length === 0" class="empty-state">
|
||
<el-empty description="开始您的陪练对话吧!" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 输入区域 -->
|
||
<div class="input-area">
|
||
<div class="input-wrapper">
|
||
<el-input
|
||
v-model="userInput"
|
||
type="textarea"
|
||
:autosize="{ minRows: 1, maxRows: 4 }"
|
||
placeholder="输入您的回复..."
|
||
:disabled="isLoading"
|
||
@keydown.ctrl.enter="handleSend"
|
||
class="message-input"
|
||
/>
|
||
<div class="input-actions">
|
||
<div class="action-buttons">
|
||
<!-- 切换到语音模式 -->
|
||
<el-button
|
||
type="info"
|
||
:icon="Microphone"
|
||
@click="switchToVoiceMode"
|
||
>
|
||
切换语音对话
|
||
</el-button>
|
||
|
||
<!-- 中断按钮 -->
|
||
<el-button
|
||
v-if="isLoading"
|
||
type="warning"
|
||
:icon="VideoPlay"
|
||
@click="handleAbort"
|
||
>
|
||
中断
|
||
</el-button>
|
||
|
||
<!-- 发送按钮 -->
|
||
<el-button
|
||
v-else
|
||
type="primary"
|
||
:icon="Position"
|
||
:disabled="!userInput.trim()"
|
||
@click="handleSend"
|
||
>
|
||
发送
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
||
import { storeToRefs } from 'pinia'
|
||
import { usePracticeStore } from '@/stores/practiceStore'
|
||
import { ElMessage } from 'element-plus'
|
||
import {
|
||
ArrowUp,
|
||
ArrowDown,
|
||
User,
|
||
Service,
|
||
Position,
|
||
VideoPlay,
|
||
Microphone
|
||
} from '@element-plus/icons-vue'
|
||
|
||
// Store
|
||
const practiceStore = usePracticeStore()
|
||
const { currentScene, messageList, isLoading } = storeToRefs(practiceStore)
|
||
|
||
// Refs
|
||
const userInput = ref<string>('')
|
||
const showContext = ref<boolean>(true)
|
||
const messageContainerRef = ref<HTMLElement | null>(null)
|
||
|
||
// 生命周期
|
||
onMounted(() => {
|
||
// 移动端默认收起场景信息
|
||
if (window.innerWidth <= 768) {
|
||
showContext.value = false
|
||
}
|
||
})
|
||
|
||
// 计算属性
|
||
const userAvatar = computed(() => {
|
||
// TODO: 从用户信息中获取头像
|
||
return ''
|
||
})
|
||
|
||
const botAvatar = computed(() => {
|
||
// TODO: 从Bot配置中获取头像
|
||
return ''
|
||
})
|
||
|
||
// 方法
|
||
const handleSend = async () => {
|
||
if (!userInput.value.trim()) {
|
||
return
|
||
}
|
||
|
||
const message = userInput.value.trim()
|
||
userInput.value = ''
|
||
|
||
await practiceStore.sendMessage(message)
|
||
|
||
// 滚动到底部
|
||
nextTick(() => {
|
||
scrollToBottom()
|
||
})
|
||
}
|
||
|
||
const handleAbort = () => {
|
||
practiceStore.abortChat()
|
||
}
|
||
|
||
const switchToVoiceMode = () => {
|
||
practiceStore.setChatModel('voice')
|
||
ElMessage.info('已切换到语音对话模式')
|
||
}
|
||
|
||
const formatContent = (content: string): string => {
|
||
// 简单的换行处理
|
||
return content.replace(/\n/g, '<br />')
|
||
}
|
||
|
||
const scrollToBottom = () => {
|
||
if (messageContainerRef.value) {
|
||
messageContainerRef.value.scrollTop = messageContainerRef.value.scrollHeight
|
||
}
|
||
}
|
||
|
||
// 监听消息列表变化,自动滚动
|
||
watch(() => messageList.value.length, () => {
|
||
nextTick(() => {
|
||
scrollToBottom()
|
||
})
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.text-chat-wrapper {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #f5f7fa;
|
||
|
||
.scene-context {
|
||
margin: 16px;
|
||
border-radius: 8px;
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.context-content {
|
||
.context-section {
|
||
margin-bottom: 16px;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
h4 {
|
||
margin: 0 0 8px 0;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
p {
|
||
margin: 0;
|
||
line-height: 1.6;
|
||
color: #606266;
|
||
}
|
||
|
||
ul {
|
||
margin: 0;
|
||
padding-left: 20px;
|
||
|
||
li {
|
||
line-height: 1.8;
|
||
color: #606266;
|
||
}
|
||
}
|
||
|
||
.keywords {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.message-container {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
|
||
.message-item {
|
||
display: flex;
|
||
margin-bottom: 20px;
|
||
gap: 12px;
|
||
animation: slideIn 0.3s ease-out;
|
||
|
||
&.user {
|
||
flex-direction: row-reverse;
|
||
|
||
.message-bubble {
|
||
background: #409eff;
|
||
color: white;
|
||
}
|
||
}
|
||
|
||
&.assistant {
|
||
flex-direction: row;
|
||
|
||
.message-bubble {
|
||
background: white;
|
||
color: #303133;
|
||
}
|
||
}
|
||
|
||
.message-avatar {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.message-bubble {
|
||
max-width: 70%;
|
||
padding: 12px 16px;
|
||
border-radius: 12px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||
|
||
.message-content {
|
||
line-height: 1.6;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.typing-indicator {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-top: 8px;
|
||
|
||
span {
|
||
width: 8px;
|
||
height: 8px;
|
||
background: currentColor;
|
||
opacity: 0.3;
|
||
border-radius: 50%;
|
||
animation: bounce 1.4s infinite ease-in-out;
|
||
|
||
&:nth-child(1) { animation-delay: -0.32s; }
|
||
&:nth-child(2) { animation-delay: -0.16s; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.empty-state {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
}
|
||
}
|
||
|
||
.input-area {
|
||
flex-shrink: 0;
|
||
background: white;
|
||
padding: 16px;
|
||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||
|
||
.input-wrapper {
|
||
.message-input {
|
||
:deep(.el-textarea__inner) {
|
||
border-radius: 8px;
|
||
resize: none;
|
||
}
|
||
}
|
||
|
||
.input-actions {
|
||
margin-top: 12px;
|
||
|
||
.action-buttons {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 12px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@keyframes slideIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(10px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
@keyframes bounce {
|
||
0%, 80%, 100% { transform: scale(0); }
|
||
40% { transform: scale(1); }
|
||
}
|
||
|
||
// 移动端适配
|
||
@media (max-width: 768px) {
|
||
.text-chat-wrapper {
|
||
.scene-context {
|
||
margin: 10px;
|
||
|
||
:deep(.el-card__header) {
|
||
padding: 10px 15px;
|
||
}
|
||
|
||
:deep(.el-card__body) {
|
||
padding: 15px;
|
||
}
|
||
}
|
||
|
||
.message-container {
|
||
padding: 10px;
|
||
|
||
.message-item {
|
||
margin-bottom: 16px;
|
||
gap: 8px;
|
||
|
||
.message-avatar {
|
||
:deep(.el-avatar) {
|
||
width: 32px;
|
||
height: 32px;
|
||
|
||
.el-icon {
|
||
font-size: 18px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.message-bubble {
|
||
max-width: 85%;
|
||
padding: 10px 14px;
|
||
font-size: 15px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.input-area {
|
||
padding: 10px;
|
||
|
||
.input-wrapper {
|
||
.message-input {
|
||
margin-bottom: 8px;
|
||
|
||
:deep(.el-textarea__inner) {
|
||
font-size: 16px; // 防止iOS缩放
|
||
}
|
||
}
|
||
|
||
.input-actions {
|
||
margin-top: 0;
|
||
|
||
.action-buttons {
|
||
.el-button {
|
||
padding: 8px 16px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|
||
|