feat: 站点媒体库与移动端底部导航,服务测试补强

- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
2026-09-27 02:35:38 +08:00
parent 734afbc39f
commit 1d862a0dd3
61 changed files with 4757 additions and 1129 deletions

View 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>
);
}