feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
Download,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
apiAdminCreateLibraryDoc,
|
||||
apiAdminDeleteLibraryDoc,
|
||||
apiAdminDeleteLibraryFile,
|
||||
apiAdminImportLibraryZip,
|
||||
apiAdminListLibraryDocs,
|
||||
apiAdminPurgeLibraryDoc,
|
||||
apiAdminUpdateLibraryDoc,
|
||||
@@ -25,17 +26,20 @@ import {
|
||||
apiUploadPostImage,
|
||||
type LibraryDoc,
|
||||
type LibraryFileItem,
|
||||
type LibraryImportReport,
|
||||
type LibraryInput,
|
||||
} from "@/lib/api";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
import { libraryDocPath } from "@/lib/urlStyle";
|
||||
import { libraryDocPath, userPath } from "@/lib/urlStyle";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import { toast } from "@/lib/toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||
import { AdminEmpty, AdminField, AdminPageHeader, AdminStatusChip, AdminSwitch } from "@/components/admin";
|
||||
import LibrarySectionsPanel from "./LibrarySectionsPanel";
|
||||
import LibraryCoverPicker from "./LibraryCoverPicker";
|
||||
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||
|
||||
// 与后端 libraryAllowedExts 保持一致;前端先拦一道,后端仍会校验
|
||||
const ALLOWED_EXTS = new Set([
|
||||
@@ -59,9 +63,12 @@ function formatSize(n: number): string {
|
||||
|
||||
interface DocForm {
|
||||
title: string;
|
||||
author: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
cover_url: string;
|
||||
cover_width: number;
|
||||
cover_height: number;
|
||||
published: boolean;
|
||||
sort_order: number;
|
||||
entries_auto: boolean;
|
||||
@@ -69,9 +76,12 @@ interface DocForm {
|
||||
|
||||
const EMPTY_FORM: DocForm = {
|
||||
title: "",
|
||||
author: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
cover_url: "",
|
||||
cover_width: 0,
|
||||
cover_height: 0,
|
||||
published: false,
|
||||
sort_order: 0,
|
||||
entries_auto: false,
|
||||
@@ -80,9 +90,12 @@ const EMPTY_FORM: DocForm = {
|
||||
function formOfDoc(d: LibraryDoc): DocForm {
|
||||
return {
|
||||
title: d.title,
|
||||
author: d.author ?? "",
|
||||
slug: d.slug,
|
||||
description: d.description ?? "",
|
||||
cover_url: d.cover_url ?? "",
|
||||
cover_width: d.cover_width ?? 0,
|
||||
cover_height: d.cover_height ?? 0,
|
||||
published: d.published,
|
||||
sort_order: d.sort_order,
|
||||
entries_auto: d.entries_auto ?? false,
|
||||
@@ -145,6 +158,13 @@ export default function LibraryAdmin({
|
||||
const [fileDeleteTarget, setFileDeleteTarget] = useState<LibraryFileItem | null>(null);
|
||||
const [fileDeleteBusy, setFileDeleteBusy] = useState(false);
|
||||
|
||||
// 书库导入恢复(上传导出 ZIP)
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importReport, setImportReport] = useState<LibraryImportReport | null>(null);
|
||||
// create 模式遇到 conflict 时保留 File,供确认后 overwrite 重传
|
||||
const [importOverwrite, setImportOverwrite] = useState<{ file: File; count: number } | null>(null);
|
||||
|
||||
// 条目删除(软删)与彻底删除
|
||||
const [docDeleteTarget, setDocDeleteTarget] = useState<LibraryDoc | null>(null);
|
||||
const [docDeleteBusy, setDocDeleteBusy] = useState(false);
|
||||
@@ -170,6 +190,34 @@ export default function LibraryAdmin({
|
||||
}
|
||||
}
|
||||
|
||||
async function runImport(file: File, mode: "create" | "overwrite") {
|
||||
setImporting(true);
|
||||
setImportReport(null);
|
||||
try {
|
||||
const rep = await apiAdminImportLibraryZip(file, mode);
|
||||
setImportReport(rep);
|
||||
const okN = rep.imported.length;
|
||||
const failN = rep.failed.length;
|
||||
if (okN > 0) toast(`成功导入 ${okN} 本书${failN ? `,${failN} 本未导入` : ""}`, "ok");
|
||||
else toast("没有书籍被导入,请查看失败明细", "error");
|
||||
await reload();
|
||||
if (mode === "create") {
|
||||
const conflicts = rep.failed.filter((f) => f.reason === "conflict").length;
|
||||
if (conflicts > 0) setImportOverwrite({ file, count: conflicts });
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "导入失败");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onPickImportFile(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ""; // 允许重选同一文件
|
||||
if (file) void runImport(file, "create");
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
setForm(EMPTY_FORM);
|
||||
setSnapshot(JSON.stringify(EMPTY_FORM));
|
||||
@@ -221,8 +269,11 @@ export default function LibraryAdmin({
|
||||
const input: LibraryInput = {
|
||||
slug,
|
||||
title,
|
||||
author: form.author.trim(),
|
||||
description: form.description,
|
||||
cover_url: form.cover_url.trim(),
|
||||
cover_width: form.cover_width,
|
||||
cover_height: form.cover_height,
|
||||
published: form.published,
|
||||
sort_order: form.sort_order,
|
||||
entries_auto: form.entries_auto,
|
||||
@@ -250,9 +301,13 @@ export default function LibraryAdmin({
|
||||
if (!file) return;
|
||||
setCoverBusy(true);
|
||||
try {
|
||||
const res = await apiUploadPostImage(file);
|
||||
const res = await apiUploadPostImage(file, undefined, "library_cover");
|
||||
if (res.url) {
|
||||
patchForm({ cover_url: res.url });
|
||||
patchForm({
|
||||
cover_url: res.url,
|
||||
cover_width: res.attachment?.width ?? 0,
|
||||
cover_height: res.attachment?.height ?? 0,
|
||||
});
|
||||
} else {
|
||||
setFormError(res.error || "封面上传失败");
|
||||
}
|
||||
@@ -267,8 +322,8 @@ export default function LibraryAdmin({
|
||||
async function pickCoverFromMedia(attachmentId: number) {
|
||||
setCoverBusy(true);
|
||||
try {
|
||||
const url = await apiLibraryCoverFromMedia(attachmentId);
|
||||
patchForm({ cover_url: url });
|
||||
const { url, width, height } = await apiLibraryCoverFromMedia(attachmentId);
|
||||
patchForm({ cover_url: url, cover_width: width, cover_height: height });
|
||||
} catch (e) {
|
||||
setFormError(e instanceof Error ? e.message : "选用图库图片失败");
|
||||
} finally {
|
||||
@@ -374,17 +429,103 @@ export default function LibraryAdmin({
|
||||
title="书库管理"
|
||||
description="书籍、文档与页面作品。书籍由介绍、目录与章节正文组成,像书一样阅读;附件仅提供下载分发。"
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => guardNav(openNew)}
|
||||
disabled={saving}
|
||||
>
|
||||
<Plus size={15} /> 新建条目
|
||||
</button>
|
||||
<>
|
||||
<a
|
||||
href="/api/admin/library/export"
|
||||
className="btn btn-ghost"
|
||||
title="把全部书籍打包为 ZIP(介绍、章节、附件与封面),用于迁移或备份"
|
||||
>
|
||||
<Download size={15} aria-hidden />
|
||||
导出全部
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
disabled={importing}
|
||||
title="上传导出的 ZIP 备份包,恢复书籍(介绍、章节、附件与封面)"
|
||||
>
|
||||
{importing ? (
|
||||
<Loader2 size={15} className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Upload size={15} aria-hidden />
|
||||
)}
|
||||
{importing ? "导入中…" : "导入恢复"}
|
||||
</button>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
className="hidden"
|
||||
onChange={onPickImportFile}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => guardNav(openNew)}
|
||||
disabled={saving}
|
||||
>
|
||||
<Plus size={15} /> 新建条目
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{importReport ? (
|
||||
<div
|
||||
className="panel mx-3.5 sm:mx-4 my-3 px-4 py-3 text-[13px]"
|
||||
role="status"
|
||||
style={{ borderColor: "var(--line)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<strong style={{ color: "var(--ink)" }}>
|
||||
导入完成:成功 {importReport.imported.length} 本
|
||||
(新建 {importReport.imported.filter((i) => i.action === "created").length} / 覆盖{" "}
|
||||
{importReport.imported.filter((i) => i.action === "overwritten").length}),失败{" "}
|
||||
{importReport.failed.length} 本
|
||||
</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto btn btn-ghost"
|
||||
style={{ padding: "2px 6px" }}
|
||||
onClick={() => setImportReport(null)}
|
||||
aria-label="关闭导入结果"
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
{importReport.failed.length > 0 ? (
|
||||
<ul className="mt-2 flex flex-col gap-1">
|
||||
{importReport.failed.map((f, i) => (
|
||||
<li key={`${f.slug}-${i}`} className="meta flex flex-wrap items-baseline gap-x-2">
|
||||
<span style={{ color: "var(--ink-2)" }}>
|
||||
{f.title || f.slug}
|
||||
{f.slug ? `(${f.slug})` : ""}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
f.reason === "conflict" || f.reason === "deleted"
|
||||
? "var(--warn, #b45309)"
|
||||
: "var(--danger, #b91c1c)",
|
||||
}}
|
||||
>
|
||||
{f.reason === "conflict"
|
||||
? "同名书籍已存在,可覆盖导入"
|
||||
: f.reason === "deleted"
|
||||
? "地址被已删除条目占用,需先彻底删除"
|
||||
: f.reason === "invalid"
|
||||
? "备份数据不受支持"
|
||||
: "导入失败"}
|
||||
</span>
|
||||
<span>{f.error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="admin-library-split"
|
||||
data-view={editorOpen ? "editor" : "list"}
|
||||
@@ -435,9 +576,11 @@ export default function LibraryAdmin({
|
||||
}}
|
||||
>
|
||||
{d.cover_url ? (
|
||||
// 36px 封面位:本站图走 48/96 变体
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={d.cover_url}
|
||||
src={isOwnImage(d.cover_url) ? variantURL(d.cover_url, pickVariantWidth(36)) : d.cover_url}
|
||||
srcSet={isOwnImage(d.cover_url) ? densitySrcSet(d.cover_url, 36) : undefined}
|
||||
alt=""
|
||||
className="w-9 h-9 rounded-lg object-cover shrink-0"
|
||||
style={{ border: "1px solid var(--line)" }}
|
||||
@@ -560,6 +703,16 @@ export default function LibraryAdmin({
|
||||
? `${editingDoc.sections?.length ?? 0} 章节 · ${editingDoc.files?.length ?? 0} 附件`
|
||||
: ""}
|
||||
</span>
|
||||
{typeof editingId === "number" ? (
|
||||
<a
|
||||
href={`/api/admin/library/docs/${editingId}/export`}
|
||||
className="btn btn-line btn-sm shrink-0"
|
||||
title="导出本书为 ZIP(介绍、章节、附件与本地封面),用于迁移或备份"
|
||||
>
|
||||
<Download size={13} aria-hidden />
|
||||
导出备份
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 书籍信息 tab(两个 tab 均保持挂载,避免章节编辑状态丢失)。
|
||||
@@ -586,6 +739,15 @@ export default function LibraryAdmin({
|
||||
className="admin-doc-title"
|
||||
/>
|
||||
|
||||
<AdminField
|
||||
id="lib-author"
|
||||
label="作者"
|
||||
maxLength={100}
|
||||
value={form.author}
|
||||
onChange={(author) => patchForm({ author })}
|
||||
placeholder="作者或编著者,多位可用逗号分隔(可选)"
|
||||
/>
|
||||
|
||||
<div className="admin-doc-field">
|
||||
<label className="admin-doc-label" htmlFor="lib-slug">
|
||||
访问地址
|
||||
@@ -628,6 +790,7 @@ export default function LibraryAdmin({
|
||||
placeholder="条目介绍,支持 Markdown…"
|
||||
rows={12}
|
||||
ariaLabel="条目介绍"
|
||||
uploadSource="library_content"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -636,56 +799,58 @@ export default function LibraryAdmin({
|
||||
<aside className="flex flex-col gap-4 lg:min-h-0">
|
||||
<div className="admin-doc-field">
|
||||
<p className="admin-doc-label">封面(可选)</p>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
{form.cover_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={form.cover_url}
|
||||
src={isOwnImage(form.cover_url) ? variantURL(form.cover_url, 256) : form.cover_url}
|
||||
alt="封面预览"
|
||||
className="w-12 h-12 rounded-lg object-cover shrink-0"
|
||||
className="w-full h-32 rounded-xl object-cover"
|
||||
style={{ border: "1px solid var(--line)" }}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="w-12 h-12 rounded-lg flex items-center justify-center shrink-0"
|
||||
className="w-full h-32 rounded-xl flex flex-col items-center justify-center gap-1.5"
|
||||
style={{ border: "1px dashed var(--control-border)" }}
|
||||
aria-label="上传封面"
|
||||
onClick={() => coverInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus size={16} style={{ color: "var(--ink-3)" }} aria-hidden />
|
||||
<ImagePlus size={20} style={{ color: "var(--ink-3)" }} aria-hidden />
|
||||
<span className="text-[11.5px]" style={{ color: "var(--ink-3)" }}>
|
||||
点击上传封面
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm flex-1"
|
||||
disabled={coverBusy}
|
||||
onClick={() => coverInputRef.current?.click()}
|
||||
>
|
||||
{coverBusy ? (
|
||||
<Loader2 size={13} className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<ImagePlus size={13} aria-hidden />
|
||||
)}
|
||||
{form.cover_url ? "更换" : "上传"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm flex-1"
|
||||
disabled={coverBusy}
|
||||
onClick={() => setCoverPickerOpen(true)}
|
||||
>
|
||||
<Images size={13} aria-hidden />
|
||||
图库
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={coverBusy}
|
||||
onClick={() => coverInputRef.current?.click()}
|
||||
>
|
||||
{coverBusy ? (
|
||||
<Loader2 size={13} className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<ImagePlus size={13} aria-hidden />
|
||||
)}
|
||||
{form.cover_url ? "更换" : "上传"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={coverBusy}
|
||||
onClick={() => setCoverPickerOpen(true)}
|
||||
>
|
||||
<Images size={13} aria-hidden />
|
||||
图库
|
||||
</button>
|
||||
{form.cover_url ? (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-doc-textbtn"
|
||||
onClick={() => patchForm({ cover_url: "" })}
|
||||
className="admin-doc-textbtn ml-auto"
|
||||
style={{ minHeight: 0, minWidth: 0, padding: "1px 6px", fontSize: 12 }}
|
||||
onClick={() => patchForm({ cover_url: "", cover_width: 0, cover_height: 0 })}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
@@ -749,6 +914,30 @@ export default function LibraryAdmin({
|
||||
<p className="admin-doc-hint">数字越小越靠前。</p>
|
||||
</div>
|
||||
|
||||
{typeof editingId === "number" && editingDoc?.creator ? (
|
||||
<div className="admin-doc-field">
|
||||
<p className="admin-doc-label">创建者</p>
|
||||
<a
|
||||
href={userPath(urlStyle, editingDoc.creator.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-[13px] font-medium max-w-full"
|
||||
style={{ color: "var(--ink)" }}
|
||||
title={`@${editingDoc.creator.username}`}
|
||||
>
|
||||
<Avatar
|
||||
size={22}
|
||||
name={editingDoc.creator.nickname || editingDoc.creator.username}
|
||||
src={editingDoc.creator.avatar}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{editingDoc.creator.nickname || editingDoc.creator.username}
|
||||
</span>
|
||||
</a>
|
||||
<p className="admin-doc-hint">条目创建后归属不变。</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 附件管理:长列表列内滚动 */}
|
||||
<div className="admin-doc-field flex flex-col lg:flex-1 lg:min-h-0">
|
||||
<p className="admin-doc-label shrink-0">
|
||||
@@ -956,6 +1145,27 @@ export default function LibraryAdmin({
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 导入遇到同名书籍:确认后覆盖重传(保留同一 File) */}
|
||||
<ConfirmDialog
|
||||
open={!!importOverwrite}
|
||||
title="覆盖导入同名书籍?"
|
||||
message={
|
||||
importOverwrite
|
||||
? `备份包中有 ${importOverwrite.count} 本书的地址已被现有书籍占用。选择覆盖将用备份包整体替换这些书籍的介绍、章节、附件与封面,现有内容不可恢复;其余书籍正常新建。`
|
||||
: ""
|
||||
}
|
||||
danger
|
||||
busy={importing}
|
||||
confirmLabel="覆盖导入"
|
||||
cancelLabel="保留现有"
|
||||
onCancel={() => !importing && setImportOverwrite(null)}
|
||||
onConfirm={() => {
|
||||
const pending = importOverwrite;
|
||||
setImportOverwrite(null);
|
||||
if (pending) void runImport(pending.file, "overwrite");
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 未保存关闭/切换确认 */}
|
||||
<ConfirmDialog
|
||||
open={!!pendingNav}
|
||||
|
||||
@@ -4,8 +4,35 @@ 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,
|
||||
@@ -45,13 +72,15 @@ export default function LibraryCoverPicker({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const groups = groupBySource(items);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="从图库选择封面"
|
||||
description="选择后会复制一份作为封面素材,之后删除原图不影响封面。"
|
||||
description="已选作封面的图片会单独分组,再次选择直接复用。"
|
||||
onClose={onClose}
|
||||
maxWidthClass="max-w-lg"
|
||||
maxWidthClass="max-w-2xl"
|
||||
dismissOnOverlay
|
||||
>
|
||||
{loading ? (
|
||||
@@ -67,28 +96,52 @@ export default function LibraryCoverPicker({
|
||||
图库还没有图片。上传封面或发帖插图后,会自动出现在这里。
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[46vh] overflow-y-auto">
|
||||
{items.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="复制一份用作封面"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={item.url} 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" }}
|
||||
<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)" }}
|
||||
>
|
||||
<Images size={14} />
|
||||
</span>
|
||||
</button>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -410,6 +410,7 @@ export default function LibrarySectionsPanel({
|
||||
placeholder="本章正文,支持 Markdown…"
|
||||
rows={16}
|
||||
ariaLabel="章节正文"
|
||||
uploadSource="library_content"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import LibraryAdmin from "./LibraryAdmin";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { fetchAdminLibraryDocs, type LibraryDoc } from "@/lib/api";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import { hasPerm, PERMS } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
|
||||
export default async function AdminLibraryPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
if (!me.user || !hasPerm(me.user, PERMS.LIBRARY)) {
|
||||
return <Forbidden text="书库管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user