597 lines
20 KiB
JavaScript
597 lines
20 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import axios from '../../services/axios';
|
||
import { adminAPI } from '../../services/api';
|
||
import { toast } from 'react-toastify';
|
||
import '../../styles/admin/CategoryManagement.css';
|
||
|
||
const CategoryManagement = () => {
|
||
const [categories, setCategories] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||
const [showEditModal, setShowEditModal] = useState(false);
|
||
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
||
const [currentCategory, setCurrentCategory] = useState(null);
|
||
const [categoryName, setCategoryName] = useState('');
|
||
const [categoryDescription, setCategoryDescription] = useState('');
|
||
const [parentCategory, setParentCategory] = useState(''); // 用于创建和编辑时的父分类选择
|
||
const [templates, setTemplates] = useState([]);
|
||
const [selectedTemplate, setSelectedTemplate] = useState('');
|
||
const [expandedCategories, setExpandedCategories] = useState({}); // 用于跟踪哪些分类被展开
|
||
|
||
// 切换分类展开/折叠状态
|
||
const toggleCategory = (categoryId) => {
|
||
setExpandedCategories(prev => {
|
||
const newState = { ...prev };
|
||
// 如果当前是折叠状态,则展开;如果是展开状态,则折叠
|
||
newState[categoryId] = !prev[categoryId];
|
||
return newState;
|
||
});
|
||
};
|
||
|
||
// 辅助函数:渲染分类列表(递归)
|
||
const renderCategoryList = (categoryList, isRoot = true) => {
|
||
if (!categoryList || !Array.isArray(categoryList)) {
|
||
console.error('renderCategoryList: categoryList is not an array', categoryList);
|
||
return null;
|
||
}
|
||
|
||
// 如果不是根级列表,并且父分类没有被展开,则不渲染
|
||
if (!isRoot && categoryList[0]?.parent_id && !expandedCategories[categoryList[0]?.parent_id]) {
|
||
return null;
|
||
}
|
||
|
||
return categoryList.map(category => {
|
||
const hasChildren = category.children && Array.isArray(category.children) && category.children.length > 0;
|
||
const isExpanded = expandedCategories[category.id];
|
||
|
||
return (
|
||
<React.Fragment key={category.id}>
|
||
<tr className={`category-row ${category.level > 1 ? 'child-category' : 'parent-category'}`}>
|
||
<td>{category.id}</td>
|
||
<td>
|
||
<div className="d-flex align-items-center">
|
||
{/* 缩进显示 */}
|
||
<div style={{ width: `${(category.level - 1) * 20}px` }}></div>
|
||
|
||
{/* 展开/折叠图标 */}
|
||
<span
|
||
className="category-toggle me-2"
|
||
onClick={() => toggleCategory(category.id)}
|
||
style={{ cursor: 'pointer' }}
|
||
>
|
||
{hasChildren ? (
|
||
isExpanded ? (
|
||
<span className="chevron-down">▼</span>
|
||
) : (
|
||
<span className="chevron-right">▶</span>
|
||
)
|
||
) : (
|
||
<span className="category-no-children">●</span>
|
||
)}
|
||
</span>
|
||
|
||
{/* 分类名称 */}
|
||
<span className={hasChildren ? 'fw-bold' : ''}>
|
||
{category.name}
|
||
</span>
|
||
|
||
{/* 父级标识 - 移除显示父级ID,通过缩进和层次结构已经清晰表达 */}
|
||
</div>
|
||
</td>
|
||
<td>{category.description}</td>
|
||
<td>
|
||
<button
|
||
className="btn btn-sm btn-primary me-2"
|
||
onClick={() => openEditModal(category)}
|
||
>
|
||
编辑
|
||
</button>
|
||
<button
|
||
className="btn btn-sm btn-danger"
|
||
onClick={() => deleteCategory(category.id)}
|
||
>
|
||
删除
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
|
||
{/* 子分类,只有当父分类被展开时才渲染 */}
|
||
{hasChildren && isExpanded && renderCategoryList(category.children, false)}
|
||
</React.Fragment>
|
||
);
|
||
});
|
||
};
|
||
|
||
// 获取分类列表
|
||
const fetchCategories = async () => {
|
||
try {
|
||
setLoading(true);
|
||
// 添加hierarchical=true参数,获取层次化的分类数据
|
||
const res = await adminAPI.getCategories({hierarchical: true});
|
||
// 检查返回的数据结构
|
||
console.log('API返回的分类数据:', res.data);
|
||
// 确保categories是一个数组
|
||
let categoriesData = [];
|
||
|
||
if (res.data && res.data.categories) {
|
||
// 如果API返回的是 {categories: [...]} 格式
|
||
categoriesData = res.data.categories || [];
|
||
} else if (Array.isArray(res.data)) {
|
||
// 如果API直接返回数组
|
||
categoriesData = res.data;
|
||
} else {
|
||
console.error('API返回的分类数据格式不正确:', res.data);
|
||
categoriesData = [];
|
||
}
|
||
|
||
// 递归处理分类数据,确保每个分类都有必要的字段
|
||
const processCategories = (categories) => {
|
||
return categories.map(cat => ({
|
||
...cat,
|
||
id: cat.id || cat._id || cat.category_id || '',
|
||
name: cat.name || cat.category_name || '未命名分类',
|
||
level: cat.level || 1,
|
||
parent_id: cat.parent_id || null,
|
||
children: cat.children ? processCategories(cat.children) : []
|
||
}));
|
||
};
|
||
|
||
const formattedCategories = processCategories(categoriesData);
|
||
|
||
// 初始化展开状态 - 默认只展开顶级分类
|
||
const initialExpandedState = {};
|
||
formattedCategories.forEach(cat => {
|
||
// 只有顶级分类(parent_id为null或undefined的分类)默认展开
|
||
if (!cat.parent_id) {
|
||
initialExpandedState[cat.id] = true;
|
||
}
|
||
});
|
||
setExpandedCategories(initialExpandedState);
|
||
|
||
setCategories(formattedCategories);
|
||
} catch (err) {
|
||
toast.error('获取分类列表失败');
|
||
console.error(err);
|
||
setCategories([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 获取分类模板列表
|
||
const fetchTemplates = async () => {
|
||
try {
|
||
const res = await axios.get('/api/admin/category-templates');
|
||
// 确保templates是一个数组
|
||
let templatesData = [];
|
||
|
||
if (res.data && res.data.templates) {
|
||
// 如果API返回的是 {templates: [...]} 格式
|
||
templatesData = res.data.templates || [];
|
||
} else if (Array.isArray(res.data)) {
|
||
// 如果API直接返回数组
|
||
templatesData = res.data;
|
||
} else {
|
||
console.error('API返回的模板数据格式不正确:', res.data);
|
||
templatesData = [];
|
||
}
|
||
|
||
// 确保每个模板对象都有id和name属性
|
||
const formattedTemplates = templatesData.map(template => {
|
||
return {
|
||
...template,
|
||
id: template.id || template._id || template.template_id || '',
|
||
name: template.name || template.template_name || '未命名模板'
|
||
};
|
||
});
|
||
|
||
setTemplates(formattedTemplates);
|
||
} catch (err) {
|
||
toast.error('获取分类模板列表失败');
|
||
console.error(err);
|
||
setTemplates([]);
|
||
}
|
||
};
|
||
|
||
// 应用分类模板
|
||
const applyTemplate = async () => {
|
||
if (!selectedTemplate) {
|
||
toast.error('请选择一个模板');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await axios.post(`/api/admin/category-templates/${selectedTemplate}/apply`);
|
||
toast.success('分类模板应用成功');
|
||
setShowTemplateModal(false);
|
||
setSelectedTemplate('');
|
||
fetchCategories();
|
||
} catch (err) {
|
||
toast.error('应用分类模板失败');
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchCategories();
|
||
fetchTemplates();
|
||
}, []);
|
||
|
||
// 检查是否存在重复的分类名称(在同一父级下)
|
||
const checkDuplicateCategoryName = (name, parentId, excludeId = null) => {
|
||
// 扁平化分类列表,便于检查
|
||
const flattenCategories = (cats) => {
|
||
let result = [];
|
||
cats.forEach(cat => {
|
||
result.push(cat);
|
||
if (cat.children && cat.children.length > 0) {
|
||
result = [...result, ...flattenCategories(cat.children)];
|
||
}
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const allCategories = flattenCategories(categories);
|
||
|
||
// 检查是否存在同名分类(在相同父级下,排除自身)
|
||
return allCategories.some(cat =>
|
||
cat.name.toLowerCase() === name.toLowerCase() &&
|
||
cat.parent_id === parentId &&
|
||
cat.id !== excludeId
|
||
);
|
||
};
|
||
|
||
// 创建分类
|
||
const createCategory = async () => {
|
||
try {
|
||
// 检查分类名称是否为空
|
||
if (!categoryName.trim()) {
|
||
toast.error('分类名称不能为空');
|
||
return;
|
||
}
|
||
|
||
// 检查是否存在同名分类(在相同父级下)
|
||
const isCategoryNameExists = checkDuplicateCategoryName(categoryName.trim(), parentCategory);
|
||
if (isCategoryNameExists) {
|
||
toast.error('该父级下已存在同名分类,请使用其他名称');
|
||
return;
|
||
}
|
||
|
||
await adminAPI.createCategory({
|
||
name: categoryName,
|
||
description: categoryDescription,
|
||
parent_id: parentCategory || null // 如果没有选择父分类,则为null
|
||
});
|
||
toast.success('分类创建成功');
|
||
setShowCreateModal(false);
|
||
setCategoryName('');
|
||
setCategoryDescription('');
|
||
setParentCategory('');
|
||
fetchCategories();
|
||
} catch (err) {
|
||
toast.error('创建分类失败');
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
// 更新分类
|
||
const updateCategory = async () => {
|
||
if (!currentCategory) return;
|
||
|
||
try {
|
||
// 检查分类名称是否为空
|
||
if (!categoryName.trim()) {
|
||
toast.error('分类名称不能为空');
|
||
return;
|
||
}
|
||
|
||
// 检查是否存在同名分类(在相同父级下,排除自身)
|
||
const isCategoryNameExists = checkDuplicateCategoryName(
|
||
categoryName.trim(),
|
||
currentCategory.parent_id,
|
||
currentCategory.id
|
||
);
|
||
|
||
if (isCategoryNameExists) {
|
||
toast.error('该父级下已存在同名分类,请使用其他名称');
|
||
return;
|
||
}
|
||
|
||
await adminAPI.updateCategory(currentCategory.id, {
|
||
name: categoryName,
|
||
description: categoryDescription
|
||
// parent_id 不允许直接修改,因为会影响路径,由后端处理
|
||
});
|
||
toast.success('分类更新成功');
|
||
setShowEditModal(false);
|
||
fetchCategories();
|
||
} catch (err) {
|
||
toast.error('更新分类失败');
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
// 删除分类
|
||
const deleteCategory = async (categoryId) => {
|
||
if (window.confirm('确定要删除这个分类吗?')) {
|
||
try {
|
||
await adminAPI.deleteCategory(categoryId);
|
||
toast.success('分类已删除');
|
||
fetchCategories();
|
||
} catch (err) {
|
||
toast.error('删除分类失败');
|
||
console.error(err);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 打开创建分类模态框
|
||
const openCreateModal = () => {
|
||
setCategoryName('');
|
||
setCategoryDescription('');
|
||
setParentCategory('');
|
||
setShowCreateModal(true);
|
||
};
|
||
|
||
// 打开模板模态框
|
||
const openTemplateModal = () => {
|
||
setSelectedTemplate('');
|
||
setShowTemplateModal(true);
|
||
};
|
||
|
||
// 打开编辑分类模态框
|
||
const openEditModal = (category) => {
|
||
setCurrentCategory(category);
|
||
setCategoryName(category.name);
|
||
setCategoryDescription(category.description);
|
||
setParentCategory(category.parent_id || ''); // 设置当前父分类
|
||
setShowEditModal(true);
|
||
};
|
||
|
||
// 辅助函数:生成父分类选项(扁平化列表)
|
||
const getParentOptions = (cats, level = 0) => {
|
||
let options = [];
|
||
cats.forEach(cat => {
|
||
options.push({
|
||
id: cat.id,
|
||
name: '— '.repeat(level) + cat.name
|
||
});
|
||
if (cat.children && cat.children.length > 0) {
|
||
options = options.concat(getParentOptions(cat.children, level + 1));
|
||
}
|
||
});
|
||
return options;
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="d-flex justify-content-center align-items-center vh-100">
|
||
<div className="spinner-border text-primary" role="status">
|
||
<span className="visually-hidden">Loading...</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="category-management-container">
|
||
<div className="d-flex justify-content-between align-items-center mb-4">
|
||
<h1 className="category-title">分类管理</h1>
|
||
<div className="category-actions">
|
||
{/*<button */}
|
||
{/* className="btn btn-success me-2"*/}
|
||
{/* onClick={openTemplateModal}*/}
|
||
{/*>*/}
|
||
{/* 应用模板*/}
|
||
{/*</button>*/}
|
||
<button className="btn-admin-primary" onClick={openCreateModal}>
|
||
<i className="bi bi-plus-circle me-2"></i>添加分类
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<div className="card-body">
|
||
<div className="table-responsive">
|
||
<table className="table table-hover">
|
||
<thead className="category-table-header">
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>名称</th>
|
||
<th>描述</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="category-table-body">
|
||
{categories && categories.length > 0 ? (
|
||
renderCategoryList(categories)
|
||
) : (
|
||
<tr>
|
||
<td colSpan="4" className="text-center">暂无分类数据</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 创建分类模态框 */}
|
||
{showCreateModal && (
|
||
<div className="modal-overlay" onClick={() => setShowCreateModal(false)}>
|
||
<div className="admin-modal" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h5 className="modal-title">创建分类</h5>
|
||
<button className="modal-close" onClick={() => setShowCreateModal(false)}>×</button>
|
||
</div>
|
||
<div className="modal-body">
|
||
<div className="mb-3">
|
||
<label className="form-label">分类名称</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
value={categoryName}
|
||
onChange={(e) => setCategoryName(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="mb-3">
|
||
<label className="form-label">描述</label>
|
||
<textarea
|
||
className="form-control"
|
||
rows="3"
|
||
value={categoryDescription}
|
||
onChange={(e) => setCategoryDescription(e.target.value)}
|
||
></textarea>
|
||
</div>
|
||
<div className="mb-3">
|
||
<label className="form-label">父分类 (可选)</label>
|
||
<select
|
||
className="form-select"
|
||
value={parentCategory}
|
||
onChange={(e) => setParentCategory(e.target.value)}
|
||
>
|
||
<option value="">无</option>
|
||
{getParentOptions(categories).map(cat => (
|
||
<option key={cat.id} value={cat.id}>
|
||
{cat.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button className="btn-admin-outline-secondary" onClick={() => setShowCreateModal(false)}>
|
||
取消
|
||
</button>
|
||
<button className="btn-admin-outline-primary" onClick={createCategory}>
|
||
创建
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 编辑分类模态框 */}
|
||
{showEditModal && (
|
||
<div className="modal show" style={{ display: 'block' }}>
|
||
<div className="modal-dialog">
|
||
<div className="modal-content">
|
||
<div className="modal-header">
|
||
<h5 className="modal-title">编辑分类</h5>
|
||
<button
|
||
type="button"
|
||
className="btn-close"
|
||
onClick={() => setShowEditModal(false)}
|
||
></button>
|
||
</div>
|
||
<div className="modal-body">
|
||
<div className="mb-3">
|
||
<label className="form-label">分类名称</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
value={categoryName}
|
||
onChange={(e) => setCategoryName(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="mb-3">
|
||
<label className="form-label">描述</label>
|
||
<textarea
|
||
className="form-control"
|
||
rows="3"
|
||
value={categoryDescription}
|
||
onChange={(e) => setCategoryDescription(e.target.value)}
|
||
></textarea>
|
||
</div>
|
||
{/* 父分类不允许直接修改,因为会影响路径,由后端处理 */}
|
||
{currentCategory && currentCategory.parent_id && (
|
||
<div className="mb-3">
|
||
<label className="form-label">父分类</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
value={getParentOptions(categories).find(cat => cat.id === currentCategory.parent_id)?.name || '无'}
|
||
disabled
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={() => setShowEditModal(false)}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
onClick={updateCategory}
|
||
>
|
||
更新
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 分类模板模态框 */}
|
||
{showTemplateModal && (
|
||
<div className="modal show" style={{ display: 'block' }}>
|
||
<div className="modal-dialog">
|
||
<div className="modal-content">
|
||
<div className="modal-header">
|
||
<h5 className="modal-title">应用分类模板</h5>
|
||
<button
|
||
type="button"
|
||
className="btn-close"
|
||
onClick={() => setShowTemplateModal(false)}
|
||
></button>
|
||
</div>
|
||
<div className="modal-body">
|
||
<div className="mb-3">
|
||
<label className="form-label">选择模板</label>
|
||
<select
|
||
className="form-select"
|
||
value={selectedTemplate}
|
||
onChange={(e) => setSelectedTemplate(e.target.value)}
|
||
>
|
||
<option value="">请选择模板</option>
|
||
{templates.map(template => (
|
||
<option key={template.id} value={template.id}>
|
||
{template.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="alert alert-info">
|
||
<small>应用模板将创建预设的分类结构,不会影响现有分类。</small>
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={() => setShowTemplateModal(false)}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-success"
|
||
onClick={applyTemplate}
|
||
>
|
||
应用模板
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default CategoryManagement; |