init: initial commit

This commit is contained in:
Blizzard
2026-04-27 17:12:13 +08:00
commit 9af7fe7f37
81 changed files with 11646 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
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>
)
}