Files
AI-interview-web/src/components/ChatWindow.vue

502 lines
12 KiB
Vue
Raw Normal View History

2025-09-17 21:36:49 +08:00
<template>
<div class="chat-window-container">
<div class="chat-header">
<div class="header-left">
<h2>{{ mode === 'chat' ? 'AI对话' : mode === 'ai' ? 'AI智能面试' : '题库面试' }}</h2>
<span class="status-indicator" :class="interviewStatus"></span>
<span class="status-text">{{ statusText }}</span>
</div>
<div class="header-right">
<el-button v-if="mode !== 'chat'" @click="endInterview" size="small">结束面试</el-button>
<el-button @click="$emit('close')" size="small" v-if="$emit('close')">关闭</el-button>
</div>
</div>
<div class="messages-container" ref="messagesContainer">
<div
v-for="(message, index) in messages"
:key="index"
class="message-row"
:class="'message-' + message.sender.toLowerCase()"
>
<el-avatar
class="avatar"
:style="{ backgroundColor: message.sender === 'AI' ? '#409EFF' : '#67C23A' }"
>
{{ message.sender === 'AI' ? 'AI' : candidateName.charAt(0) }}
</el-avatar>
<div class="message-content">
<div class="message-bubble" v-html="formatMessage(message.content)"></div>
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
</div>
</div>
<!-- AI思考中的动画 -->
<div v-if="isAiThinking" class="message-row message-ai">
<el-avatar class="avatar" style="background-color: #409EFF;">AI</el-avatar>
<div class="message-content">
<div class="message-bubble thinking">
<span></span><span></span><span></span>
</div>
</div>
</div>
</div>
<div class="input-area">
<el-input
type="textarea"
:rows="4"
v-model="userAnswer"
placeholder="在此输入您的回答..."
:disabled="isLoading || interviewStatus === 'COMPLETED'"
resize="none"
class="answer-input"
@keypress.enter.prevent="sendMessage"
></el-input>
<div class="input-actions">
<span class="char-counter">{{ userAnswer.length }} / 1000</span>
<el-button
type="primary"
@click="sendMessage"
:loading="isLoading"
:disabled="interviewStatus === 'COMPLETED' || !userAnswer.trim()"
class="send-button"
>
发送回答
</el-button>
</div>
</div>
<!-- 面试结束提示 -->
<div v-if="interviewStatus === 'COMPLETED'" class="completion-overlay">
<div class="completion-content">
<el-result
icon="success"
title="面试已完成"
sub-title="感谢您的参与!您可以查看面试报告或开始新的面试。"
>
<template #extra>
<el-button type="primary" @click="$emit('end-interview')">返回</el-button>
<el-button>查看报告</el-button>
</template>
</el-result>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
mode: {
type: String,
default: 'chat'
},
categories: {
type: Array,
default: () => []
},
candidateName: {
type: String,
default: ''
},
sessionId: {
type: String,
default: ''
}
})
const emit = defineEmits(['end-interview', 'close'])
// 响应式状态
const messages = ref([])
const userAnswer = ref('')
const isLoading = ref(false)
const isAiThinking = ref(false)
const interviewStatus = ref('ACTIVE') // ACTIVE, COMPLETED
const messagesContainer = ref(null)
// 计算属性
const statusText = computed(() => {
return interviewStatus.value === 'ACTIVE' ? '进行中' : '已结束'
})
// 生命周期
onMounted(() => {
initializeChat()
scrollToBottom()
})
// 初始化聊天
const initializeChat = () => {
let welcomeMessage = ''
if (props.mode === 'chat') {
welcomeMessage = '您好我是AI助手很高兴与您对话。有什么我可以帮助您的吗'
} else if (props.mode === 'ai') {
welcomeMessage = '您好我是AI面试官接下来将为您进行模拟面试。请简单介绍一下您自己。'
} else {
welcomeMessage = '您好!我将从您选择的题库中抽取题目进行面试。请简单介绍一下您自己。'
}
messages.value = [
{
sender: 'AI',
content: welcomeMessage,
timestamp: new Date()
}
]
}
// 格式化消息
const formatMessage = (content) => {
return content ? content.replace(/\n/g, '<br />') : ''
}
// 格式化时间
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
// 发送消息
const sendMessage = async () => {
if (!userAnswer.value.trim()) return
const userMessage = {
sender: 'USER',
content: userAnswer.value,
timestamp: new Date()
}
messages.value.push(userMessage)
const currentAnswer = userAnswer.value
userAnswer.value = ''
isAiThinking.value = true
scrollToBottom()
try {
// 模拟API调用延迟
await new Promise(resolve => setTimeout(resolve, 1000))
// 生成AI回复
const aiResponse = generateAIResponse(currentAnswer)
messages.value.push({
sender: 'AI',
content: aiResponse,
timestamp: new Date()
})
// 随机结束面试(仅面试模式)
if (props.mode !== 'chat' && Math.random() > 0.8) {
interviewStatus.value = 'COMPLETED'
messages.value.push({
sender: 'AI',
content: '本次面试到此结束,感谢您的参与!您可以从左侧菜单查看面试报告。',
timestamp: new Date()
})
}
} catch (error) {
ElMessage.error('发送失败:' + error.message)
} finally {
isAiThinking.value = false
scrollToBottom()
}
}
// 生成AI回复模拟
const generateAIResponse = (userAnswer) => {
if (props.mode === 'chat') {
const chatResponses = [
"我理解您的意思,能详细说明一下吗?",
"这是一个很好的问题!让我为您解释一下...",
"根据您提供的信息,我建议您可以考虑...",
"您还有其他相关问题吗?我很乐意帮助您。",
"感谢分享,这对我来说很有启发。"
]
return chatResponses[Math.floor(Math.random() * chatResponses.length)]
}
const interviewResponses = [
"很好,请继续。",
"能详细说明一下吗?",
"您在这方面有什么具体的经验?",
"很有趣的观点,还有其他补充吗?",
"感谢分享,接下来我们换个话题。"
]
// 如果是题库模式,可以从题库中抽取问题
if (props.mode === 'local' && props.categories.length > 0) {
const question = getQuestionFromBank()
return question
}
return interviewResponses[Math.floor(Math.random() * interviewResponses.length)]
}
// 从题库中获取问题(模拟)
const getQuestionFromBank = () => {
const questionsByCategory = {
1: ["请解释一下什么是闭包?", "React和Vue有什么区别", "如何优化前端性能?"],
2: ["谈谈你对微服务的理解", "RESTful API的设计原则是什么", "如何保证API的安全性"],
3: ["请实现一个快速排序算法", "二叉树和链表有什么区别?", "动态规划的核心思想是什么?"],
4: ["数据库索引的原理是什么?", "SQL和NoSQL的区别是什么", "如何设计一个高效的数据库 schema"],
5: ["TCP和UDP的区别是什么", "HTTP和HTTPS有什么不同", "什么是DNS解析过程"],
6: ["如何设计一个短链接服务?", "请设计一个电商系统的数据库结构", "如何设计一个高可用的系统?"],
7: ["你最大的优点和缺点是什么?", "请描述一次你解决复杂问题的经历", "你如何处理团队冲突?"],
8: ["为什么井盖是圆的?", "如何称出一头大象的重量?", "有多少个乒乓球能装满这个房间?"]
}
// 从选中的分类中随机选择一个问题
const randomCategory = props.categories[Math.floor(Math.random() * props.categories.length)]
const questions = questionsByCategory[randomCategory] || ["请介绍一下你自己"]
return questions[Math.floor(Math.random() * questions.length)]
}
// 结束面试
const endInterview = () => {
interviewStatus.value = 'COMPLETED'
messages.value.push({
sender: 'AI',
content: '面试已手动结束,感谢您的参与!',
timestamp: new Date()
})
scrollToBottom()
}
</script>
<style scoped>
.chat-window-container {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: linear-gradient(135deg, #409EFF 0%, #64b5ff 100%);
color: white;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-left h2 {
margin: 0;
font-size: 1.5rem;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: #67C23A;
}
.status-indicator.COMPLETED {
background: #909399;
}
.status-text {
font-size: 0.9rem;
opacity: 0.9;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 24px;
background: #f9fafb;
}
.message-row {
display: flex;
margin-bottom: 24px;
max-width: 80%;
}
.message-ai {
justify-content: flex-start;
}
.message-user {
justify-content: flex-end;
margin-left: auto;
}
.avatar {
flex-shrink: 0;
margin-right: 12px;
font-weight: bold;
}
.message-user .avatar {
order: 2;
margin-right: 0;
margin-left: 12px;
}
.message-content {
display: flex;
flex-direction: column;
max-width: calc(100% - 52px);
}
.message-bubble {
padding: 12px 16px;
border-radius: 18px;
line-height: 1.6;
word-wrap: break-word;
position: relative;
}
.message-ai .message-bubble {
background-color: white;
color: #303133;
border-top-left-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.message-user .message-bubble {
background-color: #409EFF;
color: white;
border-top-right-radius: 4px;
}
.message-time {
font-size: 0.75rem;
color: #909399;
margin-top: 4px;
padding: 0 4px;
}
.message-ai .message-time {
text-align: left;
}
.message-user .message-time {
text-align: right;
}
.input-area {
padding: 16px 24px;
border-top: 1px solid #e6e8eb;
background: white;
}
.answer-input {
margin-bottom: 12px;
}
.input-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.char-counter {
font-size: 0.8rem;
color: #909399;
}
.send-button {
min-width: 100px;
}
/* AI思考动画 */
.thinking span {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #909399;
margin: 0 2px;
animation: thinking-dots 1.4s infinite ease-in-out both;
}
.thinking span:nth-child(1) {
animation-delay: -0.32s;
}
.thinking span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes thinking-dots {
0%, 80%, 100% {
transform: scale(0);
}
40% {
transform: scale(1.0);
}
}
/* 完成面试覆盖层 */
.completion-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.completion-content {
text-align: center;
max-width: 500px;
padding: 24px;
}
@media (max-width: 768px) {
.message-row {
max-width: 90%;
}
.chat-header {
padding: 12px 16px;
}
.header-left h2 {
font-size: 1.2rem;
}
.messages-container {
padding: 16px;
}
.input-area {
padding: 12px 16px;
}
}
</style>