'use client'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; import { EmptyState } from '@/components/feedback/empty-state'; import { ErrorState } from '@/components/feedback/error-state'; import { InlineMessage } from '@/components/feedback/inline-message'; import { LoadingState } from '@/components/feedback/loading-state'; import { useAuth } from '@/components/providers/auth-provider'; import { PageHeader } from '@/components/layout/page-header'; import { AppCard } from '@/components/ui/app-card'; import { buildFileAbsoluteUrl, buildFileDownloadUrl, deleteFile, getFiles, searchFiles, uploadFiles, } from '@/features/files/api/files'; import type { FileItem } from '@/features/files/types'; import { DangerButton, PrimaryButton, ResourceField, ResourceInput, SecondaryButton, } from '@/features/shared/components/resource-primitives'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; const ITEMS_PER_PAGE = 10; const filesQueryKey = ['files', 'list'] as const; type FeedbackState = { tone: 'info' | 'success' | 'danger'; message: string; }; function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } async function copyToClipboard(value: string) { await navigator.clipboard.writeText(value); } export function FilesPage() { const queryClient = useQueryClient(); const { user } = useAuth(); const [page, setPage] = useState(0); const [searchInput, setSearchInput] = useState(''); const [searchKeyword, setSearchKeyword] = useState(''); const [description, setDescription] = useState(''); const [selectedFiles, setSelectedFiles] = useState([]); const [feedback, setFeedback] = useState(null); const isAdmin = (user?.role ?? 0) >= 10; const filesPageQuery = useQuery({ queryKey: [...filesQueryKey, page], queryFn: () => getFiles(page), enabled: isAdmin && searchKeyword.length === 0, }); const filesSearchQuery = useQuery({ queryKey: [...filesQueryKey, 'search', searchKeyword], queryFn: () => searchFiles(searchKeyword), enabled: isAdmin && searchKeyword.length > 0, }); const uploadMutation = useMutation({ mutationFn: async () => uploadFiles(selectedFiles, description.trim()), onSuccess: async () => { setFeedback({ tone: 'success', message: `已上传 ${selectedFiles.length} 个文件。` }); setSelectedFiles([]); setDescription(''); await queryClient.invalidateQueries({ queryKey: filesQueryKey }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const deleteMutation = useMutation({ mutationFn: deleteFile, onSuccess: async () => { setFeedback({ tone: 'success', message: '文件已删除。' }); await queryClient.invalidateQueries({ queryKey: filesQueryKey }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const activeQuery = searchKeyword.length > 0 ? filesSearchQuery : filesPageQuery; const files = activeQuery.data ?? []; const summary = useMemo(() => { return [ { label: searchKeyword ? '搜索结果' : '当前页文件', value: files.length }, { label: '当前上传批次', value: selectedFiles.length }, { label: '下载总次数', value: files.reduce((total, item) => total + (item.download_counter || 0), 0), }, { label: '上传者数', value: new Set(files.map((item) => item.uploader_id)).size, }, ]; }, [files, searchKeyword, selectedFiles.length]); const handleUpload = () => { if (selectedFiles.length === 0) { setFeedback({ tone: 'info', message: '请先选择要上传的文件。' }); return; } setFeedback(null); uploadMutation.mutate(); }; const handleDelete = (file: FileItem) => { if (!window.confirm(`确认删除文件“${file.filename}”吗?`)) { return; } setFeedback(null); deleteMutation.mutate(file.id); }; const handleCopy = async (link: string) => { try { await copyToClipboard(buildFileAbsoluteUrl(link)); setFeedback({ tone: 'success', message: '文件链接已复制到剪贴板。' }); } catch (error) { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); } }; const handleSearchSubmit = () => { setFeedback(null); setPage(0); setSearchKeyword(searchInput.trim()); }; const handleResetSearch = () => { setSearchInput(''); setSearchKeyword(''); setPage(0); setFeedback(null); }; if (!isAdmin) { return (
); } return (
{feedback ? : null}
{summary.map((item) => (

{item.label}

{item.value}

))}
{uploadMutation.isPending ? '上传中...' : '开始上传'} } >
setDescription(event.target.value)} placeholder='例如:节点安装包 / 测试证书 / 宣传素材' /> setSelectedFiles(Array.from(event.target.files ?? []))} className='block w-full rounded-2xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-6 text-sm text-[var(--foreground-secondary)] file:mr-4 file:rounded-xl file:border-0 file:bg-[var(--brand-primary)] file:px-4 file:py-2 file:text-sm file:font-medium file:text-[var(--foreground-inverse)]' /> {selectedFiles.length > 0 ? (

待上传文件

    {selectedFiles.map((file) => (
  • {file.name} · {(file.size / 1024).toFixed(1)} KB
  • ))}
) : ( )}
void queryClient.invalidateQueries({ queryKey: filesQueryKey })} > 刷新 } >
setSearchInput(event.target.value)} placeholder='搜索文件名、上传者或上传者 ID' className='md:max-w-xl' />
搜索 清空
{searchKeyword.length === 0 ? (
setPage((value) => Math.max(value - 1, 0))} disabled={page === 0 || activeQuery.isLoading}> 上一页 setPage((value) => value + 1)} disabled={activeQuery.isLoading || files.length < ITEMS_PER_PAGE} > 下一页
) : null}
{activeQuery.isLoading ? ( ) : activeQuery.isError ? ( ) : files.length === 0 ? ( ) : (
{files.map((file) => ( ))}
文件 上传者 上传时间 下载量 操作
{file.filename}

{file.description || '无描述信息'}

存储链接:{file.link}

{file.uploader}

上传者 ID:{file.uploader_id}

{formatRelativeTime(file.upload_time)} · {formatDateTime(file.upload_time)} {file.download_counter || 0}
window.open(buildFileDownloadUrl(file.link), '_blank', 'noopener,noreferrer')} className='px-3 py-2 text-xs' > 下载 void handleCopy(file.link)} className='px-3 py-2 text-xs' > 复制链接 handleDelete(file)} disabled={deleteMutation.isPending} className='px-3 py-2 text-xs' > 删除
)}
); }