chore: checkpoint admin editor and perf work
This commit is contained in:
190
admin/src/pages/media-page.tsx
Normal file
190
admin/src/pages/media-page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Copy, Image as ImageIcon, RefreshCcw, Trash2 } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import type { AdminMediaObjectResponse } from '@/lib/types'
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '0 B'
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = value
|
||||
let unitIndex = 0
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
export function MediaPage() {
|
||||
const [items, setItems] = useState<AdminMediaObjectResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [deletingKey, setDeletingKey] = useState<string | null>(null)
|
||||
const [prefixFilter, setPrefixFilter] = useState('all')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [provider, setProvider] = useState<string | null>(null)
|
||||
const [bucket, setBucket] = useState<string | null>(null)
|
||||
|
||||
const loadItems = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
const prefix = prefixFilter === 'all' ? undefined : prefixFilter
|
||||
const result = await adminApi.listMediaObjects({ prefix, limit: 200 })
|
||||
startTransition(() => {
|
||||
setItems(result.items)
|
||||
setProvider(result.provider)
|
||||
setBucket(result.bucket)
|
||||
})
|
||||
if (showToast) {
|
||||
toast.success('媒体对象列表已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '媒体对象列表加载失败。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [prefixFilter])
|
||||
|
||||
useEffect(() => {
|
||||
void loadItems(false)
|
||||
}, [loadItems])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const keyword = searchTerm.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return items
|
||||
}
|
||||
return items.filter((item) => item.key.toLowerCase().includes(keyword))
|
||||
}, [items, searchTerm])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">媒体库</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">对象存储媒体管理</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
查看当前对象存储里的封面资源,支持筛选、复制链接和删除无用对象。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" onClick={() => void loadItems(true)} disabled={refreshing}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>当前存储</CardTitle>
|
||||
<CardDescription>
|
||||
Provider:{provider ?? '未配置'} / Bucket:{bucket ?? '未配置'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 lg:grid-cols-[220px_1fr]">
|
||||
<Select value={prefixFilter} onChange={(event) => setPrefixFilter(event.target.value)}>
|
||||
<option value="all">全部目录</option>
|
||||
<option value="post-covers/">文章封面</option>
|
||||
<option value="review-covers/">评测封面</option>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="按对象 key 搜索"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton className="h-[520px] rounded-3xl" />
|
||||
) : (
|
||||
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
{filteredItems.map((item) => (
|
||||
<Card key={item.key} className="overflow-hidden">
|
||||
<div className="aspect-[16/9] overflow-hidden bg-muted/30">
|
||||
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<div className="space-y-2">
|
||||
<p className="line-clamp-2 break-all text-sm font-medium">{item.key}</p>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatBytes(item.size_bytes)}</span>
|
||||
{item.last_modified ? <span>{item.last_modified}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(item.url)
|
||||
toast.success('图片链接已复制。')
|
||||
} catch {
|
||||
toast.error('复制失败,请手动复制。')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
复制链接
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={deletingKey === item.key}
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`确定删除 ${item.key} 吗?`)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setDeletingKey(item.key)
|
||||
await adminApi.deleteMediaObject(item.key)
|
||||
startTransition(() => {
|
||||
setItems((current) => current.filter((currentItem) => currentItem.key !== item.key))
|
||||
})
|
||||
toast.success('媒体对象已删除。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '删除媒体对象失败。')
|
||||
} finally {
|
||||
setDeletingKey(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deletingKey === item.key ? '删除中...' : '删除'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{!filteredItems.length ? (
|
||||
<Card className="xl:col-span-2 2xl:col-span-3">
|
||||
<CardContent className="flex flex-col items-center gap-3 px-6 py-16 text-center text-muted-foreground">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
<p>当前筛选条件下没有媒体对象。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user