502 lines
12 KiB
Vue
502 lines
12 KiB
Vue
<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> |