feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
@@ -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)" }}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
327
frontend/components/BookSearch.tsx
Normal file
327
frontend/components/BookSearch.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)" }} />
|
||||
|
||||
@@ -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)" }} /> 管理面板
|
||||
|
||||
@@ -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"
|
||||
|
||||
241
frontend/components/LibrarySearchGrid.tsx
Normal file
241
frontend/components/LibrarySearchGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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)" }}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user