feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等

新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
This commit is contained in:
2026-10-01 03:06:05 +08:00
parent 7fbd6ba699
commit ff2ab286fb
141 changed files with 7527 additions and 1407 deletions

View File

@@ -1,7 +1,7 @@
import Link from "next/link";
import { CheckCircle2, CircleHelp, Coins } from "lucide-react";
import type { AcceptedAnswer } from "@/lib/api";
import { userPath } from "@/lib/urlStyle";
import { userPath, commentAnchor } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
import Avatar from "@/components/Avatar";
import MarkdownBody from "@/components/MarkdownBody";
@@ -74,7 +74,7 @@ export default async function AcceptedAnswerCard({
<>
<span aria-hidden>·</span>
<a
href={`#comment-${answer.id}`}
href={commentAnchor(answer.floor, answer.is_root, answer.id)}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
@@ -95,7 +95,7 @@ export default async function AcceptedAnswerCard({
<>
<span aria-hidden>·</span>
<a
href={`#comment-${answer.id}`}
href={commentAnchor(answer.floor, answer.is_root, answer.id)}
className="hover:underline"
style={{ color: "var(--accent)" }}
>

View File

@@ -1,51 +1,92 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { variantURL } from "@/lib/responsiveImage";
/** 顶部横版槽位的最小门槛:自然宽度低于该值的横版图视为过小,不占顶部布局、归入左栏 */
const MIN_TOP_WIDTH = 600;
/** 由自然宽高判定当前槽位是否该显示(与 measure() 内逻辑保持一致) */
function slotMatches(
orientation: "portrait" | "landscape",
w: number,
h: number
): boolean {
const landscape = w > h;
// 仅"横版且足够大"才配得上顶部槽位;过小的横版图随竖版走左栏
const topWorthy = landscape && w >= MIN_TOP_WIDTH;
return orientation === "landscape" ? topWorthy : !topWorthy;
}
/**
* 封面自适应槽位:同一封面在两个位置各放一个槽位(左栏=竖版、顶部全宽=横版),
* 组件用隐藏探测图读取自然宽高,只渲染与自身方向匹配的槽位。
* 竖版槽默认显示(书封多为竖版),探测为横版时两槽自动换位,避免闪空。
* 横版但过小(自然宽度 < MIN_TOP_WIDTH)的图同样只渲染在左栏槽位。
* 组件按方向决定是否渲染自身内容。
*
* 已知尺寸(naturalWidth/naturalHeight > 0)时同步判定,SSR 首帧即正确方向——
* 避免"先竖版占位、hydration 后探测再切横版"的闪烁与布局抖动。
* 尺寸未知(外链/存量未存尺寸)时回退到隐藏探测图运行时测量。
*/
export default function AdaptiveCoverSlot({
src,
orientation,
naturalWidth,
naturalHeight,
children,
}: {
src: string;
orientation: "portrait" | "landscape";
/** 封面自然宽;>0 时跳过探测直接判定 */
naturalWidth?: number;
/** 封面自然高;>0 时跳过探测直接判定 */
naturalHeight?: number;
children: ReactNode;
}) {
const known =
typeof naturalWidth === "number" &&
naturalWidth > 0 &&
typeof naturalHeight === "number" &&
naturalHeight > 0;
const ref = useRef<HTMLImageElement>(null);
const [match, setMatch] = useState(orientation === "portrait");
const measure = (img: HTMLImageElement) => {
if (!img.naturalWidth || !img.naturalHeight) return;
const landscape = img.naturalWidth > img.naturalHeight;
// 仅“横版且足够大”才配得上顶部槽位;过小的横版图随竖版走左栏
const topWorthy = landscape && img.naturalWidth >= MIN_TOP_WIDTH;
setMatch(orientation === "landscape" ? topWorthy : !topWorthy);
};
// 已知尺寸 → 同步初值;未知 → 仍默认竖版显示(书封多为竖版),探测后纠正
const [match, setMatch] = useState(
known ? slotMatches(orientation, naturalWidth!, naturalHeight!) : orientation === "portrait"
);
// 仅未知尺寸时需要探测
useEffect(() => {
if (known) return;
// 缓存图可能在 hydration 前就完成加载而错过 onLoad
if (ref.current?.complete) measure(ref.current);
}, []);
if (ref.current?.complete) {
const img = ref.current;
if (img.naturalWidth && img.naturalHeight) {
setMatch(slotMatches(orientation, img.naturalWidth, img.naturalHeight));
}
}
}, [known, orientation]);
// 已知尺寸时不渲染探测图:省一次请求,且 SSR 输出即稳定
if (known) {
return <>{match ? children : null}</>;
}
return (
<>
{/* 探测只需尺寸信息:取 768 档变体而非原图(600~767 宽的源后端透传原尺寸,
≥768 的源探测宽 768 仍 ≥MIN_TOP_WIDTH,横竖版与门槛判定均不失真) */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={ref}
src={src}
src={variantURL(src, 768)}
alt=""
aria-hidden
className="hidden"
onLoad={(e) => measure(e.currentTarget)}
onLoad={(e) => {
const img = e.currentTarget;
if (img.naturalWidth && img.naturalHeight) {
setMatch(slotMatches(orientation, img.naturalWidth, img.naturalHeight));
}
}}
/>
{match ? children : null}
</>

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { densitySrcSet, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
// 柔和多色头像:按用户名确定性取色,圆形软底,浅/暗主题在 globals.css 各自配色
function hash(s: string) {
@@ -30,6 +31,8 @@ export default function Avatar({
// 切换到新的图片地址时重置失败状态
useEffect(() => setImgFailed(false), [src]);
const showImg = !!src && !imgFailed;
// showImg 为真时 src 必为字符串;统一收敛类型供变体 helper 使用
const imgSrc = src ?? "";
return (
<span
@@ -40,11 +43,15 @@ export default function Avatar({
aria-hidden="true"
>
{showImg ? (
// 源图 ≤512px,按显示尺寸取 1x/2x 变体(PageSpeed:38px 位不再下 512 原图)
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
src={variantURL(imgSrc, pickVariantWidth(size))}
srcSet={densitySrcSet(imgSrc, size)}
alt={name.trim() || "用户头像"}
className="w-full h-full object-cover"
draggable={false}
onContextMenu={(e) => e.preventDefault()}
onError={() => setImgFailed(true)}
/>
) : (

View File

@@ -0,0 +1,327 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronRight, Search, X } from "lucide-react";
import Modal from "@/components/Modal";
import { libraryDocReadPath, type UrlStyle } from "@/lib/urlStyle";
import { searchBook, type BookChapterIndex, type BookSnippet } from "@/lib/bookSearch";
/** 阅读页正文章节容器 id(read/page.tsx 的 <article>),高亮作用域 */
const ARTICLE_ID = "j13-book-article";
/** 关键词长度上限(避免超长输入卡顿) */
const MAX_QUERY = 64;
/** CSS Custom Highlight 注册名(对应 globals.css 的 ::highlight(...)) */
const HL_ALL = "j13-find";
const HL_CURRENT = "j13-find-current";
/** 特性检测:CSS Custom Highlight API(Chrome105+/Safari17.2+/FF140+),不支持时只跳转不高亮 */
function highlightsSupported(): boolean {
return (
typeof window !== "undefined" &&
typeof window.Highlight !== "undefined" &&
typeof CSS !== "undefined" &&
!!CSS.highlights
);
}
function clearHighlights(): void {
if (!highlightsSupported()) return;
CSS.highlights!.delete(HL_ALL);
CSS.highlights!.delete(HL_CURRENT);
}
/**
* 运行时注入高亮样式:构建器的 CSS 压缩器不识别 ::highlight() 会丢规则,
* 且 highlight 伪元素不继承自定义属性,这里从 :root 读出 --accent/--accent-on
* 的实际色值拼规则。hex 转带透明度的 rgba;非 hex 值原样使用。
*/
let highlightStyleInjected = false;
function ensureHighlightStyle(): void {
if (highlightStyleInjected || !highlightsSupported()) return;
const css = getComputedStyle(document.documentElement);
const accent = (css.getPropertyValue("--accent") || "").trim();
const accentOn = (css.getPropertyValue("--accent-on") || "").trim() || "#fff";
let h = accent;
if (h.length === 4) h = "#" + h.slice(1).split("").map((c) => c + c).join("");
const soft =
/^#[0-9a-f]{6}$/i.test(h)
? `rgba(${parseInt(h.slice(1, 3), 16)},${parseInt(h.slice(3, 5), 16)},${parseInt(h.slice(5, 7), 16)},0.24)`
: accent;
const style = document.createElement("style");
style.dataset.j13 = "find-highlight";
style.textContent =
`::highlight(${HL_ALL}){background-color:${soft}}` +
`::highlight(${HL_CURRENT}){background-color:${accent};color:${accentOn}}`;
document.head.appendChild(style);
highlightStyleInjected = true;
}
/**
* 收集正文内命中 Range:按文本节点逐个匹配(大小写不敏感子串),不改 DOM。
* 用 Range 而非包 <mark>:正文是 server 组件渲染的,直接替换文本节点会让
* React 在章节切换后的原地复用错位;Range 随章节重建自然失效,无副作用。
* 跨标签边界的词匹配不到——可接受,绝大多数中文命中落在同一文本节点。
*/
function collectRanges(root: HTMLElement, query: string): Range[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const targets: Text[] = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const el = node.parentElement;
if (!el || !node.nodeValue) return NodeFilter.FILTER_REJECT;
const tag = el.tagName;
if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT") {
return NodeFilter.FILTER_REJECT;
}
return node.nodeValue.toLowerCase().includes(q)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
let n: Node | null;
while ((n = walker.nextNode())) targets.push(n as Text);
const ranges: Range[] = [];
for (const textNode of targets) {
const tv = textNode.nodeValue ?? "";
const low = tv.toLowerCase();
let from = 0;
for (;;) {
const idx = low.indexOf(q, from);
if (idx < 0) break;
const range = document.createRange();
range.setStart(textNode, idx);
range.setEnd(textNode, idx + q.length);
ranges.push(range);
from = idx + q.length;
}
}
return ranges;
}
/** 注册高亮(全部命中 + 当前命中两组);返回文档序命中 Range 列表 */
function paintRanges(query: string, currentOrdinal: number): Range[] {
const root = document.getElementById(ARTICLE_ID);
if (!root) return [];
const ranges = collectRanges(root, query);
if (!highlightsSupported()) return ranges;
ensureHighlightStyle();
clearHighlights();
if (ranges.length > 0) {
const all = new Highlight(...ranges);
const cur = new Highlight();
const curRange = ranges[Math.min(Math.max(currentOrdinal, 0), ranges.length - 1)];
if (curRange) cur.add(curRange);
CSS.highlights!.set(HL_ALL, all);
CSS.highlights!.set(HL_CURRENT, cur);
}
return ranges;
}
/** 滚动到命中 Range(视口垂直居中,留出吸顶头空间) */
function scrollToRange(range: Range, smooth: boolean): void {
const rect = range.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
const top =
window.scrollY + rect.top - window.innerHeight / 2 + (rect.height || 16) / 2;
window.scrollTo({ top: Math.max(0, top), behavior: smooth ? "smooth" : "auto" });
}
/** 重新高亮当前章并定位到指定命中(ordinal 为章内 0 基命中序号) */
function highlightAndScroll(query: string, ordinal: number, smooth: boolean): void {
const ranges = paintRanges(query, ordinal);
const target = ranges[Math.min(Math.max(ordinal, 0), ranges.length - 1)];
if (!target) return;
// 双层 rAF:等章节切换后的布局与图片占位稳定再定位
requestAnimationFrame(() =>
requestAnimationFrame(() => scrollToRange(target, smooth))
);
}
function SnippetText({ snip }: { snip: BookSnippet }) {
return (
<span className="leading-relaxed">
{snip.before ? <span className="meta">…{snip.before}</span> : null}
<mark className="j13-find-mark">{snip.match}</mark>
{snip.after ? <span className="meta">{snip.after}…</span> : null}
</span>
);
}
export interface BookSearchProps {
slug: string;
urlStyle: UrlStyle;
/** 当前章序号(1 基) */
currentNo: number;
chapters: BookChapterIndex[];
}
export default function BookSearch({ slug, urlStyle, currentNo, chapters }: BookSearchProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
/**
* 跨章跳转待执行的高亮。组件跨章导航不重挂载(位置稳定),query 状态保留,
* 仅在 currentNo 变化、新章 DOM 提交后执行一次,因此无需把关键词写进 URL。
*/
const pendingRef = useRef<{ q: string; ordinal: number } | null>(null);
const trimmed = query.trim();
const results = useMemo(() => searchBook(chapters, trimmed), [chapters, trimmed]);
const totalHits = useMemo(() => results.reduce((acc, h) => acc + h.count, 0), [results]);
useEffect(() => {
const pending = pendingRef.current;
if (!pending) return;
pendingRef.current = null;
highlightAndScroll(pending.q, pending.ordinal, false);
}, [currentNo]);
// 关键词清空或离开读页时撤掉正文高亮
useEffect(() => {
if (!trimmed) clearHighlights();
}, [trimmed]);
useEffect(() => () => clearHighlights(), []);
const focusHit = (no: number, ordinal: number) => {
if (no === currentNo) {
highlightAndScroll(trimmed, ordinal, true);
setOpen(false);
return;
}
pendingRef.current = { q: trimmed, ordinal };
setOpen(false);
router.push(libraryDocReadPath(urlStyle, slug, no));
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && results[0]) {
const first = results[0];
focusHit(first.no, first.snippets[0]?.ordinal ?? 0);
}
};
return (
<>
<button
type="button"
onClick={() => {
setOpen(true);
requestAnimationFrame(() => inputRef.current?.focus());
}}
className="inline-flex items-center gap-2 rounded-full pl-3.5 pr-4 py-2 text-[13px] transition-colors hover:bg-[var(--panel-2)] hover:border-[color-mix(in_srgb,var(--accent)_40%,var(--line))] shrink-0 w-36 sm:w-52"
style={{ color: "var(--ink-3)", border: "1px solid var(--line)", background: "var(--panel)" }}
aria-label="搜索本书"
>
<Search size={14} className="shrink-0" aria-hidden />
<span className="truncate text-left">搜索本书</span>
</button>
<Modal
open={open}
onClose={() => setOpen(false)}
title="搜索本书"
description={`共 ${chapters.length} 章 · 命中后可直接跳到对应位置`}
maxWidthClass="max-w-lg"
panelClassName="p-5 sm:p-6"
>
<div className="relative">
<Search
size={15}
className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
<input
ref={inputRef}
type="text"
value={query}
maxLength={MAX_QUERY}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="搜索章节标题与正文"
aria-label="搜索章节标题与正文"
className="j13-search-field w-full rounded-xl pl-10 pr-9 py-2.5 text-[13.5px]"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除关键词"
className="absolute right-3 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-3)" }}
>
<X size={13} />
</button>
) : null}
</div>
<div className="mt-3 max-h-[56vh] overflow-y-auto j13-rail-scroll -mx-1 px-1">
{!trimmed ? (
<p className="meta text-[12.5px] py-8 text-center">
输入关键词,按章查看命中片段,点击即可跳转
</p>
) : totalHits === 0 ? (
<p className="meta text-[12.5px] py-8 text-center">全书没有找到「{trimmed}」</p>
) : (
<div className="flex flex-col gap-2">
<p className="meta text-[11.5px] px-1">
{results.length} 章命中 · 共 {totalHits} 处
</p>
{results.map((hit) => {
const isCurrent = hit.no === currentNo;
return (
<section
key={hit.no}
className="rounded-xl overflow-hidden"
style={{ border: "1px solid var(--line)" }}
>
<button
type="button"
onClick={() => focusHit(hit.no, 0)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--panel-2)] transition-colors"
>
<span
className="text-[12.5px] font-bold truncate min-w-0"
style={{ color: "var(--ink)" }}
>
第 {hit.no} 章 · {hit.title}
</span>
{isCurrent ? (
<span
className="shrink-0 text-[10px] font-semibold px-1.5 py-0.5 rounded"
style={{ color: "var(--accent)", background: "var(--accent-soft)" }}
>
本章
</span>
) : null}
<span className="meta text-[11px] ml-auto shrink-0 inline-flex items-center gap-0.5">
{hit.count} 处
<ChevronRight size={12} aria-hidden />
</span>
</button>
{hit.snippets.map((snip) => (
<button
key={snip.ordinal}
type="button"
onClick={() => focusHit(hit.no, snip.ordinal)}
className="block w-full text-left px-3 py-1.5 text-[12.5px] border-t border-[var(--line)] hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-2)" }}
>
<SnippetText snip={snip} />
</button>
))}
</section>
);
})}
</div>
)}
</div>
</Modal>
</>
);
}

View File

@@ -1,4 +1,5 @@
import { brandLogoBox, type BrandLogoFit, type BrandLogoSize, type BrandMark } from "@/lib/brand";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
/** 页眉品牌锁:图加文字 / 纯图 / 纯文字。图槽按画幅占位,铺法决定图怎么填进框。 */
export default function BrandLockup({
@@ -45,11 +46,11 @@ export default function BrandLockup({
{src ? (
both ? (
<>
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" />
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" />
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" cssPx={Math.max(box.w, box.h)} />
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" cssPx={Math.max(box.w, box.h)} />
</>
) : (
<Logo src={src} fit={fit} alt={label} decorative={showText} />
<Logo src={src} fit={fit} alt={label} decorative={showText} cssPx={Math.max(box.w, box.h)} />
)
) : (
<Bubble h={box.h} label={showText ? undefined : label} />
@@ -81,6 +82,7 @@ function Logo({
alt,
decorative = false,
extra = "",
cssPx,
}: {
src: string;
fit: BrandLogoFit;
@@ -88,6 +90,8 @@ function Logo({
/** 旁边已有站名时,读屏跳过,HTML 仍保留 alt 供检索 */
decorative?: boolean;
extra?: string;
/** 图槽最大边 CSS px,用于密度变体 */
cssPx: number;
}) {
if (fit === "repeat") {
return (
@@ -100,9 +104,17 @@ function Logo({
/>
);
}
// 本站光栅图走 1x/2x 变体(SVG/ICO 后端透传);外链图保持原地址
const own = isOwnImage(src);
return (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={src} alt={alt} aria-hidden={decorative || undefined} className={`j13-brand-logo ${extra}`} />
<img
src={own ? variantURL(src, pickVariantWidth(cssPx)) : src}
srcSet={own ? densitySrcSet(src, cssPx) : undefined}
alt={alt}
aria-hidden={decorative || undefined}
className={`j13-brand-logo ${extra}`}
/>
);
}

View File

@@ -16,11 +16,26 @@ import { userPath } from "@/lib/urlStyle";
import { apiGetUserProfile, apiOpenDirectChat, type UserProfile, type User } from "@/lib/api";
import { toast } from "@/lib/toast";
/** 计算禁言剩余时间,返回「x天x小时x分钟」格式 */
function formatMuteRemain(untilIso: string): string {
const until = new Date(untilIso).getTime();
const diff = until - Date.now();
if (diff <= 0) return "即将";
const days = Math.floor(diff / 86_400_000);
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
const minutes = Math.floor((diff % 3_600_000) / 60_000);
if (days > 0) return `${days} 天`;
if (hours > 0) return `${hours} 小时`;
return `${minutes} 分钟`;
}
export type ChatUserCardTarget = {
user: User;
/** 群内身份(可从成员表查到时携带) */
roomRole?: "owner" | "admin" | "member";
muted?: boolean;
/** 禁言截止时间(ISO 字符串);null/缺省表示永久 */
mutedUntil?: string | null;
};
/**
@@ -149,6 +164,9 @@ export default function ChatUserCard({
style={{ color: "var(--ink-3)" }}
>
<VolumeX size={12} /> 禁言中
{target.mutedUntil && (
<span>· {formatMuteRemain(target.mutedUntil)}后解除</span>
)}
</p>
)}
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
@@ -40,8 +40,7 @@ import ConfirmDialog from "@/components/ConfirmDialog";
import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal";
import EditHistoryModal from "@/components/EditHistoryModal";
import { scrollFlashTo } from "@/lib/flash";
import { fetchCommentLocation } from "@/lib/api";
import { postPath, userPath } from "@/lib/urlStyle";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
interface CommentSectionProps {
@@ -194,7 +193,9 @@ function CommentMoreMenu({ items }: { items: MoreItem[] }) {
function focusCommentControl(id: number, kind: "reply" | "more") {
window.requestAnimationFrame(() => {
document.querySelector<HTMLElement>(`#comment-${id} [data-cmt-focus="${kind}"]`)?.focus();
document
.querySelector<HTMLElement>(`[data-comment-id="${id}"] [data-cmt-focus="${kind}"]`)
?.focus();
});
}
@@ -293,26 +294,44 @@ export default function CommentSection({
return () => window.removeEventListener("hashchange", focusComposer);
}, [user, commentsOpen]);
// 深链定位:#comment-{id} → 滚动 + 主题色浅底高亮渐隐。
// 目标是折叠楼层里的子回复时先展开所属楼层;不在当前页时查楼层号换页后重试。
// 深链定位:#comment-{floor}(主楼)或 #comment-{floor}-r{cid}(楼中楼)。
// 锚点数字是帖内相对楼层号,直接决定目标分页;目标是折叠楼层里的回复时先展开;
// 楼层号锚点(#楼)、「回复 @xx」、采纳答案卡等页内跳转经 hashchange 同样获得高亮。
const locatedRef = useRef<number | null>(null); // 已跨页尝试过的评论 ID,防循环
const attemptedHashRef = useRef<string | null>(null); // 已跨页尝试过的锚点,防循环
useEffect(() => {
if (!commentsOpen) return;
let cancelled = false;
const locate = async () => {
const m = window.location.hash.match(/^#comment-(\d+)$/);
const locate = () => {
const m = window.location.hash.match(/^#comment-(\d+)(?:-r(\d+))?$/);
if (!m) return;
const id = Number(m[1]);
const el = document.getElementById(`comment-${id}`);
if (el) {
scrollFlashTo(el);
const floor = Number(m[1]);
const replyId = m[2] ? Number(m[2]) : 0;
if (floor > total) return; // 楼层不存在(异常/过期链接),停留在当前页
const destPage = Math.max(1, Math.ceil(floor / size));
const hash = window.location.hash;
if (destPage !== page) {
// 不在目标页:带锚点换页,comments 刷新后本 effect 重新运行完成定位
if (attemptedHashRef.current === hash) return;
attemptedHashRef.current = hash;
router.replace(`${postPath(urlStyle, Number(postId))}?page=${destPage}${hash}`, {
scroll: false,
});
return;
}
// 不在 DOM:先看是否在本页数据里(折叠的子回复)→ 展开所属楼层后定位
const node = findComment(comments, id);
if (node) {
const rootId = node.root_id ?? node.parent_id ?? node.id;
const flashTarget = () => {
const el = replyId
? document.querySelector<HTMLElement>(`[data-comment-id="${replyId}"]`)
: document.getElementById(`comment-${floor}`);
if (el) scrollFlashTo(el);
};
if (!replyId) {
flashTarget();
return;
}
// 楼中楼:先展开所属楼层(折叠态不渲染回复节点),再滚动高亮
const node = findComment(comments, replyId);
const rootId = node?.root_id ?? node?.parent_id ?? 0;
if (node && rootId) {
setExpandedFloors((prev) => {
if (prev.has(rootId)) return prev;
const next = new Set(prev);
@@ -320,21 +339,10 @@ export default function CommentSection({
return next;
});
window.setTimeout(() => {
if (!cancelled) scrollFlashTo(document.getElementById(`comment-${id}`));
if (!cancelled) flashTarget();
}, 160);
return;
}
// 不在本页:查楼层号 → 换页(comments 变化后本 effect 再次运行并定位)
if (locatedRef.current === id) return; // 已尝试仍找不到(如被拒/未公开),放弃
locatedRef.current = id;
try {
const floor = await fetchCommentLocation(postId, id);
if (cancelled || floor <= 0) return;
const destPage = Math.max(1, Math.ceil(floor / size));
if (destPage === page) return; // 同页却无节点(异常数据),放弃
router.replace(`${postPath(urlStyle, Number(postId))}?page=${destPage}#comment-${id}`, { scroll: false });
} catch {
/* 定位失败静默:停留在当前页 */
} else {
flashTarget();
}
};
locate();
@@ -344,7 +352,7 @@ export default function CommentSection({
window.removeEventListener("hashchange", locate);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [comments, commentsOpen, page, postId, size]);
}, [comments, commentsOpen, page, postId, size, total]);
const submitFloor = async () => {
if (!commentsOpen || !content.trim() || submitLock.current || floorUploading) return;
@@ -360,10 +368,11 @@ export default function CommentSection({
toast("已提交,等待审核通过后公开");
}
if (destPage !== page) {
const anchor = commentAnchor(total + 1, true);
setAwayHref(
destPage > 1
? `${postPath(urlStyle, Number(postId))}?page=${destPage}#comment-${res.comment.id}`
: `${postPath(urlStyle, Number(postId))}#comment-${res.comment.id}`
? `${postPath(urlStyle, Number(postId))}?page=${destPage}${anchor}`
: `${postPath(urlStyle, Number(postId))}${anchor}`
);
} else {
setAwayHref(null);
@@ -853,9 +862,9 @@ export default function CommentSection({
const totalPages = Math.max(1, Math.ceil(total / size));
// 内联回复表单(出现在目标评论正下方;全站关评时不渲染)
// 内联回复表单:渲染在所属楼层块外侧(见下方列表 map),任意回复层级均全宽;全站关评时不渲染
const inlineReplyForm = commentsOpen && replyTarget && (
<form onSubmit={handleSubmitReply} className="cmt-reply-form mt-2">
<form onSubmit={handleSubmitReply} className="cmt-reply-form">
<div className="flex items-center justify-between gap-2 mb-2 text-[13px]" style={{ color: "var(--ink-2)" }}>
<span>
回复 <span style={{ color: "var(--accent)", fontWeight: 600 }}>@{replyTarget.nickname}</span>
@@ -1016,7 +1025,12 @@ export default function CommentSection({
if (!isFloor) {
return (
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
<div
key={c.id}
id={floor ? `comment-${floor}-r${c.id}` : undefined}
data-comment-id={c.id}
className="cmt-reply-row scroll-mt-24"
>
<div className="flex gap-3">
{identity}
<div className="flex-1 min-w-0">
@@ -1040,7 +1054,12 @@ export default function CommentSection({
}
return (
<div key={c.id} id={`comment-${c.id}`} className="cmt-item scroll-mt-24">
<div
key={c.id}
id={floor ? `comment-${floor}` : undefined}
data-comment-id={c.id}
className="cmt-item scroll-mt-24"
>
<div className="flex gap-3">
{identity}
<div className="flex-1 min-w-0">
@@ -1050,7 +1069,7 @@ export default function CommentSection({
{timeMeta(c)}
</div>
{floor ? (
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
<a href={`#comment-${floor}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
#{floor}
</a>
) : null}
@@ -1088,7 +1107,12 @@ export default function CommentSection({
const isReplyToReply = depth >= 2;
const isOp = (author?.id ?? c.user_id) === authorId;
return (
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
<div
key={c.id}
id={floor ? `comment-${floor}-r${c.id}` : undefined}
data-comment-id={c.id}
className="cmt-reply-row scroll-mt-24"
>
<div className="flex gap-3">
{author ? (
<Link href={userPath(urlStyle, author.id)} className="shrink-0" aria-label={authorName}>
@@ -1120,7 +1144,7 @@ export default function CommentSection({
{timeMeta(c)}
{editedMark(c)}
{isReplyToReply && parent && (
<a href={`#comment-${parent.id}`} className="cmt-reply-target">
<a href={`#comment-${floor}-r${parent.id}`} className="cmt-reply-target">
回复 {parentAuthor}
</a>
)}
@@ -1174,7 +1198,6 @@ export default function CommentSection({
)}
<CommentMoreMenu items={moreItemsFor(c)} />
</div>
{replyTarget?.id === c.id && inlineReplyForm}
</div>
</div>
{node.replies && node.replies.length > 0 && (
@@ -1189,7 +1212,12 @@ export default function CommentSection({
// 主评论(楼层)
const isOp = (author?.id ?? c.user_id) === authorId;
return (
<div key={c.id} id={`comment-${c.id}`} className="cmt-item scroll-mt-24">
<div
key={c.id}
id={floor ? `comment-${floor}` : undefined}
data-comment-id={c.id}
className="cmt-item scroll-mt-24"
>
<div className="flex gap-3">
{author ? (
<Link href={userPath(urlStyle, author.id)} className="shrink-0" aria-label={authorName}>
@@ -1223,7 +1251,7 @@ export default function CommentSection({
{editedMark(c)}
</div>
{floor ? (
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
<a href={`#comment-${floor}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
#{floor}
</a>
) : null}
@@ -1276,7 +1304,6 @@ export default function CommentSection({
)}
<CommentMoreMenu items={moreItemsFor(c)} />
</div>
{replyTarget?.id === c.id && inlineReplyForm}
{node.replies && node.replies.length > 0 && (
<div className="mt-3">
<button
@@ -1396,7 +1423,14 @@ export default function CommentSection({
)}
<div>
{comments.map((node, i) => renderComment(node, 0, (page - 1) * size + i + 1))}
{/* 回复框统一渲染在所属楼层块外侧:无论回复楼层还是任意深度子回复,
都按全宽显示,不受头像列与嵌套缩进挤压 */}
{comments.map((node, i) => (
<Fragment key={node.id}>
{renderComment(node, 0, (page - 1) * size + i + 1)}
{replyTarget?.rootId === node.id ? inlineReplyForm : null}
</Fragment>
))}
{comments.length === 0 && (
<div className="text-center py-14">
<MessagesSquare size={28} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />

View File

@@ -24,7 +24,7 @@ import {
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
import { userPath } from "@/lib/urlStyle";
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
import { canAccessAdminMessages, hasAnyChannelPerm, isStaff } from "@/lib/roles";
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
@@ -598,7 +598,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
</Link>
</div>
{(isStaff(user.role) || canAccessAdminMessages(user)) && (
{(isStaff(user.role) || canAccessAdminMessages(user) || hasAnyChannelPerm(user)) && (
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<Link href="/admin" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<ShieldCheck size={15} style={{ color: "var(--accent)" }} /> 管理面板

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { Images, Link2, Loader2, Upload } from "lucide-react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
type InsertTab = "upload" | "url" | "media";
@@ -212,7 +213,9 @@ export default function ImageInsertModal({
>
{/* 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="媒体库图片"
className="w-full h-full object-contain"
loading="lazy"

View File

@@ -0,0 +1,241 @@
"use client";
import { useMemo, useState, type CSSProperties } from "react";
import Link from "next/link";
import { FileDown, PenLine, Search, X } from "lucide-react";
import type { LibraryDocListItem } from "@/lib/api";
import { coverHue } from "@/lib/coverColor";
import { libraryDocPath, type UrlStyle } from "@/lib/urlStyle";
import { variantURL, widthSrcSet } from "@/lib/responsiveImage";
import { formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
function formatSize(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0 B";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** 简介 markdown → 纯文本摘要(去标记、压平空白,卡片两行截断用) */
function plainExcerpt(md: string): string {
return md
.replace(/!\[[^\]]*\]\([^)]*\)/g, "") // 图片
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // 链接保留文字
.replace(/[*_~`#>]+/g, "") // 行内/块级标记
.replace(/\s+/g, " ")
.trim();
}
/** 文字封面:无图时用完整书名 + 书名派生稳定色相洗底(lib/coverColor.ts) */
function TextCover({ title }: { title: string }) {
const name = title.trim() || "书";
return (
<div
className="j13-text-cover w-full h-full flex items-center justify-center px-4 select-none"
style={{ "--cover-h": coverHue(name) } as CSSProperties}
aria-hidden
>
<span className="j13-library-title text-[19px] font-bold leading-snug text-center line-clamp-4 break-all tracking-wide">
{name}
</span>
</div>
);
}
function ExtChip({ ext }: { ext: string }) {
return (
<span
className="text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide"
style={{
color: "var(--accent)",
background: "var(--accent-soft)",
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
}}
>
{ext}
</span>
);
}
function DocCard({ doc, href }: { doc: LibraryDocListItem; href: string }) {
const exts = Array.from(new Set(doc.files.map((f) => f.ext))).slice(0, 4);
const totalSize = doc.files.reduce((acc, f) => acc + f.size, 0);
const creatorName = doc.creator ? doc.creator.nickname || doc.creator.username : "";
const excerpt = plainExcerpt(doc.description ?? "");
return (
<Link href={href} className="group flex flex-col min-w-0">
{/* 竖版书形封面:书脊光影 + 悬停浮出简介 */}
<div
className="relative aspect-[3/4] overflow-hidden rounded-[10px] shadow-[var(--shadow-sm)] transition-[transform,box-shadow] duration-200 group-hover:-translate-y-1.5 group-hover:shadow-[var(--shadow-lg)]"
style={{ background: "var(--panel-2)" }}
>
{doc.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={variantURL(doc.cover_url, 384)}
srcSet={widthSrcSet(doc.cover_url, [384, 512, 768])}
sizes="(max-width:640px) 46vw, (max-width:1024px) 30vw, (max-width:1280px) 22vw, 220px"
alt={doc.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
draggable={false}
/>
) : (
<TextCover title={doc.title} />
)}
{/* 书脊/书口光影,营造实体书体积感 */}
<div
aria-hidden
className="absolute inset-0 rounded-[10px] pointer-events-none"
style={{
background:
"linear-gradient(90deg, rgba(0,0,0,0.16) 0%, rgba(0,0,0,0.05) 2.5%, transparent 5%, transparent 96%, rgba(0,0,0,0.05) 98.5%, rgba(0,0,0,0.1) 100%)",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.06)",
}}
/>
{excerpt ? (
<div
className="absolute inset-0 flex items-end p-3.5 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none"
style={{
background:
"linear-gradient(to top, rgba(18,20,26,0.92) 0%, rgba(18,20,26,0.55) 55%, rgba(18,20,26,0.15) 100%)",
}}
>
<p className="text-[12px] leading-relaxed text-white/90 line-clamp-5">{excerpt}</p>
</div>
) : null}
</div>
{/* 封面下方书目信息:衬线书名 + 作者 + 文件概要 */}
<div className="pt-3 flex flex-col gap-1 min-w-0">
<h2
className="j13-library-title text-[15px] font-bold leading-snug line-clamp-2"
style={{ color: "var(--ink)" }}
>
{doc.title}
</h2>
{doc.author ? (
<p className="meta flex items-center gap-1.5 text-[12px] min-w-0">
<PenLine size={11} className="shrink-0" style={{ color: "var(--accent)" }} aria-hidden />
<span className="truncate" title={doc.author}>
{doc.author}
</span>
</p>
) : null}
<div className="mt-1 flex items-center gap-1.5 min-w-0">
{exts.map((ext) => (
<ExtChip key={ext} ext={ext} />
))}
<span className="meta text-[11px] ml-auto inline-flex items-center gap-1 shrink-0">
<FileDown size={11} />
{doc.file_count > 0 ? `${doc.file_count} 个文件 · ${formatSize(totalSize)}` : "暂无文件"}
</span>
</div>
<div className="flex items-center gap-1.5 min-w-0">
{doc.creator ? (
<span className="flex items-center gap-1.5 min-w-0 shrink">
<Avatar size={16} name={creatorName} src={doc.creator.avatar} />
<span className="meta text-[11px] truncate max-w-[6rem]" title={creatorName}>
{creatorName}
</span>
</span>
) : null}
<span className="meta text-[11px] truncate shrink min-w-0">
{doc.creator ? "· " : ""}
{formatRelative(doc.created_at)}
</span>
</div>
</div>
</Link>
);
}
/**
* 书库目录:书架式竖版封面网格 + 书名/作者即时过滤(目录接口一次返回全部已发布
* 条目,纯客户端过滤;上限与后端 ListPublished 的 100 条一致)。
*/
export default function LibrarySearchGrid({
docs,
urlStyle,
}: {
docs: LibraryDocListItem[];
urlStyle: UrlStyle;
}) {
const [query, setQuery] = useState("");
const q = query.trim().toLowerCase();
const filtered = useMemo(() => {
if (!q) return docs;
return docs.filter(
(d) => d.title.toLowerCase().includes(q) || d.author.toLowerCase().includes(q)
);
}, [docs, q]);
if (docs.length === 0) {
return (
<div
className="mt-8 rounded-[24px] px-6 py-16 text-center"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<p className="text-[15px] font-medium" style={{ color: "var(--ink-2)" }}>
书库还没有内容
</p>
<p className="meta text-[13px] mt-2">管理员可在后台「书库管理」发布条目</p>
</div>
);
}
return (
<div className="mt-2">
{/* 搜索:居中宽框,与大气 Hero 呼应 */}
<div className="relative max-w-lg mx-auto">
<Search
size={16}
className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索书名或作者"
aria-label="搜索书名或作者"
className="j13-search-field w-full rounded-full pl-11 pr-10 py-3 text-[14px] shadow-[var(--shadow-sm)]"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除搜索"
className="absolute right-3 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-3)" }}
>
<X size={14} />
</button>
) : null}
</div>
{filtered.length === 0 ? (
<div
className="mt-8 rounded-[24px] px-6 py-16 text-center"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<p className="text-[14px] font-medium" style={{ color: "var(--ink-2)" }}>
没有找到与「{query.trim()}」相关的书籍
</p>
<p className="meta text-[12.5px] mt-2">换个书名或作者关键词试试</p>
</div>
) : (
<div className="mt-8 grid gap-x-5 gap-y-9 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
{filtered.map((d) => (
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
))}
</div>
)}
</div>
);
}

View File

@@ -2,29 +2,43 @@
import { useState } from "react";
import ImageLightbox from "@/components/markdown/ImageLightbox";
import { variantURL, widthSrcSet } from "@/lib/responsiveImage";
/**
* 静态图片图箱入口(区别于 markdown 管线的 MdImage):
* 封面等非 markdown 图片点击进全屏图箱(单图,无多图切换)。
* 在 Link 内使用时 preventDefault + stopPropagation 阻断外层导航——
* 点图片本身是看图,点卡片其余区域仍走原链接。
*
* 可变宽度位可传 variantWidths(白名单档位)+ sizes:页面内显示变体,
* 图箱始终打开 data-full 指向的原图。
*/
export default function LightboxImage({
src,
alt,
className,
variantWidths,
sizes,
}: {
src: string;
alt: string;
className?: string;
/** 传入白名单档位则输出宽度 srcset;不传则直连原图 */
variantWidths?: readonly number[];
/** 配合 srcset 的 sizes 提示 */
sizes?: string;
}) {
const [open, setOpen] = useState(false);
const hasVariants = !!variantWidths && variantWidths.length > 0;
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
src={hasVariants ? variantURL(src, variantWidths[0]) : src}
srcSet={hasVariants ? widthSrcSet(src, variantWidths) : undefined}
sizes={hasVariants ? sizes : undefined}
alt={alt}
data-full={src}
className={`cursor-zoom-in${className ? ` ${className}` : ""}`}
loading="lazy"
decoding="async"

View File

@@ -83,6 +83,8 @@ type MarkdownEditorProps = {
onUploadingChange?: (uploading: boolean) => void;
/** 书级「全章节条目卡模式」:插入免壳模板、预览按隐式条目渲染 */
entriesAuto?: boolean;
/** 图片上传时标记的 source(如 library_content),默认 post */
uploadSource?: string;
};
type EditorMode = "write" | "preview";
@@ -164,6 +166,7 @@ export default function MarkdownEditor({
error,
onUploadingChange,
entriesAuto = false,
uploadSource,
}: MarkdownEditorProps) {
const taRef = useRef<HTMLTextAreaElement>(null);
const urlStyle = useUrlStyle();
@@ -466,7 +469,7 @@ export default function MarkdownEditor({
}
setUploading(true);
try {
const res = await apiUploadPostImage(file);
const res = await apiUploadPostImage(file, undefined, uploadSource);
if (!res.url) {
const msg = res.error || "上传失败";
setUploadError(msg);
@@ -1268,12 +1271,13 @@ export default function MarkdownEditor({
</div>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="j13-md-toolbar-end flex items-center gap-2 shrink-0">
<div className="j13-md-mode" role="tablist" aria-label="编辑模式">
<button
type="button"
role="tab"
aria-selected={mode === "write"}
aria-label="编写模式"
className={mode === "write" ? "is-on" : ""}
onMouseDown={(e) => {
e.preventDefault();
@@ -1281,12 +1285,13 @@ export default function MarkdownEditor({
}}
onClick={() => setMode("write")}
>
<Pencil size={12} /> 编写
<Pencil size={12} /> <span className="j13-md-mode-text">编写</span>
</button>
<button
type="button"
role="tab"
aria-selected={mode === "preview"}
aria-label="预览模式"
className={mode === "preview" ? "is-on" : ""}
onMouseDown={(e) => {
e.preventDefault();
@@ -1294,7 +1299,7 @@ export default function MarkdownEditor({
}}
onClick={() => setMode("preview")}
>
<Eye size={12} /> 预览
<Eye size={12} /> <span className="j13-md-mode-text">预览</span>
</button>
</div>
{!toolNarrow && (

View File

@@ -14,7 +14,7 @@ import {
import { realtime, RT_NOTIFICATION_NEW } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { postPath, userPath } from "@/lib/urlStyle";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
export default function NotificationBell({ initialUnread = 0 }: { initialUnread?: number }) {
const router = useRouter();
@@ -111,9 +111,11 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
return;
} else if (n.type === "follow") {
router.push(userPath(urlStyle, n.actor_id));
} else if (n.comment_id > 0) {
} else if (n.comment_id > 0 && n.comment_floor) {
// 评论/回复/点赞等:直达评论所在楼层(跨页由帖子页深链定位换页)
router.push(`${postPath(urlStyle, n.post_id)}#comment-${n.comment_id}`);
router.push(
`${postPath(urlStyle, n.post_id)}${commentAnchor(n.comment_floor, n.comment_is_root ?? true, n.comment_id)}`
);
} else {
router.push(postPath(urlStyle, n.post_id));
}

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { Pin } from "lucide-react";
import { Pin, Star } from "lucide-react";
import type { PostListItem } from "@/lib/api";
import { postPath } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
@@ -71,7 +71,16 @@ export default async function PinnedPosts({
<span className="truncate">{p.board.name}</span>
</>
)}
{p.recommended && <span className="badge-rec !text-[10.5px] !px-1.5 !py-0 shrink-0">推荐</span>}
{p.recommended && (
<span
className="badge-rec shrink-0"
style={{ width: 16, height: 16 }}
title="推荐"
aria-label="推荐"
>
<Star size={10} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
</div>
</div>
<div className="hidden sm:flex items-center gap-3 shrink-0 meta text-[12.5px]">

View File

@@ -18,7 +18,7 @@ import {
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import { useAllowComments } from "@/components/CommentsPolicyProvider";
import { postPath, userPath } from "@/lib/urlStyle";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
export default function PostInteractPanels({
@@ -250,7 +250,11 @@ export function QuestionStatusCard({ postId, state }: { postId: number; state: Q
<>
已采纳{" "}
<a
href={`#comment-${state.accepted_comment_id}`}
href={commentAnchor(
state.accepted_floor,
state.accepted_answer?.is_root ?? true,
state.accepted_comment_id
)}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
@@ -530,7 +534,11 @@ function BountyPanel({ state }: { postId: number; state: BountyState }) {
<p className="j13-typed-meta">
已采纳{" "}
<a
href={`#comment-${state.accepted_comment_id}`}
href={commentAnchor(
state.accepted_floor,
state.accepted_answer?.is_root ?? true,
state.accepted_comment_id
)}
className="hover:underline"
style={{ color: "var(--accent)" }}
>

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { ArrowLeft, Pin, Star } from "lucide-react";
import type { PostListItem } from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { getPublicSettingsCached } from "@/lib/serverData";
@@ -44,6 +44,8 @@ export default async function PostRow({
const pt = normalizePostType(post.post_type);
const statusBadge = pt !== "discussion" ? typeStatusLabel(pt, post.type_status) : "";
const statusClass = statusBadge ? typeStatusClass(pt, post.type_status) : "";
// 移动端徽章过多会挤占标题字数:仅隐藏「进行中」(开放式状态,详情页仍可见),终态徽章保留
const hideStatusOnMobile = statusBadge === "进行中";
const authorName = post.user.nickname || post.user.username;
return (
@@ -70,8 +72,16 @@ export default async function PostRow({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
{/* 仅置顶/推荐在标题左侧 */}
{post.pinned > 0 && <span className="badge-pin shrink-0">置顶</span>}
{post.recommended && <span className="badge-rec shrink-0">推荐</span>}
{post.pinned > 0 && (
<span className="badge-pin shrink-0" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
{post.recommended && (
<span className="badge-rec shrink-0" title="推荐" aria-label="推荐">
<Star size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-hidden">
<Link
href={postPath(urlStyle, post.id)}
@@ -88,7 +98,11 @@ export default async function PostRow({
<span className="badge-type shrink-0">{postTypeLabel(post.post_type)}</span>
)}
{statusBadge && statusClass && (
<span className={`${statusClass} shrink-0`}>{statusBadge}</span>
<span
className={`${statusClass} shrink-0${hideStatusOnMobile ? " hidden sm:inline-flex" : ""}`}
>
{statusBadge}
</span>
)}
</div>
</div>

View File

@@ -2,11 +2,38 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
import { apiAdminSetUserBan, apiAdminSetUserRole, apiAdminSetUserMessages, type Board } from "@/lib/api";
import type { LucideIcon } from "lucide-react";
import {
Ban,
BookOpen,
CircleDollarSign,
ExternalLink,
FileText,
Loader2,
Megaphone,
RotateCcw,
ShieldCheck,
Users as UsersIcon,
} from "lucide-react";
import {
apiAdminSetUserBan,
apiAdminSetUserPerms,
apiAdminSetUserRole,
apiAdminSetUserMessages,
type Board,
} from "@/lib/api";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
import { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
import {
CHANNEL_PERMS,
PERM_META,
PERMS,
ROLE_LEVEL,
ROLE_META,
ROLES,
roleLevel,
roleMeta,
} from "@/lib/roles";
import { formatDate } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
@@ -23,12 +50,22 @@ export interface RoleAccountTarget {
board_ids?: number[];
banned: boolean;
can_manage_messages?: boolean;
/** 账号级渠道权限(公告/单页/书库/广告);undefined 表示尚未加载完成 */
perm_overrides?: string[];
avatar?: string;
email?: string;
points?: number;
created_at?: string;
}
// 渠道权限图标(与后台导航一致)
const CHANNEL_ICONS: Record<(typeof CHANNEL_PERMS)[number], LucideIcon> = {
[PERMS.ANNOUNCEMENTS]: Megaphone,
[PERMS.PAGES]: FileText,
[PERMS.LIBRARY]: BookOpen,
[PERMS.ADS]: CircleDollarSign,
};
interface RoleOption {
value: string;
title: string;
@@ -80,6 +117,7 @@ export default function RoleAccountModal({
const [boardIds, setBoardIds] = useState<number[]>([]);
const [banned, setBanned] = useState(false);
const [manageMessages, setManageMessages] = useState(false);
const [permSel, setPermSel] = useState<string[]>([]);
const [err, setErr] = useState("");
useEffect(() => {
@@ -88,6 +126,7 @@ export default function RoleAccountModal({
setBoardIds(target.board_ids ?? []);
setBanned(!!target.banned);
setManageMessages(!!target.can_manage_messages);
setPermSel(target.perm_overrides ?? []);
setErr("");
}
}, [target]);
@@ -98,6 +137,10 @@ export default function RoleAccountModal({
setBoardIds((prev) => (prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id]));
};
const togglePerm = (perm: string) => {
setPermSel((prev) => (prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]));
};
const sameBoards =
JSON.stringify([...(target.board_ids ?? [])].sort((a, b) => a - b)) ===
JSON.stringify([...boardIds].sort((a, b) => a - b));
@@ -108,6 +151,13 @@ export default function RoleAccountModal({
target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN &&
manageMessages !== !!target.can_manage_messages;
/** 渠道权限可编辑性跟随弹窗中选中的角色:管理员及以上自带全部渠道 */
const channelEditable = roleLevel(role) < ROLE_LEVEL[ROLES.ADMIN];
const targetPerms = target.perm_overrides ?? [];
const permsChanged =
target.perm_overrides !== undefined &&
channelEditable &&
JSON.stringify([...targetPerms].sort()) !== JSON.stringify([...permSel].sort());
const willBan = banChanged && banned;
const willUnban = banChanged && !banned;
@@ -116,7 +166,7 @@ export default function RoleAccountModal({
setErr("请至少选择一个授权板块");
return;
}
if (!roleChanged && !banChanged && !messagesChanged) {
if (!roleChanged && !banChanged && !messagesChanged && !permsChanged) {
onClose();
return;
}
@@ -126,6 +176,7 @@ export default function RoleAccountModal({
let nextBoards = target.board_ids ?? [];
let nextBanned = !!target.banned;
let nextMessages = !!target.can_manage_messages;
let nextPerms = target.perm_overrides ?? [];
if (roleChanged) {
const res = await apiAdminSetUserRole(
target.id,
@@ -135,6 +186,14 @@ export default function RoleAccountModal({
nextRole = res.user.role;
nextBoards = res.user.board_ids ?? [];
}
if (permsChanged) {
const res = await apiAdminSetUserPerms(target.id, permSel);
nextPerms = res.user.perm_overrides ?? [...permSel].sort();
}
if (roleChanged && roleLevel(nextRole) >= ROLE_LEVEL[ROLES.ADMIN]) {
// 升至管理员及以上:后端已清空账号级渠道授权
nextPerms = [];
}
if (banChanged) {
const res = await apiAdminSetUserBan(target.id, banned);
nextBanned = !!res.user.banned;
@@ -145,6 +204,7 @@ export default function RoleAccountModal({
}
const parts: string[] = [];
if (roleChanged) parts.push("角色已更新");
if (permsChanged) parts.push("渠道权限已更新");
if (willBan) parts.push("已封禁");
if (willUnban) parts.push("已解封");
if (messagesChanged) parts.push(manageMessages ? "已授予消息管理" : "已撤销消息管理");
@@ -155,6 +215,7 @@ export default function RoleAccountModal({
board_ids: nextBoards,
banned: nextBanned,
can_manage_messages: nextMessages,
perm_overrides: nextPerms,
});
onClose();
} catch (e) {
@@ -291,7 +352,7 @@ export default function RoleAccountModal({
})}
</div>
)}
{err && (
{err && role === ROLES.BOARD_ADMIN && (
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
{err}
</p>
@@ -299,6 +360,108 @@ export default function RoleAccountModal({
</div>
)}
{target.role !== ROLES.OWNER && (
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
渠道权限
</p>
{target.perm_overrides === undefined ? (
<p
className="text-[12px] flex items-center gap-1.5"
style={{ color: "var(--ink-3)" }}
aria-live="polite"
>
<Loader2 size={13} className="animate-spin" aria-hidden /> 渠道权限加载中…
</p>
) : (
<>
<div className="flex flex-col gap-2">
{CHANNEL_PERMS.map((perm) => {
const Icon = CHANNEL_ICONS[perm];
const meta = PERM_META[perm];
const on = channelEditable && permSel.includes(perm);
const body = (
<>
<span className="flex items-center gap-2.5 min-w-0">
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{
background: on || !channelEditable ? "var(--accent-soft)" : "var(--panel)",
color: on || !channelEditable ? "var(--accent)" : "var(--ink-3)",
}}
aria-hidden
>
<Icon size={15} />
</span>
<span className="min-w-0">
<span
className="block text-[13.5px] font-medium"
style={{ color: on ? "var(--accent)" : "var(--ink)" }}
>
{meta.label}
</span>
<span className="block mt-0.5 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
{meta.desc}
</span>
</span>
</span>
{channelEditable ? (
<span
className="shrink-0 w-10 h-6 rounded-full relative transition-colors"
style={{ background: on ? "var(--accent)" : "var(--line)" }}
aria-hidden
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
style={{ left: on ? "1.125rem" : "0.125rem" }}
/>
</span>
) : (
<span
className="shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6]"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
角色自带
</span>
)}
</>
);
const rowStyle = {
border: `1.5px solid ${on ? "var(--accent)" : "var(--line)"}`,
background: on ? "var(--accent-soft)" : "var(--panel-2)",
};
const rowCls =
"w-full rounded-xl px-3.5 py-3 text-left transition-colors flex items-center justify-between gap-3";
return channelEditable ? (
<button
key={perm}
type="button"
role="switch"
aria-checked={on}
disabled={acting}
onClick={() => togglePerm(perm)}
className={rowCls}
style={rowStyle}
>
{body}
</button>
) : (
<div key={perm} className={rowCls} style={rowStyle} aria-disabled="true">
{body}
</div>
);
})}
</div>
<p className="mt-2 text-[11.5px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
{channelEditable
? "授予后该账号即可进入后台管理对应渠道,立即生效;可随时收回。"
: "管理员及以上角色自带全部渠道权限,无需单独授予。"}
</p>
</>
)}
</div>
)}
{canGrantSuper &&
target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN && (
@@ -396,6 +559,12 @@ export default function RoleAccountModal({
</p>
)}
</div>
{err && role !== ROLES.BOARD_ADMIN && (
<p className="mt-3 text-[12px]" style={{ color: "var(--danger)" }}>
{err}
</p>
)}
</Modal>
);
}

View File

@@ -7,7 +7,6 @@ import Footer from "@/components/Footer";
import BackToTop from "@/components/BackToTop";
import MobileTabBar from "@/components/MobileTabBar";
import type { User } from "@/lib/api";
import { isWideShellPath } from "@/lib/siteUrl";
import { libraryPath, userPath } from "@/lib/urlStyle";
import { useSiteBrand } from "@/components/SiteBrandProvider";
@@ -93,9 +92,6 @@ export default function SiteChrome({
if (enabledTabs.has("me") && meHref) tabBarRoots.push(meHref);
const showTabBar =
!!pathname && !pathIsComposeStudio(pathname) && tabBarRoots.includes(pathname);
// 书库页正文区放宽(Header/Footer 保持 1440,避免顶栏菜单位置随页面跳动)
const wideShell = isWideShellPath(pathname);
return (
<>
<Header
@@ -114,7 +110,7 @@ export default function SiteChrome({
<main
id="main"
tabIndex={-1}
className={`flex-1 w-full ${wideShell ? "max-w-[1680px]" : "max-w-[1440px]"} mx-auto px-4 sm:px-6 pt-4 sm:pt-[var(--main-pad-y)] pb-[var(--main-pad-y)] outline-none`}
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 pt-4 sm:pt-[var(--main-pad-y)] pb-[var(--main-pad-y)] outline-none"
>
{children}
</main>

View File

@@ -13,6 +13,7 @@ import {
Users,
} from "lucide-react";
import {
apiAdminGetUserPerms,
apiAdminSetUserBan,
apiListBoards,
apiMe,
@@ -61,6 +62,8 @@ export default function StaffUserMenu({
const [roleOpen, setRoleOpen] = useState(false);
const [boards, setBoards] = useState<Board[]>([]);
const [acting, setActing] = useState(false);
/** 目标账号的渠道权限(打开弹窗时按需拉取;undefined = 加载中) */
const [rolePerms, setRolePerms] = useState<string[] | undefined>(undefined);
useEffect(() => {
apiMe()
@@ -99,11 +102,15 @@ export default function StaffUserMenu({
const openRoleModal = () => {
setOpen(false);
setRoleOpen(true);
setRolePerms(undefined);
if (boards.length === 0) {
apiListBoards()
.then((res) => setBoards(res.boards ?? []))
.catch(() => toast("板块列表加载失败"));
}
apiAdminGetUserPerms(target.id)
.then((res) => setRolePerms(res.perm_overrides))
.catch(() => toast("渠道权限加载失败"));
};
const handleBanConfirm = async () => {
@@ -130,6 +137,7 @@ export default function StaffUserMenu({
role: target.role,
board_ids: target.board_ids,
banned,
perm_overrides: rolePerms,
avatar: target.avatar,
created_at: target.created_at,
points: target.points,
@@ -241,6 +249,7 @@ export default function StaffUserMenu({
onClose={() => setRoleOpen(false)}
onSaved={(next) => {
setBanned(next.banned);
setRolePerms(next.perm_overrides);
onBannedChange?.(next.banned);
setRoleOpen(false);
router.refresh();

View File

@@ -129,6 +129,10 @@ function shouldShowTip(host: Element, tip: string): boolean {
/**
* 全站悬停即显提示:监听 [data-tip],替代浏览器原生 title 的延迟气泡。
* 挂在 root layout 即可,业务侧写 data-tip="文案" 并保留 aria-label。
*
* 移动端策略:仅在 (hover: hover) and (pointer: fine) 设备上响应真实鼠标
* 指针与键盘焦点。触屏 tap 既派发合成鼠标事件又会抢占焦点,若照常弹出,
* 会出现「一点击就弹、只能点别处关闭」的体验,因此触屏一律不触发。
*/
export default function TipHost() {
const [tip, setTip] = useState<TipState>(null);
@@ -193,6 +197,15 @@ export default function TipHost() {
}, delay);
};
// 仅在具备真实悬停能力的设备(鼠标/触控板)上启用;纯触屏直接不参与
const hoverMq = window.matchMedia("(hover: hover) and (pointer: fine)");
let canHover = hoverMq.matches;
// 只在键盘导航期间允许聚焦气泡(:focus-visible 语义),点按产生的焦点不弹
let keyboardActive = false;
// 触摸后的短暂窗口,兜底忽略浏览器派发的合成鼠标事件
let lastTouchAt = 0;
const TOUCH_GUARD_MS = 500;
const onOver = (e: Event) => {
const t = e.target;
if (!(t instanceof Element)) return;
@@ -204,8 +217,19 @@ export default function TipHost() {
tryShow(host);
};
const onPointerOver = (e: PointerEvent) => {
if (!canHover || e.pointerType !== "mouse") return;
if (Date.now() - lastTouchAt < TOUCH_GUARD_MS) return;
onOver(e);
};
const onFocusIn = (e: FocusEvent) => {
if (!canHover || !keyboardActive) return;
onOver(e);
};
const onOut = (e: Event) => {
if (!(e instanceof MouseEvent) && !(e instanceof FocusEvent)) return;
if (!(e instanceof PointerEvent) && !(e instanceof FocusEvent)) return;
const related = "relatedTarget" in e ? e.relatedTarget : null;
if (
related instanceof Node &&
@@ -217,25 +241,52 @@ export default function TipHost() {
hide();
};
const onKeyDown = () => {
keyboardActive = true;
};
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "keyboard") keyboardActive = false;
};
const onTouchStart = () => {
lastTouchAt = Date.now();
keyboardActive = false;
hide();
};
const onHoverMqChange = (e: MediaQueryListEvent) => {
canHover = e.matches;
if (!canHover) hide();
};
const onScrollOrResize = () => {
if (!activeEl.current) return;
tryShow(activeEl.current);
};
// mouseover 冒泡,便于 closest;立即显示,无延时
document.addEventListener("mouseover", onOver);
document.addEventListener("mouseout", onOut);
document.addEventListener("focusin", onOver);
// pointerover 冒泡,便于 closest;仅响应真实鼠标指针,触屏/手写笔不弹
document.addEventListener("pointerover", onPointerOver);
document.addEventListener("pointerout", onOut);
document.addEventListener("focusin", onFocusIn);
document.addEventListener("focusout", onOut);
document.addEventListener("keydown", onKeyDown, true);
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("touchstart", onTouchStart, true);
hoverMq.addEventListener("change", onHoverMqChange);
window.addEventListener("scroll", onScrollOrResize, true);
window.addEventListener("resize", onScrollOrResize);
return () => {
clearPending();
document.removeEventListener("mouseover", onOver);
document.removeEventListener("mouseout", onOut);
document.removeEventListener("focusin", onOver);
document.removeEventListener("pointerover", onPointerOver);
document.removeEventListener("pointerout", onOut);
document.removeEventListener("focusin", onFocusIn);
document.removeEventListener("focusout", onOut);
document.removeEventListener("keydown", onKeyDown, true);
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("touchstart", onTouchStart, true);
hoverMq.removeEventListener("change", onHoverMqChange);
window.removeEventListener("scroll", onScrollOrResize, true);
window.removeEventListener("resize", onScrollOrResize);
};

View File

@@ -37,10 +37,12 @@ import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
import {
canModerateAny,
canAccessAdminMessages,
hasPerm,
isAdminOrAbove,
isOwner,
isStaff,
isSuperOrOwner,
PERMS,
type RoleUserLike,
} from "@/lib/roles";
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
@@ -102,19 +104,19 @@ const NAV_GROUPS: NavGroup[] = [
href: "/admin/announcements",
label: "公告管理",
icon: Megaphone,
visible: (u) => isAdminOrAbove(u.role),
visible: (u) => hasPerm(u, PERMS.ANNOUNCEMENTS),
},
{
href: "/admin/pages",
label: "单页管理",
icon: FileText,
visible: (u) => isAdminOrAbove(u.role),
visible: (u) => hasPerm(u, PERMS.PAGES),
},
{
href: "/admin/library",
label: "书库管理",
icon: BookOpen,
visible: (u) => isAdminOrAbove(u.role),
visible: (u) => hasPerm(u, PERMS.LIBRARY),
},
{
href: "/admin/friend-links",
@@ -155,7 +157,7 @@ const NAV_GROUPS: NavGroup[] = [
href: "/admin/ads",
label: "推广与赞助",
icon: CircleDollarSign,
visible: (u) => isAdminOrAbove(u.role),
visible: (u) => hasPerm(u, PERMS.ADS),
},
],
},

View File

@@ -43,6 +43,7 @@ import {
AD_TIP_DELAY_MS,
adHoverTip,
} from "@/lib/ads";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
@@ -428,9 +429,11 @@ export function FriendLinks({ links }: { links: FriendLink[] }) {
title={l.description || l.name}
>
{l.logo_url ? (
// 14px 显示位:本站图走 48/96 变体(PageSpeed 典型场景);外链 logo 保持原地址
// eslint-disable-next-line @next/next/no-img-element
<img
src={l.logo_url}
src={isOwnImage(l.logo_url) ? variantURL(l.logo_url, pickVariantWidth(14)) : l.logo_url}
srcSet={isOwnImage(l.logo_url) ? densitySrcSet(l.logo_url, 14) : undefined}
alt=""
width={14}
height={14}
@@ -511,9 +514,11 @@ export function AdsPanel({
data-tip={adHoverTip(ad.ends_at)}
data-tip-delay={AD_TIP_DELAY_MS}
>
{/* 固定 248×60 位:本站图走 256/512 密度变体 */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ad.image_url}
src={isOwnImage(ad.image_url ?? "") ? variantURL(ad.image_url ?? "", pickVariantWidth(AD_IMAGE_WIDTH)) : ad.image_url}
srcSet={isOwnImage(ad.image_url ?? "") ? densitySrcSet(ad.image_url ?? "", AD_IMAGE_WIDTH) : undefined}
alt="推广"
className="j13-ad-image"
width={AD_IMAGE_WIDTH}
@@ -579,8 +584,15 @@ export function SponsorsPanel({
const tip = (sp.hover_text || sp.name || "").trim();
const href = sp.link_url?.trim() || undefined;
const inner = (
// logo ≤52px 高:本站图走 96/160 变体
// eslint-disable-next-line @next/next/no-img-element
<img src={sp.logo_url} alt={sp.name} className="j13-sponsor-logo" loading="lazy" />
<img
src={isOwnImage(sp.logo_url) ? variantURL(sp.logo_url, pickVariantWidth(52)) : sp.logo_url}
srcSet={isOwnImage(sp.logo_url) ? densitySrcSet(sp.logo_url, 52) : undefined}
alt={sp.name}
className="j13-sponsor-logo"
loading="lazy"
/>
);
return (
<li key={sp.id} className="j13-sponsor">

View File

@@ -2,27 +2,33 @@
import { useState, useCallback, type ImgHTMLAttributes } from "react";
import ImageLightbox, { type LightboxItem } from "./ImageLightbox";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
type MdImageProps = ImgHTMLAttributes<HTMLImageElement>;
/** 图箱收集的原图地址:data-full 优先(页面显示变体时仍指向原图) */
function fullSrc(el: HTMLImageElement): string {
return el.dataset.full || el.currentSrc || el.getAttribute("src") || "";
}
function collectGallery(from: HTMLImageElement): { items: LightboxItem[]; index: number } {
const root = from.closest(".prose-content");
const nodes = root
? Array.from(root.querySelectorAll<HTMLImageElement>("img.md-content-img[src]"))
: [from];
const items: LightboxItem[] = nodes
.map((el) => ({
src: el.currentSrc || el.getAttribute("src") || "",
alt: el.dataset.caption || undefined,
}))
.filter((it) => !!it.src);
const selfSrc = from.currentSrc || from.getAttribute("src") || "";
let index = items.findIndex((it) => it.src === selfSrc);
if (index < 0) index = 0;
if (items.length === 0 && selfSrc) {
return { items: [{ src: selfSrc, alt: from.dataset.caption || undefined }], index: 0 };
}
return { items, index };
const root = from.closest(".prose-content");
const nodes = root
? Array.from(root.querySelectorAll<HTMLImageElement>("img.md-content-img[src]"))
: [from];
const items: LightboxItem[] = nodes
.map((el) => ({
src: fullSrc(el),
alt: el.dataset.caption || undefined,
}))
.filter((it) => !!it.src);
const selfSrc = fullSrc(from);
let index = items.findIndex((it) => it.src === selfSrc);
if (index < 0) index = 0;
if (items.length === 0 && selfSrc) {
return { items: [{ src: selfSrc, alt: from.dataset.caption || undefined }], index: 0 };
}
return { items, index };
}
/** Markdown 正文图片:点击进入图箱,同文多图可切换 */
@@ -42,6 +48,9 @@ export default function MdImage({ src, alt, title, className, ...rest }: MdImage
const provided = (typeof alt === "string" ? alt.trim() : "") || (typeof title === "string" ? title.trim() : "");
const imgAlt = provided || "图片";
// 同源图(本地 /uploads 或 /api/media 对象)才可走变体端点;外链图原样加载
const isLocalSrc = typeof src === "string" && isOwnImage(src);
// 抖音表情(/emoji/ 路径):行内小图,不进图箱、不带 caption;
// alt 由 expandDyEmojiShortcodes 产出("dy:<key>"),悬停 title 显示其短代码
const isEmoji = typeof src === "string" && src.includes("/emoji/");
@@ -78,9 +87,13 @@ export default function MdImage({ src, alt, title, className, ...rest }: MdImage
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
{...rest}
src={src}
// 同源图(/uploads、/api/media)页面内走宽度变体;外链图保持原 src 不回归
src={isLocalSrc ? variantURL(src, 512) : src}
srcSet={isLocalSrc ? widthSrcSet(src, [512, 768, 1080, 1536]) : undefined}
sizes={isLocalSrc ? "(max-width:768px) 92vw, 720px" : undefined}
alt={imgAlt}
data-caption={provided || undefined}
data-full={src}
title={title}
className={`md-content-img${className ? ` ${className}` : ""}`}
loading="lazy"

View File

@@ -1,14 +1,17 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
import type { SidebarWidgetPublic, User } from "@/lib/api";
import { scopeWidgetCss } from "@/lib/widgetCode";
import { mountWidget } from "@/lib/widgetRuntime";
import { ensureWidgetApi } from "@/lib/widgetApi";
// 站长自定义工具块:HTML/CSS 随 SSR 直出(dangerouslySetInnerHTML),
// JS 在客户端挂载时注入执行、卸载时移除。站长可信内容,不做消毒/沙箱。
// CSS 经 scopeWidgetCss 限定在 `.j13-widget-{id}` 容器内,不污染页面其他区域;
// 工具内可直接用站点主题变量(var(--accent) 等)与 window.J13 接口。
// 站长自定义工具块:所有工具统一在 Shadow DOM 内渲染(attachShadow 是客户端能力,
// 因此服务端只输出空容器,HTML/CSS/JS 全部在客户端 effect 中注入,无水合不一致)。
// - CSS 注入 shadow 内,不污染全站,也不被全站样式污染;
// - JS 以函数形式执行,接收 (root, shadow, toolId, registerCleanup),
// DOM 查找从 root 出发,同页多个同款工具互不干扰;
// - unmount 时统一执行 cleanup 并清空 shadow。
// 站长可信内容,不做消毒/沙箱。
export default function CustomWidgetBlock({
widget,
viewer,
@@ -16,39 +19,36 @@ export default function CustomWidgetBlock({
widget: SidebarWidgetPublic;
viewer?: User | null;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const hostRef = useRef<HTMLElement>(null);
// 先于站长 JS 建立站点接口(声明顺序保证本 effect 先执行)
useEffect(() => {
// 服务端用 useEffect 避免告警,客户端用 layout effect 首帧前完成注入,避免闪烁
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
// 两个 effect 同类型、按声明顺序执行:先建立站点接口,再挂载工具,
// 保证用户脚本首帧同步读取 window.J13 时接口已就绪。
useIsomorphicLayoutEffect(() => {
ensureWidgetApi(viewer);
}, [viewer]);
useEffect(() => {
useIsomorphicLayoutEffect(() => {
const host = hostRef.current;
if (!host || !widget.js) return;
const script = document.createElement("script");
script.dataset.widgetScript = String(widget.id);
script.textContent = widget.js;
host.appendChild(script);
return () => {
script.remove();
};
}, [widget.id, widget.js]);
if (!host) return;
const handle = mountWidget(host, {
id: widget.id,
html: widget.html,
css: widget.css,
js: widget.js,
});
return () => handle.unmount();
}, [widget.id, widget.html, widget.css, widget.js]);
return (
<section
className={`j13-widget-body j13-widget-${widget.id}`}
ref={hostRef}
className="j13-widget-body"
data-widget-id={widget.id}
data-widget-name={widget.name}
>
{widget.css ? (
<style dangerouslySetInnerHTML={{ __html: scopeWidgetCss(widget.css, widget.id) }} />
) : null}
{widget.html ? (
<div className="j13-widget-html" dangerouslySetInnerHTML={{ __html: widget.html }} />
) : null}
{/* 自定义 JS 的宿主节点:脚本由客户端创建后挂到此处执行 */}
<div ref={hostRef} />
</section>
/>
);
}