Files
jiang13-bbs/frontend/app/admin/media/MediaLibrary.tsx
freefire 1d862a0dd3 feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
2026-09-27 02:36:11 +08:00

275 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}