Files
termi-blog/admin/src/pages/media-page.tsx
limitcool ee0bec4a78
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
test: add full playwright ui regression coverage
2026-04-02 00:55:34 +08:00

699 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}