filesend/backend/app/uploads/c3ffaced2cc24b1ca2b7bed508c63d42_FileManagement.js

518 lines
20 KiB
JavaScript
Raw Permalink Normal View History

2025-10-10 17:25:29 +08:00
/* FileManagement.js 直接复制即可使用 */
import React, { useState, useEffect, useCallback } from 'react';
import axios from '../../services/axios';
import { adminAPI } from '../../services/api';
import { toast } from 'react-toastify';
import Pagination from '../../components/Pagination';
import '../../styles/Pagination.css';
import '../../styles/file-management.css';
import { debounce } from '../../utils/debounce';
const FileManagement = () => {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc');
const [showEditModal, setShowEditModal] = useState(false);
const [currentFile, setCurrentFile] = useState(null);
const [description, setDescription] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [selectedFiles, setSelectedFiles] = useState([]);
const [showBatchModal, setShowBatchModal] = useState(false);
const [batchAction, setBatchAction] = useState('');
const [categories, setCategories] = useState([]);
const [selectedCategory, setSelectedCategory] = useState('all');
const [editFileCategoryId, setEditFileCategoryId] = useState('');
/* ---------------- 数据获取 ---------------- */
const fetchCategories = async () => {
try {
const res = await adminAPI.getCategories({ hierarchical: true });
const data = res.data?.categories || res.data || [];
const processCategories = (cats, level = 0) => {
let result = [];
cats.forEach(cat => {
const item = {
...cat,
id: cat.id || cat._id || cat.category_id || '',
name: cat.name || cat.category_name || '未命名分类',
level: cat.level || level,
children: cat.children || [],
};
result.push(item);
if (item.children.length) {
result = result.concat(processCategories(item.children, level + 1));
}
});
return result;
};
setCategories(processCategories(data));
} catch (err) {
toast.error('获取分类列表失败');
console.error(err);
setCategories([]);
}
};
const fetchFiles = useCallback(async (reset = false) => {
try {
setLoading(true);
const currentPage = reset ? 1 : page;
if (reset) setPage(1);
const params = {
page: currentPage,
per_page: itemsPerPage,
search: searchQuery,
category_id: selectedCategory === 'all' ? '' : selectedCategory,
sort_by: sortBy,
sort_order: sortOrder,
status: filterStatus === 'all' ? '' : filterStatus,
};
const res = await axios.get('/api/admin/files', { params });
const { files: newFiles, total_pages, total_files } = res.data;
setFiles(newFiles);
setTotalPages(total_pages);
setTotalItems(total_files);
} catch (err) {
toast.error('获取文件列表失败');
console.error(err);
} finally {
setLoading(false);
}
}, [page, itemsPerPage, searchQuery, selectedCategory, sortBy, sortOrder, filterStatus]);
useEffect(() => { fetchCategories(); }, []);
const debouncedSearch = useCallback(debounce(() => fetchFiles(true), 500), [fetchFiles]);
useEffect(() => { debouncedSearch(); }, [searchQuery, debouncedSearch]);
useEffect(() => { fetchFiles(); }, [filterStatus, selectedCategory, sortBy, sortOrder, itemsPerPage, page]);
/* ---------------- 工具方法 ---------------- */
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
/* ---------------- 文件操作 ---------------- */
const downloadFile = async (fileId) => {
try {
const res = await axios.get(`/api/admin/files/${fileId}/download`, { responseType: 'blob' });
const file = files.find(f => f.id === fileId);
const url = window.URL.createObjectURL(new Blob([res.data]));
const a = document.createElement('a');
a.href = url;
a.download = file?.original_filename || 'file';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
toast.error('下载文件失败');
console.error(err);
}
};
const deleteFile = async (fileId) => {
if (!window.confirm('确定要删除这个文件吗?此操作不可恢复。')) return;
try {
const res = await adminAPI.deleteFile(fileId);
toast.success(res.data.message || '文件已删除');
fetchFiles();
} catch (err) {
toast.error(err.response?.data?.message || '删除文件失败');
console.error(err);
}
};
const openEditModal = (file) => {
setCurrentFile(file);
setDescription(file.description || '');
setEditFileCategoryId(file.category_id || '');
setShowEditModal(true);
};
const saveFileEdit = async () => {
if (!currentFile) return;
try {
await axios.put(`/api/admin/files/${currentFile.id}`, {
description,
category_id: editFileCategoryId || null,
});
toast.success('文件信息已更新');
setShowEditModal(false);
fetchFiles();
} catch (err) {
toast.error('更新文件信息失败');
console.error(err);
}
};
/* ---------------- 批量逻辑 ---------------- */
const handleSelectFile = (fileId) => {
setSelectedFiles(prev =>
prev.includes(fileId) ? prev.filter(id => id !== fileId) : [...prev, fileId]
);
};
const handleSelectAll = () => {
if (selectedFiles.length === files.length) {
setSelectedFiles([]);
} else {
setSelectedFiles(files.map(f => f.id));
}
};
const handleBatchAction = (action) => {
if (selectedFiles.length === 0) {
toast.warning('请先选择文件');
return;
}
setBatchAction(action);
setShowBatchModal(true);
};
const executeBatchAction = async () => {
let success = 0;
try {
if (batchAction === 'delete') {
if (!window.confirm(`确定要删除选中的 ${selectedFiles.length} 个文件吗?此操作不可恢复。`)) return;
for (const id of selectedFiles) {
try {
await adminAPI.deleteFile(id);
success++;
} catch (e) {
console.error(`删除文件 ${id} 失败`, e);
}
}
toast.success(`成功删除 ${success}/${selectedFiles.length} 个文件`);
} else if (batchAction === 'download') {
for (const id of selectedFiles) {
try {
await downloadFile(id);
success++;
} catch (e) {
console.error(`下载文件 ${id} 失败`, e);
}
}
toast.success(`开始下载 ${success}/${selectedFiles.length} 个文件`);
}
} catch (err) {
toast.error('批量操作失败');
console.error(err);
} finally {
setShowBatchModal(false);
setSelectedFiles([]);
fetchFiles();
}
};
/* ---------------- 渲染 ---------------- */
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="admin-container">
<div className="admin-content">
{/* 头部 */}
<div className="file-management-header">
<h2 className="file-management-title">文件管理</h2>
<p className="file-management-subtitle">管理所有上传的文件</p>
</div>
{/* 筛选栏 */}
<div className="file-management-filters">
<input
type="text"
className="search-input"
placeholder="搜索文件名或描述..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<select
className="filter-select"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
>
<option value="all">所有分类</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>
{cat.level > 0 ? ' '.repeat(cat.level) + '└ ' : ''}{cat.name}
</option>
))}
</select>
<select
className="filter-select"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
>
<option value="all">所有状态</option>
<option value="available">可领取</option>
<option value="taken">已领取</option>
</select>
<select
className="filter-select"
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
>
<option value="created_at">按上传时间排序</option>
<option value="file_size">按文件大小排序</option>
</select>
<select
className="filter-select"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
>
<option value="desc">降序</option>
<option value="asc">升序</option>
</select>
</div>
{/* 批量操作条 */}
{selectedFiles.length > 0 && (
<div className="batch-operation-bar">
<span className="batch-info">已选择 {selectedFiles.length} 个文件</span>
<div className="batch-actions">
<button className="btn-batch btn-batch-primary" onClick={() => handleBatchAction('download')}>
批量下载
</button>
<button className="btn-batch btn-batch-danger" onClick={() => handleBatchAction('delete')}>
批量删除
</button>
</div>
</div>
)}
{/* 列表 */}
<div className="file-list-container">
<table className="file-table">
<thead>
<tr>
<th className="checkbox-column">
<input
type="checkbox"
checked={files.length > 0 && selectedFiles.length === files.length}
onChange={handleSelectAll}
disabled={files.length === 0}
/>
</th>
<th className="id-column">ID</th>
<th className="filename-column">文件名</th>
<th className="size-column">大小</th>
<th className="status-column">状态</th>
<th className="taken-column">领取人</th>
<th className="time-column">上传时间</th>
<th className="actions-column">操作</th>
</tr>
</thead>
<tbody>
{files.length === 0 ? (
<tr>
<td colSpan="8" className="empty-state">
<div className="empty-content">
<i className="empty-icon">📁</i>
<p>暂无文件数据</p>
</div>
</td>
</tr>
) : (
files.map(file => (
<tr key={file.id} className="file-item">
<td className="checkbox-column">
<input
type="checkbox"
checked={selectedFiles.includes(file.id)}
onChange={() => handleSelectFile(file.id)}
/>
</td>
<td className="id-column">{file.id}</td>
<td className="filename-column">
<div className="file-info">
<span className="filename">{file.original_filename}</span>
{file.description && (
<span className="file-description">{file.description}</span>
)}
</div>
</td>
<td className="size-column">{formatFileSize(file.file_size)}</td>
<td className="status-column">
<span className={`status-badge ${file.is_taken ? 'status-taken' : 'status-available'}`}>
{file.is_taken ? '已领取' : '可领取'}
</span>
</td>
<td className="taken-column">
{file.taken_by ? file.taken_by_username || `用户#${file.taken_by}` : '未领取'}
</td>
<td className="time-column">{new Date(file.created_at).toLocaleString()}</td>
<td className="actions-column">
<div className="file-actions">
<button
className="btn-action btn-download"
onClick={() => downloadFile(file.id)}
title="下载文件"
>
<i className="icon-download"></i>
</button>
<button
className="btn-action btn-edit"
onClick={() => openEditModal(file)}
title="编辑文件"
>
<i className="icon-edit"></i>
</button>
<button
className="btn-action btn-delete"
onClick={() => deleteFile(file.id)}
title="删除文件"
>
<i className="icon-delete">🗑</i>
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 分页 */}
<div className="pagination-container">
<div className="pagination-info">
<span> <strong>{totalItems}</strong> </span>
<span> <strong>{page}</strong> / {totalPages} </span>
</div>
<div className="pagination-controls">
<button
className={`pagination-btn ${page === 1 ? 'disabled' : ''}`}
onClick={() => setPage(page - 1)}
disabled={page === 1}
title="上一页"
>
上一页
</button>
<div className="pagination-numbers">
{[...Array(totalPages)].map((_, index) => {
const pageNum = index + 1;
const isCurrent = page === pageNum;
const isNearCurrent = Math.abs(pageNum - page) <= 2;
const isFirstOrLast = pageNum === 1 || pageNum === totalPages;
if (totalPages <= 7 || isFirstOrLast || isNearCurrent) {
return (
<button
key={index}
className={`pagination-number ${isCurrent ? 'active' : ''}`}
onClick={() => setPage(pageNum)}
title={`${pageNum}`}
>
{pageNum}
</button>
);
} else if (pageNum === page - 3 || pageNum === page + 3) {
return <span key={index} className="pagination-ellipsis">...</span>;
}
return null;
})}
</div>
<button
className={`pagination-btn ${page === totalPages ? 'disabled' : ''}`}
onClick={() => setPage(page + 1)}
disabled={page === totalPages}
title="下一页"
>
下一页
</button>
</div>
</div>
</div>
</div>
{/* ===== 以下弹窗与 admin-container 同级,保证始终居中视口 ===== */}
{/* 编辑单文件弹窗 */}
{showEditModal && (
<div className="modal-overlay">
<div className="admin-modal">
<div className="modal-header">
<h5 className="modal-title">编辑文件</h5>
<button type="button" className="modal-close" onClick={() => setShowEditModal(false)}>×</button>
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">描述</label>
<textarea
className="form-control"
rows="3"
value={description}
onChange={(e) => setDescription(e.target.value)}
></textarea>
</div>
<div className="mb-3">
<label className="form-label">分类 (可选)</label>
<select
className="form-select"
value={editFileCategoryId}
onChange={(e) => setEditFileCategoryId(e.target.value)}
>
<option value=""></option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>
{cat.level > 0 ? ' '.repeat(cat.level) + '└ ' : ''}{cat.name}
</option>
))}
</select>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowEditModal(false)}>取消</button>
<button className="btn btn-primary" onClick={saveFileEdit}>保存</button>
</div>
</div>
</div>
)}
{/* 批量操作确认弹窗 */}
{showBatchModal && (
<div className="modal-overlay">
<div className="admin-modal">
<div className="modal-header">
<h5 className="modal-title">确认批量操作</h5>
<button type="button" className="modal-close" onClick={() => setShowBatchModal(false)}>×</button>
</div>
<div className="modal-body">
{batchAction === 'delete' && (
<p>确定要删除选中的 {selectedFiles.length} 个文件吗此操作不可恢复</p>
)}
{batchAction === 'download' && (
<p>确定要下载选中的 {selectedFiles.length} 个文件吗</p>
)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowBatchModal(false)}>取消</button>
<button className="btn btn-primary" onClick={executeBatchAction}>确认</button>
</div>
</div>
</div>
)}
</>
);
};
export default FileManagement;