Files
jiang13-bbs/frontend/app/admin/library/LibraryCoverPicker.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

151 lines
5.2 KiB
TypeScript
Raw Permalink 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 { Images, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
/** 来源标签映射 */
const SOURCE_LABELS: Record<string, string> = {
library_cover: "书籍封面",
library_content: "书籍内容",
post: "帖子插图",
};
function sourceLabel(source?: string): string {
return SOURCE_LABELS[source || "post"] || source || "帖子插图";
}
/** 按来源分组(library_cover 优先,其次 library_content,最后 post/其他) */
function groupBySource(items: MediaAttachment[]): [string, MediaAttachment[]][] {
const groups = new Map<string, MediaAttachment[]>();
for (const item of items) {
const key = item.source || "post";
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(item);
}
// 按优先级排序
const order = ["library_cover", "library_content", "post"];
return Array.from(groups.entries()).sort(
([a], [b]) => order.indexOf(a) - order.indexOf(b)
);
}
/** 书籍封面图库选择弹窗:列出本人媒体库图片,按来源分组 */
export default function LibraryCoverPicker({
open,
onClose,
onPick,
}: {
open: boolean;
onClose: () => void;
onPick: (item: MediaAttachment) => void;
}) {
const [items, setItems] = useState<MediaAttachment[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError("");
void apiMyMedia()
.then((res) => {
if (cancelled) return;
if (res.error) {
setError(res.error);
setItems([]);
return;
}
setItems((res.attachments || []).filter((item) => item.kind === "image"));
})
.catch(() => {
if (!cancelled) setError("加载媒体库失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open]);
const groups = groupBySource(items);
return (
<Modal
open={open}
title="从图库选择封面"
description="已选作封面的图片会单独分组,再次选择直接复用。"
onClose={onClose}
maxWidthClass="max-w-2xl"
dismissOnOverlay
>
{loading ? (
<p className="meta text-[13px] flex items-center gap-2 py-6 justify-center">
<Loader2 size={14} className="animate-spin" /> 加载中
</p>
) : error ? (
<p className="text-[13px] py-4" style={{ color: "var(--danger, #b42318)" }}>
{error}
</p>
) : items.length === 0 ? (
<p className="meta text-[13px] py-6 text-center">
图库还没有图片。上传封面或发帖插图后,会自动出现在这里。
</p>
) : (
<div className="flex flex-col gap-5 max-h-[60vh] overflow-y-auto">
{groups.map(([source, groupItems]) => (
<section key={source}>
<h4
className="text-[11.5px] font-bold tracking-wide uppercase mb-2 px-0.5"
style={{ color: "var(--ink-3)" }}
>
{sourceLabel(source)}
<span className="ml-1.5 font-normal opacity-70">({groupItems.length})</span>
</h4>
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
{groupItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPick(item);
onClose();
}}
className="relative group aspect-square rounded-xl overflow-hidden"
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
data-tip={
item.source === "library_cover"
? "已作封面,点击直接复用"
: "复制一份用作封面"
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
sizes="(max-width:520px) 23vw, 96px"
alt="图库候选封面"
className="w-full h-full object-contain"
loading="lazy"
/>
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
>
<Images size={14} />
</span>
</button>
))}
</div>
</section>
))}
</div>
)}
</Modal>
);
}