Files
AI-interview-web/src/views/question/index.vue

784 lines
20 KiB
Vue
Raw Normal View History

2025-09-17 21:36:49 +08:00
<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>