feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
@@ -46,6 +46,7 @@ import Modal from "@/components/Modal";
|
||||
import { AdText } from "@/components/AdText";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||
|
||||
type StatusFilter = "" | "pending" | "active" | "rejected" | "expired";
|
||||
type SectionId = "overview" | "queue" | "promote" | "sponsors";
|
||||
@@ -882,8 +883,14 @@ function AdCard({
|
||||
{/* 预览缩略 */}
|
||||
<div className="j13-ads-thumb shrink-0" aria-hidden>
|
||||
{ad.kind === "image" && ad.image_url ? (
|
||||
// 88×44 缩略位:本站图走 96/160 密度变体;外链图保持原地址
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={ad.image_url} alt="" className="h-full w-full object-contain" />
|
||||
<img
|
||||
src={isOwnImage(ad.image_url) ? variantURL(ad.image_url, pickVariantWidth(88)) : ad.image_url}
|
||||
srcSet={isOwnImage(ad.image_url) ? densitySrcSet(ad.image_url, 88) : undefined}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : ad.kind === "text" && ad.title ? (
|
||||
<div className="flex h-full w-full items-center justify-center p-1.5">
|
||||
<AdText title={ad.title} textColor={ad.text_color} bgColor={ad.bg_color} />
|
||||
@@ -990,8 +997,14 @@ function PaymentEditor({
|
||||
{/* 二维码:点击即上传 */}
|
||||
<label className="j13-ads-qrbox shrink-0 cursor-pointer" title="点击上传/更换收款码">
|
||||
{payment.qr_url ? (
|
||||
// 96×96 收款码位:本站图走 96/160 变体
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={payment.qr_url} alt="" className="h-full w-full object-contain" />
|
||||
<img
|
||||
src={isOwnImage(payment.qr_url) ? variantURL(payment.qr_url, pickVariantWidth(96)) : payment.qr_url}
|
||||
srcSet={isOwnImage(payment.qr_url) ? densitySrcSet(payment.qr_url, 96) : undefined}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||
<Upload size={16} />
|
||||
@@ -1084,8 +1097,14 @@ function SponsorEditor({
|
||||
{/* Logo:点击即上传 */}
|
||||
<label className="j13-ads-logo shrink-0 cursor-pointer" title="点击上传/更换 Logo">
|
||||
{item.logo_url ? (
|
||||
// 120×56 Logo 位:本站图走 160/256 变体
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.logo_url} alt="" className="h-full w-full rounded-lg object-contain" />
|
||||
<img
|
||||
src={isOwnImage(item.logo_url) ? variantURL(item.logo_url, pickVariantWidth(120)) : item.logo_url}
|
||||
srcSet={isOwnImage(item.logo_url) ? densitySrcSet(item.logo_url, 120) : undefined}
|
||||
alt=""
|
||||
className="h-full w-full rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||
<Upload size={15} />
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type SponsorsConfig,
|
||||
} 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 = {
|
||||
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
|
||||
export default async function AdminAdsPage() {
|
||||
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.ADS)) {
|
||||
return <Forbidden text="推广与赞助管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import { hasPerm, PERMS } from "@/lib/roles";
|
||||
import Forbidden from "../../../Forbidden";
|
||||
import AnnouncementEditLoader from "./AnnouncementEditLoader";
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function AdminAnnouncementEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
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.ANNOUNCEMENTS)) {
|
||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import { hasPerm, PERMS } from "@/lib/roles";
|
||||
import Forbidden from "../../Forbidden";
|
||||
import AnnouncementComposeClient from "../AnnouncementComposeClient";
|
||||
|
||||
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
|
||||
export default async function AdminAnnouncementNewPage() {
|
||||
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.ANNOUNCEMENTS)) {
|
||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,18 @@ import AnnouncementAdmin from "./AnnouncementAdmin";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { fetchAdminAnnouncements, type Announcement } 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 = {
|
||||
title: "站点公告管理",
|
||||
};
|
||||
|
||||
// 公告管理仅管理员及以上可用(板块管理员无公告权;后端路由另有守卫)
|
||||
// 公告管理:管理员及以上角色自带;超管/站长亦可授予任意账号
|
||||
export default async function AdminAnnouncementsPage() {
|
||||
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.ANNOUNCEMENTS)) {
|
||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||
import Modal from "@/components/Modal";
|
||||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
||||
import { mediaURLFitsBackground, type BgSurface } from "@/lib/bg";
|
||||
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||
|
||||
const SURFACE_LABEL: Record<BgSurface, string> = {
|
||||
site: "前台背景",
|
||||
@@ -101,7 +102,9 @@ export default function BgMediaPicker({
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.url}
|
||||
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={`${SURFACE_LABEL[surface]}候选图`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { canModerateAuthor } from "@/lib/roles";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { postPath } from "@/lib/urlStyle";
|
||||
import { postPath, commentAnchor } from "@/lib/urlStyle";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
|
||||
import Avatar from "@/components/Avatar";
|
||||
@@ -692,7 +692,7 @@ export default function ContentAdmin({
|
||||
c.post_title
|
||||
) : (
|
||||
<Link
|
||||
href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
|
||||
href={`${postPath(urlStyle, c.post_id)}${commentAnchor(c.floor, c.is_root, c.id)}`}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type FriendLinkStatus,
|
||||
} from "@/lib/api";
|
||||
import { AdminPageHeader, AdminSettingsPage, AdminEmpty } from "@/components/admin";
|
||||
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||
|
||||
interface Props {
|
||||
initialLinks: FriendLink[];
|
||||
@@ -237,9 +238,11 @@ export default function FriendLinksAdmin({ initialLinks, initError }: Props) {
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
{l.logo_url ? (
|
||||
// 36px 位:本站图走 48/96 变体;外链保持原样
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={l.logo_url}
|
||||
src={isOwnImage(l.logo_url) ? variantURL(l.logo_url, pickVariantWidth(36)) : l.logo_url}
|
||||
srcSet={isOwnImage(l.logo_url) ? densitySrcSet(l.logo_url, 36) : undefined}
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { authCookieHeader, REFRESH_COOKIE, CSRF_COOKIE, TOKEN_COOKIE } from "@/lib/cookies";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
|
||||
import { canAccessAdminMessages, hasAnyChannelPerm, isStaff } from "@/lib/roles";
|
||||
import AdminShell from "@/components/admin/AdminShell";
|
||||
import AdminSessionRecover from "./AdminSessionRecover";
|
||||
|
||||
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
// 权限以 Go 端为唯一判定;此处仅决定管理界面是否直出。
|
||||
// 允许:管理角色,或仅有消息管理/群管权限的用户(仅消息菜单可见)。
|
||||
// 允许:管理角色 / 消息管理/群管权限 / 任一渠道权限(公告/单页/书库/广告)。
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = await cookies();
|
||||
const cookie = authCookieHeader(cookieStore);
|
||||
@@ -24,7 +24,8 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
||||
|
||||
const allowed =
|
||||
!!me.user && (isStaff(me.user.role) || canAccessAdminMessages(me.user));
|
||||
!!me.user &&
|
||||
(isStaff(me.user.role) || canAccessAdminMessages(me.user) || hasAnyChannelPerm(me.user));
|
||||
|
||||
if (!allowed || !me.user) {
|
||||
// 已登录但无后台权限:保留提示卡片
|
||||
|
||||
@@ -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="书库管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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`;
|
||||
@@ -24,10 +25,11 @@ function formatDims(item: MediaLibraryItem): string {
|
||||
return "—";
|
||||
}
|
||||
|
||||
/** 网格缩略图:本地图走服务端缩略接口(磁盘缓存),远程对象直连原图(头像类本身小) */
|
||||
/** 网格缩略图:本站图走统一变体端点(磁盘缓存,256 档);非本站图原样返回 */
|
||||
function thumbSrc(item: MediaLibraryItem): string {
|
||||
if (item.url.startsWith("/uploads/")) {
|
||||
return `/api/admin/media-library/thumb?u=${encodeURIComponent(item.url)}`;
|
||||
// 网格缩略走统一变体端点(256 档);非本站图原样返回
|
||||
if (isOwnImage(item.url)) {
|
||||
return variantURL(item.url, 256);
|
||||
}
|
||||
return item.url;
|
||||
}
|
||||
@@ -143,6 +145,8 @@ export default function MediaLibrary({
|
||||
<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"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import { hasPerm, PERMS } from "@/lib/roles";
|
||||
import Forbidden from "../../../Forbidden";
|
||||
import PageEditLoader from "./PageEditLoader";
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function AdminPageEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
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.PAGES)) {
|
||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import { hasPerm, PERMS } from "@/lib/roles";
|
||||
import Forbidden from "../../Forbidden";
|
||||
import PageComposeClient from "../PageComposeClient";
|
||||
|
||||
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
|
||||
export default async function AdminPageNewPage() {
|
||||
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.PAGES)) {
|
||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import PagesAdmin from "./PagesAdmin";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { fetchAdminSitePages, type SitePage } 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 AdminPagesPage() {
|
||||
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.PAGES)) {
|
||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||
import Modal from "@/components/Modal";
|
||||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
||||
import { mediaURLFitsSlot, type BrandSlot } from "@/lib/brand";
|
||||
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||
|
||||
const SLOT_LABEL: Record<BrandSlot, string> = {
|
||||
logo_light: "浅色 Logo",
|
||||
@@ -88,7 +89,14 @@ export default function BrandMediaPicker({
|
||||
data-tip="用作草稿预览,点保存后才生效"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={item.url} alt={`${SLOT_LABEL[slot]}候选图`} className="w-full h-full object-contain" loading="lazy" />
|
||||
<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={`${SLOT_LABEL[slot]}候选图`}
|
||||
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" }}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type BrandSlot,
|
||||
} from "@/lib/brand";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||
import BrandMediaPicker from "./BrandMediaPicker";
|
||||
|
||||
type SlotDraft = {
|
||||
@@ -233,8 +234,13 @@ export default function BrandSeoPanel({
|
||||
<div key={slot} className={`admin-brand-asset${compact ? " is-compact" : ""}`}>
|
||||
<div className={`admin-brand-swatch ${swatchClass}`}>
|
||||
{preview ? (
|
||||
// 预览槽(≤56px 高,宽标可能更宽):本站图走 160/384 密度变体
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={preview} alt="" />
|
||||
<img
|
||||
src={isOwnImage(preview) ? variantURL(preview, pickVariantWidth(160)) : preview}
|
||||
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 160) : undefined}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<span>未设置</span>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -64,6 +65,7 @@ import Modal from "@/components/Modal";
|
||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { mergeWidgetCode, parseMixedWidgetCode } from "@/lib/widgetCode";
|
||||
import { lintWidgetCode, type WidgetLintIssue } from "@/lib/widgetLint";
|
||||
|
||||
type Side = "left" | "right";
|
||||
type SectionId = SidebarScope | "widgets";
|
||||
@@ -102,6 +104,9 @@ const SIDES: { scope: SidebarScope; icon: LucideIcon; desc: string; sides: Side[
|
||||
const SIDE_LABEL: Record<Side, string> = { left: "左栏", right: "右栏" };
|
||||
const WIDGET_CODE_MAX_BYTES = 64 * 1024;
|
||||
|
||||
// 提交给后端的工具草稿(混写框拆分后的三字段 + 名称)
|
||||
type WidgetDraftCode = { name: string; html: string; css: string; js: string };
|
||||
|
||||
export default function SidebarAdmin({
|
||||
initialConfig,
|
||||
initialWidgets,
|
||||
@@ -125,6 +130,10 @@ export default function SidebarAdmin({
|
||||
const [draftName, setDraftName] = useState("");
|
||||
const [mixed, setMixed] = useState("");
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
// 保存时静态扫描结果(error 禁止保存并列在此处;warn 弹二次确认框)
|
||||
const [lintIssues, setLintIssues] = useState<WidgetLintIssue[] | null>(null);
|
||||
const [warnConfirmOpen, setWarnConfirmOpen] = useState(false);
|
||||
const pendingCodeRef = useRef<WidgetDraftCode | null>(null);
|
||||
|
||||
const draftBytes = useMemo(() => new TextEncoder().encode(mixed).length, [mixed]);
|
||||
const draftOk = draftName.trim().length > 0 && draftBytes <= WIDGET_CODE_MAX_BYTES;
|
||||
@@ -196,6 +205,9 @@ export default function SidebarAdmin({
|
||||
setEditingId(null);
|
||||
setDraftName("");
|
||||
setMixed("");
|
||||
setLintIssues(null);
|
||||
setWarnConfirmOpen(false);
|
||||
pendingCodeRef.current = null;
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
@@ -203,14 +215,15 @@ export default function SidebarAdmin({
|
||||
setEditingId(w.id);
|
||||
setDraftName(w.name);
|
||||
setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js }));
|
||||
setLintIssues(null);
|
||||
setWarnConfirmOpen(false);
|
||||
pendingCodeRef.current = null;
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const submitWidget = async () => {
|
||||
if (!draftOk) return;
|
||||
const doSubmitWidget = async (code: WidgetDraftCode) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const code = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
|
||||
if (editingId == null) {
|
||||
await apiAdminCreateSidebarWidget(code);
|
||||
toast("工具已创建", "ok");
|
||||
@@ -220,6 +233,9 @@ export default function SidebarAdmin({
|
||||
toast("工具已保存", "ok");
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setLintIssues(null);
|
||||
setWarnConfirmOpen(false);
|
||||
pendingCodeRef.current = null;
|
||||
await reloadAll();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败", "error");
|
||||
@@ -228,6 +244,34 @@ export default function SidebarAdmin({
|
||||
}
|
||||
};
|
||||
|
||||
// 保存按钮:先静态扫描——有 error 禁止保存并列出;只有 warn 弹确认框;无问题直接保存
|
||||
const submitWidget = () => {
|
||||
if (!draftOk || busy) return;
|
||||
const code: WidgetDraftCode = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
|
||||
const issues = lintWidgetCode(code);
|
||||
setLintIssues(issues.length ? issues : null);
|
||||
if (issues.some((i) => i.level === "error")) return;
|
||||
const warns = issues.filter((i) => i.level === "warn");
|
||||
if (warns.length > 0) {
|
||||
pendingCodeRef.current = code;
|
||||
setWarnConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
void doSubmitWidget(code);
|
||||
};
|
||||
|
||||
const confirmWarnSubmit = () => {
|
||||
const code = pendingCodeRef.current;
|
||||
pendingCodeRef.current = null;
|
||||
setWarnConfirmOpen(false);
|
||||
if (code) void doSubmitWidget(code);
|
||||
};
|
||||
|
||||
const cancelWarnSubmit = () => {
|
||||
pendingCodeRef.current = null;
|
||||
setWarnConfirmOpen(false);
|
||||
};
|
||||
|
||||
const toggleWidget = async (w: SidebarWidgetRecord) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -402,7 +446,7 @@ export default function SidebarAdmin({
|
||||
<SectionHead
|
||||
icon={<Code2 size={16} />}
|
||||
title="自定义工具"
|
||||
desc="HTML + CSS + JS 代码块,前台原样渲染执行(不做消毒)"
|
||||
desc="HTML + CSS + JS 代码块,在独立 Shadow DOM 内渲染执行(不做消毒)"
|
||||
extra={<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}><Plus size={14} /> 新增工具</button>}
|
||||
/>
|
||||
<div className="p-4 sm:p-5">
|
||||
@@ -487,7 +531,7 @@ export default function SidebarAdmin({
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={() => setEditorOpen(false)}>取消</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => void submitWidget()}>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => submitWidget()}>
|
||||
{busy ? "保存中…" : editingId == null ? "创建" : "保存"}
|
||||
</button>
|
||||
</>
|
||||
@@ -506,13 +550,20 @@ export default function SidebarAdmin({
|
||||
<textarea
|
||||
className="field w-full min-h-[240px] font-mono text-[12.5px]"
|
||||
value={mixed}
|
||||
onChange={(e) => setMixed(e.target.value)}
|
||||
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 页面加载后在工具位置执行\n</script>`}
|
||||
onChange={(e) => {
|
||||
setMixed(e.target.value);
|
||||
setLintIssues(null);
|
||||
}}
|
||||
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 脚本参数:(root, shadow, toolId, registerCleanup)\n // 元素查找一律从 root 出发:root.querySelector(".my-banner")\n</script>`}
|
||||
/>
|
||||
</label>
|
||||
{lintIssues && lintIssues.length > 0 && <WidgetLintPanel issues={lintIssues} />}
|
||||
<details className="rounded-xl border px-3 py-2" style={{ borderColor: "var(--line)" }}>
|
||||
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:window.J13 / 主题变量</summary>
|
||||
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:脚本参数 / window.J13 / 主题变量</summary>
|
||||
<div className="mt-2 space-y-1.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
|
||||
<p>脚本接收参数 <code>(root, shadow, toolId, registerCleanup)</code>:元素查找一律 <code>root.querySelector(...)</code>,禁止 <code>document</code> 全局查找(同页多个同款工具会互相取错)</p>
|
||||
<p>定时器 / 监听器请通过 <code>registerCleanup(fn)</code> 注册清理,离开页面时自动执行(如 <code>clearInterval</code>)</p>
|
||||
<p>样式仅在工具内生效,直接写类名即可;禁止 <code>body</code> / <code>html</code> / <code>*</code> 全局选择器与 <code>position: fixed</code>,变量请定义在 <code>.tool-root</code> 上</p>
|
||||
<p><code>J13.user</code>:当前登录用户(未登录为 null)</p>
|
||||
<p><code>J13.theme.get()</code> 返回 "light" | "dark";<code> J13.theme.onChange(fn)</code> 监听明暗切换</p>
|
||||
<p><code>J13.getOverview()</code>:站点公开统计(Promise,users / posts / online 等)</p>
|
||||
@@ -520,11 +571,22 @@ export default function SidebarAdmin({
|
||||
</div>
|
||||
</details>
|
||||
<p className="meta text-[11.5px]">
|
||||
CSS 自动限定在本板块内、不污染其他区域;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
|
||||
工具运行在独立 Shadow DOM 内,样式不外泄;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 仅 warn 级问题时的二次确认 */}
|
||||
<ConfirmDialog
|
||||
open={warnConfirmOpen}
|
||||
title="代码存在风险提醒"
|
||||
message={`检测到 ${lintIssues?.filter((i) => i.level === "warn").length ?? 0} 条提醒(不阻止保存,明细见编辑器列表)。确认了解风险并保存?`}
|
||||
confirmLabel="仍要保存"
|
||||
busy={busy}
|
||||
onConfirm={confirmWarnSubmit}
|
||||
onCancel={cancelWarnSubmit}
|
||||
/>
|
||||
|
||||
{/* 删除工具确认 */}
|
||||
<ConfirmDialog
|
||||
open={deleteId != null}
|
||||
@@ -550,6 +612,75 @@ export default function SidebarAdmin({
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 工具代码静态扫描结果面板 ─── */
|
||||
const LINT_FIELD_LABEL: Record<WidgetLintIssue["field"], string> = {
|
||||
js: "JS",
|
||||
css: "CSS",
|
||||
html: "HTML",
|
||||
};
|
||||
|
||||
function WidgetLintPanel({ issues }: { issues: WidgetLintIssue[] }) {
|
||||
const errors = issues.filter((i) => i.level === "error");
|
||||
const warns = issues.filter((i) => i.level === "warn");
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{errors.length > 0 && (
|
||||
<div
|
||||
className="rounded-xl border p-2.5"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--danger) 40%, var(--line))",
|
||||
background: "var(--danger-soft)",
|
||||
}}
|
||||
>
|
||||
<p className="mb-1.5 flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: "var(--danger)" }}>
|
||||
<AlertTriangle size={13} aria-hidden />
|
||||
{errors.length} 个错误,修复后才能保存
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{errors.map((issue, idx) => (
|
||||
<LintIssueRow key={`e-${idx}`} issue={issue} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{warns.length > 0 && (
|
||||
<div
|
||||
className="rounded-xl border p-2.5"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--gold) 40%, var(--line))",
|
||||
background: "var(--gold-soft)",
|
||||
}}
|
||||
>
|
||||
<p className="mb-1.5 flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: "var(--gold)" }}>
|
||||
<Info size={13} aria-hidden />
|
||||
{warns.length} 个提醒,确认风险后仍可保存
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{warns.map((issue, idx) => (
|
||||
<LintIssueRow key={`w-${idx}`} issue={issue} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LintIssueRow({ issue }: { issue: WidgetLintIssue }) {
|
||||
const tone = issue.level === "error" ? "var(--danger)" : "var(--gold)";
|
||||
return (
|
||||
<li className="flex gap-1.5 text-[11.5px] leading-relaxed">
|
||||
<span
|
||||
className="mt-px shrink-0 rounded px-1 py-px font-mono text-[10.5px] font-semibold tabular-nums"
|
||||
style={{ color: tone, background: "color-mix(in srgb, currentColor 10%, transparent)" }}
|
||||
>
|
||||
{LINT_FIELD_LABEL[issue.field]} {issue.line}:{issue.column}
|
||||
</span>
|
||||
<span style={{ color: "var(--ink-2)" }}>{issue.message}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 分区卡片头 ─── */
|
||||
function SectionHead({
|
||||
icon,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { formatDate, formatRelative } from "@/lib/format";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
import { postPath, userPath } from "@/lib/urlStyle";
|
||||
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
|
||||
import {
|
||||
AdminEmpty,
|
||||
AdminPager,
|
||||
@@ -356,7 +356,7 @@ export default function UserAuditClient({
|
||||
<span className="text-[11.5px] meta">帖子 #{c.post_id}</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
|
||||
href={`${postPath(urlStyle, c.post_id)}${commentAnchor(c.floor, c.is_root, c.id)}`}
|
||||
className="text-[11.5px] hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user