feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

View File

@@ -1,4 +1,13 @@
import { Copy, Image as ImageIcon, RefreshCcw, Trash2 } from 'lucide-react'
import {
CheckSquare,
Copy,
Image as ImageIcon,
RefreshCcw,
Replace,
Square,
Trash2,
Upload,
} from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
@@ -9,6 +18,11 @@ 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'
function formatBytes(value: number) {
@@ -30,10 +44,18 @@ export function MediaPage() {
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 [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
const [compressQuality, setCompressQuality] = useState('0.82')
const loadItems = useCallback(async (showToast = false) => {
try {
@@ -62,6 +84,12 @@ export function MediaPage() {
void loadItems(false)
}, [loadItems])
useEffect(() => {
setSelectedKeys((current) =>
current.filter((key) => items.some((item) => item.key === key)),
)
}, [items])
const filteredItems = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) {
@@ -70,6 +98,40 @@ export function MediaPage() {
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">
@@ -78,7 +140,7 @@ export function MediaPage() {
<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>
@@ -88,27 +150,119 @@ export function MediaPage() {
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
<Button
variant="danger"
disabled={!selectedKeys.length || batchDeleting}
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>
<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 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
placeholder="按对象 key 搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
</div>
<div className="grid gap-3 lg:grid-cols-[1fr_auto_auto_auto]">
<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}
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>
@@ -116,64 +270,138 @@ export function MediaPage() {
<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}
{filteredItems.map((item, index) => {
const selected = selectedKeys.includes(item.key)
const replaceInputId = `replace-media-${index}`
return (
<Card key={item.key} className="overflow-hidden">
<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>
</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>
))}
<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="outline" asChild>
<label htmlFor={replaceInputId} className="cursor-pointer">
<Replace className="h-4 w-4" />
</label>
</Button>
<input
id={replaceInputId}
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}
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">
@@ -185,6 +413,37 @@ export function MediaPage() {
) : 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>
)
}