filesend/backend/app/uploads/c3ffaced2cc24b1ca2b7bed508c63d42_FileManagement.js

518 lines
20 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.

/* 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;