395 lines
13 KiB
JavaScript
395 lines
13 KiB
JavaScript
|
|
import React, { useState, useEffect } from 'react'; // 补充导入useEffect
|
|||
|
|
import axios from '../../services/axios';
|
|||
|
|
import { toast } from 'react-toastify';
|
|||
|
|
import { ProgressBar, Card, Button, Form } from 'react-bootstrap';
|
|||
|
|
import { adminAPI } from '../../services/api';
|
|||
|
|
|
|||
|
|
const FileUpload = () => {
|
|||
|
|
const [selectedFiles, setSelectedFiles] = useState([]);
|
|||
|
|
const [descriptions, setDescriptions] = useState({});
|
|||
|
|
const [uploading, setUploading] = useState(false);
|
|||
|
|
const [progress, setProgress] = useState({});
|
|||
|
|
const [filePreviews, setFilePreviews] = useState({});
|
|||
|
|
const [uploadMode, setUploadMode] = useState('single'); // 'single' or 'batch'
|
|||
|
|
const [categories, setCategories] = useState([]);
|
|||
|
|
const [selectedCategory, setSelectedCategory] = useState('');
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
const fetchCategories = async () => {
|
|||
|
|
try {
|
|||
|
|
// 添加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, level = 0) => {
|
|||
|
|
return categories.map(cat => ({
|
|||
|
|
id: cat.id || cat._id || cat.category_id || '',
|
|||
|
|
name: cat.name || cat.category_name || '未命名分类',
|
|||
|
|
level: cat.level || level,
|
|||
|
|
parent_id: cat.parent_id || null,
|
|||
|
|
children: cat.children ? processCategories(cat.children, level + 1) : []
|
|||
|
|
}));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 处理分类数据,构建扁平化的分类列表(用于下拉选择)
|
|||
|
|
const flattenCategories = (categories, result = []) => {
|
|||
|
|
categories.forEach(category => {
|
|||
|
|
result.push({
|
|||
|
|
id: category.id,
|
|||
|
|
name: category.name,
|
|||
|
|
level: category.level,
|
|||
|
|
parent_id: category.parent_id
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (category.children && category.children.length > 0) {
|
|||
|
|
flattenCategories(category.children, result);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const processedCategories = processCategories(categoriesData);
|
|||
|
|
const flattenedCategories = flattenCategories(processedCategories);
|
|||
|
|
|
|||
|
|
setCategories(flattenedCategories);
|
|||
|
|
} catch (err) {
|
|||
|
|
toast.error('获取分类列表失败');
|
|||
|
|
console.error(err);
|
|||
|
|
setCategories([]);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
fetchCategories();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const handleFileChange = (e) => {
|
|||
|
|
const files = Array.from(e.target.files);
|
|||
|
|
if (uploadMode === 'single') {
|
|||
|
|
if (files.length > 0) {
|
|||
|
|
const file = files[0];
|
|||
|
|
setSelectedFiles([file]);
|
|||
|
|
|
|||
|
|
// 生成文件预览(仅支持图片)
|
|||
|
|
if (file.type.startsWith('image/')) {
|
|||
|
|
const reader = new FileReader();
|
|||
|
|
reader.onload = (event) => {
|
|||
|
|
setFilePreviews({ [file.name]: event.target.result });
|
|||
|
|
};
|
|||
|
|
reader.readAsDataURL(file);
|
|||
|
|
} else {
|
|||
|
|
setFilePreviews({});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 批量上传模式
|
|||
|
|
setSelectedFiles(files);
|
|||
|
|
|
|||
|
|
files.forEach(file => {
|
|||
|
|
if (file.type.startsWith('image/')) {
|
|||
|
|
const reader = new FileReader();
|
|||
|
|
reader.onload = (event) => {
|
|||
|
|
setFilePreviews(prev => ({ ...prev, [file.name]: event.target.result }));
|
|||
|
|
};
|
|||
|
|
reader.readAsDataURL(file);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleUpload = async (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
|
|||
|
|
if (selectedFiles.length === 0) {
|
|||
|
|
toast.warning('请选择要上传的文件');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!selectedCategory) {
|
|||
|
|
toast.warning('请选择文件分类');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setUploading(true);
|
|||
|
|
|
|||
|
|
if (uploadMode === 'single') {
|
|||
|
|
// 单文件上传
|
|||
|
|
const file = selectedFiles[0];
|
|||
|
|
const formData = new FormData();
|
|||
|
|
formData.append('file', file);
|
|||
|
|
formData.append('description', descriptions[file.name] || '');
|
|||
|
|
formData.append('category_id', selectedCategory);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
setProgress({ [file.name]: 0 });
|
|||
|
|
|
|||
|
|
await adminAPI.uploadFile(formData, (progressEvent) => {
|
|||
|
|
const percent = Math.round(
|
|||
|
|
(progressEvent.loaded / progressEvent.total) * 100
|
|||
|
|
);
|
|||
|
|
setProgress({ [file.name]: percent });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
toast.success('文件上传成功!');
|
|||
|
|
resetForm();
|
|||
|
|
} catch (err) {
|
|||
|
|
toast.error(`文件上传失败: ${err.response?.data?.message || '请重试'}`);
|
|||
|
|
console.error(err);
|
|||
|
|
} finally {
|
|||
|
|
setUploading(false);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 批量上传(修复逻辑结构)
|
|||
|
|
const CONCURRENT_LIMIT = 3;
|
|||
|
|
const uploadQueue = [...selectedFiles];
|
|||
|
|
const results = [];
|
|||
|
|
|
|||
|
|
// 并发上传工作函数
|
|||
|
|
const worker = async () => {
|
|||
|
|
while (uploadQueue.length > 0) {
|
|||
|
|
const file = uploadQueue.shift();
|
|||
|
|
try {
|
|||
|
|
const formData = new FormData();
|
|||
|
|
formData.append('file', file);
|
|||
|
|
formData.append('description', descriptions[file.name] || '');
|
|||
|
|
formData.append('category_id', selectedCategory);
|
|||
|
|
|
|||
|
|
await adminAPI.uploadFile(formData, (progressEvent) => {
|
|||
|
|
const percent = Math.round(
|
|||
|
|
(progressEvent.loaded / progressEvent.total) * 100
|
|||
|
|
);
|
|||
|
|
setProgress(prev => ({ ...prev, [file.name]: percent }));
|
|||
|
|
});
|
|||
|
|
results.push({ file: file.name, status: 'success' });
|
|||
|
|
} catch (error) {
|
|||
|
|
results.push({ file: file.name, status: 'error', error: error.message });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 启动并发上传
|
|||
|
|
const workers = Array(CONCURRENT_LIMIT).fill(null).map(worker);
|
|||
|
|
await Promise.allSettled(workers);
|
|||
|
|
|
|||
|
|
// 统计结果
|
|||
|
|
const successful = results.filter(r => r.status === 'success').length;
|
|||
|
|
const failed = results.filter(r => r.status === 'error').length;
|
|||
|
|
|
|||
|
|
if (failed === 0) {
|
|||
|
|
toast.success(`批量上传完成!成功上传 ${successful} 个文件`);
|
|||
|
|
} else {
|
|||
|
|
toast.warning(`批量上传完成!成功 ${successful} 个,失败 ${failed} 个`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resetForm();
|
|||
|
|
setUploading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resetForm = () => {
|
|||
|
|
setSelectedFiles([]);
|
|||
|
|
setDescriptions({});
|
|||
|
|
setProgress({});
|
|||
|
|
setFilePreviews({});
|
|||
|
|
setSelectedCategory('');
|
|||
|
|
document.getElementById('fileInput').value = '';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDescriptionChange = (filename, value) => {
|
|||
|
|
setDescriptions(prev => ({ ...prev, [filename]: value }));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const removeFile = (filename) => {
|
|||
|
|
setSelectedFiles(prev => prev.filter(file => file.name !== filename));
|
|||
|
|
setDescriptions(prev => {
|
|||
|
|
const newDescriptions = { ...prev };
|
|||
|
|
delete newDescriptions[filename];
|
|||
|
|
return newDescriptions;
|
|||
|
|
});
|
|||
|
|
setProgress(prev => {
|
|||
|
|
const newProgress = { ...prev };
|
|||
|
|
delete newProgress[filename];
|
|||
|
|
return newProgress;
|
|||
|
|
});
|
|||
|
|
setFilePreviews(prev => {
|
|||
|
|
const newPreviews = { ...prev };
|
|||
|
|
delete newPreviews[filename];
|
|||
|
|
return newPreviews;
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div style={{ paddingBottom: '80px' }}>
|
|||
|
|
<h1 className="mb-4">上传文件</h1>
|
|||
|
|
|
|||
|
|
<div className="mb-3">
|
|||
|
|
<Button
|
|||
|
|
variant={uploadMode === 'single' ? 'primary' : 'outline-primary'}
|
|||
|
|
onClick={() => setUploadMode('single')}
|
|||
|
|
className="me-2"
|
|||
|
|
>
|
|||
|
|
单文件上传
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
variant={uploadMode === 'batch' ? 'primary' : 'outline-primary'}
|
|||
|
|
onClick={() => setUploadMode('batch')}
|
|||
|
|
>
|
|||
|
|
批量上传
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<Card>
|
|||
|
|
<Card.Body>
|
|||
|
|
<Form onSubmit={handleUpload}>
|
|||
|
|
<Form.Group className="mb-3">
|
|||
|
|
<Form.Label>
|
|||
|
|
{uploadMode === 'single' ? '选择文件' : '选择多个文件'}
|
|||
|
|
</Form.Label>
|
|||
|
|
<Form.Control
|
|||
|
|
type="file"
|
|||
|
|
id="fileInput"
|
|||
|
|
onChange={handleFileChange}
|
|||
|
|
disabled={uploading}
|
|||
|
|
multiple={uploadMode === 'batch'}
|
|||
|
|
/>
|
|||
|
|
</Form.Group>
|
|||
|
|
|
|||
|
|
<Form.Group className="mb-3">
|
|||
|
|
<Form.Label>选择分类 <span className="text-danger">*</span></Form.Label>
|
|||
|
|
<Form.Select
|
|||
|
|
value={selectedCategory}
|
|||
|
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
|||
|
|
disabled={uploading}
|
|||
|
|
required
|
|||
|
|
>
|
|||
|
|
<option value="">请选择分类</option>
|
|||
|
|
{categories.map(cat => (
|
|||
|
|
<option key={cat.id} value={cat.id}>
|
|||
|
|
{/* 使用缩进表示层级关系 */}
|
|||
|
|
{cat.level > 0 ? ' '.repeat(cat.level) + '└ ' : ''}{cat.name}
|
|||
|
|
</option>
|
|||
|
|
))}
|
|||
|
|
</Form.Select>
|
|||
|
|
<Form.Text className="text-muted">
|
|||
|
|
必须选择一个文件分类(包括子分类)
|
|||
|
|
</Form.Text>
|
|||
|
|
</Form.Group>
|
|||
|
|
|
|||
|
|
{selectedFiles.length > 0 && (
|
|||
|
|
<div className="mb-3">
|
|||
|
|
<h5>已选择的文件 ({selectedFiles.length}个):</h5>
|
|||
|
|
{selectedFiles.map((file, index) => (
|
|||
|
|
<Card key={index} className="mb-2">
|
|||
|
|
<Card.Body className="p-3">
|
|||
|
|
<div className="d-flex justify-content-between align-items-start">
|
|||
|
|
<div className="flex-grow-1">
|
|||
|
|
<div className="d-flex align-items-center">
|
|||
|
|
{filePreviews[file.name] && (
|
|||
|
|
<img
|
|||
|
|
src={filePreviews[file.name]}
|
|||
|
|
alt="预览"
|
|||
|
|
style={{ width: '50px', height: '50px', objectFit: 'cover', marginRight: '10px' }}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
<div>
|
|||
|
|
<strong>{file.name}</strong>
|
|||
|
|
<div className="text-muted small">
|
|||
|
|
{(file.size / 1024 / 1024).toFixed(2)} MB
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<Form.Group className="mt-2">
|
|||
|
|
<Form.Control
|
|||
|
|
type="text"
|
|||
|
|
placeholder="文件描述(可选)"
|
|||
|
|
value={descriptions[file.name] || ''}
|
|||
|
|
onChange={(e) => handleDescriptionChange(file.name, e.target.value)}
|
|||
|
|
disabled={uploading}
|
|||
|
|
size="sm"
|
|||
|
|
/>
|
|||
|
|
</Form.Group>
|
|||
|
|
|
|||
|
|
{progress[file.name] !== undefined && (
|
|||
|
|
<ProgressBar
|
|||
|
|
now={progress[file.name]}
|
|||
|
|
label={`${progress[file.name]}%`}
|
|||
|
|
className="mt-2"
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<Button
|
|||
|
|
variant="outline-danger"
|
|||
|
|
size="sm"
|
|||
|
|
onClick={() => removeFile(file.name)}
|
|||
|
|
disabled={uploading}
|
|||
|
|
className="ms-2"
|
|||
|
|
>
|
|||
|
|
移除
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</Card.Body>
|
|||
|
|
</Card>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</Form>
|
|||
|
|
</Card.Body>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
{/* 固定在底部的上传按钮区域 */}
|
|||
|
|
<div style={{
|
|||
|
|
position: 'fixed',
|
|||
|
|
bottom: 0,
|
|||
|
|
left: 0,
|
|||
|
|
right: 0,
|
|||
|
|
backgroundColor: '#f8f9fa',
|
|||
|
|
borderTop: '1px solid #dee2e6',
|
|||
|
|
padding: '15px 20px',
|
|||
|
|
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)',
|
|||
|
|
zIndex: 1000,
|
|||
|
|
display: 'flex',
|
|||
|
|
justifyContent: 'center',
|
|||
|
|
alignItems: 'center',
|
|||
|
|
gap: '10px'
|
|||
|
|
}}>
|
|||
|
|
<Button
|
|||
|
|
type="button"
|
|||
|
|
variant="primary"
|
|||
|
|
onClick={handleUpload}
|
|||
|
|
disabled={selectedFiles.length === 0 || uploading}
|
|||
|
|
size="lg"
|
|||
|
|
>
|
|||
|
|
{uploading ? '上传中...' :
|
|||
|
|
uploadMode === 'single' ? '上传文件' : `批量上传 (${selectedFiles.length}个文件)`}
|
|||
|
|
</Button>
|
|||
|
|
|
|||
|
|
{uploading && (
|
|||
|
|
<Button
|
|||
|
|
variant="outline-secondary"
|
|||
|
|
onClick={resetForm}
|
|||
|
|
size="lg"
|
|||
|
|
>
|
|||
|
|
取消
|
|||
|
|
</Button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default FileUpload;
|