+
{form.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element

) : (
)}
-
-
-
-
-
+
+
+
{form.cover_url ? (
@@ -749,6 +914,30 @@ export default function LibraryAdmin({
数字越小越靠前。
+ {typeof editingId === "number" && editingDoc?.creator ? (
+
+ ) : null}
+
{/* 附件管理:长列表列内滚动 */}
@@ -956,6 +1145,27 @@ export default function LibraryAdmin({
+ {/* 导入遇到同名书籍:确认后覆盖重传(保留同一 File) */}
+
!importing && setImportOverwrite(null)}
+ onConfirm={() => {
+ const pending = importOverwrite;
+ setImportOverwrite(null);
+ if (pending) void runImport(pending.file, "overwrite");
+ }}
+ />
+
{/* 未保存关闭/切换确认 */}
= {
+ 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();
+ 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 (
{loading ? (
@@ -67,28 +96,52 @@ export default function LibraryCoverPicker({
图库还没有图片。上传封面或发帖插图后,会自动出现在这里。
) : (
-
- {items.map((item) => (
-
+ {sourceLabel(source)}
+
({groupItems.length})
+
+
+ {groupItems.map((item) => (
+
+ ))}
+
+
))}
)}
diff --git a/frontend/app/admin/library/LibrarySectionsPanel.tsx b/frontend/app/admin/library/LibrarySectionsPanel.tsx
index f2bf5a0..3ff1cfe 100644
--- a/frontend/app/admin/library/LibrarySectionsPanel.tsx
+++ b/frontend/app/admin/library/LibrarySectionsPanel.tsx
@@ -410,6 +410,7 @@ export default function LibrarySectionsPanel({
placeholder="本章正文,支持 Markdown…"
rows={16}
ariaLabel="章节正文"
+ uploadSource="library_content"
/>
diff --git a/frontend/app/admin/library/page.tsx b/frontend/app/admin/library/page.tsx
index 9b46739..395af4d 100644
--- a/frontend/app/admin/library/page.tsx
+++ b/frontend/app/admin/library/page.tsx
@@ -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
;
}
diff --git a/frontend/app/admin/media/MediaLibrary.tsx b/frontend/app/admin/media/MediaLibrary.tsx
index 399bc36..c99552d 100644
--- a/frontend/app/admin/media/MediaLibrary.tsx
+++ b/frontend/app/admin/media/MediaLibrary.tsx
@@ -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({
;
}
diff --git a/frontend/app/admin/pages/new/page.tsx b/frontend/app/admin/pages/new/page.tsx
index 7a274de..af989af 100644
--- a/frontend/app/admin/pages/new/page.tsx
+++ b/frontend/app/admin/pages/new/page.tsx
@@ -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 ;
}
diff --git a/frontend/app/admin/pages/page.tsx b/frontend/app/admin/pages/page.tsx
index 7cf0814..a638ba1 100644
--- a/frontend/app/admin/pages/page.tsx
+++ b/frontend/app/admin/pages/page.tsx
@@ -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 ;
}
diff --git a/frontend/app/admin/settings/BrandMediaPicker.tsx b/frontend/app/admin/settings/BrandMediaPicker.tsx
index d552061..d6d582e 100644
--- a/frontend/app/admin/settings/BrandMediaPicker.tsx
+++ b/frontend/app/admin/settings/BrandMediaPicker.tsx
@@ -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 = {
logo_light: "浅色 Logo",
@@ -88,7 +89,14 @@ export default function BrandMediaPicker({
data-tip="用作草稿预览,点保存后才生效"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
-
+
{preview ? (
+ // 预览槽(≤56px 高,宽标可能更宽):本站图走 160/384 密度变体
// eslint-disable-next-line @next/next/no-img-element
-

+
)
) : (
未设置
)}
diff --git a/frontend/app/admin/sidebar/SidebarAdmin.tsx b/frontend/app/admin/sidebar/SidebarAdmin.tsx
index 3c088cf..3c3161d 100644
--- a/frontend/app/admin/sidebar/SidebarAdmin.tsx
+++ b/frontend/app/admin/sidebar/SidebarAdmin.tsx
@@ -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
= { 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(null);
+ // 保存时静态扫描结果(error 禁止保存并列在此处;warn 弹二次确认框)
+ const [lintIssues, setLintIssues] = useState(null);
+ const [warnConfirmOpen, setWarnConfirmOpen] = useState(false);
+ const pendingCodeRef = useRef(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({
}
title="自定义工具"
- desc="HTML + CSS + JS 代码块,前台原样渲染执行(不做消毒)"
+ desc="HTML + CSS + JS 代码块,在独立 Shadow DOM 内渲染执行(不做消毒)"
extra={}
/>
@@ -487,7 +531,7 @@ export default function SidebarAdmin({
footer={
<>
-
+ {/* 仅 warn 级问题时的二次确认 */}
+ i.level === "warn").length ?? 0} 条提醒(不阻止保存,明细见编辑器列表)。确认了解风险并保存?`}
+ confirmLabel="仍要保存"
+ busy={busy}
+ onConfirm={confirmWarnSubmit}
+ onCancel={cancelWarnSubmit}
+ />
+
{/* 删除工具确认 */}
= {
+ 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 (
+
+ {errors.length > 0 && (
+
+
+
+ {errors.length} 个错误,修复后才能保存
+
+
+ {errors.map((issue, idx) => (
+
+ ))}
+
+
+ )}
+ {warns.length > 0 && (
+
+
+
+ {warns.length} 个提醒,确认风险后仍可保存
+
+
+ {warns.map((issue, idx) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function LintIssueRow({ issue }: { issue: WidgetLintIssue }) {
+ const tone = issue.level === "error" ? "var(--danger)" : "var(--gold)";
+ return (
+
+
+ {LINT_FIELD_LABEL[issue.field]} {issue.line}:{issue.column}
+
+ {issue.message}
+
+ );
+}
+
/* ─── 分区卡片头 ─── */
function SectionHead({
icon,
diff --git a/frontend/app/admin/users/[id]/UserAuditClient.tsx b/frontend/app/admin/users/[id]/UserAuditClient.tsx
index 1c1ffc8..543831e 100644
--- a/frontend/app/admin/users/[id]/UserAuditClient.tsx
+++ b/frontend/app/admin/users/[id]/UserAuditClient.tsx
@@ -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({
帖子 #{c.post_id}
) : (
diff --git a/frontend/app/ads/buy/AdsBuyClient.tsx b/frontend/app/ads/buy/AdsBuyClient.tsx
index e5a12ba..841b5e5 100644
--- a/frontend/app/ads/buy/AdsBuyClient.tsx
+++ b/frontend/app/ads/buy/AdsBuyClient.tsx
@@ -29,6 +29,7 @@ import {
AD_IMAGE_WIDTH,
AD_TEXT_COL_WIDTH,
} from "@/lib/ads";
+import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
type Kind = "image" | "text";
@@ -183,7 +184,8 @@ export default function AdsBuyClient() {
>
{/* eslint-disable-next-line @next/next/no-img-element */}
{/* eslint-disable-next-line @next/next/no-img-element */}
diff --git a/frontend/app/announcement/[id]/page.tsx b/frontend/app/announcement/[id]/page.tsx
index 1c80aca..562aa0d 100644
--- a/frontend/app/announcement/[id]/page.tsx
+++ b/frontend/app/announcement/[id]/page.tsx
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
-import { Megaphone } from "lucide-react";
+import { Megaphone, Pin } from "lucide-react";
import { fetchAnnouncementDetail, fetchOverview } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
-import { isAdminOrAbove } from "@/lib/roles";
+import { hasPerm, PERMS } from "@/lib/roles";
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
import MarkdownBody from "@/components/MarkdownBody";
import PostToc from "@/components/PostToc";
@@ -59,7 +59,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
const loginHref = `/login?redirect=${encodeURIComponent(`/announcement/${id}`)}`;
const sidebarPages = overview?.sidebar_pages ?? [];
const announcements = overview?.announcements ?? [];
- const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
+ const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
const siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const toc = extractToc(a.content);
@@ -116,7 +116,11 @@ export default async function AnnouncementPage({ params }: PageProps) {
showTimeOfDay
badges={
<>
- {a.pinned ? 置顶 : null}
+ {a.pinned ? (
+
+
+
+ ) : null}
{a.tag}
>
}
diff --git a/frontend/app/announcements/page.tsx b/frontend/app/announcements/page.tsx
index d7dfe56..9fca982 100644
--- a/frontend/app/announcements/page.tsx
+++ b/frontend/app/announcements/page.tsx
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import Link from "next/link";
-import { Megaphone } from "lucide-react";
+import { Megaphone, Pin } from "lucide-react";
import { fetchAnnouncementsPage, fetchOverview, type Announcement } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
-import { isAdminOrAbove } from "@/lib/roles";
+import { hasPerm, PERMS } from "@/lib/roles";
import { formatDate } from "@/lib/format";
import Pagination from "@/components/Pagination";
import {
@@ -57,7 +57,11 @@ function FeaturedAnnouncement({ a, urlStyle }: { a: Announcement; urlStyle: UrlS
>
置顶公告
- {a.pinned ? 置顶 : null}
+ {a.pinned ? (
+
+
+
+ ) : null}
{a.tag}