import { CheckSquare, Copy, Image as ImageIcon, RefreshCcw, Replace, Save, Square, Trash2, Upload, } 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 { formatCompressionPreview, maybeCompressImageWithPrompt, normalizeCoverImageWithPrompt, } from '@/lib/image-compress' import type { AdminMediaObjectResponse } from '@/lib/types' import { FormField } from '@/components/form-field' import { Textarea } from '@/components/ui/textarea' 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]}` } type MediaMetadataFormState = { title: string altText: string caption: string tags: string notes: string } const defaultMetadataForm: MediaMetadataFormState = { title: '', altText: '', caption: '', tags: '', notes: '', } function normalizeMediaTags(value: unknown): string[] { if (!Array.isArray(value)) { return [] } return value .filter((item): item is string => typeof item === 'string') .map((item) => item.trim()) .filter(Boolean) } function normalizeMediaItem(item: AdminMediaObjectResponse): AdminMediaObjectResponse { return { ...item, tags: normalizeMediaTags(item.tags), } } function toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFormState { if (!item) { return defaultMetadataForm } return { title: item.title ?? '', altText: item.alt_text ?? '', caption: item.caption ?? '', tags: normalizeMediaTags(item.tags).join(', '), notes: item.notes ?? '', } } function parseTagList(value: string) { return Array.from( new Set( value .split(',') .map((item) => item.trim()) .filter(Boolean), ), ) } export function MediaPage() { const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [deletingKey, setDeletingKey] = useState(null) const [replacingKey, setReplacingKey] = useState(null) const [uploading, setUploading] = useState(false) const [batchDeleting, setBatchDeleting] = useState(false) const [prefixFilter, setPrefixFilter] = useState('all') const [uploadPrefix, setUploadPrefix] = useState('post-covers/') const [searchTerm, setSearchTerm] = useState('') const [provider, setProvider] = useState(null) const [bucket, setBucket] = useState(null) const [uploadFiles, setUploadFiles] = useState([]) const [selectedKeys, setSelectedKeys] = useState([]) const [activeKey, setActiveKey] = useState(null) const [metadataForm, setMetadataForm] = useState(defaultMetadataForm) const [metadataSaving, setMetadataSaving] = useState(false) const [compressBeforeUpload, setCompressBeforeUpload] = useState(true) const [compressQuality, setCompressQuality] = useState('0.82') 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 }) const normalizedItems = result.items.map(normalizeMediaItem) startTransition(() => { setItems(normalizedItems) 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]) useEffect(() => { setSelectedKeys((current) => current.filter((key) => items.some((item) => item.key === key)), ) }, [items]) useEffect(() => { if (!items.length) { setActiveKey(null) setMetadataForm(defaultMetadataForm) return } setActiveKey((current) => (current && items.some((item) => item.key === current) ? current : items[0].key)) }, [items]) const activeItem = useMemo( () => items.find((item) => item.key === activeKey) ?? null, [activeKey, items], ) useEffect(() => { setMetadataForm(toMetadataForm(activeItem)) }, [activeItem]) const filteredItems = useMemo(() => { const keyword = searchTerm.trim().toLowerCase() if (!keyword) { return items } return items.filter((item) => item.key.toLowerCase().includes(keyword)) }, [items, searchTerm]) const allFilteredSelected = filteredItems.length > 0 && filteredItems.every((item) => selectedKeys.includes(item.key)) async function prepareFiles(files: File[], targetPrefix = uploadPrefix) { if (!compressBeforeUpload) { return files } const quality = Number.parseFloat(compressQuality) const safeQuality = Number.isFinite(quality) ? Math.min(Math.max(quality, 0.4), 0.95) : 0.82 const normalizeCover = targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/' const result: File[] = [] for (const file of files) { const compressed = normalizeCover ? await normalizeCoverImageWithPrompt(file, { quality: safeQuality, ask: true, contextLabel: `封面规范化上传(${file.name})`, }) : await maybeCompressImageWithPrompt(file, { quality: safeQuality, ask: true, contextLabel: `媒体库上传(${file.name})`, }) if (compressed.preview) { toast.message(formatCompressionPreview(compressed.preview) || '压缩评估完成') } result.push(compressed.file) } return result } return (
媒体库

对象存储媒体管理

查看当前对象存储里的封面资源,支持上传、批量删除、压缩上传和原位替换。

上传与处理 Provider:{provider ?? '未配置'} / Bucket:{bucket ?? '未配置'}
setSearchTerm(event.target.value)} />
{ const files = Array.from(event.target.files || []) setUploadFiles(files) }} /> setCompressQuality(event.target.value)} placeholder="0.82" disabled={!compressBeforeUpload} />
{uploadFiles.length ? (

已选择 {uploadFiles.length} 个文件。 {uploadPrefix === 'post-covers/' || uploadPrefix === 'review-covers/' ? ' 当前会自动裁切为 16:9 封面,并优先转成 AVIF/WebP。' : ''}

) : null}
{activeItem ? ( 媒体元数据 当前编辑:{activeItem.key}。这里维护标题、alt、说明和标签,供文章封面 / 媒体选择器统一复用。
setMetadataForm((current) => ({ ...current, title: event.target.value })) } placeholder="文章封面 / 站点横幅" /> setMetadataForm((current) => ({ ...current, altText: event.target.value })) } placeholder="夜色下的终端风格博客封面" />
setMetadataForm((current) => ({ ...current, tags: event.target.value })) } placeholder="cover, astro, terminal" />