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>
|