@@ -28,7 +28,8 @@
< el -avatar
< el -avatar
class = "avatar"
class = "avatar"
style = "background-color: #409EFF; margin-right: 12px;"
style = "background-color: #409EFF; margin-right: 12px;"
> AI < / el-avatar >
> AI
< / el-avatar >
< div class = "message-content" >
< div class = "message-content" >
< div class = "message-bubble markdown-body" v-html = "formatMessage(message.content)" > < / div >
< div class = "message-bubble markdown-body" v-html = "formatMessage(message.content)" > < / div >
@@ -45,7 +46,8 @@
< el-avatar
< el-avatar
class = "avatar"
class = "avatar"
style = "background-color: #67C23A; margin-left: 12px;"
style = "background-color: #67C23A; margin-left: 12px;"
> 我 < / el-avatar >
> 我
< / el-avatar >
< / template >
< / template >
< / div >
< / div >
@@ -59,6 +61,29 @@
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< div v-if = "hasNextQuestionFailed && interviewStatus !== 'COMPLETED'" class="system-tips-area" >
< el -alert
title = "系统异常: 未能自动获取AI响应或下一题"
type = "warning"
show -icon
:closable = "false"
>
< div >
自动重试已达上限 ( { { MAX _FAILURES } } 次 ) 。 请检查网络连接或等待片刻后 ,
< el-button
type = "primary"
:loading = "isLoading"
@click ="manualRetryGetAiResponse"
size = "small"
style = "margin-left: 10px;"
>
手动重试获取AI响应
< / el-button >
< / div >
< / el-alert >
< / div >
< / div >
< div class = "input-area" >
< div class = "input-area" >
@@ -67,7 +92,7 @@
:rows = "4"
:rows = "4"
v-model = "userAnswer"
v-model = "userAnswer"
placeholder = "在此输入您的回答 (Enter 键发送)..."
placeholder = "在此输入您的回答 (Enter 键发送)..."
: disabled = "isLoading || interviewStatus === 'COMPLETED'"
: disabled = "isLoading || interviewStatus === 'COMPLETED' || hasNextQuestionFailed "
resize = "none"
resize = "none"
maxlength = "1000"
maxlength = "1000"
show -word -limit
show -word -limit
@@ -81,7 +106,7 @@
type = "primary"
type = "primary"
@click ="sendMessage"
@click ="sendMessage"
:loading = "isLoading"
:loading = "isLoading"
: disabled = "interviewStatus === 'COMPLETED' || !userAnswer.trim()"
: disabled = "interviewStatus === 'COMPLETED' || !userAnswer.trim() || hasNextQuestionFailed "
class = "send-button"
class = "send-button"
>
>
< el-icon > < i class = "el-icon-s-promotion" > < / i > < / el-icon >
< el-icon > < i class = "el-icon-s-promotion" > < / i > < / el-icon >
@@ -111,9 +136,10 @@
import { ref , onMounted , nextTick , computed } from 'vue'
import { ref , onMounted , nextTick , computed } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage } from 'element-plus'
import { useRoute , useRouter } from 'vue-router'
import { useRoute , useRouter } from 'vue-router'
// **保持原有接口导入,不对接口逻辑进行修改**
// 假设这些接口都已存在于此
import { getMessageListBySessionId } from "@/api/interview-message.js" ;
import { getMessageListBySessionId } from "@/api/interview-message.js" ;
import { endInterview , getNextQuestion , submitAnswer } from "@/api/interview.js" ;
import { endInterview , getNextQuestion , submitAnswer } from "@/api/interview.js" ;
import { getQuestionProgressInfo } from '@/api/question-progress.js'
import MarkdownIt from 'markdown-it'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css' // 导入您选择的 highlight.js 主题
import 'highlight.js/styles/github.css' // 导入您选择的 highlight.js 主题
@@ -140,9 +166,6 @@ const md = new MarkdownIt({
const route = useRoute ( )
const route = useRoute ( )
const router = useRouter ( )
const router = useRouter ( )
// 定义事件
const emit = defineEmits ( [ 'end-interview' , 'close' ] )
// 响应式状态
// 响应式状态
const messages = ref ( [ ] )
const messages = ref ( [ ] )
const userAnswer = ref ( '' )
const userAnswer = ref ( '' )
@@ -150,7 +173,13 @@ const isLoading = ref(false)
const isAiThinking = ref ( false )
const isAiThinking = ref ( false )
const interviewStatus = ref ( 'ACTIVE' ) // ACTIVE, COMPLETED
const interviewStatus = ref ( 'ACTIVE' ) // ACTIVE, COMPLETED
const messagesContainer = ref ( null )
const messagesContainer = ref ( null )
const questionProgressId = ref ( 0 )
const questionProgressId = ref ( 0 ) // 当前正在等待回答或正在评估的问题ID
const nextQuestionPollingTimer = ref ( null ) // 定时器实例
const consecutiveFailureCount = ref ( 0 ) // 连续失败次数
const MAX _FAILURES = 3 // 最大连续失败次数
const POLLING _INTERVAL = 5000 // 5秒轮询间隔
const hasNextQuestionFailed = ref ( false ) // 是否获取AI响应失败 (达到重试上限)
// 从路由 query 中获取模式参数
// 从路由 query 中获取模式参数
const mode = route . query . mode || 'chat'
const mode = route . query . mode || 'chat'
@@ -165,8 +194,9 @@ const pageTitle = computed(() => {
} )
} )
const statusText = computed ( ( ) => {
const statusText = computed ( ( ) => {
// 根据实际的 interviewStatus 更新
if ( interviewStatus . value === 'COMPLETED' ) return '已结束'
if ( interviewStatus . value === 'COMPLETED' ) return '已结束'
if ( hasNextQuestionFailed . value ) return '等待用户手动获取...'
if ( isAiThinking . value ) return 'AI思考中...'
if ( interviewStatus . value === 'ACTIVE' ) return '进行中'
if ( interviewStatus . value === 'ACTIVE' ) return '进行中'
return '加载中'
return '加载中'
} )
} )
@@ -174,7 +204,15 @@ const statusText = computed(() => {
// 生命周期钩子
// 生命周期钩子
onMounted ( ( ) => {
onMounted ( ( ) => {
if ( sessionId ) {
if ( sessionId ) {
getHistoryList ( sessionId )
getHistoryList ( sessionId ) . then ( ( ) => {
// 检查当前状态,如果 questionProgressId > 0 且面试未结束,则启动轮询检查当前问题的评估状态
if ( interviewStatus . value === 'ACTIVE' && questionProgressId . value > 0 ) {
startAiResponsePolling ( ) ;
} else if ( interviewStatus . value === 'ACTIVE' && questionProgressId . value === 0 ) {
// 如果是新会话( progressId=0) , 应尝试获取第一个问题
getAndHandleAiResponse ( false , true ) ;
}
} )
} else {
} else {
ElMessage . error ( '会话ID缺失, 请返回首页。' )
ElMessage . error ( '会话ID缺失, 请返回首页。' )
router . push ( '/interview' )
router . push ( '/interview' )
@@ -182,23 +220,48 @@ onMounted(() => {
scrollToBottom ( )
scrollToBottom ( )
} )
} )
// 清除定时器
const stopAiResponsePolling = ( ) => {
if ( nextQuestionPollingTimer . value ) {
clearInterval ( nextQuestionPollingTimer . value )
nextQuestionPollingTimer . value = null
}
}
// 启动定时器轮询获取 AI 响应
const startAiResponsePolling = ( ) => {
stopAiResponsePolling ( ) ; // 确保旧的定时器被清除
hasNextQuestionFailed . value = false ; // 清除失败状态
if ( interviewStatus . value === 'ACTIVE' && consecutiveFailureCount . value < MAX _FAILURES ) {
nextQuestionPollingTimer . value = setInterval ( async ( ) => {
// 如果正在处理中,或者已经完成了,就停止轮询
if ( isAiThinking . value || interviewStatus . value === 'COMPLETED' ) {
stopAiResponsePolling ( ) ;
return ;
}
await getAndHandleAiResponse ( true ) ; // 传入 true 表示是轮询调用
} , POLLING _INTERVAL )
}
}
// 获取历史消息
const getHistoryList = async ( sessionId ) => {
const getHistoryList = async ( sessionId ) => {
try {
try {
const res = await getMessageListBySessionId ( sessionId )
const res = await getMessageListBySessionId ( sessionId )
if ( res . code === 0 ) {
if ( res . code === 0 ) {
// **保持与原始逻辑一致**
messages . value = res . data || [ ]
messages . value = res . data || [ ]
// 假设 后端返回的数据结构中直接包含 status 字段
// 从 后端返回的消息中确定状态和最新的 progressId
if ( res . data && res . data . status ) {
const sessionStatus = res . data . find ( item => item . status ) ? . status || 'ACTIVE' ;
interviewStatus . value = r es. data . status
interviewStatus . value = s essionStatus === 'COMPLETED' || sessionStatus === 'TERMINATED' ? 'COMPLETED' : 'ACTIVE'
}
if ( messages . value && messages . value . length > 0 ) {
if ( messages . value && messages . value . length > 0 ) {
const filterList = messages . value . filter ( item => item . sender === 'AI' ) ;
const filterList = messages . value . filter ( item => item . sender === 'AI' ) ;
if ( filterList && filterList . length > 0 ) {
if ( filterList && filterList . length > 0 ) {
// 获取最新的 AI 消息对应的 questionProgressId
// 获取最新的 AI 消息对应的 questionProgressId
questionProgressId . value = filterList [ filterList . length - 1 ] . questionProgressId
questionProgressId . value = filterList [ filterList . length - 1 ] . questionProgressId
} else {
questionProgressId . value = 0
}
}
}
}
} else {
} else {
@@ -211,20 +274,165 @@ const getHistoryList = async (sessionId) => {
}
}
}
}
/**
* 核心逻辑:获取并处理 AI 响应(评估结果)和下一题
* @param {boolean} isPollingOrRetry 是否是定时器轮询或重试触发
* @param {boolean} isInitialFetch 是否是会话开始时获取第一个问题
*/
const getAndHandleAiResponse = async ( isPollingOrRetry = false , isInitialFetch = false ) => {
if ( interviewStatus . value === 'COMPLETED' || ( isAiThinking . value && ! isPollingOrRetry ) ) return ;
// 如果连续失败次数已达上限,且是轮询/重试,则不再尝试
if ( isPollingOrRetry && consecutiveFailureCount . value >= MAX _FAILURES ) {
hasNextQuestionFailed . value = true ;
stopAiResponsePolling ( ) ; // 停止轮询
return ;
}
if ( ! isPollingOrRetry ) {
// 只有在用户主动触发(发送消息或手动重试)时显示思考中
isAiThinking . value = true ;
}
try {
let aiMessageData = null ;
let isComplete = false ;
if ( isInitialFetch ) {
// 场景 1: 获取第一个问题 (progressId=0 时)
const nextQuestionRes = await getNextQuestion ( sessionId , 0 ) ;
if ( nextQuestionRes . code === 0 && nextQuestionRes . data ) {
aiMessageData = nextQuestionRes . data ;
} else {
throw new Error ( nextQuestionRes . message || '获取第一个问题失败' ) ;
}
} else {
// 场景 2: 轮询检查用户回答的评估状态
const progressInfoRes = await getQuestionProgressInfo ( questionProgressId . value ) ;
if ( progressInfoRes . code === 0 && progressInfoRes . data ) {
const progressData = progressInfoRes . data ;
// 判断是否已评估
if ( progressData . status === 'COMPLETED' ) {
// 构造 AI 消息内容 (整合评估信息和下一题)
// let aiResponseContent = `**【您的回答得分】:${progressData.score} 分**\n\n`;
// if (progressData.feedback) {
// aiResponseContent += `**【AI反馈】**:\n${progressData.feedback}\n\n`;
// }
// if (progressData.suggestions) {
// aiResponseContent += `**【AI建议】**:\n${progressData.suggestions}\n\n`;
// }
// if (progressData.aiAnswer) {
// aiResponseContent += `**【参考答案】**:\n${progressData.aiAnswer}\n\n`;
// }
// 如果未结束,获取下一题
const nextQuestionRes = await getNextQuestion ( sessionId , questionProgressId . value ) ;
isComplete = nextQuestionRes . code === 0 && ! nextQuestionRes . data ;
if ( nextQuestionRes . code === 0 && nextQuestionRes . data ) {
aiMessageData = nextQuestionRes . data ;
// aiMessageData.content = aiResponseContent + `**【下一题】**:\n${aiMessageData.content}`;
} else {
// 面试结束,使用最终报告作为内容
aiMessageData = {
sender : 'AI' ,
content : ` **【面试总结】**: \ n ${ progressData . finalReport || '面试已全部完成。' } ` ,
createdTime : new Date ( ) . toISOString ( ) ,
questionProgressId : questionProgressId . value // 保持当前ID
} ;
throw new Error ( nextQuestionRes . message || '获取下一题失败' ) ;
}
} else if ( ! isPollingOrRetry ) {
// 状态不是 COMPLETED 且不是轮询触发的,启动轮询
startAiResponsePolling ( ) ;
ElMessage . warning ( '答案已提交, AI正在评估中, 请稍候...' ) ;
return ; // 退出,等待轮询结果
} else {
// 轮询中,但状态仍未 COMPLETED, 继续等待
return ;
}
} else {
// progressInfo 接口调用失败
throw new Error ( progressInfoRes . message || '获取问题进度信息失败' ) ;
}
} // End of else (场景 2)
// ==========================================================
// 统一处理 AI 消息推送和状态更新
// ==========================================================
if ( aiMessageData ) {
// 成功获取 AI 响应或下一题
// 仅在 progressId 变化时才添加新消息(防止重复添加第一个问题)
const newProgressId = aiMessageData . questionProgressId ;
if ( newProgressId !== questionProgressId . value ) {
questionProgressId . value = newProgressId ;
}
// 检查消息是否已存在 (避免重复推送评估结果)
const isNewMessage = messages . value . every ( msg =>
msg . questionProgressId !== aiMessageData . questionProgressId || msg . sender === 'USER'
) ;
if ( isNewMessage ) {
messages . value . push ( aiMessageData ) ;
}
// 清除失败计数和状态
consecutiveFailureCount . value = 0 ;
hasNextQuestionFailed . value = false ;
stopAiResponsePolling ( ) ;
if ( isComplete ) {
await interviewEnd ( true ) ;
interviewStatus . value = 'COMPLETED' ;
}
}
} catch ( error ) {
console . error ( "获取 AI 响应/下一题失败:" , error ) ;
if ( ! isPollingOrRetry ) {
// 非轮询触发的失败,需要开始/继续轮询
consecutiveFailureCount . value ++ ;
if ( consecutiveFailureCount . value >= MAX _FAILURES ) {
hasNextQuestionFailed . value = true ;
stopAiResponsePolling ( ) ;
ElMessage . error ( ` 获取 AI 响应连续失败 ${ MAX _FAILURES } 次,请检查网络或手动重试。 ` ) ;
} else {
startAiResponsePolling ( ) ;
ElMessage . warning ( ` 获取 AI 响应失败,正在尝试重试 ${ consecutiveFailureCount . value } / ${ MAX _FAILURES } ` ) ;
}
}
} finally {
if ( ! isPollingOrRetry ) {
isAiThinking . value = false ;
}
// scrollToBottom();
}
}
// 发送消息
// 发送消息
const sendMessage = async ( ) => {
const sendMessage = async ( ) => {
if ( ! userAnswer . value . trim ( ) || isLoading . value || interviewStatus . value === 'COMPLETED' ) return
if ( ! userAnswer . value . trim ( ) || isLoading . value || interviewStatus . value === 'COMPLETED' || hasNextQuestionFailed . value ) return
const currentAnswer = userAnswer . value
const currentAnswer = userAnswer . value
stopAiResponsePolling ( ) ; // 用户发送新消息,先停止任何正在进行的轮询
// 1. 立即显示用户消息
// 1. 立即显示用户消息
const userMessage = {
const userMessage = {
sender : 'USER' ,
sender : 'USER' ,
content : currentAnswer ,
content : currentAnswer ,
createdTime : new Date ( ) . toISOString ( ) // 使用当前时间作为占位符
createdTime : new Date ( ) . toISOString ( ) ,
questionProgressId : questionProgressId . value // 关联到当前问题
}
}
messages . value . push ( userMessage )
messages . value . push ( userMessage )
userAnswer . value = ''
userAnswer . value = ''
isLoading . value = true ;
isAiThinking . value = true
isAiThinking . value = true
scrollToBottom ( )
scrollToBottom ( )
@@ -238,31 +446,38 @@ const sendMessage = async () => {
}
}
)
)
// 3. 获取下一题或结束信息 (不修改接口调用逻辑)
if ( answerRes . code === 0 ) {
if ( answerRes . code === 0 ) {
const nextQuestionRes = await getNextQuestion ( sessionId , questionProgressId . value )
// 提交成功,启动轮询检查 AI 评估结果
startAiResponsePolling ( ) ;
// 获取下一题
const nextQuestionRes = await getNextQuestion ( sessionId , questionProgressId . value ) ;
if ( nextQuestionRes . code === 0 ) {
if ( nextQuestionRes . code === 0 ) {
if ( ! nextQuestionRes . data || nextQuestionRes . data . isComplete ) {
if ( nextQuestionRes . data ) {
messages . value . push ( nextQuestionRes . data )
} else {
// 明确判断是否结束
// 明确判断是否结束
await interviewEnd ( true ) // 传递一个参数表示是正常流程结束
await interviewEnd ( true ) // 传递一个参数表示是正常流程结束
interviewStatus . value = 'COMPLETED'
interviewStatus . value = 'COMPLETED'
} else {
// 接收新问题
questionProgressId . value = nextQuestionRes . data . questionProgressId
messages . value . push ( nextQuestionRes . data )
}
}
} else {
ElMessage . error ( '获取下一题失败: ' + nextQuestionRes . message )
}
}
ElMessage . success ( '回答提交成功,等待 AI 评估...' ) ;
} else {
} else {
// 提交失败,提示用户
ElMessage . error ( '提交回答失败: ' + answerRes . message )
ElMessage . error ( '提交回答失败: ' + answerRes . message )
// 提交失败后,强制停止 AI Thinking 状态,允许用户重试
}
}
} catch ( error ) {
} catch ( error ) {
console . error ( error )
console . error ( error )
ElMessage . error ( '发送失败,请检查网络或联系管理员。' )
ElMessage . error ( '发送失败,请检查网络或联系管理员。' )
} finally {
} finally {
isAiThink ing . value = false
isLoad ing . value = false ;
// isAiThinking 的控制权交给轮询函数,如果提交失败,立即关闭
if ( isLoading . value === false ) {
isAiThinking . value = false ;
}
scrollToBottom ( )
scrollToBottom ( )
}
}
}
}
@@ -270,6 +485,7 @@ const sendMessage = async () => {
// 结束面试 (不修改接口调用逻辑)
// 结束面试 (不修改接口调用逻辑)
const interviewEnd = async ( isAutoCompleted = false ) => {
const interviewEnd = async ( isAutoCompleted = false ) => {
if ( interviewStatus . value === 'COMPLETED' ) return ;
if ( interviewStatus . value === 'COMPLETED' ) return ;
stopAiResponsePolling ( ) ; // 结束面试时清除定时器
try {
try {
const res = await endInterview ( sessionId )
const res = await endInterview ( sessionId )
@@ -295,9 +511,8 @@ const formatMessage = (content) => {
// 格式化时间
// 格式化时间
const formatTime = ( timestamp ) => {
const formatTime = ( timestamp ) => {
if ( ! timestamp ) return ''
if ( ! timestamp ) return ''
// 确保 timestamp 是一个 Date 对象或可以被 Date 构造函数解析
const date = new Date ( timestamp ) ;
const date = new Date ( timestamp ) ;
if ( isNaN ( date . getTime ( ) ) ) return '' ; // 如果时间无效,返回空字符串
if ( isNaN ( date . getTime ( ) ) ) return '' ;
return date . toLocaleTimeString ( 'zh-CN' , {
return date . toLocaleTimeString ( 'zh-CN' , {
hour : '2-digit' ,
hour : '2-digit' ,
@@ -313,6 +528,17 @@ const scrollToBottom = () => {
}
}
} )
} )
}
}
// 手动重试获取 AI 响应/下一题
const manualRetryGetAiResponse = async ( ) => {
if ( isLoading . value || interviewStatus . value === 'COMPLETED' ) return ;
// 清除失败计数,允许重新开始 3 次尝试
consecutiveFailureCount . value = 0 ;
hasNextQuestionFailed . value = false ;
// 触发非轮询的获取/重试逻辑
await getAndHandleAiResponse ( false ) ;
}
< / script >
< / script >
< style scoped >
< style scoped >
@@ -412,9 +638,11 @@ const scrollToBottom = () => {
padding : 20 px 24 px ;
padding : 20 px 24 px ;
background : var ( -- chat - bg ) ;
background : var ( -- chat - bg ) ;
/* 滚动条美化( Webkit Only) */
/* 滚动条美化( Webkit Only) */
& : : - webkit - scrollbar {
& : : - webkit - scrollbar {
width : 8 px ;
width : 8 px ;
}
}
& : : - webkit - scrollbar - thumb {
& : : - webkit - scrollbar - thumb {
background - color : rgba ( 0 , 0 , 0 , 0.1 ) ;
background - color : rgba ( 0 , 0 , 0 , 0.1 ) ;
border - radius : 4 px ;
border - radius : 4 px ;
@@ -544,10 +772,24 @@ const scrollToBottom = () => {
}
}
/* 系统提示区域 */
. system - tips - area {
padding : 0 24 px 10 px 24 px ; /* 顶部留白,与输入区靠近 */
background : white ;
flex - shrink : 0 ;
border - top : 1 px solid # e6e8eb ;
}
. system - tips - area : deep ( . el - alert _ _description ) {
display : flex ;
align - items : center ;
}
/* 输入区域优化 */
/* 输入区域优化 */
. input - area {
. input - area {
padding : 16 px 24 px ;
padding : 16 px 24 px ;
border - top : 1 px solid # e6e8eb ;
/* 移除顶部分割线,因为有 system-tips-area */
/* border-top: 1px solid #e6e8eb; */
background : white ;
background : white ;
flex - shrink : 0 ;
flex - shrink : 0 ;
}
}
@@ -586,6 +828,7 @@ const scrollToBottom = () => {
height : auto ; /* 自动高度 */
height : auto ; /* 自动高度 */
}
}
/* 完成遮罩层 */
/* 完成遮罩层 */
. completion - overlay {
. completion - overlay {
position : absolute ;
position : absolute ;
@@ -672,6 +915,22 @@ const scrollToBottom = () => {
border - radius : 16 px ;
border - radius : 16 px ;
}
}
. system - tips - area {
padding : 0 16 px 10 px 16 px ;
}
. system - tips - area : deep ( . el - alert _ _description ) {
flex - direction : column ;
align - items : flex - start ;
gap : 8 px ;
}
. system - tips - area : deep ( . el - button ) {
width : 100 % ;
margin - left : 0 ! important ;
}
. input - area {
. input - area {
padding : 12 px 16 px ;
padding : 12 px 16 px ;
}
}