修改AI面试相关内容

This commit is contained in:
2025-09-17 21:36:49 +08:00
parent 6ae1738ac0
commit 7e6cf25295
27 changed files with 3601 additions and 1858 deletions

View File

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

View File

@@ -1,24 +1,52 @@
import apiClient from './index'; import apiClient from "@/api/index.js";
export const questionCategoryApi = { // 获取分类树形列表
// 获取分类树列表 export function getCategoryTree(params) {
getTreeList: () => apiClient.get('/question-category/tree-list'), return apiClient({
url: '/question-category/tree-list',
// 获取分类详情 method: 'get',
getDetail: (id) => apiClient.get(`/question-category/${id}`), params
})
// 创建分类 }
create: (data) => apiClient.post('/question-category', data),
// 获取分类详情
// 更新分类 export function getCategoryDetail(id) {
update: (id, data) => apiClient.put(`/question-category/${id}`, data), return apiClient({
url: `/question-category/${id}`,
// 删除分类 method: 'get'
delete: (id) => apiClient.delete(`/question-category/${id}`), })
}
// 更新状态
updateState: (id, state) => apiClient.patch(`/question-category/${id}/state`, { state }), // 添加分类
export function addCategory(data) {
// 获取分类选项(用于下拉选择) return apiClient({
getOptions: () => apiClient.get('/question-category/options') url: '/question-category',
method: 'post',
data
})
}
// 修改分类
export function updateCategory(data) {
return apiClient({
url: '/question-category/update',
method: 'post',
data
})
}
// 删除分类
export function deleteCategory(id) {
return apiClient({
url: `/question-category/${id}`,
method: 'delete'
})
}
// 获取所有父级分类(用于选择父级)
export function getParentOptions() {
return apiClient({
url: '/question-category/parentOptions',
method: 'get'
})
} }

View File

