feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
274
frontend/app/admin/media/MediaLibrary.tsx
Normal file
274
frontend/app/admin/media/MediaLibrary.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Images, Copy, ExternalLink, Eye, Check } from "lucide-react";
|
||||
import Modal from "@/components/Modal";
|
||||
import AdminEmpty from "@/components/admin/AdminEmpty";
|
||||
import type { MediaLibraryItem } from "@/lib/api";
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function formatTime(iso?: string): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function formatDims(item: MediaLibraryItem): string {
|
||||
if (item.width > 0 && item.height > 0) return `${item.width} × ${item.height}`;
|
||||
return "—";
|
||||
}
|
||||
|
||||
/** 网格缩略图:本地图走服务端缩略接口(磁盘缓存),远程对象直连原图(头像类本身小) */
|
||||
function thumbSrc(item: MediaLibraryItem): string {
|
||||
if (item.url.startsWith("/uploads/")) {
|
||||
return `/api/admin/media-library/thumb?u=${encodeURIComponent(item.url)}`;
|
||||
}
|
||||
return item.url;
|
||||
}
|
||||
|
||||
/** 管理后台媒体库:全站图片网格 + 分类筛选 + 详情弹窗(WebP 优先,可查看原图) */
|
||||
export default function MediaLibrary({
|
||||
items,
|
||||
counts,
|
||||
total,
|
||||
initError,
|
||||
}: {
|
||||
items: MediaLibraryItem[];
|
||||
counts: Record<string, number>;
|
||||
total: number;
|
||||
initError?: string;
|
||||
}) {
|
||||
const [category, setCategory] = useState<string>("all");
|
||||
const [active, setActive] = useState<MediaLibraryItem | null>(null);
|
||||
const [showOriginal, setShowOriginal] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => (category === "all" ? items : items.filter((i) => i.category === category)),
|
||||
[items, category]
|
||||
);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
const list: { key: string; label: string; count: number }[] = [
|
||||
{ key: "all", label: "全部", count: total },
|
||||
];
|
||||
// 按现有条目实际分类生成 tab,保持稳定顺序
|
||||
const seen = new Map<string, string>();
|
||||
for (const i of items) seen.set(i.category, i.category_name);
|
||||
for (const [key, label] of seen) {
|
||||
list.push({ key, label, count: counts[key] ?? 0 });
|
||||
}
|
||||
return list;
|
||||
}, [items, counts, total]);
|
||||
|
||||
const openItem = (item: MediaLibraryItem) => {
|
||||
setActive(item);
|
||||
setShowOriginal(false);
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const copyLink = async (item: MediaLibraryItem) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${window.location.origin}${item.url}`);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* 剪贴板不可用静默 */
|
||||
}
|
||||
};
|
||||
|
||||
if (initError) {
|
||||
return (
|
||||
<AdminEmpty
|
||||
icon={<Images size={20} />}
|
||||
title="媒体库加载失败"
|
||||
description={initError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<AdminEmpty
|
||||
icon={<Images size={20} />}
|
||||
title="暂无图片资源"
|
||||
description="站点内还没有任何上传图片"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 分类 tab */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap mb-4">
|
||||
{tabs.map((t) => {
|
||||
const on = category === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setCategory(t.key)}
|
||||
aria-pressed={on}
|
||||
className="h-8 px-3 rounded-full text-[12.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
|
||||
style={{
|
||||
border: `1px solid ${on ? "var(--accent)" : "var(--line)"}`,
|
||||
background: on ? "var(--accent-soft)" : "var(--panel)",
|
||||
color: on ? "var(--accent)" : "var(--ink-2)",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
<span className="tabular-nums opacity-70">{t.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 图片网格 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-3">
|
||||
{filtered.map((item) => (
|
||||
<button
|
||||
key={item.url}
|
||||
type="button"
|
||||
onClick={() => openItem(item)}
|
||||
className="group text-left rounded-xl overflow-hidden transition-transform hover:-translate-y-0.5 focus-visible:-translate-y-0.5"
|
||||
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
|
||||
title={item.name}
|
||||
>
|
||||
<span className="block relative aspect-square overflow-hidden">
|
||||
<img
|
||||
src={thumbSrc(item)}
|
||||
alt={item.name}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{item.original_url && (
|
||||
<span
|
||||
className="absolute top-1.5 left-1.5 px-1.5 py-0.5 rounded text-[10px] font-semibold"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
WebP
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block px-2.5 py-2">
|
||||
<span
|
||||
className="block text-[12px] font-medium truncate"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="block meta text-[11px] mt-0.5">
|
||||
{formatDims(item)} · {formatBytes(item.size)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
open={active !== null}
|
||||
onClose={() => setActive(null)}
|
||||
title={active?.name ?? ""}
|
||||
description={active ? `${active.category_name}${active.uploader ? ` · 上传者 ${active.uploader}` : ""}` : undefined}
|
||||
maxWidthClass="max-w-2xl"
|
||||
dismissOnOverlay
|
||||
bodyClassName=""
|
||||
>
|
||||
{active && (
|
||||
<div>
|
||||
<div
|
||||
className="rounded-xl overflow-hidden flex items-center justify-center"
|
||||
style={{
|
||||
background:
|
||||
"repeating-conic-gradient(var(--panel-2) 0% 25%, var(--panel) 0% 50%) 50% / 20px 20px",
|
||||
maxHeight: "52vh",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={showOriginal && active.original_url ? active.original_url : active.url}
|
||||
alt={active.name}
|
||||
className="max-w-full max-h-[52vh] w-auto h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{active.original_url && (
|
||||
<p className="meta text-[12px] mt-2.5">
|
||||
{showOriginal
|
||||
? "当前显示原图(原格式,体积更大)"
|
||||
: "当前显示 WebP 压缩版;站点内容引用的均为 WebP"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-[12.5px]">
|
||||
{[
|
||||
["分类", active.category_name],
|
||||
["尺寸", formatDims(active)],
|
||||
["大小", formatBytes(active.size)],
|
||||
["格式", active.mime],
|
||||
["上传者", active.uploader || "—"],
|
||||
["时间", formatTime(active.uploaded_at)],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex items-baseline gap-2 min-w-0">
|
||||
<dt className="meta shrink-0 w-11 text-right">{k}</dt>
|
||||
<dd className="truncate" style={{ color: "var(--ink)" }} title={v}>
|
||||
{v}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
{active.source_url && (
|
||||
<div className="flex items-baseline gap-2 min-w-0 col-span-2">
|
||||
<dt className="meta shrink-0 w-11 text-right">来源</dt>
|
||||
<dd className="truncate">
|
||||
<a
|
||||
href={active.source_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={active.source_label || active.source_url}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{active.source_label || active.source_url}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2 flex-wrap">
|
||||
{active.original_url && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setShowOriginal((v) => !v)}
|
||||
>
|
||||
<Eye size={14} aria-hidden />
|
||||
{showOriginal ? "看 WebP" : "查看原图"}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-sm" onClick={() => copyLink(active)}>
|
||||
{copied ? <Check size={14} aria-hidden /> : <Copy size={14} aria-hidden />}
|
||||
{copied ? "已复制" : "复制链接"}
|
||||
</button>
|
||||
<a
|
||||
className="btn btn-sm"
|
||||
href={active.original_url && showOriginal ? active.original_url : active.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden />
|
||||
新窗口打开
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user