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