feat: ship blog platform admin and deploy stack
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user