From 34e56aa06a0fb17534847a7d0feb6a8782cb1cc1 Mon Sep 17 00:00:00 2001
From: qingqiu <1764183241@qq.com>
Date: Wed, 22 Oct 2025 19:50:21 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B9=E7=9B=AE=EF=BC=8C?=
=?UTF-8?q?=E8=BF=9B=E8=A1=8C=E5=93=8D=E5=BA=94=E5=BC=8F=E5=A4=84=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/api/index.js | 2 +-
src/api/question-progress.js | 4 +
src/views/interview/chat.vue | 323 +++++++++++++++++++++++++++++++----
vite.config.js | 2 +-
4 files changed, 297 insertions(+), 34 deletions(-)
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);
+}