@@ -51,3 +51,7 @@ export const importQuestionsByAi = (formData) => {
export const checkQuestionData = () => { export const checkQuestionData = () => {
return apiClient.post('/question/check-question-data'); return apiClient.post('/question/check-question-data');
} }
export const getTreeListByCategory = (data) => {
return apiClient.post('/question/tree-list-category', data);
}

View File

@@ -0,0 +1,502 @@
<template>
<div class="chat-window-container">
<div class="chat-header">
<div class="header-left">
<h2>{{ mode === 'chat' ? 'AI对话' : mode === 'ai' ? 'AI智能面试' : '题库面试' }}</h2>
<span class="status-indicator" :class="interviewStatus"></span>
<span class="status-text">{{ statusText }}</span>
</div>
<div class="header-right">
<el-button v-if="mode !== 'chat'" @click="endInterview" size="small">结束面试</el-button>
<el-button @click="$emit('close')" size="small" v-if="$emit('close')">关闭</el-button>
</div>
</div>
<div class="messages-container" ref="messagesContainer">
<div
v-for="(message, index) in messages"
:key="index"
class="message-row"
:class="'message-' + message.sender.toLowerCase()"
>
<el-avatar
class="avatar"
:style="{ backgroundColor: message.sender === 'AI' ? '#409EFF' : '#67C23A' }"
>
{{ message.sender === 'AI' ? 'AI' : candidateName.charAt(0) }}
</el-avatar>
<div class="message-content">
<div class="message-bubble" v-html="formatMessage(message.content)"></div>
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
</div>
</div>
<!-- AI思考中的动画 -->
<div v-if="isAiThinking" class="message-row message-ai">
<el-avatar class="avatar" style="background-color: #409EFF;">AI</el-avatar>
<div class="message-content">
<div class="message-bubble thinking">
<span></span><span></span><span></span>
</div>
</div>
</div>
</div>
<div class="input-area">
<el-input
type="textarea"
:rows="4"
v-model="userAnswer"
placeholder="在此输入您的回答..."
:disabled="isLoading || interviewStatus === 'COMPLETED'"
resize="none"
class="answer-input"
@keypress.enter.prevent="sendMessage"
></el-input>
<div class="input-actions">
<span class="char-counter">{{ userAnswer.length }} / 1000</span>
<el-button
type="primary"
@click="sendMessage"
:loading="isLoading"
:disabled="interviewStatus === 'COMPLETED' || !userAnswer.trim()"
class="send-button"
>
发送回答
</el-button>
</div>
</div>
<!-- 面试结束提示 -->
<div v-if="interviewStatus === 'COMPLETED'" class="completion-overlay">
<div class="completion-content">
<el-result
icon="success"
title="面试已完成"
sub-title="感谢您的参与您可以查看面试报告或开始新的面试"
>
<template #extra>
<el-button type="primary" @click="$emit('end-interview')">返回</el-button>
<el-button>查看报告</el-button>
</template>
</el-result>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
mode: {
type: String,
default: 'chat'
},
categories: {
type: Array,
default: () => []
},
candidateName: {
type: String,
default: ''
},
sessionId: {
type: String,
default: ''
}
})
const emit = defineEmits(['end-interview', 'close'])
// 响应式状态
const messages = ref([])
const userAnswer = ref('')
const isLoading = ref(false)
const isAiThinking = ref(false)
const interviewStatus = ref('ACTIVE') // ACTIVE, COMPLETED
const messagesContainer = ref(null)
// 计算属性
const statusText = computed(() => {
return interviewStatus.value === 'ACTIVE' ? '进行中' : '已结束'
})
// 生命周期
onMounted(() => {
initializeChat()
scrollToBottom()
})
// 初始化聊天
const initializeChat = () => {
let welcomeMessage = ''
if (props.mode === 'chat') {
welcomeMessage = '您好我是AI助手很高兴与您对话。有什么我可以帮助您的吗'
} else if (props.mode === 'ai') {
welcomeMessage = '您好我是AI面试官接下来将为您进行模拟面试。请简单介绍一下您自己。'
} else {
welcomeMessage = '您好!我将从您选择的题库中抽取题目进行面试。请简单介绍一下您自己。'
}
messages.value = [
{
sender: 'AI',
content: welcomeMessage,
timestamp: new Date()
}
]
}
// 格式化消息
const formatMessage = (content) => {
return content ? content.replace(/\n/g, '<br />') : ''
}
// 格式化时间
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
// 发送消息
const sendMessage = async () => {
if (!userAnswer.value.trim()) return
const userMessage = {
sender: 'USER',
content: userAnswer.value,
timestamp: new Date()
}
messages.value.push(userMessage)
const currentAnswer = userAnswer.value
userAnswer.value = ''
isAiThinking.value = true
scrollToBottom()
try {
// 模拟API调用延迟
await new Promise(resolve => setTimeout(resolve, 1000))
// 生成AI回复
const aiResponse = generateAIResponse(currentAnswer)
messages.value.push({
sender: 'AI',
content: aiResponse,
timestamp: new Date()
})
// 随机结束面试(仅面试模式)
if (props.mode !== 'chat' && Math.random() > 0.8) {
interviewStatus.value = 'COMPLETED'
messages.value.push({
sender: 'AI',
content: '本次面试到此结束,感谢您的参与!您可以从左侧菜单查看面试报告。',
timestamp: new Date()
})
}
} catch (error) {
ElMessage.error('发送失败:' + error.message)
} finally {
isAiThinking.value = false
scrollToBottom()
}
}
// 生成AI回复模拟
const generateAIResponse = (userAnswer) => {
if (props.mode === 'chat') {
const chatResponses = [
"我理解您的意思,能详细说明一下吗?",
"这是一个很好的问题!让我为您解释一下...",
"根据您提供的信息,我建议您可以考虑...",
"您还有其他相关问题吗?我很乐意帮助您。",
"感谢分享,这对我来说很有启发。"
]
return chatResponses[Math.floor(Math.random() * chatResponses.length)]
}
const interviewResponses = [
"很好,请继续。",
"能详细说明一下吗?",
"您在这方面有什么具体的经验?",
"很有趣的观点,还有其他补充吗?",
"感谢分享,接下来我们换个话题。"
]
// 如果是题库模式,可以从题库中抽取问题
if (props.mode === 'local' && props.categories.length > 0) {
const question = getQuestionFromBank()
return question
}
return interviewResponses[Math.floor(Math.random() * interviewResponses.length)]
}
// 从题库中获取问题(模拟)
const getQuestionFromBank = () => {
const questionsByCategory = {
1: ["请解释一下什么是闭包?", "React和Vue有什么区别", "如何优化前端性能?"],
2: ["谈谈你对微服务的理解", "RESTful API的设计原则是什么", "如何保证API的安全性"],
3: ["请实现一个快速排序算法", "二叉树和链表有什么区别?", "动态规划的核心思想是什么?"],
4: ["数据库索引的原理是什么?", "SQL和NoSQL的区别是什么", "如何设计一个高效的数据库 schema"],
5: ["TCP和UDP的区别是什么", "HTTP和HTTPS有什么不同", "什么是DNS解析过程"],
6: ["如何设计一个短链接服务?", "请设计一个电商系统的数据库结构", "如何设计一个高可用的系统?"],
7: ["你最大的优点和缺点是什么?", "请描述一次你解决复杂问题的经历", "你如何处理团队冲突?"],
8: ["为什么井盖是圆的?", "如何称出一头大象的重量?", "有多少个乒乓球能装满这个房间?"]
}
// 从选中的分类中随机选择一个问题
const randomCategory = props.categories[Math.floor(Math.random() * props.categories.length)]
const questions = questionsByCategory[randomCategory] || ["请介绍一下你自己"]
return questions[Math.floor(Math.random() * questions.length)]
}
// 结束面试
const endInterview = () => {
interviewStatus.value = 'COMPLETED'
messages.value.push({
sender: 'AI',
content: '面试已手动结束,感谢您的参与!',
timestamp: new Date()
})
scrollToBottom()
}
</script>
<style scoped>
.chat-window-container {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: linear-gradient(135deg, #409EFF 0%, #64b5ff 100%);
color: white;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-left h2 {
margin: 0;
font-size: 1.5rem;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: #67C23A;
}
.status-indicator.COMPLETED {
background: #909399;
}
.status-text {
font-size: 0.9rem;
opacity: 0.9;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 24px;
background: #f9fafb;
}
.message-row {
display: flex;
margin-bottom: 24px;
max-width: 80%;
}
.message-ai {
justify-content: flex-start;
}
.message-user {
justify-content: flex-end;
margin-left: auto;
}
.avatar {
flex-shrink: 0;
margin-right: 12px;
font-weight: bold;
}
.message-user .avatar {
order: 2;
margin-right: 0;
margin-left: 12px;
}
.message-content {
display: flex;
flex-direction: column;
max-width: calc(100% - 52px);
}
.message-bubble {
padding: 12px 16px;
border-radius: 18px;
line-height: 1.6;
word-wrap: break-word;
position: relative;
}
.message-ai .message-bubble {
background-color: white;
color: #303133;
border-top-left-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.message-user .message-bubble {
background-color: #409EFF;
color: white;
border-top-right-radius: 4px;
}
.message-time {
font-size: 0.75rem;
color: #909399;
margin-top: 4px;
padding: 0 4px;
}
.message-ai .message-time {
text-align: left;
}
.message-user .message-time {
text-align: right;
}
.input-area {
padding: 16px 24px;
border-top: 1px solid #e6e8eb;
background: white;
}
.answer-input {
margin-bottom: 12px;
}
.input-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.char-counter {
font-size: 0.8rem;
color: #909399;
}
.send-button {
min-width: 100px;
}
/* AI思考动画 */
.thinking span {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #909399;
margin: 0 2px;
animation: thinking-dots 1.4s infinite ease-in-out both;
}
.thinking span:nth-child(1) {
animation-delay: -0.32s;
}
.thinking span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes thinking-dots {
0%, 80%, 100% {
transform: scale(0);
}
40% {
transform: scale(1.0);
}
}
/* 完成面试覆盖层 */
.completion-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.completion-content {
text-align: center;
max-width: 500px;
padding: 24px;
}
@media (max-width: 768px) {
.message-row {
max-width: 90%;
}
.chat-header {
padding: 12px 16px;
}
.header-left h2 {
font-size: 1.2rem;
}
.messages-container {
padding: 16px;
}
.input-area {
padding: 12px 16px;
}
}
</style>

View File

@@ -0,0 +1,440 @@
<template>
<div class="question-bank-section">
<div class="section-header">
<h3>题库选择</h3>
<div class="selection-actions">
<el-button link @click="selectAllValid">全选</el-button>
<el-button link @click="clearAll">清空</el-button>
</div>
</div>
<div class="category-selector">
<el-tree
ref="categoryTree"
:data="categoryTreeData"
:props="treeProps"
node-key="id"
show-checkbox
:default-expand-all="false"
:expand-on-click-node="true"
@check="handleTreeCheck"
:default-checked-keys="defaultCheckedKeys"
:filter-node-method="filterNode"
>
<template #default="{ node, data }">
<span class="tree-node" :class="{ 'disabled-node': isCategoryEmpty(data) }">
<span class="node-label">{{ node.label }}</span>
<span v-if="data.type === 'question'" class="difficulty-tag" :class="data.difficulty?.toLowerCase()">
{{ data.difficulty }}
</span>
<span v-else class="count-badge" :class="{ 'no-questions': data.count === 0 }">
{{ data.count }}
</span>
</span>
</template>
</el-tree>
</div>
<div v-if="hasSelection" class="selected-summary">
<h4>选择统计</h4>
<div class="summary-content">
<div class="summary-item">
<span class="summary-label">已选分类</span>
<span class="summary-value">{{ selectedCategories.length }} 个分类</span>
</div>
<div class="summary-item">
<span class="summary-label">已选题目</span>
<span class="summary-value">{{ selectedQuestions.length }} 道题目</span>
</div>
<div class="summary-item">
<span class="summary-label">总计题目</span>
<span class="summary-value">{{ categoryTreeData && categoryTreeData[0].count }} 道题目</span>
</div>
</div>
</div>
<div class="difficulty-filter">
<h4>难度筛选</h4>
<el-radio-group v-model="selectedDifficulty" size="large" @change="handleDifficultyChange">
<el-radio-button label="全部" value="ALL"/>
<el-radio-button label="简单" value="Easy"/>
<el-radio-button label="中等" value="Medium"/>
<el-radio-button label="困难" value="Hard"/>
</el-radio-group>
</div>
<div class="question-stats">
<el-alert
:title="selectionSummary"
type="info"
:closable="false"
show-icon
/>
</div>
</div>
</template>
<script setup>
import {onMounted, ref, computed, watch, nextTick} from 'vue'
import {ElMessage, ElLoading} from 'element-plus'
import {getTreeListByCategory} from "@/api/question.js"
// 组件内部状态
const categoryTreeData = ref([])
const categoryTree = ref(null)
const selectedDifficulty = ref('ALL')
const defaultCheckedKeys = ref([])
// 存储所有选中的节点ID包括分类和题目
const selectedNodeIds = ref(new Set())
const selectedNodeList = ref([])
const treeProps = {
children: 'children',
label: 'name'
}
// 计算属性
const hasSelection = computed(() => selectedNodeIds.value.size > 0)
const selectedCategories = computed(() => {
return Array.from(selectedNodeIds.value)
.map(id => {
const node = findNodeById(id)
return node && node.type === 'category' && !isCategoryEmpty(node) ?
{id: node.id, name: node.name} : null
})
.filter(Boolean)
})
const selectedQuestions = computed(() => {
return Array.from(selectedNodeIds.value)
.map(id => {
const node = findNodeById(id)
return node && node.type === 'question' ?
{id: node.id, name: node.name} : null
})
.filter(Boolean)
})
const selectionSummary = computed(() => {
const catCount = selectedCategories.value.length
const queCount = selectedQuestions.value.length
if (catCount === 0 && queCount === 0) {
return '请选择题目或分类(空分类不可选)'
}
return `已选择 ${catCount} 个分类,${queCount} 道单独题目,共计 ${categoryTreeData.value.length} 道题目`
})
// 方法
// 检查分类是否为空(没有题目)
const isCategoryEmpty = (category) => {
return category.type === 'category' && category.count === 0
}
// 扁平化树结构(缓存结果)
let flattenedTreeCache = []
const flattenTree = (nodes) => {
let result = []
nodes.forEach(node => {
result.push(node)
if (node.children && node.children.length > 0) {
result = result.concat(flattenTree(node.children))
}
})
return result
}
// 更新缓存
const updateTreeCache = () => {
flattenedTreeCache = flattenTree(categoryTreeData.value)
}
// 根据ID查找节点使用缓存
const findNodeById = (id) => {
return flattenedTreeCache.find(node => node.id === id)
}
// 过滤空分类节点
const filterNode = (value, data) => {
if (data.type === 'category') {
return !isCategoryEmpty(data)
}
return true
}
// 处理树节点选择(使用批量处理)
const handleTreeCheck = (node, {checkedKeys, halfCheckedKeys}) => {
// 使用防抖处理频繁选择
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
processTreeSelection(checkedKeys)
}, 100)
selectedNodeList.value = categoryTree.value.getCheckedNodes()
}
let debounceTimer = null
// 处理树选择结果
const processTreeSelection = (checkedKeys) => {
// 更新选中节点
selectedNodeIds.value = new Set(checkedKeys)
}
// 选择所有有效内容
const selectAllValid = async () => {
// 获取所有非空节点ID
const allValidNodeIds = flattenedTreeCache
.filter(node => node.type === 'question' || (node.type === 'category' && !isCategoryEmpty(node)))
.map(node => node.id)
// 更新选择
selectedNodeIds.value = new Set(allValidNodeIds)
// 更新树选择状态
if (categoryTree.value) {
categoryTree.value.setCheckedKeys(allValidNodeIds)
}
}
// 清空所有选择
const clearAll = async () => {
selectedNodeIds.value.clear()
if (categoryTree.value) {
categoryTree.value.setCheckedKeys([])
}
}
// 处理难度筛选变化
const handleDifficultyChange = () => {
selectedNodeIds.value.clear()
selectedNodeList.value = []
loadCategories()
}
// 加载分类数据(使用分页或虚拟滚动优化大数据量)
const loadCategories = async () => {
try {
const res = await getTreeListByCategory({
difficulty: selectedDifficulty.value === 'ALL' ? null : selectedDifficulty.value
})
categoryTreeData.value = res.data || []
// 更新树缓存
updateTreeCache()
// 重新应用选择状态
if (categoryTree.value && selectedNodeIds.value.size > 0) {
const currentSelected = Array.from(selectedNodeIds.value)
categoryTree.value.setCheckedKeys(currentSelected)
}
} catch (error) {
ElMessage.error('加载分类数据失败:' + error.message)
}
}
// 获取最终选择结果(供父组件调用)
const getSelectionResult = () => {
return {
nodeIds: Array.from(selectedNodeIds.value),
totalQuestions: categoryTreeData.value.length,
selectedNodeList: selectedNodeList.value ? selectedNodeList.value : []
}
}
// 组件挂载时加载数据
onMounted(() => {
loadCategories()
})
// 暴露方法给父组件
defineExpose({
getSelectionResult
})
</script>
<style scoped>
.question-bank-section {
background: #fff;
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
margin-bottom: 24px;
border: 1px solid #eaeef2;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid #f0f4f8;
}
.section-header h3 {
margin: 0;
color: #1f2d3d;
font-size: 20px;
font-weight: 600;
}
.selection-actions {
display: flex;
gap: 16px;
}
.category-selector {
max-height: 400px;
overflow-y: auto;
border: 2px solid #f0f4f8;
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
background: #fafbfc;
}
.selected-summary {
margin: 20px 0;
padding: 16px;
background: #f8fafc;
border-radius: 12px;
border-left: 4px solid #409eff;
}
.selected-summary h4 {
margin: 0 0 12px 0;
color: #2c3e50;
font-weight: 600;
}
.summary-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.summary-item {
display: flex;
align-items: center;
gap: 12px;
}
.summary-label {
min-width: 80px;
color: #5a6c7d;
font-weight: 500;
}
.summary-value {
font-weight: 600;
color: #1f2d3d;
}
.difficulty-filter {
margin: 24px 0;
padding: 20px;
background: #f8fafc;
border-radius: 12px;
}
.difficulty-filter h4 {
margin: 0 0 16px 0;
color: #2c3e50;
font-weight: 600;
}
:deep(.el-radio-button) {
margin-right: 8px;
}
:deep(.el-radio-button__inner) {
border-radius: 8px !important;
padding: 12px 20px;
font-weight: 500;
}
.question-stats {
margin-top: 20px;
}
:deep(.tree-node) {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 0;
}
:deep(.node-label) {
font-weight: 500;
color: #1f2d3d;
}
:deep(.disabled-node) {
opacity: 0.6;
cursor: not-allowed;
}
:deep(.difficulty-tag) {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: white;
}
:deep(.difficulty-tag.easy) {
background: #67c23a;
}
:deep(.difficulty-tag.medium) {
background: #e6a23c;
}
:deep(.difficulty-tag.hard) {
background: #f56c6c;
}
:deep(.count-badge) {
background: #409eff;
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
:deep(.count-badge.no-questions) {
background: #c0c4cc;
}
:deep(.el-tree-node__content) {
height: 44px;
border-radius: 6px;
margin: 2px 0;
}
:deep(.el-tree-node__content:hover) {
background-color: #f0f7ff !important;
}
:deep(.el-tree-node:focus > .el-tree-node__content) {
background-color: #ecf5ff !important;
}
:deep(.el-tree-node.is-disabled .el-tree-node__content) {
cursor: not-allowed;
opacity: 0.6;
}
</style>

View File

@@ -25,7 +25,7 @@
<el-icon><House /></el-icon> <el-icon><House /></el-icon>
<span>仪表盘</span> <span>仪表盘</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/interview"> <el-menu-item index="/home">
<el-icon><ChatLineRound /></el-icon> <el-icon><ChatLineRound /></el-icon>
<span>模拟面试</span> <span>模拟面试</span>
</el-menu-item> </el-menu-item>

View File

@@ -9,33 +9,48 @@ const routes = [
{ {
path: '', path: '',
name: 'Dashboard', name: 'Dashboard',
component: () => import('../views/Dashboard.vue'), component: () => import('@/views/dashboard/index.vue'),
},
{
path: 'chat',
name: 'Chat',
component: () => import('@/views/chat/index.vue'),
},
{
path: 'home',
name: 'InterviewHome',
component: () => import('@/views/interview/home.vue'),
}, },
{ {
path: 'interview', path: 'interview',
name: 'Interview', name: 'Interview',
component: () => import('../views/InterviewView.vue'), component: () => import('@/views/interview/index.vue'),
},
{
path: 'interview-chat',
name: 'InterviewChat',
component: () => import('@/views/interview/chat.vue'),
}, },
{ {
path: 'question-bank', path: 'question-bank',
name: 'QuestionBank', name: 'QuestionBank',
component: () => import('../views/QuestionBank.vue'), component: () => import('@/views/question/index.vue'),
}, },
{ {
path: 'history', path: 'history',
name: 'InterviewHistory', name: 'InterviewHistory',
component: () => import('../views/InterviewHistory.vue'), component: () => import('@/views/interview-history/index.vue'),
}, },
{ {
path: 'report/:sessionId', path: 'report/:sessionId',
name: 'InterviewReport', name: 'InterviewReport',
component: () => import('../views/InterviewReport.vue'), component: () => import('@/views/interview-report/index.vue'),
props: true, // 将路由参数作为props传递给组件 props: true, // 将路由参数作为props传递给组件
}, },
{ {
path: 'answer-record', path: 'answer-record',
name: 'AnswerRecord', name: 'AnswerRecord',
component: () => import('../views/AnswerRecord.vue'), component: () => import('@/views/answer-record/index.vue'),
}, },
{ {
path: 'question-category', path: 'question-category',

View File

@@ -1,408 +0,0 @@
<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>

View File

@@ -1,280 +0,0 @@
<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

@@ -1,21 +1,20 @@
<script setup lang="ts"> <script setup>
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import type { ComponentSize } from 'element-plus' import { pageList } from '@/api/question-progress'
import { pageList } from '../api/question-progress'
const tableData = ref([]) const tableData = ref([])
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const size = ref<ComponentSize>('default') const size = ref('default')
const background = ref(false) const background = ref(false)
const disabled = ref(false) const disabled = ref(false)
const handleSizeChange = (val: number) => { const handleSizeChange = (val) => {
console.log(`${val} items per page`) console.log(`${val} items per page`)
pageSize.value = val pageSize.value = val
fetchData() fetchData()
} }
const total = ref(0) const total = ref(0)
const handleCurrentChange = (val: number) => { const handleCurrentChange = (val) => {
console.log(`current page: ${val}`) console.log(`current page: ${val}`)
currentPage.value = val currentPage.value = val
fetchData() fetchData()

83
src/views/chat/index.vue Normal file
View File

@@ -0,0 +1,83 @@
<template>
<div class="chat-view-container">
<div class="chat-view-header">
<h1>AI对话助手</h1>
<p>与AI进行自由对话获取帮助和建议</p>
<el-button @click="$router.push('/')" class="back-button">返回首页</el-button>
</div>
<ChatWindow
mode="chat"
:candidate-name="userName"
class="standalone-chat"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ChatWindow from '@/components/ChatWindow.vue'
const userName = ref('用户') // 可以从用户信息获取
</script>
<style scoped>
.chat-view-container {
padding: 0;
height: 100vh;
display: flex;
flex-direction: column;
}
.chat-view-header {
text-align: center;
padding: 24px;
background: linear-gradient(135deg, #409EFF 0%, #64b5ff 100%);
color: white;
position: relative;
}
.chat-view-header h1 {
font-size: 2.2rem;
margin-bottom: 8px;
}
.chat-view-header p {
font-size: 1.1rem;
opacity: 0.9;
}
.back-button {
position: absolute;
top: 20px;
left: 20px;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
}
.standalone-chat {
flex: 1;
border-radius: 0;
}
@media (max-width: 768px) {
.chat-view-header h1 {
font-size: 1.8rem;
}
.chat-view-header {
padding: 16px;
}
.back-button {
top: 10px;
left: 10px;
padding: 8px 12px;
}
}
</style>

View File

@@ -53,7 +53,7 @@
// VueEChartsAPI // VueEChartsAPI
import { ref, onMounted, nextTick } from 'vue'; import { ref, onMounted, nextTick } from 'vue';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import { getDashboardStats } from '../api/dashboard'; import { getDashboardStats } from '@/api/dashboard.js';
import { DataAnalysis, MessageBox } from '@element-plus/icons-vue'; import { DataAnalysis, MessageBox } from '@element-plus/icons-vue';
// --- --- // --- ---

View File

@@ -60,7 +60,7 @@
// VueAPI // VueAPI
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { getInterviewHistoryList } from '../api/interview'; import { getInterviewHistoryList } from '@/api/interview.js';
import { Calendar, MessageBox, Refresh } from '@element-plus/icons-vue'; import { Calendar, MessageBox, Refresh } from '@element-plus/icons-vue';
// --- --- // --- ---

View File

@@ -91,7 +91,7 @@
// VueAPI // VueAPI
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { getInterviewReportDetail } from '../api/interview'; import { getInterviewReportDetail } from '@/api/interview.js';
import { DataAnalysis, ChatDotRound } from '@element-plus/icons-vue'; import { DataAnalysis, ChatDotRound } from '@element-plus/icons-vue';
// --- Props & Router --- // --- Props & Router ---

View File

@@ -0,0 +1,53 @@
<template>
<div class="interview-chat-container">
<ChatWindow
:mode="interviewMode"
:categories="selectedCategories"
:candidate-name="candidateName"
:session-id="sessionId"
@end-interview="handleInterviewEnd"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ChatWindow from '@/components/ChatWindow.vue'
const route = useRoute()
const router = useRouter()
const interviewMode = ref('ai')
const selectedCategories = ref([])
const candidateName = ref('')
const sessionId = ref('')
onMounted(() => {
// 从路由参数获取数据
interviewMode.value = route.query.mode || 'ai'
sessionId.value = route.query.sessionId
if (route.query.categories) {
selectedCategories.value = route.query.categories.split(',').map(id => parseInt(id))
}
// 从本地存储获取候选人姓名
const sessionData = JSON.parse(localStorage.getItem('currentSession') || '{}')
candidateName.value = sessionData.candidateName || '候选人'
sessionId.value = Date.now().toString()
})
const handleInterviewEnd = () => {
// 清除会话数据
localStorage.removeItem('currentSession')
// 返回面试选择页面
router.push('/interview')
}
</script>
<style scoped>
.interview-chat-container {
height: 100vh;
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div class="home-container">
<div class="hero-section">
<h1>AI面试与对话系统</h1>
<p>提升您的面试技能与AI进行智能对话</p>
</div>
<div class="options-container">
<el-card class="option-card" shadow="hover" @click="navigateTo('chat')">
<div class="card-content">
<el-icon size="48" color="#409EFF">
<ChatLineRound/>
</el-icon>
<h3>AI对话</h3>
<p>与AI进行自由对话获取帮助和建议</p>
<el-button type="primary" class="action-btn">开始对话</el-button>
</div>
</el-card>
<el-card class="option-card" shadow="hover" @click="navigateTo('interview')">
<div class="card-content">
<el-icon size="48" color="#67C23A">
<User/>
</el-icon>
<h3>AI面试</h3>
<p>进行模拟面试提升面试技巧</p>
<el-button type="success" class="action-btn">开始面试</el-button>
</div>
</el-card>
</div>
</div>
</template>
<script setup>
import {useRouter} from 'vue-router'
import {ChatLineRound, User} from '@element-plus/icons-vue'
const router = useRouter()
const navigateTo = (type) => {
if (type === 'chat') {
router.push('/chat')
} else if (type === 'interview') {
router.push('/interview')
}
}
</script>
<style scoped>
.home-container {
padding: 40px 20px;
max-width: 1000px;
margin: 0 auto;
text-align: center;
}
.hero-section {
margin-bottom: 60px;
}
.hero-section h1 {
font-size: 2.5rem;
color: #303133;
margin-bottom: 16px;
}
.hero-section p {
font-size: 1.2rem;
color: #606266;
}
.options-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 30px;
margin-top: 40px;
}
.option-card {
cursor: pointer;
transition: all 0.3s ease;
height: 250px;
display: flex;
align-items: center;
justify-content: center;
}
.option-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15) !important;
}
.card-content {
text-align: center;
padding: 20px;
}
.card-content h3 {
font-size: 1.5rem;
margin: 16px 0;
color: #303133;
}
.card-content p {
color: #606266;
margin-bottom: 24px;
}
.action-btn {
margin-top: 10px;
}
@media (max-width: 768px) {
.options-container {
grid-template-columns: 1fr;
}
.hero-section h1 {
font-size: 2rem;
}
}
</style>

View File

@@ -0,0 +1,318 @@
<template>
<div class="interview-view-container">
<div class="header-section">
<h1>选择面试模式</h1>
<p>根据您的需求选择合适的面试方式</p>
</div>
<div class="mode-cards-container">
<!-- AI智能面试卡片 -->
<el-card
:class="['mode-card', { 'active': selectedMode === 'ai' }]"
shadow="hover"
@click="selectedMode = 'ai'"
>
<div class="card-content">
<div class="card-icon">
<el-icon size="48" color="#409EFF">
<Cpu/>
</el-icon>
</div>
<h3>AI智能面试</h3>
<p class="description">由AI根据您的简历智能生成个性化面试题目</p>
<ul class="features-list">
<li>
<el-icon>
<Check/>
</el-icon>
个性化题目定制
</li>
<li>
<el-icon>
<Check/>
</el-icon>
自适应难度调整
</li>
<li>
<el-icon>
<Check/>
</el-icon>
实时反馈与评分
</li>
</ul>
</div>
</el-card>
<!-- 本地题库面试卡片 -->
<el-card
:class="['mode-card', { 'active': selectedMode === 'local' }]"
shadow="hover"
@click="selectedMode = 'local'"
>
<div class="card-content">
<div class="card-icon">
<el-icon size="48" color="#67C23A">
<Collection/>
</el-icon>
</div>
<h3>本地题库面试</h3>
<p class="description">从预设题库中选择题目进行系统化面试</p>
<ul class="features-list">
<li>
<el-icon>
<Check/>
</el-icon>
丰富的题目类型
</li>
<li>
<el-icon>
<Check/>
</el-icon>
可自定义选择范围
</li>
<li>
<el-icon>
<Check/>
</el-icon>
系统化评估体系
</li>
</ul>
</div>
</el-card>
</div>
<!-- 题库选择区域仅本地模式显示 -->
<QuestionBankSection ref="questionBankSectionRef" v-if="selectedMode === 'local'"/>
<!-- 开始面试表单 -->
<div class="start-form-section">
<el-card shadow="never">
<el-form :model="formData" label-width="120px" size="large">
<el-form-item label="您的姓名" required>
<el-input v-model="formData.candidateName" placeholder="请输入您的姓名"/>
</el-form-item>
<el-form-item label="上传简历" required>
<el-upload
vref="uploadRef"
action="#"
:limit="1"
:auto-upload="false"
:on-change="handleFileChange"
:on-exceed="handleFileExceed"
>
<template #trigger>
<el-button type="primary">选择文件</el-button>
</template>
<template #tip>
<div class="upload-tip">
支持 PDFMarkdown 或文本格式大小不超过10MB
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item>
<el-button
type="success"
:loading="isLoading"
@click="startInterviewAction"
class="start-button"
>
{{ selectedMode === 'ai' ? '开始AI面试' : '开始题库面试' }}
</el-button>
<el-button @click="$router.push('/')">返回首页</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</div>
</template>
<script setup>
import {ref, reactive} from 'vue'
import {useRouter} from 'vue-router'
import {ElMessage} from 'element-plus'
import {Cpu, Collection, Check} from '@element-plus/icons-vue'
import QuestionBankSection from '@/components/QuestionBankSection.vue'
import {startInterview, continueInterview, getInterviewReportDetail} from '@/api/interview.js';
const router = useRouter()
const questionBankSectionRef = ref(null)
// 响应式状态
const selectedMode = ref('ai') // 'ai' 或 'local'
const isLoading = ref(false)
// 表单数据
const formData = ref({
candidateName: '',
resumeFiles: []
})
// --- UI交互方法 ---
const handleFileChange = (file) => {
formData.value.resumeFiles = file.raw;
};
// 文件上传处理
const handleFileExceed = () => {
ElMessage.warning('只能上传一个简历文件')
}
const sessionId = ref('')
// 开始面试
const startInterviewAction = async () => {
console.log(formData.value)
if (!formData.value.candidateName || !formData.value.resumeFiles) {
ElMessage.error('请输入您的姓名并上传简历。');
return;
}
isLoading.value = true;
const sendFormData = new FormData();
const selectionResult = questionBankSectionRef.value.getSelectionResult()
if (!selectionResult.selectedNodeList) {
selectionResult.selectedNodeList = []
}
console.log(selectionResult)
sendFormData.append('candidateName', formData.value.candidateName);
sendFormData.append('model', selectedMode.value);
if (selectionResult.selectedNodeList && selectionResult.selectedNodeList.length > 0) {
sendFormData.append('selectedNodes', selectionResult.selectedNodeList);
}
sendFormData.append('resume', formData.value.resumeFiles);
try {
console.log(sendFormData.values())
const responseData = await startInterview(sendFormData);
const data = responseData.data;
sessionId.value = data.sessionId;
// 跳转到聊天界面
router.push({
path: '/interview-chat',
query: {
mode: selectedMode.value,
sessionId: sessionId.value
}
})
} catch (error) {
console.error('开始面试失败:', error);
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
.interview-view-container {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.header-section {
text-align: center;
margin-bottom: 32px;
}
.header-section h1 {
font-size: 2.5rem;
color: #303133;
margin-bottom: 8px;
}
.header-section p {
font-size: 1.1rem;
color: #606266;
}
.mode-cards-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 24px;
}
.mode-card {
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.mode-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15) !important;
}
.mode-card.active {
border-color: #409EFF;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
}
.card-content {
text-align: center;
padding: 20px;
}
.card-icon {
margin-bottom: 16px;
}
.card-content h3 {
font-size: 1.5rem;
margin-bottom: 12px;
color: #303133;
}
.description {
color: #606266;
margin-bottom: 16px;
line-height: 1.6;
}
.features-list {
list-style: none;
padding: 0;
margin: 0;
text-align: left;
}
.features-list li {
padding: 8px 0;
color: #606266;
display: flex;
align-items: center;
}
.features-list .el-icon {
color: #67C23A;
margin-right: 8px;
}
.start-form-section {
margin-top: 24px;
}
.upload-tip {
font-size: 0.9rem;
color: #909399;
margin-top: 8px;
}
.start-button {
min-width: 140px;
}
@media (max-width: 768px) {
.mode-cards-container {
grid-template-columns: 1fr;
}
.header-section h1 {
font-size: 2rem;
}
}
</style>

View File

@@ -1,121 +0,0 @@
<template>
<div class="category-form">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
label-position="right"
>
<el-form-item label="分类名称" prop="name">
<el-input
v-model="formData.name"
placeholder="请输入分类名称"
maxlength="32"
show-word-limit
/>
</el-form-item>
<el-form-item label="上级分类" prop="parent_id">
<el-tree-select
v-model="formData.parent_id"
:data="categoryOptions"
:props="treeProps"
check-strictly
:render-after-expand="false"
placeholder="请选择上级分类"
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" />
</el-form-item>
<el-form-item label="状态" prop="state">
<el-radio-group v-model="formData.state">
<el-radio
v-for="item in statusOptions"
:key="item.value"
:label="item.value"
>
{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit">提交</el-button>
<el-button @click="handleCancel">取消</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { FORM_RULES, CATEGORY_STATUS_OPTIONS } from '../constants/constant.js'
export default {
name: 'CategoryForm',
props: {
initialData: {
type: Object,
default: () => ({})
},
categoryOptions: {
type: Array,
default: () => []
}
},
emits: ['submit', 'cancel'],
setup(props, { emit }) {
const formRef = ref()
const formData = reactive({
name: '',
parent_id: 0,
sort: 0,
state: 1,
...props.initialData
})
const treeProps = {
value: 'id',
label: 'name',
children: 'children'
}
const handleSubmit = async () => {
try {
await formRef.value.validate()
emit('submit', { ...formData })
} catch (error) {
ElMessage.error('请完善表单信息')
}
}
const handleCancel = () => {
emit('cancel')
}
return {
formRef,
formData,
formRules: FORM_RULES,
statusOptions: CATEGORY_STATUS_OPTIONS,
treeProps,
handleSubmit,
handleCancel
}
}
}
</script>
<style scoped>
.category-form {
padding: 20px;
background: #fff;
border-radius: 4px;
}
</style>

View File

@@ -1,115 +0,0 @@
<template>
<div class="category-table">
<el-table
:data="tableData"
row-key="id"
border
stripe
v-loading="loading"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
<el-table-column prop="name" label="分类名称" min-width="200">
<template #default="{ row }">
<span class="level-indent" :style="{ marginLeft: (row.level - 1) * 16 + 'px' }"></span>
<i v-if="row.children && row.children.length > 0" class="el-icon-folder"></i>
<i v-else class="el-icon-document"></i>
<span class="name-text">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="parent_id" label="上级ID" width="90" />
<el-table-column prop="level" label="层级" width="70">
<template #default="{ row }">
<el-tag size="small">L{{ row.level }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="70" />
<el-table-column prop="state" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.state === 1 ? 'success' : 'danger'" size="small">
{{ row.state === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_time" label="创建时间" width="150">
<template #default="{ row }">
{{ formatDate(row.created_time) }}
</template>
</el-table-column>
<el-table-column prop="updated_time" label="更新时间" width="150">
<template #default="{ row }">
{{ formatDate(row.updated_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleAddChild(row)">添加子类</el-button>
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import { formatDate } from '@/utils/date'
export default {
name: 'CategoryTable',
props: {
tableData: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
}
},
emits: ['add-child', 'edit', 'delete'],
setup(props, { emit }) {
const handleAddChild = (row) => {
emit('add-child', row)
}
const handleEdit = (row) => {
emit('edit', row)
}
const handleDelete = (row) => {
emit('delete', row)
}
return {
formatDate,
handleAddChild,
handleEdit,
handleDelete
}
}
}
</script>
<style scoped>
.category-table {
background: #fff;
border-radius: 4px;
padding: 16px;
}
.level-indent {
display: inline-block;
}
.name-text {
margin-left: 6px;
}
</style>

View File

@@ -1,99 +0,0 @@
<template>
<div class="category-create">
<div class="page-header">
<h2>新增题型分类</h2>
<el-button @click="goBack">返回列表</el-button>
</div>
<div class="page-content">
<CategoryForm
ref="formRef"
:category-options="categoryOptions"
@submit="handleSubmit"
@cancel="goBack"
/>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import CategoryForm from './CategoryForm.vue'
import { questionCategoryApi } from '@/api/question-category.js'
export default {
name: 'QuestionCategoryCreate',
components: {
CategoryForm
},
setup() {
const router = useRouter()
const route = useRoute()
const formRef = ref()
const categoryOptions = ref([])
const loadCategoryOptions = async () => {
try {
const data = await questionCategoryApi.getOptions()
categoryOptions.value = data
} catch (error) {
console.error('加载分类选项失败:', error)
}
}
const handleSubmit = async (formData) => {
try {
// 如果有parentId参数优先使用
if (route.query.parentId) {
formData.parent_id = parseInt(route.query.parentId)
}
await questionCategoryApi.create(formData)
ElMessage.success('创建成功')
goBack()
} catch (error) {
console.error('创建分类失败:', error)
}
}
const goBack = () => {
router.push('/question-category')
}
onMounted(() => {
loadCategoryOptions()
})
return {
formRef,
categoryOptions,
handleSubmit,
goBack
}
}
}
</script>
<style scoped>
.category-create {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
color: #303133;
}
.page-content {
max-width: 600px;
}
</style>

View File

@@ -1,110 +0,0 @@
<template>
<div class="category-edit">
<div class="page-header">
<h2>编辑题型分类</h2>
<el-button @click="goBack">返回列表</el-button>
</div>
<div class="page-content">
<CategoryForm
ref="formRef"
:initial-data="formData"
:category-options="categoryOptions"
@submit="handleSubmit"
@cancel="goBack"
/>
</div>
</div>
</template>
<script>
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import CategoryForm from './CategoryForm.vue'
import { questionCategoryApi } from '@/api/question-category.js'
export default {
name: 'QuestionCategoryEdit',
components: {
CategoryForm
},
setup() {
const router = useRouter()
const route = useRoute()
const formRef = ref()
const formData = reactive({
name: '',
parent_id: 0,
sort: 0,
state: 1
})
const categoryOptions = ref([])
const categoryId = ref(null)
const loadData = async () => {
try {
categoryId.value = route.params.id
const [detail, options] = await Promise.all([
questionCategoryApi.getDetail(categoryId.value),
questionCategoryApi.getOptions()
])
Object.assign(formData, detail)
categoryOptions.value = options
} catch (error) {
console.error('加载数据失败:', error)
ElMessage.error('加载分类信息失败')
}
}
const handleSubmit = async (submitData) => {
try {
await questionCategoryApi.update(categoryId.value, submitData)
ElMessage.success('更新成功')
goBack()
} catch (error) {
console.error('更新分类失败:', error)
}
}
const goBack = () => {
router.push('/question-category')
}
onMounted(() => {
loadData()
})
return {
formRef,
formData,
categoryOptions,
handleSubmit,
goBack
}
}
}
</script>
<style scoped>
.category-edit {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
color: #303133;
}
.page-content {
max-width: 600px;
}
</style>

View File

@@ -1,80 +0,0 @@
<template>
<div class="search-bar">
<el-form :model="formData" inline>
<el-form-item label="分类名称">
<el-input
v-model="formData.name"
placeholder="请输入分类名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formData.state" placeholder="请选择状态" clearable>
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" icon="Search">搜索</el-button>
<el-button @click="handleReset" icon="Refresh">重置</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
import { reactive } from 'vue'
import { CATEGORY_STATUS_OPTIONS } from '../constants/constant.js'
export default {
name: 'SearchBar',
emits: ['search', 'reset'],
setup(props, { emit }) {
const formData = reactive({
name: '',
state: ''
})
const statusOptions = CATEGORY_STATUS_OPTIONS
const handleSearch = () => {
emit('search', { ...formData })
}
const handleReset = () => {
Object.assign(formData, {
name: '',
state: ''
})
emit('reset')
}
return {
formData,
statusOptions,
handleSearch,
handleReset
}
}
}
</script>
<style scoped>
.search-bar {
padding: 20px;
background: #fff;
border-radius: 4px;
margin-bottom: 16px;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<el-dialog
:title="title"
:model-value="open"
width="600px"
append-to-body
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="分类名称" prop="name">
<el-input v-model="form.name" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="父级分类" prop="parentId">
<el-cascader
v-model="selectedParentPath"
:options="parentOptions"
:props="cascaderProps"
clearable
placeholder="请选择父级分类"
style="width: 100%"
@change="handleParentChange"
/>
<div class="el-form-item__tip" v-if="form.parentId === 0">
选择空值表示顶级分类
</div>
</el-form-item>
<el-form-item label="层级路径" prop="ancestor">
<el-input v-model="form.ancestor" placeholder="自动生成层级路径" readonly />
<div class="el-form-item__tip">
根据父级分类自动生成的层级路径
</div>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" controls-position="right" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="状态" prop="state">
<el-radio-group v-model="form.state">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="handleClose"> </el-Button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { addCategory, getCategoryTree } from '@/api/question-category.js'
const props = defineProps({
open: {
type: Boolean,
required: true
},
parentCategory: {
type: Object,
default: null
}
})
const emit = defineEmits(['close', 'success'])
// 表单实例
const formRef = ref()
// 父级分类选项
const parentOptions = ref([])
// 选中的父级路径
const selectedParentPath = ref([])
// 级联选择器配置
const cascaderProps = {
value: 'id',
label: 'name',
children: 'children',
checkStrictly: true,
emitPath: false
}
// 表单数据
const form = reactive({
name: '',
parentId: 0,
ancestor: '0',
level: 1,
sort: 0,
state: 1
})
// 表单验证规则
const rules = {
name: [
{ required: true, message: '分类名称不能为空', trigger: 'blur' },
{ min: 1, max: 32, message: '分类名称长度在1到32个字符', trigger: 'blur' }
],
// parentId: [
// { required: true, message: '请选择父级分类', trigger: 'change' }
// ]
}
// 对话框标题
const title = computed(() => {
return props.parentCategory ? `添加${props.parentCategory.name}的子分类` : '添加分类'
})
// 获取父级分类选项
const getParentOptionsList = async () => {
try {
const response = await getCategoryTree()
parentOptions.value = response.data
// 添加顶级分类选项
parentOptions.value.unshift({
id: 0,
name: '顶级分类',
children: []
})
} catch (error) {
console.error('获取父级分类选项失败:', error)
ElMessage.error('获取父级分类选项失败')
}
}
// 处理父级分类变化
const handleParentChange = (value) => {
if (value && value.length > 0) {
form.parentId = value[value.length - 1]
// 根据选中的父级ID计算ancestor和level
const selectedParent = findParentById(parentOptions.value, form.parentId)
if (selectedParent) {
form.ancestor = selectedParent.ancestor ? `${selectedParent.ancestor},${selectedParent.id}` : `${selectedParent.id}`
form.level = selectedParent.level + 1
}
} else {
form.parentId = 0
form.ancestor = '0'
form.level = 1
}
}
// 递归查找父级分类
const findParentById = (categories, id) => {
for (const category of categories) {
if (category.id === id) {
return category
}
if (category.children && category.children.length > 0) {
const found = findParentById(category.children, id)
if (found) return found
}
}
return null
}
// 提交表单
const submitForm = async () => {
try {
const valid = await formRef.value.validate()
if (valid) {
await addCategory(form)
ElMessage.success('添加成功')
handleClose()
emit('success')
}
} catch (error) {
console.error('添加分类失败:', error)
ElMessage.error('添加分类失败')
}
}
// 关闭对话框
const handleClose = () => {
// 重置表单
formRef.value?.resetFields()
form.name = ''
form.parentId = 0
form.ancestor = '0'
form.level = 1
form.sort = 0
form.state = 1
selectedParentPath.value = []
emit('close')
}
// 监听父组件传递的 open 变化
watch(() => props.open, (newVal) => {
if (newVal) {
getParentOptionsList()
// 如果有父级分类设置父级ID
if (props.parentCategory) {
form.parentId = props.parentCategory.id
form.ancestor = props.parentCategory.ancestor ? `${props.parentCategory.ancestor},${props.parentCategory.id}` : `${props.parentCategory.id}`
form.level = props.parentCategory.level + 1
selectedParentPath.value = [props.parentCategory.id]
} else {
form.parentId = 0
form.ancestor = '0'
form.level = 1
selectedParentPath.value = []
}
}
})
// 组件挂载时获取父级选项
onMounted(() => {
getParentOptionsList()
})
</script>
<style scoped>
.el-form-item__tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,336 @@
<template>
<el-dialog
title="修改分类"
:model-value="open"
width="600px"
append-to-body
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" v-loading="loading">
<el-form-item label="分类名称" prop="name">
<el-input v-model="form.name" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="父级分类" prop="parentId">
<el-cascader
v-model="selectedParentPath"
:options="parentOptions"
:props="cascaderProps"
clearable
placeholder="请选择父级分类"
style="width: 100%"
@change="handleParentChange"
/>
<div class="el-form-item__tip" v-if="form.parentId === 0">
选择空值表示顶级分类
</div>
</el-form-item>
<el-form-item label="层级路径" prop="ancestor">
<el-input v-model="form.ancestor" placeholder="自动生成层级路径" readonly />
<div class="el-form-item__tip">
根据父级分类自动生成的层级路径
</div>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" controls-position="right" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="状态" prop="state">
<el-radio-group v-model="form.state">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="handleClose"> </el-Button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import {ref, reactive, watch, nextTick, onMounted, watchEffect} from 'vue'
import { ElMessage } from 'element-plus'
import { getCategoryDetail, updateCategory, getCategoryTree } from '@/api/question-category.js'
const props = defineProps({
open: {
type: Boolean,
required: true
},
categoryId: {
type: Number,
required: true
},
vis: Boolean
})
const emit = defineEmits(['close', 'success'])
// 表单实例
const formRef = ref()
// 加载状态
const loading = ref(false)
// 父级分类选项
const parentOptions = ref([])
// 选中的父级路径
const selectedParentPath = ref([])
// 级联选择器配置
const cascaderProps = {
value: 'id',
label: 'name',
children: 'children',
checkStrictly: true,
emitPath: true
}
// 表单数据
const form = reactive({
id: null,
name: '',
parentId: 0,
ancestor: '0',
level: 1,
sort: 0,
state: 1
})
// 表单验证规则
const rules = {
name: [
{ required: true, message: '分类名称不能为空', trigger: 'blur' },
{ min: 1, max: 32, message: '分类名称长度在1到32个字符', trigger: 'blur' }
]
}
// 对话框打开时的处理
const handleOpen = async () => {
console.log('对话框打开categoryId:', props.categoryId)
if (props.categoryId) {
await loadData()
}
}
// 加载数据
const loadData = async () => {
try {
loading.value = true
console.log('开始加载数据...')
// 1. 先加载父级选项
await getParentOptionsList()
console.log('父级选项加载完成')
// 2. 再加载详情数据
await getDetail()
console.log('详情数据加载完成')
} catch (error) {
console.error('加载数据失败:', error)
ElMessage.error('加载数据失败')
} finally {
loading.value = false
}
}
// 获取分类详情
const getDetail = async () => {
try {
const response = await getCategoryDetail(props.categoryId)
console.log('获取到的详情:', response.data)
// 更新表单数据
Object.assign(form, {
id: response.data.id,
name: response.data.name,
parentId: response.data.parentId || 0,
ancestor: response.data.ancestor || '0',
level: response.data.level || 1,
sort: response.data.sort || 0,
state: response.data.state || 1
})
// 设置级联选择器的选中路径
await setParentPath()
} catch (error) {
console.error('获取分类详情失败:', error)
throw error
}
}
// 设置级联选择器的父级路径
const setParentPath = async () => {
if (form.parentId === 0) {
selectedParentPath.value = []
console.log('设置为顶级分类')
return
}
await nextTick()
const path = findNodePath(parentOptions.value, form.parentId)
if (path.length > 0) {
selectedParentPath.value = path
console.log('设置级联选择器路径:', selectedParentPath.value)
} else {
selectedParentPath.value = [form.parentId]
console.log('设置父级ID:', selectedParentPath.value)
}
}
// 递归查找节点的完整路径
const findNodePath = (nodes, targetId, currentPath = []) => {
for (const node of nodes) {
const path = [...currentPath, node.id]
if (node.id === targetId) {
return path
}
if (node.children && node.children.length > 0) {
const foundPath = findNodePath(node.children, targetId, path)
if (foundPath.length > 0) {
return foundPath
}
}
}
return []
}
// 获取父级分类选项
const getParentOptionsList = async () => {
try {
const response = await getCategoryTree()
console.log('获取到的树形数据:', response.data)
// 处理分类数据
const processCategories = (categories) => {
return categories.map(category => ({
id: category.id,
name: category.name,
level: category.level || 1,
ancestor: category.ancestor || '0',
children: category.children && category.children.length > 0
? processCategories(category.children)
: []
}))
}
const processedData = processCategories(response.data)
// 添加顶级分类选项
parentOptions.value = [
{
id: 0,
name: '顶级分类',
level: 0,
ancestor: '0',
children: []
},
...processedData
]
} catch (error) {
console.error('获取父级分类选项失败:', error)
throw error
}
}
// 处理父级分类变化
const handleParentChange = (value) => {
console.log('父级选择变化:', value)
if (value && value.length > 0) {
const parentId = value[value.length - 1]
form.parentId = parentId
const parentNode = findNodeById(parentOptions.value, parentId)
if (parentNode) {
if (parentNode.id === 0) {
form.ancestor = '0'
form.level = 1
} else {
form.ancestor = parentNode.ancestor === '0'
? parentNode.id.toString()
: `${parentNode.ancestor},${parentNode.id}`
form.level = parentNode.level + 1
}
}
} else {
form.parentId = 0
form.ancestor = '0'
form.level = 1
}
}
// 根据ID查找节点
const findNodeById = (nodes, targetId) => {
for (const node of nodes) {
if (node.id === targetId) {
return node
}
if (node.children && node.children.length > 0) {
const found = findNodeById(node.children, targetId)
if (found) return found
}
}
return null
}
// 提交表单
const submitForm = async () => {
try {
const valid = await formRef.value.validate()
if (valid) {
await updateCategory(form)
ElMessage.success('修改成功')
handleClose()
emit('success')
}
} catch (error) {
console.error('修改分类失败:', error)
ElMessage.error(error.response?.data?.message || '修改分类失败')
}
}
// 关闭对话框
const handleClose = () => {
formRef.value?.resetFields()
Object.assign(form, {
id: null,
name: '',
parentId: 0,
ancestor: '0',
level: 1,
sort: 0,
state: 1
})
selectedParentPath.value = []
parentOptions.value = []
emit('close')
}
// 监听 props 变化(备用方案)
watchEffect(() => {
if (props.open && props.categoryId) {
loadData()
}
})
</script>
<style scoped>
.el-form-item__tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>

View File

@@ -1,159 +1,203 @@
<template> <template>
<div class="category-index"> <div class="question-category-list">
<div class="page-header"> <!-- 查询条件 -->
<h2>题型分类管理</h2> <el-card shadow="never" class="search-card">
<p>管理试题的分类体系支持多级分类</p> <el-form :model="queryParams" ref="queryForm" :inline="true" label-width="80px">
</div> <el-form-item label="分类名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入分类名称"
clearable
style="width: 200px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="状态" prop="state">
<el-select v-model="queryParams.state" placeholder="请选择状态" clearable style="width: 200px">
<el-option
v-for="dict in statusOptions"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleQuery">搜索</el-button>
<el-button :icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="page-content"> <!-- 操作按钮 -->
<SearchBar <el-card shadow="never" class="table-card">
@search="handleSearch" <el-row :gutter="10" class="mb8">
@reset="handleResetSearch" <el-col :span="1.5">
/> <el-button type="primary" plain :icon="Plus" @click="handleAdd">新增</el-button>
</el-col>
</el-row>
<div class="action-bar"> <!-- 表格数据 -->
<el-button type="primary" @click="handleCreate" icon="Plus">新增分类</el-button> <el-table
<el-button @click="handleRefresh" icon="Refresh">刷新</el-button> v-loading="loading"
</div> :data="categoryList"
row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
<el-table-column prop="name" label="分类名称" width="200"></el-table-column>
<el-table-column prop="level" label="层级" width="80">
<template #default="scope">
<span>{{ scope.row.level }}</span>
</template>
</el-table-column>
<el-table-column prop="parentName" label="父级分类" width="120"></el-table-column>
<el-table-column prop="sort" label="排序" width="80"></el-table-column>
<el-table-column prop="state" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.state === 1 ? 'success' : 'danger'">
{{ scope.row.state === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdTime" label="创建时间" width="160"></el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template #default="scope">
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
<el-button link type="primary" icon="Plus" @click="handleAddChild(scope.row)">添加子类</el-button>
<el-button link type="danger" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<CategoryTable <!-- 添加或修改对话框 -->
:table-data="tableData" <QuestionCategoryAdd
:loading="loading" v-if="openAdd"
@add-child="handleAddChild" :open="openAdd"
@edit="handleEdit" :parentCategory="parentCategory"
@delete="handleDelete" @close="closeDialog"
/> @success="getList"
</div> />
<QuestionCategoryEdit
v-if="openEdit"
:open="openEdit"
:categoryId="categoryId"
@close="closeDialog"
@success="getList"
/>
</div> </div>
</template> </template>
<script> <script setup>
import { ref, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import SearchBar from './components/SearchBar.vue' import QuestionCategoryAdd from './components/add.vue'
import CategoryTable from './components/CategoryTable.vue' import QuestionCategoryEdit from './components/edit.vue'
import { questionCategoryApi } from '@/api/question-category.js' import { getCategoryTree, deleteCategory } from '@/api/question-category.js'
import {Plus, Refresh, Search} from "@element-plus/icons-vue";
export default { // 状态选项
name: 'QuestionCategoryIndex', const statusOptions = ref([
components: { { value: 1, label: '启用' },
SearchBar, { value: 0, label: '禁用' }
CategoryTable ])
},
setup() {
const router = useRouter()
const tableData = ref([])
const loading = ref(false)
const searchParams = ref({})
const loadTableData = async () => { // 查询参数
loading.value = true const queryParams = reactive({
try { name: undefined,
const data = await questionCategoryApi.getTreeList() state: undefined
tableData.value = data })
} catch (error) {
console.error('加载分类数据失败:', error)
} finally {
loading.value = false
}
}
const handleSearch = (params) => { // 加载状态
searchParams.value = params const loading = ref(true)
// 这里可以根据params进行筛选实际项目中需要后端支持搜索 // 分类列表
loadTableData() const categoryList = ref([])
} // 是否显示添加对话框
const openAdd = ref(false)
// 是否显示修改对话框
const openEdit = ref(false)
// 当前分类ID
const categoryId = ref(null)
// 父级分类信息
const parentCategory = ref(null)
const handleResetSearch = () => { // 获取分类列表
searchParams.value = {} const getList = () => {
loadTableData() loading.value = true
} getCategoryTree(queryParams).then(response => {
categoryList.value = response.data
const handleCreate = () => { loading.value = false
router.push('/question-category/create') }).catch(() => {
} loading.value = false
})
const handleAddChild = (row) => {
router.push({
path: '/question-category/create',
query: { parentId: row.id }
})
}
const handleEdit = (row) => {
router.push(`/question-category/edit/${row.id}`)
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(
`确定要删除分类 "${row.name}" 吗?此操作不可恢复。`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await questionCategoryApi.delete(row.id)
ElMessage.success('删除成功')
loadTableData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
const handleRefresh = () => {
loadTableData()
}
onMounted(() => {
loadTableData()
})
return {
tableData,
loading,
handleSearch,
handleResetSearch,
handleCreate,
handleAddChild,
handleEdit,
handleDelete,
handleRefresh
}
}
} }
// 查询处理
const handleQuery = () => {
getList()
}
// 重置查询
const resetQuery = () => {
queryParams.name = undefined
queryParams.state = undefined
getList()
}
// 添加处理
const handleAdd = () => {
parentCategory.value = null
openAdd.value = true
}
// 添加子分类处理
const handleAddChild = (row) => {
parentCategory.value = row
openAdd.value = true
}
// 修改处理
const handleUpdate = (row) => {
categoryId.value = row.id
console.log('打开修改对话框', row)
openEdit.value = true
}
// 删除处理
const handleDelete = (row) => {
ElMessageBox.confirm(`确认删除"${row.name}"分类?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
return deleteCategory(row.id)
}).then(() => {
getList()
ElMessage.success('删除成功')
}).catch(() => {})
}
// 关闭对话框
// 修改对话框关闭方法
const closeDialog = () => {
openAdd.value = false
openEdit.value = false
parentCategory.value = null
categoryId.value = null
}
onMounted(() => {
getList()
})
</script> </script>
<style scoped> <style scoped>
.category-index { .search-card {
padding: 20px; margin-bottom: 10px;
} }
.table-card {
.page-header { margin-top: 10px;
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
color: #303133;
}
.page-header p {
margin: 8px 0 0;
color: #909399;
font-size: 14px;
}
.action-bar {
margin-bottom: 16px;
padding: 16px;
background: #fff;
border-radius: 4px;
} }
</style> </style>

View File

@@ -0,0 +1,784 @@
<template>
<div class="question-bank-container">
<el-container class="main-container">
<!-- 左侧分类树 -->
<el-aside width="280px" class="category-aside">
<el-card shadow="never" class="category-card">
<template #header>
<div class="category-header">
<span>题库分类</span>
<el-button
type="primary"
link
:icon="Refresh"
@click="fetchCategoryTree"
size="small"
>刷新</el-button>
</div>
</template>
<div class="category-tree-container">
<el-input
v-model="categoryFilterText"
placeholder="搜索分类..."
:prefix-icon="Search"
clearable
size="small"
class="category-filter"
/>
<el-tree
ref="categoryTreeRef"
:data="categoryTree"
:props="categoryProps"
:filter-node-method="filterCategoryNode"
node-key="id"
highlight-current
:expand-on-click-node="false"
:default-expand-all="false"
@node-click="handleCategoryNodeClick"
class="category-tree"
v-loading="categoryLoading"
>
<template #default="{ node, data }">
<span class="custom-tree-node">
<span class="node-label">{{ node.label }}</span>
<span class="node-count" v-if="data.questionCount">({{ data.questionCount }})</span>
</span>
</template>
</el-tree>
</div>
</el-card>
</el-aside>
<!-- 右侧主内容区 -->
<el-main class="content-main">
<!-- 顶部搜索和操作区域 -->
<el-card class="search-operate-card" shadow="never">
<div class="card-header">
<h2>题库管理中心</h2>
<p class="card-description">管理您的题目资源支持搜索新增批量导入和校验</p>
</div>
<el-form :model="searchParams" class="search-form">
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="10" :lg="8">
<el-form-item>
<el-input
v-model="searchParams.content"
placeholder="按题目内容搜索..."
:prefix-icon="Search"
clearable
@keyup.enter="fetchQuestionPage"
@clear="fetchQuestionPage"
class="search-input"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="14" :lg="16" class="operate-buttons">
<el-button type="primary" :icon="Search" @click="fetchQuestionPage">查询</el-button>
<el-button type="success" :icon="Plus" @click="handleOpenAddDialog">新增题目</el-button>
<el-upload
action="#"
:show-file-list="false"
:auto-upload="false"
:on-change="handleFileUpload"
accept=".txt,.doc,.docx,.pdf"
>
<el-button type="primary" :icon="UploadFilled">AI批量导入</el-button>
</el-upload>
<el-button type="warning" :icon="Warning" @click="sendCheckDataReq">校验数据</el-button>
</el-col>
</el-row>
</el-form>
</el-card>
<!-- 题目列表 -->
<el-card class="table-card" shadow="never">
<div class="table-header">
<span class="table-title">
题目列表
<span v-if="currentCategory" class="current-category">
(当前分类: {{ currentCategory.name }})
</span>
</span>
<el-button
v-if="currentCategory"
type="info"
size="small"
@click="clearCategoryFilter"
>
清除筛选
</el-button>
</div>
<el-table
:data="tableData"
border
v-loading="isLoading"
style="width: 100%"
height="calc(100vh - 320px)"
:row-class-name="tableRowClassName"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="categoryName" label="分类" width="150">
<template #default="scope">
<el-tag effect="light" :color="getCategoryColor(scope.row.categoryName)">
{{ scope.row.categoryName }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="difficulty" label="难度" width="120" align="center">
<template #default="scope">
<el-tag
:type="getDifficultyTagType(scope.row.difficulty)"
effect="dark"
round
>
{{ scope.row.difficulty }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="content" label="题目内容" min-width="300" show-overflow-tooltip />
<el-table-column prop="tags" label="标签" width="200">
<template #default="scope">
<div class="tags-container">
<el-tag
v-for="(tag, index) in (scope.row.tags || '').split(',').filter(t => t.trim() !== '')"
:key="index"
size="small"
type="info"
effect="plain"
class="tag-item"
>
{{ tag }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="scope">
<el-tooltip content="编辑" placement="top">
<el-button
size="small"
:icon="Edit"
@click="handleOpenEditDialog(scope.row)"
circle
class="action-btn"
/>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button
type="danger"
size="small"
:icon="Delete"
@click="handleDelete(scope.row.id)"
circle
class="action-btn"
/>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<!-- 分页控制器 -->
<div class="pagination-wrapper">
<el-pagination
v-if="totalItems > 0"
background
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"
/>
</div>
</el-card>
</el-main>
</el-container>
<!-- 新增/编辑题目的对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
@close="resetForm"
:close-on-click-modal="false"
>
<el-form
:model="questionForm"
ref="questionFormRef"
label-width="100px"
:rules="formRules"
status-icon
>
<el-form-item label="题目内容" prop="content">
<el-input
v-model="questionForm.content"
type="textarea"
:rows="4"
placeholder="请输入题目内容"
show-word-limit
maxlength="500"
/>
</el-form-item>
<el-form-item label="分类" prop="categoryId">
<el-cascader
v-model="questionForm.categoryId"
:options="categoryTree"
:props="cascaderProps"
placeholder="请选择分类"
filterable
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="难度" prop="difficulty">
<el-select v-model="questionForm.difficulty" placeholder="请选择难度">
<el-option
v-for="item in difficultyOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</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, reactive, onMounted, watch, nextTick } from 'vue';
import {
getQuestionPage,
addQuestion,
updateQuestion,
deleteQuestion,
importQuestionsByAi,
checkQuestionData
} from '@/api/question.js';
import {getCategoryTree} from '@/api/question-category.js'
import { ElMessage, ElMessageBox } from 'element-plus';
import {
Search,
UploadFilled,
Plus,
Edit,
Delete,
Warning,
Refresh
} from '@element-plus/icons-vue';
// --- 响应式状态定义 ---
const tableData = ref([]);
const totalItems = ref(0);
const isLoading = ref(false);
const pagination = reactive({ current: 1, size: 10 });
const searchParams = reactive({ content: '', categoryId: null });
// 分类树相关状态
const categoryTree = ref([]);
const categoryTreeRef = ref(null);
const categoryFilterText = ref('');
const categoryLoading = ref(false);
const currentCategory = ref(null);
// 选项数据
const difficultyOptions = [
{ label: '简单', value: 'Easy' },
{ label: '中等', value: 'Medium' },
{ label: '困难', value: 'Hard' }
];
// 分类树配置
const categoryProps = {
label: 'name',
children: 'children'
};
const cascaderProps = {
label: 'name',
value: 'id',
children: 'children',
checkStrictly: true,
emitPath: false
};
// --- 对话框状态 ---
const dialogVisible = ref(false);
const dialogTitle = ref('');
const isEditMode = ref(false);
const questionForm = reactive({
content: '',
categoryId: null,
difficulty: 'Medium',
tags: ''
});
const questionFormRef = ref(null);
// 表单验证规则
const formRules = reactive({
content: [
{ required: true, message: '请输入题目内容', trigger: 'blur' },
],
categoryId: [
{ required: true, message: '请选择分类', trigger: 'change' }
],
difficulty: [
{ required: true, message: '请选择难度', trigger: 'change' }
]
});
// 监听分类筛选文本变化
watch(categoryFilterText, (val) => {
categoryTreeRef.value?.filter(val);
});
// --- 方法 ---
const filterCategoryNode = (value, data) => {
if (!value) return true;
return data.name.includes(value);
};
// 获取分类树数据
const fetchCategoryTree = async () => {
categoryLoading.value = true;
try {
const response = await getCategoryTree();
categoryTree.value = response.data || [];
// 获取每个分类的题目数量
await fetchCategoryQuestionCounts();
} catch (error) {
console.error('获取分类树失败:', error);
ElMessage.error('获取分类失败');
} finally {
categoryLoading.value = false;
}
};
// 获取每个分类的题目数量
const fetchCategoryQuestionCounts = async () => {
try {
// const counts = await getCategoryQuestionCount();
const counts = categoryTree.value.length
// 递归设置分类数量
const setCategoryCount = (nodes) => {
nodes.forEach(node => {
node.questionCount = counts[node.id] || 0;
if (node.children && node.children.length > 0) {
setCategoryCount(node.children);
}
});
};
setCategoryCount(categoryTree.value);
} catch (error) {
console.error('获取分类题目数量失败:', error);
}
};
// 处理分类节点点击
const handleCategoryNodeClick = (data) => {
currentCategory.value = data;
searchParams.categoryId = data.id;
pagination.current = 1;
fetchQuestionPage();
};
// 清除分类筛选
const clearCategoryFilter = () => {
currentCategory.value = null;
searchParams.categoryId = null;
categoryTreeRef.value?.setCurrentKey(null);
pagination.current = 1;
fetchQuestionPage();
};
// --- UI辅助方法 ---
const getDifficultyTagType = (difficulty) => {
switch ((difficulty || '').toLowerCase()) {
case 'easy':
return 'success';
case 'medium':
return 'warning';
case 'hard':
return 'danger';
default:
return 'info';
}
};
const getCategoryColor = (categoryName) => {
// 根据分类名称生成一致的颜色
const colors = [
'var(--el-color-primary-light-9)',
'var(--el-color-success-light-9)',
'var(--el-color-warning-light-9)',
'var(--el-color-info-light-9)',
'var(--el-color-danger-light-9)'
];
if (!categoryName) return colors[0];
// 简单哈希算法生成稳定颜色索引
let hash = 0;
for (let i = 0; i < categoryName.length; i++) {
hash = categoryName.charCodeAt(i) + ((hash << 5) - hash);
}
hash = Math.abs(hash);
return colors[hash % colors.length];
};
const tableRowClassName = ({ rowIndex }) => {
return rowIndex % 2 === 1 ? 'even-row' : '';
};
const resetForm = () => {
Object.assign(questionForm, {
id: undefined,
content: '',
categoryId: null,
difficulty: 'Medium',
tags: ''
});
isEditMode.value = false;
if (questionFormRef.value) {
questionFormRef.value.clearValidate();
}
};
// --- 对话框处理方法 ---
const handleOpenAddDialog = () => {
resetForm();
dialogTitle.value = '新增题目';
dialogVisible.value = true;
};
const handleOpenEditDialog = (row) => {
resetForm();
isEditMode.value = true;
dialogTitle.value = '编辑题目';
Object.assign(questionForm, {
...row,
categoryId: row.categoryId || null
});
dialogVisible.value = true;
};
// --- API交互方法 ---
const fetchQuestionPage = async () => {
isLoading.value = true;
try {
const params = {
current: pagination.current,
size: pagination.size,
...searchParams
};
// 移除空值参数
Object.keys(params).forEach(key => {
if (params[key] === null || params[key] === undefined || params[key] === '') {
delete params[key];
}
});
const responseData = await getQuestionPage(params);
tableData.value = responseData.data.records;
totalItems.value = responseData.data.total;
} catch (error) {
console.error('获取题库分页失败:', error);
ElMessage.error('获取题目列表失败');
} finally {
isLoading.value = false;
}
};
const sendCheckDataReq = async () => {
try {
ElMessage.info('数据校验中,请稍候...');
const res = await checkQuestionData();
if (res.success) {
ElMessage.success('数据校验完成,未发现问题');
} else {
ElMessage.warning('数据校验完成,发现一些问题请检查');
}
} catch (error) {
console.error('数据校验失败:', error);
ElMessage.error('数据校验失败');
}
};
const handleFileUpload = async (file) => {
const formData = new FormData();
formData.append('file', file.raw);
isLoading.value = true;
try {
await importQuestionsByAi(formData);
ElMessage.success('文件上传成功AI正在后台处理请稍后刷新查看。');
setTimeout(fetchQuestionPage, 2000);
} catch (error) {
console.error('文件上传失败:', error);
ElMessage.error('文件上传失败');
} finally {
isLoading.value = false;
}
};
const handleSubmit = async () => {
try {
// 表单验证
if (!questionFormRef.value) return;
const valid = await questionFormRef.value.validate();
if (!valid) return;
if (isEditMode.value) {
await updateQuestion(questionForm);
ElMessage.success('题目更新成功!');
} else {
await addQuestion(questionForm);
ElMessage.success('题目新增成功!');
}
dialogVisible.value = false;
fetchQuestionPage();
// 刷新分类树以更新题目数量
fetchCategoryTree();
} catch (error) {
console.error('提交失败:', error);
ElMessage.error('操作失败');
}
};
const handleDelete = async (id) => {
try {
await ElMessageBox.confirm('确定要删除这道题目吗?此操作不可撤销。', '警告', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
customClass: 'delete-confirm-dialog'
});
await deleteQuestion(id);
ElMessage.success('题目删除成功!');
fetchQuestionPage();
// 刷新分类树以更新题目数量
fetchCategoryTree();
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error);
ElMessage.error('删除失败');
}
}
};
// --- 分页处理 ---
const handleSizeChange = (val) => {
pagination.size = val;
pagination.current = 1;
fetchQuestionPage();
};
const handleCurrentChange = (val) => {
pagination.current = val;
fetchQuestionPage();
};
// --- 生命周期钩子 ---
onMounted(() => {
fetchCategoryTree();
fetchQuestionPage();
});
</script>
<style scoped>
.question-bank-container {
height: 100vh;
overflow: hidden;
background-color: #f5f7fa;
}
.main-container {
height: calc(100vh - 20px);
margin: 10px;
}
.category-aside {
height: 100%;
padding-right: 10px;
}
.category-card {
height: 100%;
display: flex;
flex-direction: column;
}
.category-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.category-tree-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.category-filter {
margin-bottom: 10px;
}
.category-tree {
flex: 1;
overflow: auto;
}
.custom-tree-node {
display: flex;
align-items: center;
width: 100%;
}
.node-label {
flex: 1;
}
.node-count {
font-size: 12px;
color: #909399;
margin-left: 5px;
}
.content-main {
padding: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.search-operate-card {
margin-bottom: 10px;
border-radius: 8px;
}
.card-header {
margin-bottom: 20px;
}
.card-header h2 {
margin: 0;
color: var(--el-text-color-primary);
font-weight: 600;
}
.card-description {
margin: 5px 0 0;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.search-form {
margin-top: 10px;
}
.search-input {
width: 100%;
}
.operate-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.table-card {
flex: 1;
border-radius: 8px;
display: flex;
flex-direction: column;
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.table-title {
font-weight: 600;
font-size: 16px;
}
.current-category {
font-weight: normal;
font-size: 14px;
color: var(--el-color-primary);
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.tag-item {
margin: 2px;
}
.action-btn {
margin: 0 3px;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
padding: 10px 0;
}
:deep(.even-row) {
background-color: var(--el-fill-color-light);
}
:deep(.delete-confirm-dialog) {
width: 400px;
}
@media (max-width: 768px) {
.main-container {
flex-direction: column;
}
.category-aside {
width: 100% !important;
padding-right: 0;
padding-bottom: 10px;
}
.operate-buttons {
margin-top: 10px;
}
.operate-buttons .el-button {
margin-bottom: 10px;
}
}
</style>

View File

@@ -19,6 +19,7 @@ export default defineConfig({
target: 'http://localhost:8080', target: 'http://localhost:8080',
changeOrigin: true, // Needed for virtual hosted sites changeOrigin: true, // Needed for virtual hosted sites
secure: false, // Optional: if you are using https secure: false, // Optional: if you are using https
rewrite: (path) => path.replace(/^\/api/, ''),
}, },
}, },
}, },