修改模拟面试的相关内容

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", "axios": "^1.11.0",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"element-plus": "^2.11.1", "element-plus": "^2.11.1",
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.0",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-router": "^4.5.1" "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": { "node_modules/async-validator": {
"version": "4.2.5", "version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
@@ -1491,6 +1499,24 @@
"node": ">= 0.4" "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": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -1523,6 +1549,23 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1532,6 +1575,12 @@
"node": ">= 0.4" "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": { "node_modules/memoize-one": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
@@ -1642,6 +1691,15 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT" "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": { "node_modules/rollup": {
"version": "4.49.0", "version": "4.49.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz",
@@ -1714,6 +1772,12 @@
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD" "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": { "node_modules/vite": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",

View File

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

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: '/api', baseURL: 'http://interview.qingqiu.online/api',
timeout: 600000, // 10 min timeout 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) => { export const endInterview = (sessionId) => {
return apiClient.post('/interview/chat', data); 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 * @param {string} sessionId - 面试会话ID
*/ */
export const getInterviewReportDetail = (sessionId) => { 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 <el-button v-if="session.status === 'COMPLETED'" type="primary" plain
@click="viewReport(session.sessionId)">查看复盘报告</el-button> @click="viewReport(session.sessionId)">查看复盘报告</el-button>
<el-button v-else type="primary" <el-button v-else type="primary"
@click="$router.push({ path: '/interview', query: { sessionId: session.sessionId } })">继续答题</el-button> @click="continueInterview(session)">继续答题</el-button>
</div> </div>
</div> </div>
</el-card> </el-card>
@@ -91,6 +91,17 @@ const viewReport = (sessionId) => {
router.push({ name: 'InterviewReport', params: { 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(() => { onMounted(() => {

View File

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

View File

@@ -1,53 +1,491 @@
<template> <template>
<div class="interview-chat-container"> <div class="chat-window-container">
<ChatWindow <div class="chat-header">
:mode="interviewMode" <div class="header-left">
:categories="selectedCategories" <h2>{{ pageTitle }}</h2>
:candidate-name="candidateName" <span class="status-indicator" :class="interviewStatus"></span>
:session-id="sessionId" <span class="status-text">{{ statusText }}</span>
@end-interview="handleInterviewEnd" </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> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import {ref, onMounted, nextTick, computed} from 'vue'
import { useRoute, useRouter } from 'vue-router' import {ElMessage} from 'element-plus'
import ChatWindow from '@/components/ChatWindow.vue' import {useRoute, useRouter} from 'vue-router'
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 route = useRoute()
const router = useRouter() const router = useRouter()
const interviewMode = ref('ai') // 定义事件
const selectedCategories = ref([]) const emit = defineEmits(['end-interview', 'close'])
const candidateName = ref('')
const sessionId = ref('')
onMounted(() => { // 响应式状态
// 从路由参数获取数据 const messages = ref([])
interviewMode.value = route.query.mode || 'ai' const userAnswer = ref('')
sessionId.value = route.query.sessionId 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) { // 从路由 query 中获取模式参数
selectedCategories.value = route.query.categories.split(',').map(id => parseInt(id)) const mode = route.query.mode || 'chat'
}
// 从本地存储获取候选人姓名 // 计算属性
const sessionData = JSON.parse(localStorage.getItem('currentSession') || '{}') const pageTitle = computed(() => {
candidateName.value = sessionData.candidateName || '候选人' if (mode === 'chat') return 'AI对话'
sessionId.value = Date.now().toString() if (mode === 'ai') return 'AI智能面试'
if (mode === 'local') return '题库面试'
return 'AI助手'
}) })
const handleInterviewEnd = () => { const statusText = computed(() => {
// 清除会话数据 return interviewStatus.value === 'ACTIVE' ? '进行中' : '已结束'
localStorage.removeItem('currentSession') })
// 返回面试选择页面
router.push('/interview') // 生命周期钩子
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> </script>
<style scoped> <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> </style>

View File

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

View File

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

View File

@@ -13,14 +13,14 @@ export default defineConfig({
}, },
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
proxy: { // proxy: {
// Proxy API requests to the backend server // // Proxy API requests to the backend server
'/api': { // '/api': {
target: 'http://localhost:8080', // target: 'http://localhost:8080',
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/, ''),
}, // },
}, // },
}, },
}) })