修改模拟面试的相关内容

This commit is contained in:
2025-09-21 21:19:26 +08:00
parent 4ca9fbbe73
commit 124444671a
11 changed files with 787 additions and 137 deletions

64
package-lock.json generated
View File

@@ -12,6 +12,8 @@
"axios": "^1.11.0",
"echarts": "^6.0.0",
"element-plus": "^2.11.1",
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.0",
"normalize.css": "^8.0.1",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
@@ -1095,6 +1097,12 @@
}
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
@@ -1491,6 +1499,24 @@
"node": ">= 0.4"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -1523,6 +1549,23 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1532,6 +1575,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
@@ -1642,6 +1691,15 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/rollup": {
"version": "4.49.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz",
@@ -1714,6 +1772,12 @@
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/vite": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",

View File

@@ -13,6 +13,8 @@
"axios": "^1.11.0",
"echarts": "^6.0.0",
"element-plus": "^2.11.1",
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.0",
"normalize.css": "^8.0.1",
"vue": "^3.5.18",
"vue-router": "^4.5.1"

View File

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

View File

@@ -0,0 +1,12 @@
import apiClient from "@/api/index.js";
/**
* 获取会话消息列表
* @param sessionId
* @returns {Promise<axios.AxiosResponse<any>>}
*/
export const getMessageListBySessionId = (sessionId) => {
return apiClient.get(
`/interview-message/list-by-session-id/${sessionId}`
);
}

View File

@@ -14,12 +14,34 @@ export const startInterview = (formData) => {
};
/**
* 继续面试(发送回答)
* @param {object} data - 包含sessionId和userAnswer的数据
* 结束面试
* @param sessionId
* @returns {Promise<axios.AxiosResponse<any>>}
*/
export const continueInterview = (data) => {
return apiClient.post('/interview/chat', data);
};
export const endInterview = (sessionId) => {
return apiClient.post(
`/interview/${sessionId}/end`
);
}
/**
* 提交答案
* @param data
* @returns {Promise<axios.AxiosResponse<any>>}
*/
export const submitAnswer = (data) => {
return apiClient.post('/interview/submit-answer', data);
}
/**
* 获取下一道题
* @param sessionId 会话id
* @param progressId 进度id
* @returns {Promise<axios.AxiosResponse<any>>}
*/
export const getNextQuestion = (sessionId, progressId) => {
return apiClient.get(`/interview/next-question/${sessionId}/${progressId}`);
}
/**
* 获取面试历史列表
@@ -33,5 +55,5 @@ export const getInterviewHistoryList = () => {
* @param {string} sessionId - 面试会话ID
*/
export const getInterviewReportDetail = (sessionId) => {
return apiClient.post('/interview/get-report-detail', { sessionId });
return apiClient.post(`/interview/get-report-detail/${sessionId}`);
};

View File

