From cc51c492726e812d293449ba6f0fb41620c453a2 Mon Sep 17 00:00:00 2001 From: freefire Date: Wed, 5 Aug 2026 12:44:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=9B=9E=E5=A4=8D=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/types.ts | 2 + frontend/src/components/ArticleEditor.tsx | 59 +++- frontend/src/components/PostContent.tsx | 12 +- .../editor/MembersOnlyExtension.tsx | 3 +- .../components/editor/ReplyOnlyExtension.tsx | 265 +++++++++++++++++ frontend/src/pages/PostDetailPage.tsx | 22 +- frontend/src/styles/global.css | 275 ++++++++++++++++++ frontend/src/utils/markdownContent.ts | 76 +++-- frontend/src/utils/markdownFormat.ts | 17 +- frontend/src/utils/postContent.ts | 74 ++++- frontend/src/utils/revisionDiff.ts | 2 +- handler/api.go | 20 +- handler/seo.go | 2 +- handler/seo_bot.go | 2 +- service/comment.go | 14 + service/content.go | 29 +- service/content_test.go | 28 ++ service/post.go | 4 +- service/sanitize_html.go | 6 +- service/sanitize_html_test.go | 5 +- 20 files changed, 844 insertions(+), 73 deletions(-) create mode 100644 frontend/src/components/editor/ReplyOnlyExtension.tsx create mode 100644 service/content_test.go diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 530659e..d26eb04 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -107,6 +107,8 @@ export interface PostDetailResponse { comment_count: number; liked: boolean; favorited: boolean; + /** 当前用户是否已在本帖发表过评论(含审核中) */ + has_replied?: boolean; can_edit?: boolean; edit_block_reason?: string; is_edited?: boolean; diff --git a/frontend/src/components/ArticleEditor.tsx b/frontend/src/components/ArticleEditor.tsx index 2b311cf..12b62a0 100644 --- a/frontend/src/components/ArticleEditor.tsx +++ b/frontend/src/components/ArticleEditor.tsx @@ -15,6 +15,7 @@ import { FileCode, PenLine, Maximize2, Minimize2, Columns2, PanelLeft, PanelRight, StretchHorizontal, Table as TableIcon, BetweenHorizonalStart, BetweenVerticalStart, Rows3, Columns3, + MessageSquareLock, } from 'lucide-react'; import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent'; import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent'; @@ -25,12 +26,14 @@ import { prefixMarkdownLines, cycleMarkdownHeading, insertMarkdownMembersOnly, + insertMarkdownReplyOnly, insertMarkdownLink, } from '../utils/markdownFormat'; import { countWords } from '../utils/text'; import { api } from '../api/client'; import { notify } from '@/lib/notify'; import { MembersOnly } from './editor/MembersOnlyExtension'; +import { ReplyOnly } from './editor/ReplyOnlyExtension'; import { TabIndent } from './editor/TabIndentExtension'; import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension'; import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension'; @@ -76,6 +79,7 @@ interface ToolBtn { } const MEMBERS_ONLY_PLACEHOLDER = '在此输入仅登录用户可见的内容…'; +const REPLY_ONLY_PLACEHOLDER = '在此输入回复后可见的内容…'; /** 按选项生成 Markdown 侧插入片段(围栏 meta,便于手写) */ function buildMarkdownCodeBlockSnippet(opts: CodeBlockInsertOptions, body = '代码'): string { @@ -257,11 +261,15 @@ const ArticleEditor = forwardRef(function ArticleEdi if (node.type.name === 'paragraph' && node.parent?.type.name === 'membersOnly') { return MEMBERS_ONLY_PLACEHOLDER; } + if (node.type.name === 'paragraph' && node.parent?.type.name === 'replyOnly') { + return REPLY_ONLY_PLACEHOLDER; + } return placeholder; }, includeChildren: true, }), MembersOnly, + ReplyOnly, TabIndent, ], content: sanitizeHtml(value) || '', @@ -517,6 +525,22 @@ const ArticleEditor = forwardRef(function ArticleEdi editor.chain().focus().insertMembersOnly().run(); }, [editor]); + const wrapReplyOnly = useCallback(() => { + if (!editor) return; + + if (editor.isActive('replyOnly')) { + editor.chain().focus().exitReplyOnly().run(); + return; + } + + const { from, to, empty } = editor.state.selection; + if (!empty && from !== to) { + editor.chain().focus().wrapReplyOnly().run(); + return; + } + editor.chain().focus().insertReplyOnly().run(); + }, [editor]); + const switchToMarkdown = useCallback(() => { if (!editor) return; const html = sanitizeHtml(editor.getHTML()); @@ -653,17 +677,27 @@ const ArticleEditor = forwardRef(function ArticleEdi ); } - tools.push({ - icon: , - title: '登录可见', - hint: '插入或包裹;区块内 Ctrl+Enter 退出', - active: editor.isActive('membersOnly'), - className: 'article-tool-btn--members', - action: wrapMembersOnly, - }); + tools.push( + { + icon: , + title: '登录可见', + hint: '插入或包裹;区块内 Ctrl+Enter 退出', + active: editor.isActive('membersOnly'), + className: 'article-tool-btn--members', + action: wrapMembersOnly, + }, + { + icon: , + title: '回复可见', + hint: '读者回复后才可见;区块内 Ctrl+Enter 退出', + active: editor.isActive('replyOnly'), + className: 'article-tool-btn--reply', + action: wrapReplyOnly, + }, + ); return tools; - }, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]); + }, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapReplyOnly, wrapSelectedAsGroup, setImageDisplay]); const buildMarkdownTools = useCallback((): ToolBtn[] => [ { icon: H, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) }, @@ -686,6 +720,13 @@ const ArticleEditor = forwardRef(function ArticleEdi className: 'article-tool-btn--members', action: withMarkdown(insertMarkdownMembersOnly), }, + { + icon: , + title: '回复可见', + hint: '插入 区块', + className: 'article-tool-btn--reply', + action: withMarkdown(insertMarkdownReplyOnly), + }, ], [withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]); const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools(); diff --git a/frontend/src/components/PostContent.tsx b/frontend/src/components/PostContent.tsx index 2a61f6d..d74e0e7 100644 --- a/frontend/src/components/PostContent.tsx +++ b/frontend/src/components/PostContent.tsx @@ -13,14 +13,17 @@ interface Props { className?: string; /** 正文标题树变化时回调(用于侧栏目录) */ onHeadingsChange?: (headings: PostHeading[]) => void; + /** 点击「回复可见」门控的「去回复」 */ + onRequestReply?: () => void; } -/** 帖子正文渲染(含会员专属区块、代码块美化、图片灯箱) */ +/** 帖子正文渲染(含会员专属 / 回复可见区块、代码块美化、图片灯箱) */ export default function PostContent({ html, isLoggedIn, className = 'post-detail-content', onHeadingsChange, + onRequestReply, }: Props) { const nav = useNavigate(); const { limits } = useForumLimits(); @@ -50,6 +53,11 @@ export default function PostContent({ const handleClick = useCallback(async (e: React.MouseEvent) => { const target = e.target as HTMLElement; + if (target.closest('[data-reply-scroll]')) { + e.preventDefault(); + onRequestReply?.(); + return; + } if (target.closest('[data-members-login]')) { e.preventDefault(); nav(loginPath()); @@ -116,7 +124,7 @@ export default function PostContent({ notify.error('复制失败'); } } - }, [nav, openLightbox]); + }, [nav, openLightbox, onRequestReply]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key !== 'Enter' && e.key !== ' ') return; diff --git a/frontend/src/components/editor/MembersOnlyExtension.tsx b/frontend/src/components/editor/MembersOnlyExtension.tsx index 87d5c40..4bf9974 100644 --- a/frontend/src/components/editor/MembersOnlyExtension.tsx +++ b/frontend/src/components/editor/MembersOnlyExtension.tsx @@ -130,7 +130,8 @@ export const MembersOnly = Node.create({ }, renderHTML({ HTMLAttributes }) { - return ['members-only', mergeAttributes(HTMLAttributes), 0]; + // data-gate:消毒白名单要求自定义标签带允许属性,否则会被剥壳 + return ['members-only', mergeAttributes({ 'data-gate': 'login' }, HTMLAttributes), 0]; }, addNodeView() { diff --git a/frontend/src/components/editor/ReplyOnlyExtension.tsx b/frontend/src/components/editor/ReplyOnlyExtension.tsx new file mode 100644 index 0000000..a589179 --- /dev/null +++ b/frontend/src/components/editor/ReplyOnlyExtension.tsx @@ -0,0 +1,265 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { + ReactNodeViewRenderer, + NodeViewWrapper, + NodeViewContent, + type NodeViewProps, +} from '@tiptap/react'; +import { MessageSquareLock, Trash2 } from 'lucide-react'; + +/** 查找光标所在的回复可见节点深度 */ +function findReplyOnlyDepth($pos: { + depth: number; + node: (d: number) => { type: { name: string }; nodeSize: number }; + before: (d: number) => number; + start: (d: number) => number; +}): number { + for (let d = $pos.depth; d > 0; d -= 1) { + if ($pos.node(d).type.name === 'replyOnly') return d; + } + return -1; +} + +/** 回复可见区块是否无实质文字 */ +function isReplyOnlyEmpty(node: ProseMirrorNode): boolean { + return node.textContent.trim().length === 0; +} + +/** 编辑态「回复可见」区块视图 */ +function ReplyOnlyView({ selected, editor, node, getPos }: NodeViewProps) { + const empty = isReplyOnlyEmpty(node); + + const deleteThisBlock = () => { + const pos = getPos(); + if (typeof pos !== 'number') { + editor.chain().focus().removeReplyOnly().run(); + return; + } + editor + .chain() + .focus() + .command(({ tr, dispatch }) => { + if (dispatch) tr.delete(pos, pos + node.nodeSize); + return true; + }) + .run(); + }; + + const handleUnwrap = () => { + const pos = getPos(); + if (typeof pos !== 'number') { + editor.chain().focus().unwrapReplyOnly().run(); + return; + } + editor + .chain() + .focus() + .command(({ tr, dispatch }) => { + if (isReplyOnlyEmpty(node)) { + if (dispatch) tr.delete(pos, pos + node.nodeSize); + } else if (dispatch) { + tr.replaceWith(pos, pos + node.nodeSize, node.content); + } + return true; + }) + .run(); + }; + + return ( + +
+ + 回复可见 +
+ {!empty && ( + + )} + +
+
+ +
+ ); +} + +declare module '@tiptap/core' { + interface Commands { + replyOnly: { + insertReplyOnly: () => ReturnType; + wrapReplyOnly: () => ReturnType; + exitReplyOnly: () => ReturnType; + unwrapReplyOnly: () => ReturnType; + removeReplyOnly: () => ReturnType; + }; + } +} + +/** TipTap 自定义节点:回复后可见内容区块 */ +export const ReplyOnly = Node.create({ + name: 'replyOnly', + group: 'block', + content: 'block+', + defining: true, + isolating: true, + + parseHTML() { + return [{ tag: 'reply-only' }]; + }, + + renderHTML({ HTMLAttributes }) { + // data-gate:消毒白名单要求自定义标签带允许属性,否则会被剥壳 + return ['reply-only', mergeAttributes({ 'data-gate': 'reply' }, HTMLAttributes), 0]; + }, + + addNodeView() { + return ReactNodeViewRenderer(ReplyOnlyView); + }, + + addKeyboardShortcuts() { + return { + Backspace: ({ editor }) => { + const { $from, empty } = editor.state.selection; + if (!empty) return false; + + const depth = findReplyOnlyDepth($from); + if (depth < 0) return false; + + const node = $from.node(depth); + if (!isReplyOnlyEmpty(node)) { + if ($from.parentOffset !== 0) return false; + const start = $from.start(depth); + if ($from.pos !== start) return false; + return editor.commands.unwrapReplyOnly(); + } + + return editor.commands.removeReplyOnly(); + }, + Delete: ({ editor }) => { + const { $from, empty } = editor.state.selection; + if (!empty) return false; + + const depth = findReplyOnlyDepth($from); + if (depth < 0) return false; + + const node = $from.node(depth); + if (!isReplyOnlyEmpty(node)) return false; + + return editor.commands.removeReplyOnly(); + }, + Enter: ({ editor }) => { + const { $from, empty } = editor.state.selection; + if (!empty) return false; + + const depth = findReplyOnlyDepth($from); + if (depth < 0) return false; + + const parent = $from.parent; + const atBlockEnd = $from.parentOffset === parent.content.size; + const isEmptyBlock = parent.textContent.trim().length === 0; + if (!atBlockEnd || !isEmptyBlock) return false; + + const replyNode = $from.node(depth); + if (isReplyOnlyEmpty(replyNode) && replyNode.childCount <= 1) { + return editor.commands.removeReplyOnly(); + } + + return editor.commands.exitReplyOnly(); + }, + 'Mod-Enter': ({ editor }) => { + if (!editor.isActive('replyOnly')) return false; + return editor.commands.exitReplyOnly(); + }, + }; + }, + + addCommands() { + return { + insertReplyOnly: () => ({ chain }) => chain() + .insertContent({ + type: this.name, + content: [{ type: 'paragraph' }], + }) + .run(), + + wrapReplyOnly: () => ({ tr, state, dispatch }) => { + const { from, to, empty } = state.selection; + if (empty) return false; + + const slice = state.doc.slice(from, to); + if (!slice.content.size) return false; + + const node = state.schema.nodes.replyOnly.create(null, slice.content); + if (dispatch) { + tr.replaceRangeWith(from, to, node); + } + return true; + }, + + exitReplyOnly: () => ({ state, chain }) => { + const { $from } = state.selection; + const depth = findReplyOnlyDepth($from); + if (depth < 0) return false; + + const pos = $from.before(depth); + const node = $from.node(depth); + const end = pos + node.nodeSize; + + return chain() + .insertContentAt(end, { type: 'paragraph' }) + .setTextSelection(end + 1) + .run(); + }, + + unwrapReplyOnly: () => ({ tr, state, dispatch }) => { + const { $from } = state.selection; + const depth = findReplyOnlyDepth($from); + if (depth < 0) return false; + + const pos = $from.before(depth); + const node = $from.node(depth); + + if (isReplyOnlyEmpty(node)) { + tr.delete(pos, pos + node.nodeSize); + } else { + tr.replaceWith(pos, pos + node.nodeSize, node.content); + } + if (dispatch) dispatch(tr); + return true; + }, + + removeReplyOnly: () => ({ tr, state, dispatch }) => { + const { $from } = state.selection; + const depth = findReplyOnlyDepth($from); + if (depth < 0) return false; + + const pos = $from.before(depth); + const node = $from.node(depth); + tr.delete(pos, pos + node.nodeSize); + if (dispatch) dispatch(tr); + return true; + }, + }; + }, +}); diff --git a/frontend/src/pages/PostDetailPage.tsx b/frontend/src/pages/PostDetailPage.tsx index 31d0727..914d243 100644 --- a/frontend/src/pages/PostDetailPage.tsx +++ b/frontend/src/pages/PostDetailPage.tsx @@ -207,6 +207,25 @@ export default function PostDetailPage() { setComments(Array.isArray(comm.comments) ? comm.comments : []); }, [postId, user]); + /** 发评后重拉正文,解锁「回复可见」区块(跳过浏览计数) */ + const reloadPostContent = useCallback(async () => { + try { + const detail = await api.post(postId, { skipView: true }); + setPost(detail.post); + } catch { + // 评论已成功,正文刷新失败不阻断 + } + }, [postId]); + + const scrollToCommentBox = useCallback(() => { + const target = commentBoxRef.current || commentSectionRef.current; + target?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + window.setTimeout(() => { + const ta = commentBoxRef.current?.querySelector('textarea'); + ta?.focus({ preventScroll: true }); + }, 320); + }, []); + const jumpToFloor = useCallback((floor: number) => { const el = document.getElementById(`floor-${floor}`); if (!el) return; @@ -338,7 +357,7 @@ export default function PostDetailPage() { setReplyTo(null); setSubmitCount(c => c + 1); notify.success(r.message || (r.status === 'pending' ? '评论已提交审核' : '评论成功')); - await reloadComments(); + await Promise.all([reloadComments(), reloadPostContent()]); setTimeout(() => jumpToFloor(r.floor), 100); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '评论失败'); @@ -641,6 +660,7 @@ export default function PostDetailPage() { html={post.content || ''} isLoggedIn={!!user} onHeadingsChange={handleHeadingsChange} + onRequestReply={scrollToCommentBox} />
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 6cd38b4..d202d1b 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -4017,6 +4017,169 @@ a.post-title:visited { color: var(--j13-green-hover); } +.article-editor-tools .article-tool-btn--reply { + color: #c27803; +} + +.article-editor-tools .article-tool-btn--reply:hover, +.article-editor-tools .article-tool-btn--reply.active { + background: rgba(194, 120, 3, 0.1); + color: #a16207; +} + +.dark .article-editor-tools .article-tool-btn--reply { + color: #e8b84a; +} + +.dark .article-editor-tools .article-tool-btn--reply:hover, +.dark .article-editor-tools .article-tool-btn--reply.active { + background: rgba(232, 184, 74, 0.12); + color: #f0c85a; +} + +/* 回复可见内容区块(阅读态) */ +.post-detail-content reply-only, +.post-detail-content .post-reply-only { + display: block; + margin: 16px 0; + border-radius: 0; + overflow: visible; +} + +.post-detail-content .post-reply-only--visible { + border: none; + border-left: 3px solid rgba(194, 120, 3, 0.5); + background: transparent; + box-shadow: none; + padding: 2px 0 2px 14px; +} + +.post-detail-content .post-reply-only__badge { + display: none; +} + +.post-detail-content .post-reply-only__body { + padding: 0; +} + +.post-detail-content .post-reply-only--locked { + position: relative; + border: 1px solid rgba(194, 120, 3, 0.28); + border-left: 3px solid rgba(194, 120, 3, 0.65); + border-radius: 8px; + background: var(--j13-bg-surface); + overflow: hidden; +} + +.post-detail-content .post-reply-only__locked-wrap { + position: relative; + min-height: 0; +} + +.post-detail-content .post-reply-only__gate { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; + padding: 12px 14px; + text-align: left; +} + +.post-detail-content .post-reply-only__gate-icon { + width: 32px; + height: 32px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: rgba(194, 120, 3, 0.12); + color: #c27803; + border: none; + margin: 0; + box-shadow: none; +} + +.post-detail-content .post-reply-only__gate-text { + flex: 1 1 160px; + min-width: 0; +} + +.post-detail-content .post-reply-only__gate-title { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--color-text-1); +} + +.post-detail-content .post-reply-only__gate-desc { + margin: 2px 0 0; + font-size: 12px; + color: var(--color-text-3); + line-height: 1.4; +} + +.post-detail-content .post-reply-only__gate-actions { + display: inline-flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.post-detail-content .post-reply-only__gate-btn { + padding: 6px 14px; + border: none; + border-radius: 6px; + background: #c27803; + color: #fff; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} + +.post-detail-content .post-reply-only__gate-btn:hover { + background: #a16207; +} + +.post-detail-content .post-reply-only__gate-link { + padding: 0; + border: none; + background: none; + color: #c27803; + font-size: 12px; + font-weight: 600; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.post-detail-content .post-reply-only__gate-link:hover { + color: #a16207; +} + +.dark .post-detail-content .post-reply-only--visible { + border-left-color: rgba(232, 184, 74, 0.55); +} + +.dark .post-detail-content .post-reply-only--locked { + border-color: rgba(232, 184, 74, 0.3); + border-left-color: rgba(232, 184, 74, 0.6); +} + +.dark .post-detail-content .post-reply-only__gate-icon { + background: rgba(232, 184, 74, 0.14); + color: #e8b84a; +} + +.dark .post-detail-content .post-reply-only__gate-btn { + background: #c27803; +} + +.dark .post-detail-content .post-reply-only__gate-link { + color: #e8b84a; +} + .post-members-only__unwrap-btn { margin-left: 0; padding: 2px 8px; @@ -7418,6 +7581,118 @@ button.profile-stat:hover strong { box-shadow: inset 0 0 0 1px rgba(24, 160, 88, 0.28); } +/* 编辑态:回复可见区块 */ +.editor-reply-only.post-reply-only--visible { + border: none; + border-left: 3px solid rgba(194, 120, 3, 0.55); + background: rgba(194, 120, 3, 0.05); + border-radius: 0 8px 8px 0; + overflow: hidden; + box-shadow: none; + padding: 0; +} + +.editor-reply-only .post-reply-only__badge { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 6px; + margin: 0; + padding: 6px 12px; + border-bottom: none; + border-radius: 0; + background: transparent; + color: #c27803; + font-size: 12px; + font-weight: 600; + width: 100%; +} + +.editor-reply-only .post-reply-only__badge-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.editor-reply-only .post-reply-only__badge-actions { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; +} + +.editor-reply-only .post-reply-only__unwrap-btn { + margin-left: 0; + padding: 2px 8px; + border: none; + border-radius: 12px; + background: transparent; + color: var(--color-text-3); + font-size: 11px; + cursor: pointer; +} + +.editor-reply-only .post-reply-only__unwrap-btn:hover { + color: #c27803; + background: rgba(194, 120, 3, 0.1); +} + +.editor-reply-only .post-reply-only__remove-btn { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 8px; + border: none; + border-radius: 12px; + background: transparent; + color: var(--color-text-3); + font-size: 11px; + cursor: pointer; +} + +.editor-reply-only .post-reply-only__remove-btn:hover { + color: #c0392b; + background: rgba(192, 57, 43, 0.08); +} + +.editor-reply-only--empty .post-reply-only__remove-btn { + color: #c0392b; +} + +.editor-reply-only .post-reply-only__body { + padding: 0 12px 12px; + min-height: 48px; + background: transparent; +} + +.editor-reply-only .post-reply-only__body p.is-empty::before, +.editor-reply-only .post-reply-only__body[data-placeholder]:empty::before { + content: attr(data-placeholder); + color: var(--color-text-4); + font-style: italic; + pointer-events: none; + float: left; + height: 0; +} + +.dark .editor-reply-only.post-reply-only--visible { + border-left-color: rgba(232, 184, 74, 0.55); + background: rgba(232, 184, 74, 0.06); +} + +.dark .editor-reply-only .post-reply-only__badge { + color: #e8b84a; +} + +.editor-reply-only { + margin: 16px 0; +} + +.editor-reply-only--selected { + background: rgba(194, 120, 3, 0.08); + box-shadow: inset 0 0 0 1px rgba(194, 120, 3, 0.3); +} + .article-editor-status { position: relative; z-index: 30; diff --git a/frontend/src/utils/markdownContent.ts b/frontend/src/utils/markdownContent.ts index 530ff21..debe829 100644 --- a/frontend/src/utils/markdownContent.ts +++ b/frontend/src/utils/markdownContent.ts @@ -4,7 +4,7 @@ import DOMPurify from 'dompurify'; import { POST_CONTENT_PURIFY_CONFIG } from './postContent'; import { parseFenceInfo, formatFenceInfo } from './codeBlockOptions'; -const MEMBERS_ONLY_BLOCK_RE = /([\s\S]*?)<\/members-only>/gi; +const GATED_BLOCK_RE = /<(members-only|reply-only)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi; const TURNDOWN_OPTIONS = { headingStyle: 'atx' as const, @@ -212,27 +212,34 @@ function patchTurndownEscape(service: TurndownService): void { service.escape = (str: string) => original(str).replace(/(\d+)\\(\.)/g, '$1$2'); } -/** 登录可见区块转为 Markdown:逐子节点转换,保留首行缩进 */ +/** 将门控区块(登录可见 / 回复可见)转为 Markdown 标签 */ +function gatedBlockToMarkdown(tag: 'members-only' | 'reply-only', node: HTMLElement): string { + const parts: string[] = []; + + node.childNodes.forEach(child => { + if (child.nodeType === Node.TEXT_NODE) { + const text = (child.textContent ?? '').trim(); + if (text) parts.push(nbspToSpaces(text)); + return; + } + if (child instanceof HTMLElement) { + parts.push(nbspToSpaces(contentTurndown.turndown(child.outerHTML).trim())); + } + }); + + const body = trimBlockBoundaryLines(parts.join('\n\n')); + const gate = tag === 'reply-only' ? 'reply' : 'login'; + return `\n\n<${tag} data-gate="${gate}">\n\n${body}\n\n\n\n`; +} + turndown.addRule('membersOnly', { filter: 'members-only', - replacement: (_content, node) => { - const el = node as HTMLElement; - const parts: string[] = []; + replacement: (_content, node) => gatedBlockToMarkdown('members-only', node as HTMLElement), +}); - el.childNodes.forEach(child => { - if (child.nodeType === Node.TEXT_NODE) { - const text = (child.textContent ?? '').trim(); - if (text) parts.push(nbspToSpaces(text)); - return; - } - if (child instanceof HTMLElement) { - parts.push(nbspToSpaces(contentTurndown.turndown(child.outerHTML).trim())); - } - }); - - const body = trimBlockBoundaryLines(parts.join('\n\n')); - return `\n\n\n\n${body}\n\n\n\n`; - }, +turndown.addRule('replyOnly', { + filter: 'reply-only', + replacement: (_content, node) => gatedBlockToMarkdown('reply-only', node as HTMLElement), }); marked.setOptions({ @@ -293,7 +300,10 @@ function parseMarkdownFragment(markdown: string): string { function prepareHtmlForMarkdown(html: string): string { const doc = new DOMParser().parseFromString(sanitizeContentHtml(html), 'text/html'); - doc.querySelectorAll('.post-members-only__badge, .post-members-only__exit-btn, .post-members-only__unwrap-btn').forEach(el => { + doc.querySelectorAll([ + '.post-members-only__badge', '.post-members-only__exit-btn', '.post-members-only__unwrap-btn', + '.post-reply-only__badge', '.post-reply-only__exit-btn', '.post-reply-only__unwrap-btn', + ].join(', ')).forEach(el => { el.remove(); }); @@ -303,14 +313,22 @@ function prepareHtmlForMarkdown(html: string): string { el.innerHTML = splitParagraphBreaks(raw); }); + doc.querySelectorAll('reply-only').forEach(el => { + const body = el.querySelector('.post-reply-only__body'); + const raw = body ? body.innerHTML : el.innerHTML; + el.innerHTML = splitParagraphBreaks(raw); + }); + return doc.body.innerHTML; } -/** 规范化 members-only 标签边界,避免闭合标签与正文粘连 */ -function normalizeMembersOnlyMarkdown(markdown: string): string { +/** 规范化门控标签边界,避免闭合标签与正文粘连 */ +function normalizeGatedMarkdown(markdown: string): string { return markdown .replace(/<\/members-only>(?=[^\s\n])/g, '\n\n') - .replace(/\s*<\/members-only>/g, '\n\n'); + .replace(/]*)?>\s*<\/members-only>/g, '\n\n') + .replace(/<\/reply-only>(?=[^\s\n])/g, '\n\n') + .replace(/]*)?>\s*<\/reply-only>/g, '\n\n'); } /** 列表标记后统一为单个空格(Turndown 默认会输出两个及以上空格) */ @@ -330,13 +348,13 @@ export function htmlToMarkdown(html: string): string { /** * Markdown 源码转为编辑器 HTML。 - * 先提取 members-only 块再分别解析,避免闭合标签后同行文字被吞入区块。 + * 先提取门控块再分别解析,避免闭合标签后同行文字被吞入区块。 */ export function markdownToHtml(markdown: string): string { if (!markdown.trim()) return ''; - const normalized = normalizeMembersOnlyMarkdown(markdown); - const re = new RegExp(MEMBERS_ONLY_BLOCK_RE.source, 'gi'); + const normalized = normalizeGatedMarkdown(markdown); + const re = new RegExp(GATED_BLOCK_RE.source, 'gi'); let result = ''; let lastIndex = 0; let match: RegExpExecArray | null = re.exec(normalized); @@ -347,11 +365,13 @@ export function markdownToHtml(markdown: string): string { result += parseMarkdownFragment(before); } - const innerMd = trimBlockBoundaryLines(match[1]); + const tag = match[1]; + const gate = tag === 'reply-only' ? 'reply' : 'login'; + const innerMd = trimBlockBoundaryLines(match[2]); const innerHtml = innerMd.trim() ? splitParagraphBreaks(parseMarkdownFragment(innerMd)) : ''; - result += `${innerHtml}`; + result += `<${tag} data-gate="${gate}">${innerHtml}`; lastIndex = re.lastIndex; match = re.exec(normalized); } diff --git a/frontend/src/utils/markdownFormat.ts b/frontend/src/utils/markdownFormat.ts index 67bd57e..29a7a0c 100644 --- a/frontend/src/utils/markdownFormat.ts +++ b/frontend/src/utils/markdownFormat.ts @@ -82,9 +82,22 @@ export function insertMarkdownMembersOnly( onChange: ChangeHandler, ) { const { selectionStart, selectionEnd } = textarea; - const snippet = '\n\n\n\n\n\n\n'; + const snippet = '\n\n\n\n\n\n\n'; const next = value.slice(0, selectionStart) + snippet + value.slice(selectionEnd); - const cursor = selectionStart + '\n\n\n\n'.length; + const cursor = selectionStart + '\n\n\n\n'.length; + applyTextareaChange(textarea, next, cursor, cursor, onChange); +} + +/** 插入回复可见区块模板 */ +export function insertMarkdownReplyOnly( + textarea: HTMLTextAreaElement, + value: string, + onChange: ChangeHandler, +) { + const { selectionStart, selectionEnd } = textarea; + const snippet = '\n\n\n\n\n\n\n'; + const next = value.slice(0, selectionStart) + snippet + value.slice(selectionEnd); + const cursor = selectionStart + '\n\n\n\n'.length; applyTextareaChange(textarea, next, cursor, cursor, onChange); } diff --git a/frontend/src/utils/postContent.ts b/frontend/src/utils/postContent.ts index b1cf7fe..7bb6fe5 100644 --- a/frontend/src/utils/postContent.ts +++ b/frontend/src/utils/postContent.ts @@ -9,9 +9,9 @@ import { enhanceHeadingAnchors } from './postHeadings'; * 全局选择器仍会污染整页,故显式禁止。 */ export const POST_CONTENT_PURIFY_CONFIG: Config = { - ADD_TAGS: ['members-only'], + ADD_TAGS: ['members-only', 'reply-only'], ADD_ATTR: [ - 'data-locked', 'data-length', 'target', 'rel', + 'data-locked', 'data-length', 'data-gate', 'target', 'rel', 'data-code-copy', 'data-code-fold', 'data-lang', 'data-full', 'data-code-style', 'data-line-numbers', 'data-collapsed', 'data-line-count', 'data-lineno-digits', 'data-image-group', 'data-layout', 'data-display', @@ -25,6 +25,8 @@ export const POST_CONTENT_PURIFY_CONFIG: Config = { const LOCK_ICON_SVG = ``; +const REPLY_ICON_SVG = ``; + /** 游客看到的锁定区块:流内嵌条 + 登录引导(精简高度) */ function buildLockedGateHtml(charLength: number): string { const lengthHint = charLength > 0 @@ -47,6 +49,41 @@ function buildLockedGateHtml(charLength: number): string {
`; } +/** 回复可见锁定门控:游客引导登录,已登录引导去评论 */ +function buildReplyLockedGateHtml(charLength: number, isLoggedIn: boolean): string { + const lengthHint = charLength > 0 + ? `约 ${charLength} 字` + : '隐藏内容'; + + const actions = isLoggedIn + ? `` + : ` + `; + + return ` +
+
+ +
+

回复后可见(${lengthHint})

+

作者将此段设为回复本帖后可读

+
+
+ ${actions} +
+
+
`; +} + +/** 提取门控区块正文 HTML(去掉编辑态 badge) */ +function extractGatedInnerHtml(el: Element, bodyClass: string, badgeClass: string): string { + return el.querySelector(`.${bodyClass}`)?.innerHTML + ?? Array.from(el.childNodes) + .filter(n => !(n instanceof Element && n.classList.contains(badgeClass))) + .map(n => (n instanceof Element ? n.outerHTML : n.textContent ?? '')) + .join(''); +} + /** 判断 HTML 正文是否为空(忽略空段落等) */ export function isHtmlEmpty(html: string): boolean { if (!html.trim()) return true; @@ -81,17 +118,38 @@ export function renderPostContentHtml( return; } - const innerHtml = el.querySelector('.post-members-only__body')?.innerHTML - ?? Array.from(el.childNodes) - .filter(n => !(n instanceof Element && n.classList.contains('post-members-only__badge'))) - .map(n => (n instanceof Element ? n.outerHTML : n.textContent ?? '')) - .join(''); + const innerHtml = extractGatedInnerHtml( + el, + 'post-members-only__body', + 'post-members-only__badge', + ); // 已登录:降噪,不展示醒目 badge,仅保留结构容器 el.className = 'post-members-only post-members-only--visible'; el.innerHTML = `
${innerHtml}
`; }); + doc.querySelectorAll('reply-only').forEach(el => { + // 是否解锁由服务端 redact(data-locked)决定 + const locked = el.getAttribute('data-locked') === 'true'; + + if (locked) { + const charLength = parseInt(el.getAttribute('data-length') || '0', 10) || 0; + el.className = 'post-reply-only post-reply-only--locked'; + el.innerHTML = buildReplyLockedGateHtml(charLength, isLoggedIn); + return; + } + + const innerHtml = extractGatedInnerHtml( + el, + 'post-reply-only__body', + 'post-reply-only__badge', + ); + + el.className = 'post-reply-only post-reply-only--visible'; + el.innerHTML = `
${innerHtml}
`; + }); + doc.querySelectorAll('img').forEach(img => { if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy'); if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async'); @@ -169,7 +227,7 @@ function isBlankParagraph(el: Element): boolean { if (el.tagName !== 'P') return false; const text = (el.textContent || '').replace(/\u00a0/g, ' ').trim(); if (text.length > 0) return false; - return !el.querySelector('img, video, iframe, table, pre, blockquote, members-only'); + return !el.querySelector('img, video, iframe, table, pre, blockquote, members-only, reply-only'); } /** diff --git a/frontend/src/utils/revisionDiff.ts b/frontend/src/utils/revisionDiff.ts index 9c6bac2..7462d4c 100644 --- a/frontend/src/utils/revisionDiff.ts +++ b/frontend/src/utils/revisionDiff.ts @@ -29,7 +29,7 @@ export function htmlToDiffText(html: string): string { 'text/html', ); doc.querySelectorAll('br').forEach(br => br.replaceWith('\n')); - const blocks = doc.querySelectorAll('p, div, li, h1, h2, h3, h4, h5, h6, blockquote, pre, members-only'); + const blocks = doc.querySelectorAll('p, div, li, h1, h2, h3, h4, h5, h6, blockquote, pre, members-only, reply-only'); blocks.forEach(el => { el.prepend(doc.createTextNode('\n')); el.append(doc.createTextNode('\n')); diff --git a/handler/api.go b/handler/api.go index 2d23929..ac6cd53 100644 --- a/handler/api.go +++ b/handler/api.go @@ -875,8 +875,13 @@ func (h *Handlers) APIPostDetail(c *gin.Context) { } // 出口再消毒:兼容库内历史脏 HTML(如