Files
AI-interview-web/src/components/ChatWindow.vue
2025-09-17 21:36:49 +08:00

502 lines
12 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="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>