- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
275 lines
9.4 KiB
TypeScript
275 lines
9.4 KiB
TypeScript
"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>
|
||
);
|
||
}
|