mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
5a8722ff07
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Download,
|
|
FileArchive,
|
|
FileAudio,
|
|
FileImage,
|
|
FileText,
|
|
FileVideo,
|
|
Globe,
|
|
Loader2,
|
|
Lock,
|
|
Plus,
|
|
Search,
|
|
Trash2,
|
|
Upload,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { useTranslations } from 'next-intl';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
|
|
import { FileImagePreview } from '@/components/common/file-image-preview';
|
|
import services, { formatFileSize } from '@/lib/services';
|
|
import type { Upload as UploadRecord } from '@/lib/services/upload/types';
|
|
|
|
/* ─── 工具函数 ─────────────────────────────────────────── */
|
|
|
|
function getFileIcon(mimeType: string, className = 'size-10') {
|
|
if (mimeType.startsWith('image/'))
|
|
return <FileImage className={`${className} text-blue-400`} />;
|
|
if (mimeType.startsWith('video/'))
|
|
return <FileVideo className={`${className} text-purple-400`} />;
|
|
if (mimeType.startsWith('audio/'))
|
|
return <FileAudio className={`${className} text-green-400`} />;
|
|
if (
|
|
mimeType.includes('zip') ||
|
|
mimeType.includes('tar') ||
|
|
mimeType.includes('gzip')
|
|
)
|
|
return <FileArchive className={`${className} text-amber-400`} />;
|
|
return <FileText className={`${className} text-slate-400`} />;
|
|
}
|
|
|
|
function formatDate(dateStr: string) {
|
|
return new Date(dateStr).toLocaleString('zh-CN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
export function UserFileManager() {
|
|
const queryClient = useQueryClient();
|
|
const t = useTranslations('files');
|
|
const [keyword, setKeyword] = React.useState('');
|
|
const [debouncedKeyword, setDebouncedKeyword] = React.useState('');
|
|
const [page, setPage] = React.useState(1);
|
|
const [uploading, setUploading] = React.useState(false);
|
|
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(
|
|
null,
|
|
);
|
|
const [uploadAccessMode, setUploadAccessMode] = React.useState<number>(0); // 默认私有
|
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
const pageSize = 12; // 每页 12 个卡片更均衡(3x4 或 4x3 布局)
|
|
|
|
// 搜索防抖
|
|
React.useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
setDebouncedKeyword(keyword);
|
|
setPage(1);
|
|
}, 400);
|
|
return () => clearTimeout(timer);
|
|
}, [keyword]);
|
|
|
|
// 我的文件列表查询
|
|
const { data, isPending } = useQuery({
|
|
queryKey: ['user-files', page, pageSize, debouncedKeyword],
|
|
queryFn: () =>
|
|
services.upload.listMyUploads(
|
|
page,
|
|
pageSize,
|
|
debouncedKeyword || undefined,
|
|
),
|
|
});
|
|
|
|
const files = data?.items ?? [];
|
|
const total = data?.total ?? 0;
|
|
const totalPages = Math.ceil(total / pageSize);
|
|
|
|
// 上传文件 Mutation
|
|
const uploadMutation = useMutation({
|
|
mutationFn: async (file: File) => {
|
|
return services.upload.uploadFile(
|
|
file,
|
|
'generic',
|
|
undefined,
|
|
uploadAccessMode,
|
|
);
|
|
},
|
|
onMutate: () => {
|
|
setUploading(true);
|
|
},
|
|
onSuccess: () => {
|
|
toast.success(t('uploadSuccess'));
|
|
void queryClient.invalidateQueries({ queryKey: ['user-files'] });
|
|
},
|
|
onError: (err: Error) => {
|
|
toast.error(err.message || t('uploadFailed'));
|
|
},
|
|
onSettled: () => {
|
|
setUploading(false);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
},
|
|
});
|
|
|
|
// 删除文件 Mutation
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) => services.upload.deleteMyFile(id),
|
|
onSuccess: () => {
|
|
toast.success(t('deleteSuccess'));
|
|
void queryClient.invalidateQueries({ queryKey: ['user-files'] });
|
|
setDeleteTarget(null);
|
|
},
|
|
onError: (err: Error) => {
|
|
toast.error(err.message || t('deleteFailed'));
|
|
},
|
|
});
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
uploadMutation.mutate(file);
|
|
}
|
|
};
|
|
|
|
const triggerUploadClick = () => {
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const handleDownload = (file: UploadRecord) => {
|
|
const url = services.upload.getDownloadUrl(file.id);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = file.file_name;
|
|
a.click();
|
|
};
|
|
|
|
return (
|
|
<div className='flex w-full flex-col gap-6'>
|
|
{/* 操作栏:搜索、权限选择、上传按钮 */}
|
|
<div className='flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4'>
|
|
<div className='relative flex-1 max-w-md'>
|
|
<Search className='absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground' />
|
|
<Input
|
|
placeholder={t('searchPlaceholder')}
|
|
value={keyword}
|
|
onChange={(e) => setKeyword(e.target.value)}
|
|
className='pl-9 h-9'
|
|
/>
|
|
</div>
|
|
|
|
<div className='flex items-center gap-3 self-end sm:self-auto'>
|
|
{/* 上传权限设置 */}
|
|
<div className='flex items-center gap-1.5 border rounded-lg p-1 bg-muted/40 h-9 text-xs'>
|
|
<Button
|
|
variant={uploadAccessMode === 0 ? 'secondary' : 'ghost'}
|
|
size='sm'
|
|
className='h-7 px-2.5 text-xs font-normal'
|
|
onClick={() => setUploadAccessMode(0)}
|
|
>
|
|
<Lock className='mr-1 size-3 text-amber-500' />
|
|
{t('private')}
|
|
</Button>
|
|
<Button
|
|
variant={uploadAccessMode === 1 ? 'secondary' : 'ghost'}
|
|
size='sm'
|
|
className='h-7 px-2.5 text-xs font-normal'
|
|
onClick={() => setUploadAccessMode(1)}
|
|
>
|
|
<Globe className='mr-1 size-3 text-sky-500' />
|
|
{t('public')}
|
|
</Button>
|
|
</div>
|
|
|
|
<input
|
|
type='file'
|
|
ref={fileInputRef}
|
|
onChange={handleFileChange}
|
|
className='hidden'
|
|
disabled={uploading}
|
|
/>
|
|
<Button
|
|
onClick={triggerUploadClick}
|
|
disabled={uploading}
|
|
className='h-9 gap-1.5 text-xs font-medium'
|
|
>
|
|
{uploading ? (
|
|
<Loader2 className='size-3.5 animate-spin' />
|
|
) : (
|
|
<Plus className='size-3.5' />
|
|
)}
|
|
{uploading ? t('uploading') : t('uploadFile')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 文件展示区 */}
|
|
{isPending ? (
|
|
<div className='flex items-center justify-center py-24'>
|
|
<Loader2 className='size-8 animate-spin text-primary' />
|
|
</div>
|
|
) : files.length === 0 ? (
|
|
<Card className='border border-dashed py-16 flex flex-col items-center justify-center text-center'>
|
|
<CardContent className='flex flex-col items-center gap-4'>
|
|
<div className='p-4 bg-muted rounded-full text-muted-foreground/60'>
|
|
<Upload className='size-10' />
|
|
</div>
|
|
<div className='space-y-1'>
|
|
<h3 className='font-semibold text-sm'>{t('noFiles')}</h3>
|
|
<p className='text-xs text-muted-foreground max-w-xs'>
|
|
{debouncedKeyword ? t('noMatchingFiles') : t('uploadFirstFile')}
|
|
</p>
|
|
</div>
|
|
{!debouncedKeyword && (
|
|
<Button
|
|
onClick={triggerUploadClick}
|
|
disabled={uploading}
|
|
size='sm'
|
|
className='mt-2 text-xs'
|
|
>
|
|
{t('selectFileUpload')}
|
|
</Button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4'>
|
|
{files.map((file) => (
|
|
<Card
|
|
key={file.id}
|
|
className='group border border-border/40 hover:border-border/80 bg-card/60 hover:bg-card/95 transition-all duration-300 shadow-xs hover:shadow-md overflow-hidden relative flex flex-col h-full'
|
|
>
|
|
{/* 文件预览/图标区 */}
|
|
<div className='h-36 bg-muted/30 border-b border-dashed relative flex items-center justify-center overflow-hidden p-2 group-hover:bg-muted/10 transition-colors'>
|
|
{file.mime_type.startsWith('image/') ? (
|
|
<FileImagePreview
|
|
fileId={file.id}
|
|
alt={file.file_name}
|
|
quality='medium'
|
|
className='max-h-full max-w-full object-contain rounded-md shadow-xs transition-transform duration-350 group-hover:scale-103'
|
|
fallbackClassName='min-h-full w-full rounded-md'
|
|
/>
|
|
) : (
|
|
getFileIcon(file.mime_type)
|
|
)}
|
|
|
|
{/* 访问权限 Badge */}
|
|
<div className='absolute top-2 right-2'>
|
|
{file.access_mode === 0 ? (
|
|
<Badge
|
|
variant='secondary'
|
|
className='bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] gap-1 px-1.5 py-0 rounded-md border border-amber-500/20 font-normal'
|
|
>
|
|
<Lock className='size-2.5' />
|
|
{t('private')}
|
|
</Badge>
|
|
) : (
|
|
<Badge
|
|
variant='secondary'
|
|
className='bg-sky-500/10 text-sky-600 dark:text-sky-400 text-[10px] gap-1 px-1.5 py-0 rounded-md border border-sky-500/20 font-normal'
|
|
>
|
|
<Globe className='size-2.5' />
|
|
{t('public')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 文件详情区 */}
|
|
<CardContent className='p-4 flex-1 flex flex-col justify-between gap-3'>
|
|
<div className='space-y-1'>
|
|
<h4
|
|
className='font-semibold text-xs text-foreground truncate select-all'
|
|
title={file.file_name}
|
|
>
|
|
{file.file_name}
|
|
</h4>
|
|
<div className='flex items-center justify-between text-[10px] text-muted-foreground font-mono'>
|
|
<span>{formatFileSize(file.file_size)}</span>
|
|
<span
|
|
className='truncate max-w-[120px]'
|
|
title={file.mime_type}
|
|
>
|
|
{file.mime_type}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='flex items-center justify-between pt-1 border-t border-dashed'>
|
|
<span className='text-[9px] text-muted-foreground/80'>
|
|
{formatDate(file.created_at)}
|
|
</span>
|
|
|
|
<div className='flex items-center gap-1'>
|
|
<Button
|
|
size='icon'
|
|
variant='ghost'
|
|
className='size-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground'
|
|
title={t('downloadFile')}
|
|
onClick={() => handleDownload(file)}
|
|
>
|
|
<Download className='size-3.5' />
|
|
</Button>
|
|
<Button
|
|
size='icon'
|
|
variant='ghost'
|
|
className='size-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
|
|
title={t('deleteFile')}
|
|
onClick={() => setDeleteTarget(file)}
|
|
>
|
|
<Trash2 className='size-3.5' />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 分页 */}
|
|
{totalPages > 1 && (
|
|
<div className='flex items-center justify-center gap-2 pt-4'>
|
|
<Button
|
|
size='sm'
|
|
variant='outline'
|
|
className='border-dashed text-xs h-8 px-3'
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((p) => p - 1)}
|
|
>
|
|
<ChevronLeft className='mr-1 size-3.5' />
|
|
{t('prevPage')}
|
|
</Button>
|
|
<span className='text-xs text-muted-foreground font-medium px-2'>
|
|
{page} / {totalPages}
|
|
</span>
|
|
<Button
|
|
size='sm'
|
|
variant='outline'
|
|
className='border-dashed text-xs h-8 px-3'
|
|
disabled={page >= totalPages}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
>
|
|
{t('nextPage')}
|
|
<ChevronRight className='ml-1 size-3.5' />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 删除确认 Dialog */}
|
|
<AlertDialog
|
|
open={!!deleteTarget}
|
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
|
>
|
|
<AlertDialogContent suppressHydrationWarning>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t('confirmDeleteFile')}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t('confirmDeleteFileDesc', {
|
|
name: deleteTarget?.file_name || '',
|
|
})}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
|
{t('cancel')}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() =>
|
|
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
|
}
|
|
disabled={deleteMutation.isPending}
|
|
className='bg-destructive hover:bg-destructive/90 text-destructive-foreground'
|
|
>
|
|
{deleteMutation.isPending && (
|
|
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
|
)}
|
|
{t('confirmDelete')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|