Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 52s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 32s
ui-regression / playwright-regression (push) Failing after 14m24s
699 lines
27 KiB
TypeScript
699 lines
27 KiB
TypeScript
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<AdminMediaObjectResponse[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
const [deletingKey, setDeletingKey] = useState<string | null>(null)
|
||
const [replacingKey, setReplacingKey] = useState<string | null>(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<string | null>(null)
|
||
const [bucket, setBucket] = useState<string | null>(null)
|
||
const [uploadFiles, setUploadFiles] = useState<File[]>([])
|
||
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
|
||
const [activeKey, setActiveKey] = useState<string | null>(null)
|
||
const [metadataForm, setMetadataForm] = useState<MediaMetadataFormState>(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 (
|
||
<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>
|
||
<Button
|
||
variant="danger"
|
||
disabled={!selectedKeys.length || batchDeleting}
|
||
data-testid="media-batch-delete"
|
||
onClick={async () => {
|
||
if (!window.confirm(`确定批量删除 ${selectedKeys.length} 个对象吗?`)) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
setBatchDeleting(true)
|
||
const result = await adminApi.batchDeleteMediaObjects(selectedKeys)
|
||
if (result.failed.length) {
|
||
toast.warning(`已删除 ${result.deleted.length} 个,失败 ${result.failed.length} 个。`)
|
||
} else {
|
||
toast.success(`已删除 ${result.deleted.length} 个对象。`)
|
||
}
|
||
await loadItems(false)
|
||
} catch (error) {
|
||
toast.error(error instanceof ApiError ? error.message : '批量删除失败。')
|
||
} finally {
|
||
setBatchDeleting(false)
|
||
}
|
||
}}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
批量删除 ({selectedKeys.length})
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>上传与处理</CardTitle>
|
||
<CardDescription>
|
||
Provider:{provider ?? '未配置'} / Bucket:{bucket ?? '未配置'}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="grid gap-3 lg:grid-cols-[220px_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>
|
||
<option value="uploads/">通用上传</option>
|
||
</Select>
|
||
<Select value={uploadPrefix} onChange={(event) => setUploadPrefix(event.target.value)}>
|
||
<option value="post-covers/">上传到文章封面</option>
|
||
<option value="review-covers/">上传到评测封面</option>
|
||
<option value="uploads/">上传到通用目录</option>
|
||
</Select>
|
||
<Input
|
||
data-testid="media-search"
|
||
placeholder="按对象 key 搜索"
|
||
value={searchTerm}
|
||
onChange={(event) => setSearchTerm(event.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid gap-3 lg:grid-cols-[1fr_auto_auto_auto]">
|
||
<Input
|
||
data-testid="media-upload-input"
|
||
type="file"
|
||
multiple
|
||
accept="image/*"
|
||
onChange={(event) => {
|
||
const files = Array.from(event.target.files || [])
|
||
setUploadFiles(files)
|
||
}}
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => setCompressBeforeUpload((current) => !current)}
|
||
>
|
||
{compressBeforeUpload ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
|
||
压缩上传
|
||
</Button>
|
||
<Input
|
||
className="w-[96px]"
|
||
value={compressQuality}
|
||
onChange={(event) => setCompressQuality(event.target.value)}
|
||
placeholder="0.82"
|
||
disabled={!compressBeforeUpload}
|
||
/>
|
||
<Button
|
||
disabled={!uploadFiles.length || uploading}
|
||
data-testid="media-upload"
|
||
onClick={async () => {
|
||
try {
|
||
setUploading(true)
|
||
const files = await prepareFiles(uploadFiles)
|
||
const result = await adminApi.uploadMediaObjects(files, {
|
||
prefix: uploadPrefix,
|
||
})
|
||
toast.success(`上传完成,共 ${result.uploaded.length} 个文件。`)
|
||
setUploadFiles([])
|
||
await loadItems(false)
|
||
} catch (error) {
|
||
toast.error(error instanceof ApiError ? error.message : '上传失败。')
|
||
} finally {
|
||
setUploading(false)
|
||
}
|
||
}}
|
||
>
|
||
<Upload className="h-4 w-4" />
|
||
{uploading ? '上传中...' : '上传'}
|
||
</Button>
|
||
</div>
|
||
{uploadFiles.length ? (
|
||
<p className="text-xs text-muted-foreground">
|
||
已选择 {uploadFiles.length} 个文件。
|
||
{uploadPrefix === 'post-covers/' || uploadPrefix === 'review-covers/'
|
||
? ' 当前会自动裁切为 16:9 封面,并优先转成 AVIF/WebP。'
|
||
: ''}
|
||
</p>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{activeItem ? (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>媒体元数据</CardTitle>
|
||
<CardDescription>
|
||
当前编辑:{activeItem.key}。这里维护标题、alt、说明和标签,供文章封面 / 媒体选择器统一复用。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||
<div className="space-y-4">
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<FormField label="标题" hint="媒体资源的人类可读名称。">
|
||
<Input
|
||
value={metadataForm.title}
|
||
onChange={(event) =>
|
||
setMetadataForm((current) => ({ ...current, title: event.target.value }))
|
||
}
|
||
placeholder="文章封面 / 站点横幅"
|
||
/>
|
||
</FormField>
|
||
<FormField label="Alt 文本" hint="用于 img alt 和无障碍描述。">
|
||
<Input
|
||
value={metadataForm.altText}
|
||
onChange={(event) =>
|
||
setMetadataForm((current) => ({ ...current, altText: event.target.value }))
|
||
}
|
||
placeholder="夜色下的终端风格博客封面"
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
|
||
<FormField label="标签" hint="多个标签用英文逗号分隔。">
|
||
<Input
|
||
value={metadataForm.tags}
|
||
onChange={(event) =>
|
||
setMetadataForm((current) => ({ ...current, tags: event.target.value }))
|
||
}
|
||
placeholder="cover, astro, terminal"
|
||
/>
|
||
</FormField>
|
||
|
||
<FormField label="Caption" hint="适合前台图注、图片说明。">
|
||
<Textarea
|
||
value={metadataForm.caption}
|
||
onChange={(event) =>
|
||
setMetadataForm((current) => ({ ...current, caption: event.target.value }))
|
||
}
|
||
rows={4}
|
||
placeholder="这张图通常用于文章列表和详情页头图。"
|
||
/>
|
||
</FormField>
|
||
|
||
<FormField label="内部备注" hint="仅后台使用,例如素材来源、版权或推荐用途。">
|
||
<Textarea
|
||
value={metadataForm.notes}
|
||
onChange={(event) =>
|
||
setMetadataForm((current) => ({ ...current, notes: event.target.value }))
|
||
}
|
||
rows={4}
|
||
placeholder="来源:Unsplash / 站点截图 / AI 生成"
|
||
/>
|
||
</FormField>
|
||
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Button
|
||
disabled={metadataSaving}
|
||
data-testid="media-save-metadata"
|
||
onClick={async () => {
|
||
if (!activeItem) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
setMetadataSaving(true)
|
||
const result = await adminApi.updateMediaObjectMetadata({
|
||
key: activeItem.key,
|
||
title: metadataForm.title || null,
|
||
altText: metadataForm.altText || null,
|
||
caption: metadataForm.caption || null,
|
||
tags: parseTagList(metadataForm.tags),
|
||
notes: metadataForm.notes || null,
|
||
})
|
||
startTransition(() => {
|
||
setItems((current) =>
|
||
current.map((item) =>
|
||
item.key === result.key
|
||
? {
|
||
...item,
|
||
title: result.title,
|
||
alt_text: result.alt_text,
|
||
caption: result.caption,
|
||
tags: normalizeMediaTags(result.tags),
|
||
notes: result.notes,
|
||
}
|
||
: item,
|
||
),
|
||
)
|
||
})
|
||
toast.success('媒体元数据已保存。')
|
||
} catch (error) {
|
||
toast.error(error instanceof ApiError ? error.message : '保存媒体元数据失败。')
|
||
} finally {
|
||
setMetadataSaving(false)
|
||
}
|
||
}}
|
||
>
|
||
<Save className="h-4 w-4" />
|
||
{metadataSaving ? '保存中...' : '保存元数据'}
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setMetadataForm(toMetadataForm(activeItem))}>
|
||
重置
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4 rounded-3xl border border-border/70 bg-background/50 p-4">
|
||
<div className="aspect-[16/9] overflow-hidden rounded-2xl border border-border/70 bg-muted/30">
|
||
<img
|
||
src={activeItem.url}
|
||
alt={metadataForm.altText || activeItem.key}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 text-sm text-muted-foreground">
|
||
<p className="break-all font-medium text-foreground">{activeItem.key}</p>
|
||
<p>{formatBytes(activeItem.size_bytes)} · {activeItem.last_modified ?? '未知修改时间'}</p>
|
||
<p>{metadataForm.altText || '尚未填写 alt 文本'}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
) : null}
|
||
|
||
{loading ? (
|
||
<Skeleton className="h-[520px] rounded-3xl" />
|
||
) : (
|
||
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
|
||
{filteredItems.map((item, index) => {
|
||
const selected = selectedKeys.includes(item.key)
|
||
const replaceInputId = `replace-media-${index}`
|
||
const itemTags = normalizeMediaTags(item.tags)
|
||
|
||
return (
|
||
<Card
|
||
key={item.key}
|
||
data-testid={`media-item-${index}`}
|
||
className={`overflow-hidden ${activeKey === item.key ? 'ring-1 ring-primary/40' : ''}`}
|
||
>
|
||
<div className="relative aspect-[16/9] overflow-hidden bg-muted/30">
|
||
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
|
||
<button
|
||
type="button"
|
||
className="absolute left-2 top-2 rounded-xl border border-border/80 bg-background/80 p-1"
|
||
onClick={() => {
|
||
setSelectedKeys((current) => {
|
||
if (current.includes(item.key)) {
|
||
return current.filter((key) => key !== item.key)
|
||
}
|
||
return [...current, item.key]
|
||
})
|
||
}}
|
||
>
|
||
{selected ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4" />}
|
||
</button>
|
||
</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>
|
||
{item.title ? <p className="text-sm text-foreground">{item.title}</p> : null}
|
||
{itemTags.length ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{itemTags.slice(0, 4).map((tag) => (
|
||
<Badge key={`${item.key}-${tag}`} variant="outline">
|
||
{tag}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setActiveKey(item.key)}
|
||
data-testid={`media-edit-${index}`}
|
||
>
|
||
元数据
|
||
</Button>
|
||
<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="outline" asChild>
|
||
<label htmlFor={replaceInputId} className="cursor-pointer">
|
||
<Replace className="h-4 w-4" />
|
||
替换
|
||
</label>
|
||
</Button>
|
||
<input
|
||
id={replaceInputId}
|
||
data-testid={`media-replace-input-${index}`}
|
||
className="hidden"
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={async (event) => {
|
||
const file = event.target.files?.item(0)
|
||
event.currentTarget.value = ''
|
||
if (!file) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
setReplacingKey(item.key)
|
||
const [prepared] = await prepareFiles(
|
||
[file],
|
||
item.key.startsWith('review-covers/')
|
||
? 'review-covers/'
|
||
: item.key.startsWith('post-covers/')
|
||
? 'post-covers/'
|
||
: 'uploads/',
|
||
)
|
||
const result = await adminApi.replaceMediaObject(item.key, prepared)
|
||
startTransition(() => {
|
||
setItems((current) =>
|
||
current.map((currentItem) =>
|
||
currentItem.key === item.key
|
||
? { ...currentItem, url: result.url }
|
||
: currentItem,
|
||
),
|
||
)
|
||
})
|
||
toast.success('已替换媒体对象。')
|
||
} catch (error) {
|
||
toast.error(error instanceof ApiError ? error.message : '替换失败。')
|
||
} finally {
|
||
setReplacingKey(null)
|
||
}
|
||
}}
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="danger"
|
||
disabled={deletingKey === item.key || replacingKey === item.key}
|
||
data-testid={`media-delete-${index}`}
|
||
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),
|
||
)
|
||
setSelectedKeys((current) =>
|
||
current.filter((key) => 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
|
||
? '删除中...'
|
||
: replacingKey === 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>
|
||
)}
|
||
|
||
{filteredItems.length ? (
|
||
<Card>
|
||
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-6 text-sm text-muted-foreground">
|
||
<p>
|
||
当前筛选 {filteredItems.length} 个对象,已选择 {selectedKeys.length} 个。
|
||
</p>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
if (allFilteredSelected) {
|
||
setSelectedKeys((current) =>
|
||
current.filter(
|
||
(key) => !filteredItems.some((item) => item.key === key),
|
||
),
|
||
)
|
||
return
|
||
}
|
||
setSelectedKeys((current) => {
|
||
const next = new Set(current)
|
||
filteredItems.forEach((item) => next.add(item.key))
|
||
return Array.from(next)
|
||
})
|
||
}}
|
||
>
|
||
{allFilteredSelected ? <Square className="h-4 w-4" /> : <CheckSquare className="h-4 w-4" />}
|
||
{allFilteredSelected ? '取消全选' : '全选当前筛选'}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|