init: initial commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user