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
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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user