filesend/backend/app/uploads/42026ec5bfd24d5fbdb92f1cd6fad66b_FileUpload.js

399 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

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: '60px' }}>
<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: '20px',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'white',
border: '1px solid #dee2e6',
borderRadius: '8px',
padding: '12px 20px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
zIndex: 1000,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '10px',
width: 'auto',
minWidth: '200px',
maxWidth: '90%'
}}>
<Button
type="button"
variant="primary"
onClick={handleUpload}
disabled={selectedFiles.length === 0 || uploading}
size="md"
>
{uploading ? '上传中...' :
uploadMode === 'single' ? '上传文件' : `批量上传 (${selectedFiles.length}个文件)`}
</Button>
{uploading && (
<Button
variant="outline-secondary"
onClick={resetForm}
size="md"
>
取消
</Button>
)}
</div>
</div>
);
};
export default FileUpload;