Files
sundynix-micro-admin/src/pages/system/Files.tsx
T
2026-04-27 17:12:13 +08:00

135 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
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>
)
}