Files
jiang13-bbs/frontend/app/admin/media/MediaLibrary.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

279 lines
9.7 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";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
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 "—";
}
/** 网格缩略图:本站图走统一变体端点(磁盘缓存,256 档);非本站图原样返回 */
function thumbSrc(item: MediaLibraryItem): string {
// 网格缩略走统一变体端点(256 档);非本站图原样返回
if (isOwnImage(item.url)) {
return variantURL(item.url, 256);
}
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)}
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [160, 256, 384, 512]) : undefined}
sizes="(min-width:1280px) 16vw, (min-width:1024px) 25vw, (min-width:640px) 33vw, 50vw"
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>
);
}