diff --git a/src/api/index.js b/src/api/index.js index 690f638..fbaea45 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus'; // Create an Axios instance with a base configuration const apiClient = axios.create({ - // baseURL: 'http://interview.qingqiu.online/api', + // baseURL: 'https://interview.qingqiu.online/api', baseURL: '/api', timeout: 600000, // 10 min timeout }); diff --git a/src/api/question-progress.js b/src/api/question-progress.js index 5b70941..916b58d 100644 --- a/src/api/question-progress.js +++ b/src/api/question-progress.js @@ -2,4 +2,8 @@ import apiClient from './index'; export const pageList = (params) => { return apiClient.post('/interview-question-progress/page', params); +} + +export const getQuestionProgressInfo = (progressId) => { + return apiClient.get(`/interview-question-progress/${progressId}`); } \ No newline at end of file diff --git a/src/views/interview/chat.vue b/src/views/interview/chat.vue index cd23bbe..db89efb 100644 --- a/src/views/interview/chat.vue +++ b/src/views/interview/chat.vue @@ -28,7 +28,8 @@ AI + >AI +
@@ -45,7 +46,8 @@ + >我 +
@@ -59,6 +61,29 @@ + + + +
+ +
+ 自动重试已达上限({{ MAX_FAILURES }}次)。请检查网络连接或等待片刻后, + + 手动重试获取AI响应 + +
+
@@ -67,7 +92,7 @@ :rows="4" v-model="userAnswer" placeholder="在此输入您的回答 (Enter 键发送)..." - :disabled="isLoading || interviewStatus === 'COMPLETED'" + :disabled="isLoading || interviewStatus === 'COMPLETED' || hasNextQuestionFailed" resize="none" maxlength="1000" show-word-limit @@ -81,7 +106,7 @@ type="primary" @click="sendMessage" :loading="isLoading" - :disabled="interviewStatus === 'COMPLETED' || !userAnswer.trim()" + :disabled="interviewStatus === 'COMPLETED' || !userAnswer.trim() || hasNextQuestionFailed" class="send-button" > @@ -111,9 +136,10 @@ import {ref, onMounted, nextTick, computed} from 'vue' import {ElMessage} from 'element-plus' import {useRoute, useRouter} from 'vue-router' -// **保持原有接口导入,不对接口逻辑进行修改** +// 假设这些接口都已存在于此 import {getMessageListBySessionId} from "@/api/interview-message.js"; import {endInterview, getNextQuestion, submitAnswer} from "@/api/interview.js"; +import {getQuestionProgressInfo} from '@/api/question-progress.js' import MarkdownIt from 'markdown-it' import hljs from 'highlight.js' import 'highlight.js/styles/github.css' // 导入您选择的 highlight.js 主题 @@ -140,9 +166,6 @@ const md = new MarkdownIt({ const route = useRoute() const router = useRouter() -// 定义事件 -const emit = defineEmits(['end-interview', 'close']) - // 响应式状态 const messages = ref([]) const userAnswer = ref('') @@ -150,7 +173,13 @@ const isLoading = ref(false) const isAiThinking = ref(false) const interviewStatus = ref('ACTIVE') // ACTIVE, COMPLETED 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 中获取模式参数 const mode = route.query.mode || 'chat' @@ -165,8 +194,9 @@ const pageTitle = computed(() => { }) const statusText = computed(() => { - // 根据实际的 interviewStatus 更新 if (interviewStatus.value === 'COMPLETED') return '已结束' + if (hasNextQuestionFailed.value) return '等待用户手动获取...' + if (isAiThinking.value) return 'AI思考中...' if (interviewStatus.value === 'ACTIVE') return '进行中' return '加载中' }) @@ -174,7 +204,15 @@ const statusText = computed(() => { // 生命周期钩子 onMounted(() => { 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 { ElMessage.error('会话ID缺失,请返回首页。') router.push('/interview') @@ -182,23 +220,48 @@ onMounted(() => { 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) => { try { const res = await getMessageListBySessionId(sessionId) if (res.code === 0) { - // **保持与原始逻辑一致** messages.value = res.data || [] - // 假设后端返回的数据结构中直接包含 status 字段 - if (res.data && res.data.status) { - interviewStatus.value = res.data.status - } - + // 从后端返回的消息中确定状态和最新的 progressId + const sessionStatus = res.data.find(item => item.status)?.status || 'ACTIVE'; + interviewStatus.value = sessionStatus === 'COMPLETED' || sessionStatus === 'TERMINATED' ? 'COMPLETED' : 'ACTIVE' if (messages.value && messages.value.length > 0) { const filterList = messages.value.filter(item => item.sender === 'AI'); if (filterList && filterList.length > 0) { // 获取最新的 AI 消息对应的 questionProgressId questionProgressId.value = filterList[filterList.length - 1].questionProgressId + } else { + questionProgressId.value = 0 } } } 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 () => { - 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 + stopAiResponsePolling(); // 用户发送新消息,先停止任何正在进行的轮询 // 1. 立即显示用户消息 const userMessage = { sender: 'USER', content: currentAnswer, - createdTime: new Date().toISOString() // 使用当前时间作为占位符 + createdTime: new Date().toISOString(), + questionProgressId: questionProgressId.value // 关联到当前问题 } messages.value.push(userMessage) userAnswer.value = '' + isLoading.value = true; isAiThinking.value = true scrollToBottom() @@ -238,31 +446,38 @@ const sendMessage = async () => { } ) - // 3. 获取下一题或结束信息 (不修改接口调用逻辑) 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.data || nextQuestionRes.data.isComplete) { + if (nextQuestionRes.data) { + messages.value.push(nextQuestionRes.data) + } else { // 明确判断是否结束 await interviewEnd(true) // 传递一个参数表示是正常流程结束 interviewStatus.value = 'COMPLETED' - } else { - // 接收新问题 - questionProgressId.value = nextQuestionRes.data.questionProgressId - messages.value.push(nextQuestionRes.data) } - } else { - ElMessage.error('获取下一题失败: ' + nextQuestionRes.message) + } + + ElMessage.success('回答提交成功,等待 AI 评估...'); } else { + // 提交失败,提示用户 ElMessage.error('提交回答失败: ' + answerRes.message) + // 提交失败后,强制停止 AI Thinking 状态,允许用户重试 } } catch (error) { console.error(error) ElMessage.error('发送失败,请检查网络或联系管理员。') } finally { - isAiThinking.value = false + isLoading.value = false; + // isAiThinking 的控制权交给轮询函数,如果提交失败,立即关闭 + if (isLoading.value === false) { + isAiThinking.value = false; + } scrollToBottom() } } @@ -270,6 +485,7 @@ const sendMessage = async () => { // 结束面试 (不修改接口调用逻辑) const interviewEnd = async (isAutoCompleted = false) => { if (interviewStatus.value === 'COMPLETED') return; + stopAiResponsePolling(); // 结束面试时清除定时器 try { const res = await endInterview(sessionId) @@ -295,9 +511,8 @@ const formatMessage = (content) => { // 格式化时间 const formatTime = (timestamp) => { if (!timestamp) return '' - // 确保 timestamp 是一个 Date 对象或可以被 Date 构造函数解析 const date = new Date(timestamp); - if (isNaN(date.getTime())) return ''; // 如果时间无效,返回空字符串 + if (isNaN(date.getTime())) return ''; return date.toLocaleTimeString('zh-CN', { 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); +}