135 lines
8.5 KiB
TypeScript
135 lines
8.5 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import { Search, Trash2, Loader2, Upload, Grid, List, FileText, Image, Music, File } from 'lucide-react'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import { Checkbox } from '@/components/ui/checkbox'
|
||
import { getFileList, uploadFile, deleteFile } from '@/api/systemCrud'
|
||
import type { SystemOss } from '@/api/system'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
const getFileIcon = (suffix?: string) => {
|
||
if (!suffix) return <File className="h-5 w-5" />
|
||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(suffix)) return <Image className="h-5 w-5 text-blue-500" />
|
||
if (['mp3', 'wav', 'ogg', 'flac'].includes(suffix)) return <Music className="h-5 w-5 text-purple-500" />
|
||
return <FileText className="h-5 w-5 text-amber-500" />
|
||
}
|
||
|
||
const isImage = (suffix?: string) => suffix && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(suffix)
|
||
|
||
export default function FilesPage() {
|
||
const [files, setFiles] = useState<SystemOss[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [total, setTotal] = useState(0)
|
||
const [page, setPage] = useState(1)
|
||
const [search, setSearch] = useState('')
|
||
const [view, setView] = useState<'grid' | 'list'>('grid')
|
||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||
const [uploading, setUploading] = useState(false)
|
||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||
const [previewUrl, setPreviewUrl] = useState('')
|
||
const fileRef = useRef<HTMLInputElement>(null)
|
||
|
||
const fetchList = useCallback(async () => {
|
||
setLoading(true)
|
||
try { const res = await getFileList({ current: page, pageSize: 20, keyword: search || undefined }); if (res?.data) { setFiles(res.data.list || []); setTotal(res.data.total || 0) } }
|
||
catch (e) { console.error(e) } finally { setLoading(false) }
|
||
}, [page, search])
|
||
|
||
useEffect(() => { fetchList() }, [fetchList])
|
||
|
||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]; if (!file) return
|
||
setUploading(true)
|
||
try { await uploadFile(file); fetchList() } catch (err) { console.error(err) }
|
||
finally { setUploading(false); if (fileRef.current) fileRef.current.value = '' }
|
||
}
|
||
|
||
const toggleSelect = (id: string) => {
|
||
setSelected(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next })
|
||
}
|
||
|
||
const handleDelete = async () => {
|
||
try { await deleteFile(Array.from(selected)); setSelected(new Set()); setDeleteOpen(false); fetchList() }
|
||
catch (e) { console.error(e) }
|
||
}
|
||
|
||
const totalPages = Math.ceil(total / 20)
|
||
|
||
return (
|
||
<div className="space-y-6 animate-fadeIn">
|
||
<Card className="border-border/60 shadow-sm">
|
||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||
<div className="space-y-1">
|
||
<CardTitle className="text-xl flex items-center gap-2">文件管理 <Badge variant="secondary" className="text-xs">{total}</Badge></CardTitle>
|
||
<CardDescription>管理上传的文件和图片资源</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-3 w-full sm:w-auto flex-wrap">
|
||
<div className="relative flex-1 sm:flex-none"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="搜索文件..." className="pl-9 w-full sm:w-[200px] h-10 bg-muted/30" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} /></div>
|
||
<div className="flex gap-1 border rounded-lg p-0.5">
|
||
<Button variant={view === 'grid' ? 'secondary' : 'ghost'} size="icon" className="h-8 w-8" onClick={() => setView('grid')}><Grid className="h-4 w-4" /></Button>
|
||
<Button variant={view === 'list' ? 'secondary' : 'ghost'} size="icon" className="h-8 w-8" onClick={() => setView('list')}><List className="h-4 w-4" /></Button>
|
||
</div>
|
||
{selected.size > 0 && <Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)}><Trash2 className="h-4 w-4 mr-1" />删除({selected.size})</Button>}
|
||
<input type="file" ref={fileRef} className="hidden" onChange={handleUpload} />
|
||
<Button onClick={() => fileRef.current?.click()} disabled={uploading} className="h-10 gap-2 shadow-sm">
|
||
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}上传
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading ? <div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
|
||
: view === 'grid' ? (
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||
{files.map(f => (
|
||
<div key={f.id} className={cn("group relative rounded-xl border overflow-hidden cursor-pointer transition-all hover:shadow-md", selected.has(f.id) && "ring-2 ring-primary border-primary")}>
|
||
<div className="absolute top-2 left-2 z-10"><Checkbox checked={selected.has(f.id)} onCheckedChange={() => toggleSelect(f.id)} /></div>
|
||
<div className="aspect-square bg-muted/30 flex items-center justify-center" onClick={() => isImage(f.suffix) && setPreviewUrl(f.url)}>
|
||
{isImage(f.suffix) ? <img src={f.url} alt={f.name} className="w-full h-full object-cover" /> : getFileIcon(f.suffix)}
|
||
</div>
|
||
<div className="p-2"><p className="text-xs font-medium truncate">{f.name}</p><p className="text-[10px] text-muted-foreground">{f.suffix?.toUpperCase()}</p></div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||
<Table><TableHeader className="bg-muted/50"><TableRow><TableHead className="w-8" /><TableHead className="font-semibold pl-2">文件名</TableHead><TableHead>类型</TableHead><TableHead>创建时间</TableHead></TableRow></TableHeader>
|
||
<TableBody>{files.map(f => (
|
||
<TableRow key={f.id} className="hover:bg-muted/30">
|
||
<TableCell><Checkbox checked={selected.has(f.id)} onCheckedChange={() => toggleSelect(f.id)} /></TableCell>
|
||
<TableCell className="pl-2 flex items-center gap-2">{getFileIcon(f.suffix)}<span className="text-sm">{f.name}</span></TableCell>
|
||
<TableCell><Badge variant="secondary" className="text-xs">{f.suffix?.toUpperCase()}</Badge></TableCell>
|
||
<TableCell className="text-xs text-muted-foreground">{new Date(f.createdAt).toLocaleDateString()}</TableCell>
|
||
</TableRow>
|
||
))}</TableBody></Table>
|
||
</div>
|
||
)}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-between pt-4">
|
||
<p className="text-sm text-muted-foreground">共 {total} 条</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</Button>
|
||
<span className="text-sm text-muted-foreground px-2">{page}/{totalPages}</span>
|
||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||
<DialogContent><DialogHeader><DialogTitle>确认删除</DialogTitle><DialogDescription>确定要删除选中的 {selected.size} 个文件吗?</DialogDescription></DialogHeader>
|
||
<DialogFooter><Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button><Button variant="destructive" onClick={handleDelete}>删除</Button></DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={!!previewUrl} onOpenChange={() => setPreviewUrl('')}>
|
||
<DialogContent className="sm:max-w-[700px] p-2"><img src={previewUrl} alt="preview" className="w-full rounded-lg" /></DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|