初始化
This commit is contained in:
@@ -1,2 +1,5 @@
|
|||||||
# AI-interview-web
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||||
|
|||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + Vue</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1838
package-lock.json
generated
Normal file
1838
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-interview-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"element-plus": "^2.11.1",
|
||||||
|
"normalize.css": "^8.0.1",
|
||||||
|
"vue": "^3.5.18",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"vite": "^7.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
11
src/App.vue
Normal file
11
src/App.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup>
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
8
src/api/dashboard.js
Normal file
8
src/api/dashboard.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import apiClient from './index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取仪表盘的所有统计数据
|
||||||
|
*/
|
||||||
|
export const getDashboardStats = () => {
|
||||||
|
return apiClient.post('/dashboard/stats');
|
||||||
|
};
|
||||||
33
src/api/index.js
Normal file
33
src/api/index.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
// Create an Axios instance with a base configuration
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
timeout: 600000, // 10 min timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional: Add a response interceptor for global error handling
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
response => {
|
||||||
|
// Check if the response has the expected successful structure
|
||||||
|
if (response.data && response.data.code === 0) {
|
||||||
|
return response.data; // Return only the data part of the response
|
||||||
|
} else {
|
||||||
|
// Handle business errors (e.g., code !== 200)
|
||||||
|
const errorMessage = response.data.message || 'An unknown error occurred.';
|
||||||
|
ElMessage.error(errorMessage);
|
||||||
|
return Promise.reject(new Error(errorMessage));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
// Handle HTTP errors (e.g., 4xx, 5xx)
|
||||||
|
const errorMessage = error.response?.data?.message || 'A network error occurred. Please check your connection.';
|
||||||
|
ElMessage.error(errorMessage);
|
||||||
|
console.error('API Error:', error.response || error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
|
|
||||||
37
src/api/interview.js
Normal file
37
src/api/interview.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import apiClient from './index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始新的面试
|
||||||
|
* @param {FormData} formData - 包含简历和候选人信息的表单数据
|
||||||
|
*/
|
||||||
|
export const startInterview = (formData) => {
|
||||||
|
console.log(formData)
|
||||||
|
return apiClient.post('/interview/start', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 继续面试(发送回答)
|
||||||
|
* @param {object} data - 包含sessionId和userAnswer的数据
|
||||||
|
*/
|
||||||
|
export const continueInterview = (data) => {
|
||||||
|
return apiClient.post('/interview/chat', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取面试历史列表
|
||||||
|
*/
|
||||||
|
export const getInterviewHistoryList = () => {
|
||||||
|
return apiClient.post('/interview/get-history-list');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取详细的面试复盘报告
|
||||||
|
* @param {string} sessionId - 面试会话ID
|
||||||
|
*/
|
||||||
|
export const getInterviewReportDetail = (sessionId) => {
|
||||||
|
return apiClient.post('/interview/get-report-detail', { sessionId });
|
||||||
|
};
|
||||||
5
src/api/question-progress.js
Normal file
5
src/api/question-progress.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import apiClient from './index';
|
||||||
|
|
||||||
|
export const pageList = (params) => {
|
||||||
|
return apiClient.post('/interview-question-progress/page', params);
|
||||||
|
}
|
||||||
53
src/api/question.js
Normal file
53
src/api/question.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import apiClient from './index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页获取题库列表
|
||||||
|
* @param {object} params - 分页和查询参数
|
||||||
|
*/
|
||||||
|
export const getQuestionPage = (params) => {
|
||||||
|
return apiClient.post('/question/page', params);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增题目
|
||||||
|
* @param {object} data - 题目数据
|
||||||
|
*/
|
||||||
|
export const addQuestion = (data) => {
|
||||||
|
return apiClient.post('/question/add', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新题目
|
||||||
|
* @param {object} data - 题目数据
|
||||||
|
*/
|
||||||
|
export const updateQuestion = (data) => {
|
||||||
|
return apiClient.post('/question/update', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除题目
|
||||||
|
* @param {number} id - 题目ID
|
||||||
|
*/
|
||||||
|
export const deleteQuestion = (id) => {
|
||||||
|
return apiClient.post('/question/delete', { id });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI批量导入题目
|
||||||
|
* @param {FormData} formData - 包含文件的表单数据
|
||||||
|
*/
|
||||||
|
export const importQuestionsByAi = (formData) => {
|
||||||
|
return apiClient.post('/question/import-by-ai', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验重复数据
|
||||||
|
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||||
|
*/
|
||||||
|
export const checkQuestionData = () => {
|
||||||
|
return apiClient.post('/question/check-question-data');
|
||||||
|
}
|
||||||
135
src/ard.vue
Normal file
135
src/ard.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<!-- 欢迎横幅 -->
|
||||||
|
<el-card shadow="never" class="welcome-banner">
|
||||||
|
<div class="welcome-content">
|
||||||
|
<div class="welcome-text">
|
||||||
|
<h2>欢迎回来!</h2>
|
||||||
|
<p>准备好开始您的下一次模拟面试了吗?在这里管理您的题库,不断提升面试技巧。</p>
|
||||||
|
</div>
|
||||||
|
<img src="/src/assets/dashboard-hero.svg" alt="仪表盘插图" class="welcome-illustration" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 功能导航 -->
|
||||||
|
<div class="feature-grid">
|
||||||
|
<router-link to="/interview" class="feature-card-link">
|
||||||
|
<el-card shadow="hover" class="feature-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<el-icon class="card-icon" style="background-color: #ecf5ff; color: #409eff;"><ChatLineRound /></el-icon>
|
||||||
|
<div class="text-content">
|
||||||
|
<h3>开始模拟面试</h3>
|
||||||
|
<p>上传简历,与AI进行实战演练</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link to="/question-bank" class="feature-card-link">
|
||||||
|
<el-card shadow="hover" class="feature-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<el-icon class="card-icon" style="background-color: #f0f9eb; color: #67c23a;"><MessageBox /></el-icon>
|
||||||
|
<div class="text-content">
|
||||||
|
<h3>题库管理</h3>
|
||||||
|
<p>新增、编辑和导入您的面试题库</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link to="/history" class="feature-card-link">
|
||||||
|
<el-card shadow="hover" class="feature-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<el-icon class="card-icon" style="background-color: #fdf6ec; color: #e6a23c;"><Finished /></el-icon>
|
||||||
|
<div class="text-content">
|
||||||
|
<h3>面试历史</h3>
|
||||||
|
<p>查看过往的面试记录与AI复盘报告</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 导入Element Plus图标
|
||||||
|
import { ChatLineRound, MessageBox, Finished } from '@element-plus/icons-vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 仪表盘容器 */
|
||||||
|
.dashboard-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 欢迎横幅 */
|
||||||
|
.welcome-banner {
|
||||||
|
border: none;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-text h2 {
|
||||||
|
font-size: 1.8em;
|
||||||
|
margin-top: 0;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-text p {
|
||||||
|
color: #606266;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-illustration {
|
||||||
|
width: 200px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 功能网格布局 */
|
||||||
|
.feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card .card-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
transition: transform 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-content h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #303133;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-content p {
|
||||||
|
margin: 0;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
src/assets/css/reset.css
Normal file
23
src/assets/css/reset.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
body, html, h1, h2, h3, h4, h5, h6, ul, ol, li, dl, dt, dd, header, menu, section, p, input, td, th, ins {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, li {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
1
src/assets/dashboard-hero.svg
Normal file
1
src/assets/dashboard-hero.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M112 0C85.5 0 64 21.5 64 48v48H48c-26.5 0-48 21.5-48 48v80c0 26.5 21.5 48 48 48h16v32c0 26.5 21.5 48 48 48h320c26.5 0 48-21.5 48-48V48c0-26.5-21.5-48-48-48H112zM48 144c-8.8 0-16 7.2-16 16v80c0 8.8 7.2 16 16 16h16v96H112c-8.8 0-16-7.2-16-16V48c0-8.8 7.2-16 16-16h352c8.8 0 16 7.2 16 16v288c0 8.8-7.2 16-16 16H112v-32h48c26.5 0 48-21.5 48-48V144H48z" fill="#a0aec0"/><path d="M176 224c-17.7 0-32 14.3-32 32s14.3 32 32 32h160c17.7 0 32-14.3 32-32s-14.3-32-32-32H176z" fill="#718096"/></svg>
|
||||||
|
After Width: | Height: | Size: 560 B |
1
src/assets/interview-start.svg
Normal file
1
src/assets/interview-start.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M176 96c0-35.3 28.7-64 64-64s64 28.7 64 64v32H176V96z" fill="#a0aec0"/><path d="M240 32c-44.2 0-80 35.8-80 80v32h160V112c0-44.2-35.8-80-80-80z" fill="#718096"/><path d="M0 192c0-35.3 28.7-64 64-64h384c35.3 0 64 28.7 64 64v256c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V192zm64 32c-17.7 0-32 14.3-32 32v192c0 17.7 14.3 32 32 32h384c17.7 0 32-14.3 32-32V224c0-17.7-14.3-32-32-32H64z" fill="#a0aec0"/><path d="M160 320c-17.7 0-32 14.3-32 32s14.3 32 32 32h192c17.7 0 32-14.3 32-32s-14.3-32-32-32H160z" fill="#718096"/></svg>
|
||||||
|
After Width: | Height: | Size: 596 B |
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
137
src/layout/Layout.vue
Normal file
137
src/layout/Layout.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 整体后台布局容器 -->
|
||||||
|
<el-container class="layout-container">
|
||||||
|
<!-- 侧边栏 -->
|
||||||
|
<el-aside width="220px" class="sidebar">
|
||||||
|
<!-- Logo区域 -->
|
||||||
|
<div class="logo-container">
|
||||||
|
<el-icon class="logo-icon"><ChatDotSquare /></el-icon>
|
||||||
|
<span class="logo-title">AI面试官</span>
|
||||||
|
</div>
|
||||||
|
<!--
|
||||||
|
el-scrollbar: Element Plus的滚动条组件。
|
||||||
|
通过CSS设置其高度为100%,它会自动在内容溢出时显示滚动条,
|
||||||
|
否则滚动条不显示,解决了您提出的问题。
|
||||||
|
-->
|
||||||
|
<el-scrollbar style="height: calc(100% - 60px);">
|
||||||
|
<el-menu
|
||||||
|
:default-active="$route.path"
|
||||||
|
background-color="#0a192f"
|
||||||
|
text-color="#8892b0"
|
||||||
|
active-text-color="#64ffda"
|
||||||
|
:router="true"
|
||||||
|
>
|
||||||
|
<el-menu-item index="/">
|
||||||
|
<el-icon><House /></el-icon>
|
||||||
|
<span>仪表盘</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/interview">
|
||||||
|
<el-icon><ChatLineRound /></el-icon>
|
||||||
|
<span>模拟面试</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/question-bank">
|
||||||
|
<el-icon><MessageBox /></el-icon>
|
||||||
|
<span>题库管理</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/history">
|
||||||
|
<el-icon><Finished /></el-icon>
|
||||||
|
<span>会话历史</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/answer-record">
|
||||||
|
<el-icon><List /></el-icon>
|
||||||
|
<span>答题记录</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-scrollbar>
|
||||||
|
</el-aside>
|
||||||
|
|
||||||
|
<!-- 右侧主内容区 -->
|
||||||
|
<el-container>
|
||||||
|
<el-header class="header">
|
||||||
|
<div class="header-title">欢迎使用AI模拟面试平台</div>
|
||||||
|
</el-header>
|
||||||
|
<el-main class="main-content">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition name="fade-transform" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {House, ChatLineRound, MessageBox, ChatDotSquare, Finished, List} from '@element-plus/icons-vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout-container {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 更新后的侧边栏样式 */
|
||||||
|
.sidebar {
|
||||||
|
background-color: #0a192f; /* 更深、更现代的科技蓝 */
|
||||||
|
transition: width 0.28s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 60px;
|
||||||
|
background-color: #0a192f;
|
||||||
|
color: #ccd6f6;
|
||||||
|
font-size: 1.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
margin-right: 12px;
|
||||||
|
color: #64ffda; /* 高亮颜色 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-menu {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 菜单项激活时的样式 */
|
||||||
|
.el-menu-item.is-active {
|
||||||
|
background-color: #112240 !important; /* 深色背景 */
|
||||||
|
border-left: 3px solid #64ffda; /* 左侧高亮条 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-menu-item:hover {
|
||||||
|
background-color: #112240; /* 悬浮背景色 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-transform-enter-active,
|
||||||
|
.fade-transform-leave-active {
|
||||||
|
transition: all 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-transform-enter-from,
|
||||||
|
.fade-transform-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-30px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
13
src/main.js
Normal file
13
src/main.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import router from "./router/index.js";
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import './assets/css/reset.css'
|
||||||
|
import "normalize.css";
|
||||||
|
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
app.use( router)
|
||||||
|
app.mount('#app')
|
||||||
135
src/rd.vue
Normal file
135
src/rd.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<!-- 欢迎横幅 -->
|
||||||
|
<el-card shadow="never" class="welcome-banner">
|
||||||
|
<div class="welcome-content">
|
||||||
|
<div class="welcome-text">
|
||||||
|
<h2>欢迎回来!</h2>
|
||||||
|
<p>准备好开始您的下一次模拟面试了吗?在这里管理您的题库,不断提升面试技巧。</p>
|
||||||
|
</div>
|
||||||
|
<img src="/src/assets/dashboard-hero.svg" alt="仪表盘插图" class="welcome-illustration" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 功能导航 -->
|
||||||
|
<div class="feature-grid">
|
||||||
|
<router-link to="/interview" class="feature-card-link">
|
||||||
|
<el-card shadow="hover" class="feature-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<el-icon class="card-icon" style="background-color: #ecf5ff; color: #409eff;"><ChatLineRound /></el-icon>
|
||||||
|
<div class="text-content">
|
||||||
|
<h3>开始模拟面试</h3>
|
||||||
|
<p>上传简历,与AI进行实战演练</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link to="/question-bank" class="feature-card-link">
|
||||||
|
<el-card shadow="hover" class="feature-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<el-icon class="card-icon" style="background-color: #f0f9eb; color: #67c23a;"><MessageBox /></el-icon>
|
||||||
|
<div class="text-content">
|
||||||
|
<h3>题库管理</h3>
|
||||||
|
<p>新增、编辑和导入您的面试题库</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link to="/history" class="feature-card-link">
|
||||||
|
<el-card shadow="hover" class="feature-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<el-icon class="card-icon" style="background-color: #fdf6ec; color: #e6a23c;"><Finished /></el-icon>
|
||||||
|
<div class="text-content">
|
||||||
|
<h3>面试历史</h3>
|
||||||
|
<p>查看过往的面试记录与AI复盘报告</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 导入Element Plus图标
|
||||||
|
import { ChatLineRound, MessageBox, Finished } from '@element-plus/icons-vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 仪表盘容器 */
|
||||||
|
.dashboard-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 欢迎横幅 */
|
||||||
|
.welcome-banner {
|
||||||
|
border: none;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-text h2 {
|
||||||
|
font-size: 1.8em;
|
||||||
|
margin-top: 0;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-text p {
|
||||||
|
color: #606266;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-illustration {
|
||||||
|
width: 200px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 功能网格布局 */
|
||||||
|
.feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card .card-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
transition: transform 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-content h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #303133;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-content p {
|
||||||
|
margin: 0;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
50
src/router/index.js
Normal file
50
src/router/index.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import Layout from '../layout/Layout.vue';
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: Layout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('../views/Dashboard.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'interview',
|
||||||
|
name: 'Interview',
|
||||||
|
component: () => import('../views/InterviewView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'question-bank',
|
||||||
|
name: 'QuestionBank',
|
||||||
|
component: () => import('../views/QuestionBank.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'history',
|
||||||
|
name: 'InterviewHistory',
|
||||||
|
component: () => import('../views/InterviewHistory.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'report/:sessionId',
|
||||||
|
name: 'InterviewReport',
|
||||||
|
component: () => import('../views/InterviewReport.vue'),
|
||||||
|
props: true, // 将路由参数作为props传递给组件
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'answer-record',
|
||||||
|
name: 'AnswerRecord',
|
||||||
|
component: () => import('../views/AnswerRecord.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
103
src/views/AnswerRecord.vue
Normal file
103
src/views/AnswerRecord.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import type { ComponentSize } from 'element-plus'
|
||||||
|
import { pageList } from '../api/question-progress'
|
||||||
|
|
||||||
|
const tableData = ref([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const size = ref<ComponentSize>('default')
|
||||||
|
const background = ref(false)
|
||||||
|
const disabled = ref(false)
|
||||||
|
const handleSizeChange = (val: number) => {
|
||||||
|
console.log(`${val} items per page`)
|
||||||
|
pageSize.value = val
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
const total = ref(0)
|
||||||
|
const handleCurrentChange = (val: number) => {
|
||||||
|
console.log(`current page: ${val}`)
|
||||||
|
currentPage.value = val
|
||||||
|
fetchData()
|
||||||
|
|
||||||
|
}
|
||||||
|
const searchContent = ref('')
|
||||||
|
const fetchData = () => {
|
||||||
|
pageList({
|
||||||
|
current: currentPage.value,
|
||||||
|
size: pageSize.value,
|
||||||
|
questionName: searchContent.value
|
||||||
|
}).then(({ data }) => {
|
||||||
|
console.log(data)
|
||||||
|
tableData.value = data.records
|
||||||
|
currentPage.value = data.current
|
||||||
|
total.value = data.total
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-card>
|
||||||
|
<!-- 上半部分-->
|
||||||
|
<div>
|
||||||
|
<el-card style="width: 100%;margin-bottom: 15px;" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>答题记录</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-form :inline="true">
|
||||||
|
<el-form-item style="margin: 0;">
|
||||||
|
<el-input placeholder="请输入需要查询的题目" v-model="searchContent" style="width: 240px"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item style="margin-bottom: 0;margin-left: 15px;">
|
||||||
|
<el-button type="primary" @click="fetchData">查询</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
<!-- 数据部分-->
|
||||||
|
|
||||||
|
<el-card style="width: 100%;" shadow="hover">
|
||||||
|
<el-table :data="tableData" style="width: 100%" border height="calc(100vh - 260px)">
|
||||||
|
<el-table-column align="center" prop="questionContent" label="问题" />
|
||||||
|
<el-table-column align="center" prop="userAnswer" label="用户回答" />
|
||||||
|
<el-table-column align="center" prop="aiAnswer" label="AI回答" />
|
||||||
|
<el-table-column align="center" prop="feedback" label="AI反馈" />
|
||||||
|
<el-table-column align="center" prop="suggestions" label="AI建议" />
|
||||||
|
<el-table-column align="center" prop="score" label="AI评分" />
|
||||||
|
<el-table-column #default="scope" align="center">
|
||||||
|
<el-tag :type="scope.row.status === 'ACTIVE' ? 'primary' : 'success'">
|
||||||
|
{{ scope.row.status }}
|
||||||
|
</el-tag>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column align="center" prop="createdTime" label="创建时间" />
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination class="pagination-container" v-model:current-page="currentPage" v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]" :size="size" :disabled="disabled" :background="background"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper" :total="total" @size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange" />
|
||||||
|
</el-card>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="css">
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
150
src/views/Dashboard.vue
Normal file
150
src/views/Dashboard.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<el-row :gutter="20" class="stats-cards">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic :value="stats.totalInterviews">
|
||||||
|
<template #title>
|
||||||
|
<div class="statistic-title">
|
||||||
|
<el-icon><DataAnalysis /></el-icon>
|
||||||
|
<span>面试总次数</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic :value="stats.totalQuestions">
|
||||||
|
<template #title>
|
||||||
|
<div class="statistic-title">
|
||||||
|
<el-icon><MessageBox /></el-icon>
|
||||||
|
<span>题库总题数</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- ECharts图表 -->
|
||||||
|
<el-row :gutter="20" class="chart-row">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="never" class="chart-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">题库分类占比</div>
|
||||||
|
</template>
|
||||||
|
<div ref="categoryChart" style="height: 400px;"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="never" class="chart-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">最近7日面试次数</div>
|
||||||
|
</template>
|
||||||
|
<div ref="dailyChart" style="height: 400px;"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 导入Vue核心功能、ECharts、API客户端和图标
|
||||||
|
import { ref, onMounted, nextTick } from 'vue';
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
import { getDashboardStats } from '../api/dashboard';
|
||||||
|
import { DataAnalysis, MessageBox } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
|
// --- 响应式状态定义 ---
|
||||||
|
const stats = ref({
|
||||||
|
totalInterviews: 0,
|
||||||
|
totalQuestions: 0,
|
||||||
|
questionCategoryStats: [],
|
||||||
|
recentInterviewStats: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ECharts实例的DOM引用
|
||||||
|
const categoryChart = ref(null);
|
||||||
|
const dailyChart = ref(null);
|
||||||
|
|
||||||
|
// --- ECharts图表配置与渲染 ---
|
||||||
|
|
||||||
|
// 渲染题库分类饼图
|
||||||
|
const renderCategoryChart = () => {
|
||||||
|
const chartInstance = echarts.init(categoryChart.value);
|
||||||
|
const option = {
|
||||||
|
tooltip: { trigger: 'item' },
|
||||||
|
legend: { top: '5%', left: 'center' },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '题目分类',
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['40%', '70%'],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 },
|
||||||
|
label: { show: false, position: 'center' },
|
||||||
|
emphasis: { label: { show: true, fontSize: '20', fontWeight: 'bold' } },
|
||||||
|
labelLine: { show: false },
|
||||||
|
data: stats.value.questionCategoryStats,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
chartInstance.setOption(option);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染每日面试次数柱状图
|
||||||
|
const renderDailyChart = () => {
|
||||||
|
const chartInstance = echarts.init(dailyChart.value);
|
||||||
|
const option = {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: stats.value.recentInterviewStats.map(item => item.date),
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '面试次数',
|
||||||
|
type: 'bar',
|
||||||
|
data: stats.value.recentInterviewStats.map(item => item.count),
|
||||||
|
barWidth: '60%',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
|
};
|
||||||
|
chartInstance.setOption(option);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- API交互方法 ---
|
||||||
|
|
||||||
|
// 获取所有仪表盘数据
|
||||||
|
const fetchDashboardStats = async () => {
|
||||||
|
try {
|
||||||
|
const responseData = await getDashboardStats();
|
||||||
|
stats.value = responseData.data;
|
||||||
|
// 数据获取后,在下一个DOM更新周期渲染图表
|
||||||
|
nextTick(() => {
|
||||||
|
renderCategoryChart();
|
||||||
|
renderDailyChart();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取仪表盘数据失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 生命周期钩子 ---
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDashboardStats();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-container { padding: 10px; }
|
||||||
|
.stats-cards { margin-bottom: 20px; }
|
||||||
|
.statistic-title { display: flex; align-items: center; color: #606266; font-size: 14px; }
|
||||||
|
.statistic-title .el-icon { margin-right: 8px; }
|
||||||
|
.chart-row { margin-top: 20px; }
|
||||||
|
.chart-card { border: none; }
|
||||||
|
.card-header { font-size: 1.1em; font-weight: bold; }
|
||||||
|
</style>
|
||||||
151
src/views/InterviewHistory.vue
Normal file
151
src/views/InterviewHistory.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<div class="history-container">
|
||||||
|
<el-card class="box-card" shadow="never">
|
||||||
|
<!-- 卡片头部 -->
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>会话列表</span>
|
||||||
|
<el-tooltip class="box-item" effect="dark" content="刷新列表" placement="top">
|
||||||
|
<el-button :icon="Refresh" circle @click="fetchSessions" :loading="isLoading"></el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 加载中的骨架屏 -->
|
||||||
|
<div v-if="isLoading" class="loading-container">
|
||||||
|
<el-skeleton :rows="5" animated />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无数据时的空状态 -->
|
||||||
|
<div v-else-if="sessions.length === 0" class="empty-container">
|
||||||
|
<el-empty description="暂无面试记录,快去开始一场模拟面试吧!" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 历史记录列表 -->
|
||||||
|
<div v-else class="history-list">
|
||||||
|
<el-card v-for="session in sessions" :key="session.id" class="session-card" shadow="hover">
|
||||||
|
<div class="session-content">
|
||||||
|
<div class="session-info">
|
||||||
|
<h4>{{ session.candidateName }} 的面试</h4>
|
||||||
|
<div class="info-row">
|
||||||
|
<el-icon>
|
||||||
|
<Calendar />
|
||||||
|
</el-icon>
|
||||||
|
<span>面试日期: {{ new Date(session.createdTime).toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<el-icon>
|
||||||
|
<MessageBox />
|
||||||
|
</el-icon>
|
||||||
|
<span>面试状态:
|
||||||
|
<el-tag :type="session.status === 'COMPLETED' ? 'success' : 'info'" size="small">{{ session.status
|
||||||
|
}}</el-tag>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-actions">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 导入Vue核心功能、路由和API客户端
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { getInterviewHistoryList } from '../api/interview';
|
||||||
|
import { Calendar, MessageBox, Refresh } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
|
// --- 响应式状态定义 ---
|
||||||
|
const sessions = ref([]); // 存储面试会话列表
|
||||||
|
const isLoading = ref(false); // 加载状态
|
||||||
|
const router = useRouter(); // Vue Router实例
|
||||||
|
|
||||||
|
// --- API交互方法 ---
|
||||||
|
|
||||||
|
// 获取面试历史列表
|
||||||
|
const fetchSessions = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const responseData = await getInterviewHistoryList();
|
||||||
|
// 按创建时间倒序排序,最新的在最前面
|
||||||
|
sessions.value = responseData.data.sort((a, b) => new Date(b.createdTime) - new Date(a.createdTime));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取面试历史失败:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 事件处理 ---
|
||||||
|
|
||||||
|
// 跳转到详细的复盘报告页面
|
||||||
|
const viewReport = (sessionId) => {
|
||||||
|
router.push({ name: 'InterviewReport', params: { sessionId } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 生命周期钩子 ---
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchSessions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.history-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container,
|
||||||
|
.empty-container {
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info h4 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row .el-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
176
src/views/InterviewReport.vue
Normal file
176
src/views/InterviewReport.vue
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<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>
|
||||||
|
<!-- 报告头部 -->
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-page-header>
|
||||||
|
|
||||||
|
<!-- AI最终评估报告 -->
|
||||||
|
<el-card class="box-card report-summary" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<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-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>
|
||||||
|
</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>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 问答详情 -->
|
||||||
|
<el-card class="box-card question-details" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<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">
|
||||||
|
<h4>{{ item.questionContent }}</h4>
|
||||||
|
<p class="user-answer"><strong>您的回答:</strong> {{ item.userAnswer }}</p>
|
||||||
|
<el-divider />
|
||||||
|
<div class="feedback-section">
|
||||||
|
<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} 分" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 导入Vue核心功能、路由、API客户端和图标
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { getInterviewReportDetail } from '../api/interview';
|
||||||
|
import { DataAnalysis, ChatDotRound } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
|
// --- Props & Router ---
|
||||||
|
const props = defineProps({ sessionId: { type: String, required: true } });
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// --- 响应式状态定义 ---
|
||||||
|
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);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析最终报告JSON失败:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- API交互方法 ---
|
||||||
|
|
||||||
|
// 获取面试报告详情
|
||||||
|
const fetchReport = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const responseData = await getInterviewReportDetail(props.sessionId);
|
||||||
|
reportData.value = responseData.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取面试报告失败:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 事件处理 ---
|
||||||
|
|
||||||
|
// 返回上一页
|
||||||
|
const goBack = () => router.push('/history');
|
||||||
|
|
||||||
|
// 根据录用建议返回不同的标签类型
|
||||||
|
const getRecommendationType = (rec) => {
|
||||||
|
if (rec === '强烈推荐' || rec === '推荐') return 'success';
|
||||||
|
if (rec === '待考虑') return 'warning';
|
||||||
|
if (rec === '不推荐') return 'danger';
|
||||||
|
return 'info';
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 生命周期钩子 ---
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
</style>
|
||||||
408
src/views/InterviewView.vue
Normal file
408
src/views/InterviewView.vue
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
<template>
|
||||||
|
<div class="interview-view-container">
|
||||||
|
<!-- 面试未开始时的启动界面 -->
|
||||||
|
<div v-if="!interviewStarted" class="start-screen">
|
||||||
|
<el-card class="start-card" shadow="never">
|
||||||
|
<div class="start-content">
|
||||||
|
<img src="/src/assets/interview-start.svg" alt="开始面试插图" class="start-illustration"/>
|
||||||
|
<div class="start-form">
|
||||||
|
<h2>准备开始您的模拟面试</h2>
|
||||||
|
<p>请填写您的信息并上传简历,AI面试官已准备就绪。</p>
|
||||||
|
<el-form label-position="top" @submit.prevent="startInterview">
|
||||||
|
<el-form-item label="您的姓名" required>
|
||||||
|
<el-input v-model="candidateName" placeholder="请输入您的姓名" size="large"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="上传您的简历" required>
|
||||||
|
<el-upload
|
||||||
|
ref="uploadRef"
|
||||||
|
action="#"
|
||||||
|
:limit="1"
|
||||||
|
:auto-upload="false"
|
||||||
|
:on-change="handleFileChange"
|
||||||
|
:on-exceed="handleFileExceed"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<el-button type="primary" size="large">选择简历文件</el-button>
|
||||||
|
</template>
|
||||||
|
<template #tip>
|
||||||
|
<div class="el-upload__tip">
|
||||||
|
支持PDF或Markdown格式,文件大小不超过10MB。
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="success" @click="startInterviewAction" :loading="isLoading" size="large"
|
||||||
|
class="start-button">开始面试
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 面试开始后的聊天窗口 -->
|
||||||
|
<div v-else class="chat-window-container">
|
||||||
|
<div class="chat-window">
|
||||||
|
<!-- 消息展示区 -->
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<!-- AI思考中的动画 -->
|
||||||
|
<div v-if="isAiThinking" class="message-row message-ai">
|
||||||
|
<el-avatar class="avatar" :style="{ backgroundColor: '#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"
|
||||||
|
></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-message">
|
||||||
|
<el-alert title="面试已结束" type="success" show-icon :closable="false">
|
||||||
|
感谢您的参与!您可以从左侧菜单开始新的面试或管理题库。
|
||||||
|
</el-alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 导入Vue核心功能、API客户端和Element Plus组件
|
||||||
|
import {ref, nextTick, onMounted} from 'vue';
|
||||||
|
import {startInterview, continueInterview, getInterviewReportDetail} from '../api/interview';
|
||||||
|
import {ElMessage} from 'element-plus';
|
||||||
|
|
||||||
|
// --- 响应式状态定义 ---
|
||||||
|
const interviewStarted = ref(false); // 面试是否已开始
|
||||||
|
const isLoading = ref(false); // 全局加载状态,用于按钮和输入框
|
||||||
|
const isAiThinking = ref(false); // AI是否正在生成回答
|
||||||
|
const candidateName = ref(''); // 候选人姓名
|
||||||
|
const resumeFile = ref(null); // 上传的简历文件
|
||||||
|
const sessionId = ref(null); // 当前会话ID
|
||||||
|
const messages = ref([]); // 对话消息列表
|
||||||
|
const userAnswer = ref(''); // 用户输入框的内容
|
||||||
|
const interviewStatus = ref('ACTIVE'); // 面试状态
|
||||||
|
|
||||||
|
// --- DOM引用 ---
|
||||||
|
const messagesContainer = ref(null); // 消息容器的引用
|
||||||
|
const uploadRef = ref(null); // 上传组件的引用
|
||||||
|
|
||||||
|
// --- 生命周期钩子 ---
|
||||||
|
onMounted(() => {
|
||||||
|
// 检查URL中是否有sessionId,用于恢复面试
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const sid = urlParams.get('sessionId');
|
||||||
|
if (sid) {
|
||||||
|
fetchSessionHistory(sid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- UI交互方法 ---
|
||||||
|
const handleFileChange = (file) => {
|
||||||
|
resumeFile.value = file.raw;
|
||||||
|
};
|
||||||
|
const handleFileExceed = () => {
|
||||||
|
ElMessage.warning('只能上传一个简历文件。');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 滚动到消息列表底部
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (messagesContainer.value) {
|
||||||
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化消息,将换行符转为<br>
|
||||||
|
const formatMessage = (content) => content ? content.replace(/\n/g, '<br />') : '';
|
||||||
|
|
||||||
|
// --- API交互方法 ---
|
||||||
|
|
||||||
|
// 获取历史会话
|
||||||
|
const fetchSessionHistory = async (sid) => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const responseData = await getInterviewReportDetail(sid);
|
||||||
|
const data = responseData.data;
|
||||||
|
sessionId.value = data.sessionDetails.sessionId;
|
||||||
|
candidateName.value = data.sessionDetails.candidateName;
|
||||||
|
interviewStatus.value = data.sessionDetails.status;
|
||||||
|
// 注意:历史记录接口现在不直接返回messages,这里需要适配
|
||||||
|
// 此处暂时留空,复盘报告页将展示完整对话
|
||||||
|
messages.value = data.messages.map(msg => ({ sender: msg.sender, content: msg.content }));
|
||||||
|
currentQuestionId.value = data.currentQuestionId
|
||||||
|
interviewStarted.value = true;
|
||||||
|
// 如果是已完成的面试,直接显示提示
|
||||||
|
if (interviewStatus.value === 'COMPLETED') {
|
||||||
|
messages.value.push({sender: 'AI', content: '本次面试已完成,详细报告请在“面试历史”页面查看。'});
|
||||||
|
}
|
||||||
|
scrollToBottom();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取会话历史失败:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentQuestionId = ref('')
|
||||||
|
|
||||||
|
// 开始面试
|
||||||
|
const startInterviewAction = async () => {
|
||||||
|
if (!candidateName.value || !resumeFile.value) {
|
||||||
|
ElMessage.error('请输入您的姓名并上传简历。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isLoading.value = true;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('candidateName', candidateName.value);
|
||||||
|
formData.append('resume', resumeFile.value);
|
||||||
|
try {
|
||||||
|
console.log(formData.values())
|
||||||
|
const responseData = await startInterview(formData);
|
||||||
|
const data = responseData.data;
|
||||||
|
sessionId.value = data.sessionId;
|
||||||
|
currentQuestionId.value = data.currentQuestionId;
|
||||||
|
messages.value.push({sender: 'AI', content: data.message});
|
||||||
|
interviewStarted.value = true;
|
||||||
|
window.history.pushState({}, '', `/interview?sessionId=${data.sessionId}`);
|
||||||
|
scrollToBottom();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('开始面试失败:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送回答
|
||||||
|
const sendMessage = async () => {
|
||||||
|
if (!userAnswer.value.trim()) return;
|
||||||
|
const currentAnswer = userAnswer.value;
|
||||||
|
messages.value.push({sender: 'USER', content: currentAnswer});
|
||||||
|
userAnswer.value = '';
|
||||||
|
isAiThinking.value = true;
|
||||||
|
scrollToBottom();
|
||||||
|
try {
|
||||||
|
const responseData = await continueInterview(
|
||||||
|
{
|
||||||
|
sessionId: sessionId.value,
|
||||||
|
userAnswer: currentAnswer,
|
||||||
|
currentQuestionId: Number(currentQuestionId.value)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = responseData.data;
|
||||||
|
messages.value.push({sender: 'AI', content: data.message});
|
||||||
|
interviewStatus.value = data.status;
|
||||||
|
scrollToBottom();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送消息失败:', error);
|
||||||
|
messages.value.pop();
|
||||||
|
userAnswer.value = currentAnswer;
|
||||||
|
} finally {
|
||||||
|
isAiThinking.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* --- 启动界面样式 --- */
|
||||||
|
.start-screen {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-card {
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 40px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-illustration {
|
||||||
|
width: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-form {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-form h2 {
|
||||||
|
font-size: 1.8em;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-form p {
|
||||||
|
color: #606266;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 聊天窗口样式 --- */
|
||||||
|
.chat-window-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 140px); /* 减去顶栏和padding的高度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-window {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, .1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
max-width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-ai {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-user {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-user .avatar {
|
||||||
|
order: 2;
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble {
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-ai .message-bubble {
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
color: #303133;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-user .message-bubble {
|
||||||
|
background-color: #409eff;
|
||||||
|
color: #fff;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 输入区样式 --- */
|
||||||
|
.input-area {
|
||||||
|
padding: 20px;
|
||||||
|
border-top: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-input {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-counter {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
280
src/views/QuestionBank.vue
Normal file
280
src/views/QuestionBank.vue
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
<template>
|
||||||
|
<div class="question-bank-container">
|
||||||
|
<el-card class="box-card" shadow="never">
|
||||||
|
|
||||||
|
<!-- 卡片头部:标题和操作按钮 -->
|
||||||
|
<div class="card-header">
|
||||||
|
<el-card style="width: 100%;" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<span>题库管理中心</span>
|
||||||
|
</template>
|
||||||
|
<el-row :gutter="20" justify="space-between">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-input v-model="searchParams.content" placeholder="按题目内容搜索..." class="search-input"
|
||||||
|
:prefix-icon="Search" @clear="fetchQuestionPage" @keyup.enter="fetchQuestionPage" clearable />
|
||||||
|
<el-button type="primary" :icon="Search" @click="fetchQuestionPage"
|
||||||
|
style="margin-left: 15px;">查询</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-row>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-button type="success" :icon="Plus" @click="handleOpenAddDialog">新增题目</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-upload action="#" :show-file-list="false" :auto-upload="false" :on-change="handleFileUpload"
|
||||||
|
class="upload-button">
|
||||||
|
<el-button type="primary" :icon="UploadFilled">AI批量导入</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-button type="primary" @click="sendCheckDataReq">校验数据</el-button>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<!-- 题库表格 -->
|
||||||
|
<el-table :data="tableData" border v-loading="isLoading" style="width: 100%" height="calc(100vh - 260px)">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="category" label="分类" width="180">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag>{{ scope.row.category }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="difficulty" label="难度" width="120">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="getDifficultyTagType(scope.row.difficulty)">{{ scope.row.difficulty }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="content" label="题目内容" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="tags" label="标签" width="250">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag v-for="tag in (scope.row.tags || '').split(',').filter(t => t.trim() !== '')" :key="tag"
|
||||||
|
type="info" style="margin-right: 5px; margin-bottom: 5px;">{{ tag }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button size="small" :icon="Edit" @click="handleOpenEditDialog(scope.row)"></el-button>
|
||||||
|
<el-button type="danger" size="small" :icon="Delete" @click="handleDelete(scope.row.id)"></el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页控制器 -->
|
||||||
|
<el-pagination v-if="totalItems > 0" class="pagination-container"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper" :total="totalItems" :page-sizes="[10, 20, 50, 100]"
|
||||||
|
v-model:current-page="pagination.current" v-model:page-size="pagination.size" @size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange" />
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 新增/编辑题目的对话框 -->
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="50%" @close="resetForm">
|
||||||
|
<el-form :model="questionForm" ref="questionFormRef" label-width="80px">
|
||||||
|
<el-form-item label="题目内容" prop="content" required>
|
||||||
|
<el-input v-model="questionForm.content" type="textarea" :rows="4" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分类" prop="category" required>
|
||||||
|
<el-input v-model="questionForm.category" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="难度" prop="difficulty" required>
|
||||||
|
<el-select v-model="questionForm.difficulty" placeholder="请选择难度">
|
||||||
|
<el-option label="Easy" value="Easy" />
|
||||||
|
<el-option label="Medium" value="Medium" />
|
||||||
|
<el-option label="Hard" value="Hard" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标签" prop="tags">
|
||||||
|
<el-input v-model="questionForm.tags" placeholder="多个标签请用英文逗号分隔" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit">确认</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { getQuestionPage, addQuestion, updateQuestion, deleteQuestion, importQuestionsByAi, checkQuestionData } from '../api/question';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { Search, UploadFilled, Plus, Edit, Delete } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
|
// --- 响应式状态定义 ---
|
||||||
|
const tableData = ref([]);
|
||||||
|
const totalItems = ref(0);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const pagination = ref({ current: 1, size: 10 });
|
||||||
|
const searchParams = ref({ content: '' });
|
||||||
|
|
||||||
|
// --- 对话框状态 ---
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const dialogTitle = ref('');
|
||||||
|
const isEditMode = ref(false);
|
||||||
|
const questionForm = ref({});
|
||||||
|
const questionFormRef = ref(null);
|
||||||
|
|
||||||
|
// --- UI辅助方法 ---
|
||||||
|
const getDifficultyTagType = (difficulty) => {
|
||||||
|
switch ((difficulty || '').toLowerCase()) {
|
||||||
|
case 'easy':
|
||||||
|
return 'success';
|
||||||
|
case 'medium':
|
||||||
|
return 'warning';
|
||||||
|
case 'hard':
|
||||||
|
return 'danger';
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
questionForm.value = { content: '', category: '', difficulty: 'Medium', tags: '' };
|
||||||
|
isEditMode.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 对话框处理方法 ---
|
||||||
|
const handleOpenAddDialog = () => {
|
||||||
|
resetForm();
|
||||||
|
dialogTitle.value = '新增题目';
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditDialog = (row) => {
|
||||||
|
resetForm();
|
||||||
|
isEditMode.value = true;
|
||||||
|
dialogTitle.value = '编辑题目';
|
||||||
|
questionForm.value = { ...row };
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- API交互方法 ---
|
||||||
|
const fetchQuestionPage = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
current: pagination.value.current,
|
||||||
|
size: pagination.value.size,
|
||||||
|
...searchParams.value
|
||||||
|
};
|
||||||
|
const responseData = await getQuestionPage(params);
|
||||||
|
tableData.value = responseData.data.records;
|
||||||
|
totalItems.value = responseData.data.total;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取题库分页失败:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendCheckDataReq = async () => {
|
||||||
|
const res = await checkQuestionData()
|
||||||
|
console.log(res)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileUpload = async (file) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file.raw);
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
await importQuestionsByAi(formData);
|
||||||
|
ElMessage.success('文件上传成功!AI正在后台处理,请稍后刷新查看。');
|
||||||
|
await fetchQuestionPage()
|
||||||
|
// setTimeout(fetchQuestionPage, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('文件上传失败:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
if (isEditMode.value) {
|
||||||
|
await updateQuestion(questionForm.value);
|
||||||
|
ElMessage.success('题目更新成功!');
|
||||||
|
} else {
|
||||||
|
await addQuestion(questionForm.value);
|
||||||
|
ElMessage.success('题目新增成功!');
|
||||||
|
}
|
||||||
|
dialogVisible.value = false;
|
||||||
|
fetchQuestionPage();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('提交失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id) => {
|
||||||
|
ElMessageBox.confirm('确定要删除这道题目吗?此操作不可撤销。', '警告', {
|
||||||
|
confirmButtonText: '确定删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
await deleteQuestion(id);
|
||||||
|
ElMessage.success('题目删除成功!');
|
||||||
|
fetchQuestionPage();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error);
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
ElMessage.info('已取消删除');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 分页处理 ---
|
||||||
|
const handleSizeChange = (val) => {
|
||||||
|
pagination.value.size = val;
|
||||||
|
fetchQuestionPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCurrentChange = (val) => {
|
||||||
|
pagination.value.current = val;
|
||||||
|
fetchQuestionPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 生命周期钩子 ---
|
||||||
|
onMounted(() => {
|
||||||
|
fetchQuestionPage();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.question-bank-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
src/views/question-category/index.vue
Normal file
11
src/views/question-category/index.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup>
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
18
vite.config.js
Normal file
18
vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user