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