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,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)" }} />