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

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

458 lines
9.9 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="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>