初始化

This commit is contained in:
2025-09-08 17:31:33 +08:00
parent 4c4475aa7b
commit ee95701e74
28 changed files with 3820 additions and 1 deletions

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View 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
View 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
View File

@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
<router-view />
</template>
<style scoped>
</style>

8
src/api/dashboard.js Normal file
View File

@@ -0,0 +1,8 @@
import apiClient from './index';
/**
* 获取仪表盘的所有统计数据
*/
export const getDashboardStats = () => {
return apiClient.post('/dashboard/stats');
};

33
src/api/index.js Normal file
View 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
View 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 });
};

View 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
View 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
View 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
View 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;
}

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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>

View 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
View 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
View 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>

View File

@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
</template>
<style scoped>
</style>

18
vite.config.js Normal file
View 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
},
},
},
})