优化项目,进行响应式处理

This commit is contained in:
2025-10-22 19:50:21 +08:00
parent 9d516642b0
commit 34e56aa06a
4 changed files with 297 additions and 34 deletions

View File

@@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus';
// Create an Axios instance with a base configuration // Create an Axios instance with a base configuration
const apiClient = axios.create({ const apiClient = axios.create({
// baseURL: 'http://interview.qingqiu.online/api', // baseURL: 'https://interview.qingqiu.online/api',
baseURL: '/api', baseURL: '/api',
timeout: 600000, // 10 min timeout timeout: 600000, // 10 min timeout
}); });

View File

@@ -2,4 +2,8 @@ import apiClient from './index';
export const pageList = (params) => { export const pageList = (params) => {
return apiClient.post('/interview-question-progress/page', params); return apiClient.post('/interview-question-progress/page', params);
}
export const getQuestionProgressInfo = (progressId) => {
return apiClient.get(`/interview-question-progress/${progressId}`);
} }

View File

@@ -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 = res.data.status interviewStatus.value = sessionStatus === '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 {
isAiThinking.value = false isLoading.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: 20px 24px; padding: 20px 24px;
background: var(--chat-bg); background: var(--chat-bg);
/* 滚动条美化Webkit Only*/ /* 滚动条美化Webkit Only*/
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 8px; width: 8px;
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
border-radius: 4px; border-radius: 4px;
@@ -544,10 +772,24 @@ const scrollToBottom = () => {
} }
/* 系统提示区域 */
.system-tips-area {
padding: 0 24px 10px 24px; /* 顶部留白,与输入区靠近 */
background: white;
flex-shrink: 0;
border-top: 1px solid #e6e8eb;
}
.system-tips-area :deep(.el-alert__description) {
display: flex;
align-items: center;
}
/* 输入区域优化 */ /* 输入区域优化 */
.input-area { .input-area {
padding: 16px 24px; padding: 16px 24px;
border-top: 1px 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: 16px; border-radius: 16px;
} }
.system-tips-area {
padding: 0 16px 10px 16px;
}
.system-tips-area :deep(.el-alert__description) {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.system-tips-area :deep(.el-button) {
width: 100%;
margin-left: 0 !important;
}
.input-area { .input-area {
padding: 12px 16px; padding: 12px 16px;
} }

View File

@@ -16,7 +16,7 @@ export default defineConfig({
proxy: { proxy: {
// Proxy API requests to the backend server // Proxy API requests to the backend server
// '/api': { // '/api': {
// target: 'http://localhost:8080', // target: 'https://interview.qingqiu.online/api',
// changeOrigin: true, // Needed for virtual hosted sites // changeOrigin: true, // Needed for virtual hosted sites
// secure: false, // Optional: if you are using https // secure: false, // Optional: if you are using https
// rewrite: (path) => path.replace(/^\/api/, ''), // rewrite: (path) => path.replace(/^\/api/, ''),