feat: ship public ops features and cache docker builds
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled

This commit is contained in:
2026-04-01 13:22:19 +08:00
parent 669b79cc95
commit 497a9d713d
75 changed files with 6985 additions and 668 deletions

View File

@@ -4,6 +4,7 @@ import {
Image as ImageIcon,
RefreshCcw,
Replace,
Save,
Square,
Trash2,
Upload,
@@ -24,6 +25,8 @@ import {
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) {
@@ -39,6 +42,47 @@ function formatBytes(value: number) {
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 toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFormState {
if (!item) {
return defaultMetadataForm
}
return {
title: item.title ?? '',
altText: item.alt_text ?? '',
caption: item.caption ?? '',
tags: 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)
@@ -54,6 +98,9 @@ export function MediaPage() {
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')
@@ -90,6 +137,25 @@ export function MediaPage() {
)
}, [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) {
@@ -266,6 +332,140 @@ export function MediaPage() {
</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}
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: 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" />
) : (
@@ -275,7 +475,10 @@ export function MediaPage() {
const replaceInputId = `replace-media-${index}`
return (
<Card key={item.key} className="overflow-hidden">
<Card
key={item.key}
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
@@ -300,8 +503,21 @@ export function MediaPage() {
<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}
{item.tags.length ? (
<div className="flex flex-wrap gap-2">
{item.tags.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)}>
</Button>
<Button
size="sm"
variant="outline"