@@ -47,7 +47,7 @@
<el-button v-if="session.status === 'COMPLETED'" type="primary" plain
@click="viewReport(session.sessionId)">查看复盘报告</el-button>
<el-button v-else type="primary"
@click="$router.push({ path: '/interview', query: { sessionId: session.sessionId } })">继续答题</el-button>
@click="continueInterview(session)">继续答题</el-button>
</div>
</div>
</el-card>
@@ -91,6 +91,17 @@ const viewReport = (sessionId) => {
router.push({ name: 'InterviewReport', params: { sessionId } });
};
const continueInterview = (data) => {
console.log(data)
router.push({
path: '/interview-chat',
query: {
sessionId: data.sessionId,
mode: data.model
}
});
}
// --- 生命周期钩子 ---
onMounted(() => {

View File

@@ -1,74 +1,78 @@
<template>
<div class="report-container">
<!-- 加载中的骨架屏 -->
<div v-if="isLoading" class="loading-container">
<el-skeleton :rows="10" animated/>
</div>
<!-- 无数据时的空状态 -->
<div v-else-if="!reportData" class="empty-container">
<el-empty description="无法加载面试报告,请返回重试。"/>
</div>
<!-- 报告主内容 -->
<div v-else>
<!-- 报告头部 -->
<div v-else class="report-main">
<el-page-header @back="goBack" class="report-header">
<template #content>
<div class="header-content">
<span class="title">{{ reportData.sessionDetails.candidateName }} 的面试复盘报告</span>
<el-tag size="large">{{ new Date(reportData.sessionDetails.createdTime).toLocaleString() }}</el-tag>
<el-tag type="info" size="large">{{
new Date(reportData.sessionDetails.createdTime).toLocaleString()
}}
</el-tag>
</div>
</template>
</el-page-header>
<!-- AI最终评估报告 -->
<el-card class="box-card report-summary" shadow="never">
<el-card class="box-card report-summary" shadow="hover">
<template #header>
<div class="card-header">
<el-icon><DataAnalysis /></el-icon>
<el-icon>
<DataAnalysis/>
</el-icon>
<span>AI 最终评估报告</span>
</div>
</template>
<div v-if="finalReport" class="summary-content">
<el-row :gutter="20">
<el-col :span="8">
<el-statistic title="综合得分" :value="finalReport.overallScore" />
</el-col>
<el-col :span="16">
<el-statistic title="录用建议">
<template #formatter>
<el-tag :type="getRecommendationType(finalReport.hiringRecommendation)" size="large" effect="dark">{{ finalReport.hiringRecommendation }}</el-tag>
</template>
</el-statistic>
</el-col>
</el-row>
<el-descriptions :column="2" border>
<el-descriptions-item label="综合得分">
<el-tag size="medium">{{ finalReport.overallScore }} </el-tag>
</el-descriptions-item>
<el-descriptions-item label="录用建议">
<el-tag :type="getRecommendationType(finalReport.hiringRecommendation)" size="medium" effect="dark">
{{ finalReport.hiringRecommendation }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<el-divider/>
<h4>综合评语</h4>
<p class="feedback-paragraph">{{ finalReport.overallFeedback }}</p>
<h4>技术能力评估</h4>
<ul class="assessment-list">
<li v-for="(value, key) in finalReport.technicalAssessment" :key="key"><strong>{{ key }}:</strong> {{ value }}</li>
<li v-for="(value, key) in finalReport.technicalAssessment" :key="key">
<strong>{{ key }}:</strong> {{ value }}
</li>
</ul>
<h4>改进建议</h4>
<ol class="suggestions-list">
<li v-for="suggestion in finalReport.suggestions" :key="suggestion">{{ suggestion }}</li>
</ol>
</div>
<div v-else><el-empty description="AI最终报告正在生成中或生成失败。" /></div>
<div v-else>
<el-empty description="AI最终报告正在生成中或生成失败。"/>
</div>
</el-card>
<!-- 问答详情 -->
<el-card class="box-card question-details" shadow="never">
<el-card class="box-card question-details" shadow="hover">
<template #header>
<div class="card-header">
<el-icon><ChatDotRound /></el-icon>
<el-icon>
<ChatDotRound/>
</el-icon>
<span>问答详情与逐题评估</span>
</div>
</template>
<el-timeline>
<el-timeline-item v-for="(item, index) in reportData.questionDetails" :key="item.questionId" :timestamp="`第 ${index + 1} 题`" placement="top">
<el-card class="question-card" shadow="hover">
<el-timeline-item v-for="(item, index) in reportData.questionDetails" :key="item.questionId"
:timestamp="`第 ${index + 1} 题`" placement="top">
<el-card class="question-card" shadow="never">
<h4>{{ item.questionContent }}</h4>
<p class="user-answer"><strong>您的回答:</strong> {{ item.userAnswer }}</p>
<el-divider/>
@@ -76,7 +80,9 @@
<p><strong>AI 评语:</strong> {{ item.aiFeedback }}</p>
<p><strong>AI 建议:</strong> {{ item.suggestions }}</p>
<div class="score-section">
<strong>本题得分:</strong> <el-rate v-model="item.score" :max="5" disabled show-score text-color="#ff9900" score-template="{value} 分" />
<strong>本题得分:</strong>
<el-rate v-model="item.score" :max="5" disabled show-score text-color="#ff9900"
score-template="{value} "/>
</div>
</div>
</el-card>
@@ -88,7 +94,6 @@
</template>
<script setup>
// 导入Vue核心功能、路由、API客户端和图标
import {ref, onMounted, computed} from 'vue';
import {useRouter} from 'vue-router';
import {getInterviewReportDetail} from '@/api/interview.js';
@@ -99,16 +104,17 @@ const props = defineProps({ sessionId: { type: String, required: true } });
const router = useRouter();
// --- 响应式状态定义 ---
const reportData = ref(null); // 存储完整的报告数据
const isLoading = ref(false); // 加载状态
const reportData = ref(null);
const isLoading = ref(false);
// --- 计算属性 ---
// 安全地解析最终报告的JSON字符串
const finalReport = computed(() => {
if (reportData.value && reportData.value.sessionDetails.finalReport) {
try {
return JSON.parse(reportData.value.sessionDetails.finalReport);
// 检查 finalReport 是否是字符串,如果是则解析
return typeof reportData.value.sessionDetails.finalReport === 'string'
? JSON.parse(reportData.value.sessionDetails.finalReport)
: reportData.value.sessionDetails.finalReport;
} catch (e) {
console.error('解析最终报告JSON失败:', e);
return null;
@@ -118,14 +124,18 @@ const finalReport = computed(() => {
});
// --- API交互方法 ---
// 获取面试报告详情
const fetchReport = async () => {
isLoading.value = true;
try {
const responseData = await getInterviewReportDetail(props.sessionId);
if (responseData.code === 0 && responseData.data) {
reportData.value = responseData.data;
} else {
reportData.value = null;
console.error('获取面试报告失败:', responseData.message);
}
} catch (error) {
reportData.value = null;
console.error('获取面试报告失败:', error);
} finally {
isLoading.value = false;
@@ -133,11 +143,8 @@ const fetchReport = async () => {
};
// --- 事件处理 ---
// 返回上一页
const goBack = () => router.push('/history');
// 根据录用建议返回不同的标签类型
const getRecommendationType = (rec) => {
if (rec === '强烈推荐' || rec === '推荐') return 'success';
if (rec === '待考虑') return 'warning';
@@ -146,31 +153,121 @@ const getRecommendationType = (rec) => {
};
// --- 生命周期钩子 ---
onMounted(() => {
fetchReport();
});
</script>
<style scoped>
.report-container { padding: 10px; }
.report-header { margin-bottom: 20px; background-color: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,.1); }
.header-content { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.header-content .title { font-size: 1.2em; font-weight: 600; }
.report-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.box-card { margin-bottom: 20px; border: none; }
.card-header { font-size: 1.1em; font-weight: bold; display: flex; align-items: center; }
.card-header .el-icon { margin-right: 10px; }
.report-header {
margin-bottom: 20px;
background-color: #ffffff;
padding: 15px 20px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid #ebeef5;
}
.summary-content { padding: 10px; }
.report-summary h4 { margin: 25px 0 10px 0; font-size: 1.05em; }
.report-summary p, .report-summary li { color: #606266; line-height: 1.8; }
.feedback-paragraph { text-indent: 2em; }
.assessment-list, .suggestions-list { padding-left: 20px; }
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.question-card { margin-top: 10px; }
.user-answer { color: #303133; font-style: italic; }
.feedback-section { background-color: #f9fafb; padding: 15px; border-radius: 4px; margin-top: 15px; }
.score-section { display: flex; align-items: center; margin-top: 10px; }
.el-rate { margin-left: 10px; }
.header-content .title {
font-size: 1.5em;
font-weight: bold;
color: #303133;
}
.box-card {
margin-bottom: 20px;
border: 1px solid #ebeef5;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.card-header {
font-size: 1.2em;
font-weight: bold;
display: flex;
align-items: center;
color: #303133;
}
.card-header .el-icon {
margin-right: 10px;
color: #409eff;
}
.summary-content {
padding: 10px 0;
}
.el-descriptions {
margin-bottom: 20px;
}
.report-summary h4 {
margin: 25px 0 10px 0;
font-size: 1.1em;
color: #303133;
border-left: 4px solid #409eff;
padding-left: 10px;
}
.feedback-paragraph {
text-indent: 2em;
color: #606266;
line-height: 1.8;
margin-bottom: 20px;
}
.assessment-list, .suggestions-list {
padding-left: 20px;
list-style: disc;
color: #606266;
}
.assessment-list li, .suggestions-list li {
line-height: 1.8;
}
.question-card {
margin-top: 10px;
border: 1px solid #ebeef5;
border-radius: 8px;
}
.user-answer {
color: #606266;
font-style: italic;
background-color: #f5f7fa;
padding: 10px;
border-radius: 4px;
}
.feedback-section {
background-color: #fafbfd;
padding: 15px;
border-radius: 8px;
margin-top: 15px;
}
.score-section {
display: flex;
align-items: center;
margin-top: 10px;
}
.el-rate {
margin-left: 10px;
}
</style>

View File

@@ -1,53 +1,491 @@
<template>
<div class="interview-chat-container">
<ChatWindow
:mode="interviewMode"
:categories="selectedCategories"
:candidate-name="candidateName"
:session-id="sessionId"
@end-interview="handleInterviewEnd"
/>
<div class="chat-window-container">
<div class="chat-header">
<div class="header-left">
<h2>{{ pageTitle }}</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' && interviewStatus !== 'COMPLETED'" @click="interviewEnd" size="small">
结束面试
</el-button>
<el-button @click="$emit('close')" size="small">关闭</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' : '我' }}
</el-avatar>
<div class="message-content">
<div class="message-bubble" v-html="formatMessage(message.content)"></div>
<div class="message-time">{{ message.createdTime }}</div>
</div>
</div>
<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="$router.push('/')">返回</el-button>
<el-button @click="$router.push('/history')">查看报告</el-button>
</template>
</el-result>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import {ref, onMounted, nextTick, computed} from 'vue'
import {ElMessage} from 'element-plus'
import {useRoute, useRouter} from 'vue-router'
import ChatWindow from '@/components/ChatWindow.vue'
import {getMessageListBySessionId} from "@/api/interview-message.js";
import {endInterview, getNextQuestion, submitAnswer} from "@/api/interview.js";
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js' // 导入 highlight.js
import 'highlight.js/styles/github.css' // 或者您喜欢的任何主题
const md = new MarkdownIt({
html: true,
linkify: true,
breaks: true,
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' +
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
'</code></pre>';
} catch (__) {}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
}
});
// 从路由中获取参数
const route = useRoute()
const router = useRouter()
const interviewMode = ref('ai')
const selectedCategories = ref([])
const candidateName = ref('')
const sessionId = ref('')
// 定义事件
const emit = defineEmits(['end-interview', 'close'])
onMounted(() => {
// 从路由参数获取数据
interviewMode.value = route.query.mode || 'ai'
sessionId.value = route.query.sessionId
// 响应式状态
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 questionProgressId = ref(0)
if (route.query.categories) {
selectedCategories.value = route.query.categories.split(',').map(id => parseInt(id))
}
// 从路由 query 中获取模式参数
const mode = route.query.mode || 'chat'
// 从本地存储获取候选人姓名
const sessionData = JSON.parse(localStorage.getItem('currentSession') || '{}')
candidateName.value = sessionData.candidateName || '候选人'
sessionId.value = Date.now().toString()
// 计算属性
const pageTitle = computed(() => {
if (mode === 'chat') return 'AI对话'
if (mode === 'ai') return 'AI智能面试'
if (mode === 'local') return '题库面试'
return 'AI助手'
})
const handleInterviewEnd = () => {
// 清除会话数据
localStorage.removeItem('currentSession')
// 返回面试选择页面
const statusText = computed(() => {
return interviewStatus.value === 'ACTIVE' ? '进行中' : '已结束'
})
// 生命周期钩子
onMounted(() => {
// 从路由 query 中获取 sessionId
const sessionId = route.query.sessionId
if (sessionId) {
// 这里是根据 sessionId 从后端加载历史消息的逻辑
getHistoryList(sessionId)
} else {
// 如果没有 sessionId则初始化为新会话
router.push('/interview')
}
scrollToBottom()
})
const getHistoryList = async (sessionId) => {
const res = await getMessageListBySessionId(sessionId)
if (res.code === 0) {
messages.value = res.data
if (messages.value && messages.value.length > 0) {
const filterList = messages.value.filter(item => item.sender === 'AI');
if (filterList && filterList.length > 0) {
questionProgressId.value = filterList[filterList.length - 1].questionProgressId
}
}
} else {
ElMessage.error('获取历史消息失败')
}
}
// 发送消息
const sendMessage = async () => {
if (!userAnswer.value.trim() || isLoading.value) 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 {
const sessionId = route.query.sessionId
const answerRes = await submitAnswer(
{
sessionId,
progressId: questionProgressId.value,
answer: currentAnswer,
}
)
if (answerRes.code === 0) {
const nextQuestionRes = await getNextQuestion(sessionId, questionProgressId.value)
if (nextQuestionRes.code === 0) {
if (!nextQuestionRes.data) {
interviewEnd()
}
questionProgressId.value = nextQuestionRes.data.questionProgressId
messages.value.push(nextQuestionRes.data)
}
}
} catch (error) {
ElMessage.error('发送失败,请稍后重试。')
} finally {
isAiThinking.value = false
scrollToBottom()
}
}
// 结束面试
const interviewEnd = () => {
const sessionId = route.query.sessionId
endInterview(sessionId).then((res) => {
if (res.code === 0) {
router.push('/history')
} else {
ElMessage.error('结束面试失败: ' + res.message)
}
})
// 实际项目中,这里会调用后端 API 结束会话
// await fetch(`/api/end-session?sessionId=${sessionId}`, { method: 'POST' });
interviewStatus.value = 'COMPLETED'
scrollToBottom()
}
// 格式化消息内容,将换行符替换为<br>
const formatMessage = (content) => {
// return content ? content.replace(/\n/g, '<br />') : ''
// 如果内容不为空,则返回处理后的字符串
return content ? md.render(content) : ''
}
// 格式化时间
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
}
})
}
</script>
<style scoped>
.interview-chat-container {
height: 100vh;
/* 容器和头部样式保持不变 */
.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;
position: relative;
}
.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>

View File

@@ -6,6 +6,7 @@
</div>
<div class="options-container">
<!--
<el-card class="option-card" shadow="hover" @click="navigateTo('chat')">
<div class="card-content">
<el-icon size="48" color="#409EFF">
@@ -16,7 +17,7 @@
<el-button type="primary" class="action-btn">开始对话</el-button>
</div>
</el-card>
-->
<el-card class="option-card" shadow="hover" @click="navigateTo('interview')">
<div class="card-content">
<el-icon size="48" color="#67C23A">

View File

@@ -144,7 +144,7 @@ import {useRouter} from 'vue-router'
import {ElMessage} from 'element-plus'
import {Cpu, Collection, Check} from '@element-plus/icons-vue'
import QuestionBankSection from '@/components/QuestionBankSection.vue'
import {startInterview, continueInterview, getInterviewReportDetail} from '@/api/interview.js';
import {startInterview} from '@/api/interview.js';
const router = useRouter()
@@ -183,10 +183,7 @@ const startInterviewAction = async () => {
}
isLoading.value = true;
const sendFormData = new FormData();
const selectionResult = questionBankSectionRef.value.getSelectionResult()
if (!selectionResult.selectedNodes) {
selectionResult.selectedNodes = []
}
const sendData = {
candidateName: formData.value.candidateName,
aiModel: formData.value.aiModel,
@@ -194,6 +191,11 @@ const startInterviewAction = async () => {
model: selectedMode.value,
selectedNodes: []
}
if (selectedMode.value === 'local') {
const selectionResult = questionBankSectionRef.value.getSelectionResult()
if (!selectionResult.selectedNodes) {
selectionResult.selectedNodes = []
}
if (selectionResult.selectedNodes && selectionResult.selectedNodes.length > 0) {
const sendNodes = []
selectionResult.selectedNodes.forEach(node => {
@@ -205,6 +207,7 @@ const startInterviewAction = async () => {
})
sendData.selectedNodes = sendNodes
}
}
sendFormData.append('interviewStartDto', new Blob([JSON.stringify(sendData)], {
type: 'application/json',

View File

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