修改AI面试相关内容
This commit is contained in:
@@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus';
|
||||
|
||||
// Create an Axios instance with a base configuration
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
baseURL: '/api',
|
||||
timeout: 600000, // 10 min timeout
|
||||
});
|
||||
|
||||
|
||||
@@ -1,24 +1,52 @@
|
||||
import apiClient from './index';
|
||||
import apiClient from "@/api/index.js";
|
||||
|
||||
export const questionCategoryApi = {
|
||||
// 获取分类树列表
|
||||
getTreeList: () => apiClient.get('/question-category/tree-list'),
|
||||
|
||||
// 获取分类详情
|
||||
getDetail: (id) => apiClient.get(`/question-category/${id}`),
|
||||
|
||||
// 创建分类
|
||||
create: (data) => apiClient.post('/question-category', data),
|
||||
|
||||
// 更新分类
|
||||
update: (id, data) => apiClient.put(`/question-category/${id}`, data),
|
||||
|
||||
// 删除分类
|
||||
delete: (id) => apiClient.delete(`/question-category/${id}`),
|
||||
|
||||
// 更新状态
|
||||
updateState: (id, state) => apiClient.patch(`/question-category/${id}/state`, { state }),
|
||||
|
||||
// 获取分类选项(用于下拉选择)
|
||||
getOptions: () => apiClient.get('/question-category/options')
|
||||
// 获取分类树形列表
|
||||
export function getCategoryTree(params) {
|
||||
return apiClient({
|
||||
url: '/question-category/tree-list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取分类详情
|
||||
export function getCategoryDetail(id) {
|
||||
return apiClient({
|
||||
url: `/question-category/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 添加分类
|
||||
export function addCategory(data) {
|
||||
return apiClient({
|
||||
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'
|
||||
})
|
||||
}
|
||||
@@ -51,3 +51,7 @@ export const importQuestionsByAi = (formData) => {
|
||||
export const checkQuestionData = () => {
|
||||
return apiClient.post('/question/check-question-data');
|
||||
}
|
||||
|
||||
export const getTreeListByCategory = (data) => {
|
||||
return apiClient.post('/question/tree-list-category', data);
|
||||
}
|
||||
|
||||
502
src/components/ChatWindow.vue
Normal file
502
src/components/ChatWindow.vue
Normal 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>
|
||||
440
src/components/QuestionBankSection.vue
Normal file
440
src/components/QuestionBankSection.vue
Normal 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>
|
||||
@@ -25,7 +25,7 @@
|
||||
<el-icon><House /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/interview">
|
||||
<el-menu-item index="/home">
|
||||
<el-icon><ChatLineRound /></el-icon>
|
||||
<span>模拟面试</span>
|
||||
</el-menu-item>
|
||||
|
||||
@@ -9,33 +9,48 @@ const routes = [
|
||||
{
|
||||
path: '',
|
||||
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',
|
||||
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',
|
||||
name: 'QuestionBank',
|
||||
component: () => import('../views/QuestionBank.vue'),
|
||||
component: () => import('@/views/question/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'history',
|
||||
name: 'InterviewHistory',
|
||||
component: () => import('../views/InterviewHistory.vue'),
|
||||
component: () => import('@/views/interview-history/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'report/:sessionId',
|
||||
name: 'InterviewReport',
|
||||
component: () => import('../views/InterviewReport.vue'),
|
||||
component: () => import('@/views/interview-report/index.vue'),
|
||||
props: true, // 将路由参数作为props传递给组件
|
||||
},
|
||||
{
|
||||
path: 'answer-record',
|
||||
name: 'AnswerRecord',
|
||||
component: () => import('../views/AnswerRecord.vue'),
|
||||
component: () => import('@/views/answer-record/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'question-category',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,21 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
<script setup>
|
||||
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 currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const size = ref<ComponentSize>('default')
|
||||
const size = ref('default')
|
||||
const background = ref(false)
|
||||
const disabled = ref(false)
|
||||
const handleSizeChange = (val: number) => {
|
||||
const handleSizeChange = (val) => {
|
||||
console.log(`${val} items per page`)
|
||||
pageSize.value = val
|
||||
fetchData()
|
||||
}
|
||||
const total = ref(0)
|
||||
const handleCurrentChange = (val: number) => {
|
||||
const handleCurrentChange = (val) => {
|
||||
console.log(`current page: ${val}`)
|
||||
currentPage.value = val
|
||||
fetchData()
|
||||
83
src/views/chat/index.vue
Normal file
83
src/views/chat/index.vue
Normal 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>
|
||||
@@ -53,7 +53,7 @@
|
||||
// 导入Vue核心功能、ECharts、API客户端和图标
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
import { getDashboardStats } from '../api/dashboard';
|
||||
import { getDashboardStats } from '@/api/dashboard.js';
|
||||
import { DataAnalysis, MessageBox } from '@element-plus/icons-vue';
|
||||
|
||||
// --- 响应式状态定义 ---
|
||||
@@ -60,7 +60,7 @@
|
||||
// 导入Vue核心功能、路由和API客户端
|
||||
import { ref, onMounted } from 'vue';
|
||||
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';
|
||||
|
||||
// --- 响应式状态定义 ---
|
||||
@@ -91,7 +91,7 @@
|
||||
// 导入Vue核心功能、路由、API客户端和图标
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getInterviewReportDetail } from '../api/interview';
|
||||
import { getInterviewReportDetail } from '@/api/interview.js';
|
||||
import { DataAnalysis, ChatDotRound } from '@element-plus/icons-vue';
|
||||
|
||||
// --- Props & Router ---
|
||||
53
src/views/interview/chat.vue
Normal file
53
src/views/interview/chat.vue
Normal 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>
|
||||
122
src/views/interview/home.vue
Normal file
122
src/views/interview/home.vue
Normal 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>
|
||||
318
src/views/interview/index.vue
Normal file
318
src/views/interview/index.vue
Normal 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">
|
||||
支持 PDF、Markdown 或文本格式,大小不超过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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
227
src/views/question-category/components/add.vue
Normal file
227
src/views/question-category/components/add.vue
Normal 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>
|
||||
336
src/views/question-category/components/edit.vue
Normal file
336
src/views/question-category/components/edit.vue
Normal 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>
|
||||
@@ -1,159 +1,203 @@
|
||||
<template>
|
||||
<div class="category-index">
|
||||
<div class="page-header">
|
||||
<h2>题型分类管理</h2>
|
||||
<p>管理试题的分类体系,支持多级分类</p>
|
||||
</div>
|
||||
<div class="question-category-list">
|
||||
<!-- 查询条件 -->
|
||||
<el-card shadow="never" class="search-card">
|
||||
<el-form :model="queryParams" ref="queryForm" :inline="true" label-width="80px">
|
||||
<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
|
||||
@search="handleSearch"
|
||||
@reset="handleResetSearch"
|
||||
<!-- 操作按钮 -->
|
||||
<el-card shadow="never" class="table-card">
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button type="primary" plain :icon="Plus" @click="handleAdd">新增</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 表格数据 -->
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
: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>
|
||||
|
||||
<!-- 添加或修改对话框 -->
|
||||
<QuestionCategoryAdd
|
||||
v-if="openAdd"
|
||||
:open="openAdd"
|
||||
:parentCategory="parentCategory"
|
||||
@close="closeDialog"
|
||||
@success="getList"
|
||||
/>
|
||||
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" @click="handleCreate" icon="Plus">新增分类</el-button>
|
||||
<el-button @click="handleRefresh" icon="Refresh">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<CategoryTable
|
||||
:table-data="tableData"
|
||||
:loading="loading"
|
||||
@add-child="handleAddChild"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
<QuestionCategoryEdit
|
||||
v-if="openEdit"
|
||||
:open="openEdit"
|
||||
:categoryId="categoryId"
|
||||
@close="closeDialog"
|
||||
@success="getList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import SearchBar from './components/SearchBar.vue'
|
||||
import CategoryTable from './components/CategoryTable.vue'
|
||||
import { questionCategoryApi } from '@/api/question-category.js'
|
||||
import QuestionCategoryAdd from './components/add.vue'
|
||||
import QuestionCategoryEdit from './components/edit.vue'
|
||||
import { getCategoryTree, deleteCategory } from '@/api/question-category.js'
|
||||
import {Plus, Refresh, Search} from "@element-plus/icons-vue";
|
||||
|
||||
export default {
|
||||
name: 'QuestionCategoryIndex',
|
||||
components: {
|
||||
SearchBar,
|
||||
CategoryTable
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const tableData = ref([])
|
||||
const loading = ref(false)
|
||||
const searchParams = ref({})
|
||||
// 状态选项
|
||||
const statusOptions = ref([
|
||||
{ value: 1, label: '启用' },
|
||||
{ value: 0, label: '禁用' }
|
||||
])
|
||||
|
||||
const loadTableData = async () => {
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
name: undefined,
|
||||
state: undefined
|
||||
})
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(true)
|
||||
// 分类列表
|
||||
const categoryList = ref([])
|
||||
// 是否显示添加对话框
|
||||
const openAdd = ref(false)
|
||||
// 是否显示修改对话框
|
||||
const openEdit = ref(false)
|
||||
// 当前分类ID
|
||||
const categoryId = ref(null)
|
||||
// 父级分类信息
|
||||
const parentCategory = ref(null)
|
||||
|
||||
// 获取分类列表
|
||||
const getList = () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await questionCategoryApi.getTreeList()
|
||||
tableData.value = data
|
||||
} catch (error) {
|
||||
console.error('加载分类数据失败:', error)
|
||||
} finally {
|
||||
getCategoryTree(queryParams).then(response => {
|
||||
categoryList.value = response.data
|
||||
loading.value = false
|
||||
}).catch(() => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (params) => {
|
||||
searchParams.value = params
|
||||
// 这里可以根据params进行筛选,实际项目中需要后端支持搜索
|
||||
loadTableData()
|
||||
}
|
||||
|
||||
const handleResetSearch = () => {
|
||||
searchParams.value = {}
|
||||
loadTableData()
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
router.push('/question-category/create')
|
||||
}
|
||||
|
||||
const handleAddChild = (row) => {
|
||||
router.push({
|
||||
path: '/question-category/create',
|
||||
query: { parentId: row.id }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
router.push(`/question-category/edit/${row.id}`)
|
||||
}
|
||||
// 查询处理
|
||||
const handleQuery = () => {
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除分类 "${row.name}" 吗?此操作不可恢复。`,
|
||||
'警告',
|
||||
{
|
||||
// 重置查询
|
||||
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'
|
||||
}
|
||||
)
|
||||
|
||||
await questionCategoryApi.delete(row.id)
|
||||
}).then(() => {
|
||||
return deleteCategory(row.id)
|
||||
}).then(() => {
|
||||
getList()
|
||||
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
|
||||
}
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
// 修改对话框关闭方法
|
||||
const closeDialog = () => {
|
||||
openAdd.value = false
|
||||
openEdit.value = false
|
||||
parentCategory.value = null
|
||||
categoryId.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.category-index {
|
||||
padding: 20px;
|
||||
.search-card {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
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;
|
||||
.table-card {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
784
src/views/question/index.vue
Normal file
784
src/views/question/index.vue
Normal 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>
|
||||
@@ -19,6 +19,7 @@ export default defineConfig({
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true, // Needed for virtual hosted sites
|
||||
secure: false, // Optional: if you are using https
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user