Files
AI-interview-web/src/views/question/index.vue
2025-09-17 21:36:49 +08:00

784 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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