From bb8d415eb7ed7c65ae5f03e6ceb97d8520220c2c Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 6 Aug 2026 17:49:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=AF=84=E8=AE=BA=E7=BA=A7?= =?UTF-8?q?=E8=81=94=E8=BD=AF=E5=88=A0=E4=B8=8E=E5=90=8E=E5=8F=B0=E5=9B=9E?= =?UTF-8?q?=E6=94=B6=E7=AB=99=EF=BC=8C=E5=B9=B6=E5=AE=8C=E5=96=84=20Markdo?= =?UTF-8?q?wn=20=E4=BB=A3=E7=A0=81=E5=9B=B4=E6=A0=8F=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- frontend/src/api/client.ts | 16 ++ frontend/src/components/ArticleEditor.tsx | 6 +- frontend/src/components/CommentThreadList.tsx | 4 +- frontend/src/pages/PostDetailPage.tsx | 10 +- .../src/pages/admin/AdminCommentsPage.tsx | 155 ++++++++++++++++-- frontend/src/utils/comment.ts | 16 ++ frontend/src/utils/enhanceCodeBlocks.ts | 40 ++++- frontend/src/utils/markdownContent.ts | 10 +- frontend/src/utils/markdownFences.ts | 73 +++++++++ handler/api.go | 43 ++++- handler/handlers.go | 2 +- router/router.go | 3 + service/comment.go | 129 ++++++++++++++- 13 files changed, 479 insertions(+), 28 deletions(-) create mode 100644 frontend/src/utils/markdownFences.ts diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index be41093..6165ae2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -215,6 +215,22 @@ export const api = { method: 'POST', body: JSON.stringify({ reason: reason || '' }), }), adminDeleteComment: (id: number) => request(`/api/admin/comments/${id}`, { method: 'DELETE' }), + adminTrashComments: (params?: { page?: number; keyword?: string }) => { + const q = new URLSearchParams(); + if (params?.page) q.set('page', String(params.page)); + if (params?.keyword) q.set('keyword', params.keyword); + const qs = q.toString(); + return request<{ + comments: (Comment & { deleted_at: string })[]; + total: number; + page: number; + total_pages: number; + }>(`/api/admin/comments/trash${qs ? `?${qs}` : ''}`); + }, + adminRestoreComment: (id: number) => + request<{ message: string }>(`/api/admin/comments/${id}/restore`, { method: 'POST' }), + adminPurgeComment: (id: number) => + request<{ message: string }>(`/api/admin/comments/${id}/purge`, { method: 'DELETE' }), adminCommentRevisions: (id: number) => request<{ revisions: CommentRevision[] }>(`/api/admin/comments/${id}/revisions`), adminUsers: (page = 1, opts?: { keyword?: string; filter?: string }) => { diff --git a/frontend/src/components/ArticleEditor.tsx b/frontend/src/components/ArticleEditor.tsx index 25d710e..18336c4 100644 --- a/frontend/src/components/ArticleEditor.tsx +++ b/frontend/src/components/ArticleEditor.tsx @@ -50,6 +50,7 @@ import { formatFenceInfo, type CodeBlockInsertOptions, } from '../utils/codeBlockOptions'; +import { fenceLengthForContent } from '../utils/markdownFences'; import { Tooltip } from './ui/Tooltip'; export interface ArticleEditorHandle { @@ -85,8 +86,9 @@ const REPLY_ONLY_PLACEHOLDER = '在此输入回复后可见的内容…'; /** 按选项生成 Markdown 侧插入片段(围栏 meta,便于手写) */ function buildMarkdownCodeBlockSnippet(opts: CodeBlockInsertOptions, body = '代码'): string { const info = formatFenceInfo(opts); - const fence = info ? `\`\`\`${info}` : '```'; - return `\n${fence}\n${body}\n\`\`\`\n`; + const fence = '`'.repeat(fenceLengthForContent(body)); + const open = info ? `${fence}${info}` : fence; + return `\n${open}\n${body}\n${fence}\n`; } /** 生成 GFM 管道表;源码侧始终带表头分隔行 */ diff --git a/frontend/src/components/CommentThreadList.tsx b/frontend/src/components/CommentThreadList.tsx index c65bb3d..acefcdc 100644 --- a/frontend/src/components/CommentThreadList.tsx +++ b/frontend/src/components/CommentThreadList.tsx @@ -368,7 +368,9 @@ function CommentItem({ 确定删除该评论? - 删除后不可恢复。 + + 将同时移入回收站其下所有回复,可在后台恢复或永久删除。 + 取消 diff --git a/frontend/src/pages/PostDetailPage.tsx b/frontend/src/pages/PostDetailPage.tsx index 8af4081..e93537a 100644 --- a/frontend/src/pages/PostDetailPage.tsx +++ b/frontend/src/pages/PostDetailPage.tsx @@ -45,6 +45,7 @@ import { useAuth } from '../hooks/useAuth'; import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO'; import { getCachedSiteBranding } from '../hooks/useSiteBranding'; import { formatDateTime, isTimeDiffSignificant } from '../utils/content'; +import { collectCommentSubtreeIds } from '../utils/comment'; import { loadMyCommentIds } from '../utils/guest'; import { clearAllFeedCache } from '../utils/feedCache'; import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll'; @@ -395,10 +396,11 @@ export default function PostDetailPage() { const handleDeleteComment = async (comment: Comment) => { try { await api.deleteComment(comment.id); - setComments(list => list.filter(c => c.id !== comment.id)); - if (replyTo?.id === comment.id) setReplyTo(null); - if (editingCommentId === comment.id) setEditingCommentId(null); - notify.success('评论已删除'); + const removeIds = collectCommentSubtreeIds(comments, comment.id); + setComments(list => list.filter(c => !removeIds.has(c.id))); + if (replyTo && removeIds.has(replyTo.id)) setReplyTo(null); + if (editingCommentId != null && removeIds.has(editingCommentId)) setEditingCommentId(null); + notify.success('评论已移入回收站'); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '删除失败'); throw e; diff --git a/frontend/src/pages/admin/AdminCommentsPage.tsx b/frontend/src/pages/admin/AdminCommentsPage.tsx index c869757..62149d8 100644 --- a/frontend/src/pages/admin/AdminCommentsPage.tsx +++ b/frontend/src/pages/admin/AdminCommentsPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { Trash2, RotateCcw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Spinner } from '@/components/ui/spinner'; @@ -16,7 +17,8 @@ import type { Comment } from '../../api/types'; import CommentRevisionDialog from '../../components/CommentRevisionDialog'; import { isTimeDiffSignificant } from '../../utils/content'; -type Tab = 'pending' | 'all'; +type Tab = 'pending' | 'all' | 'trash'; +type TrashComment = Comment & { deleted_at: string }; function statusLabel(status?: string) { switch (status) { @@ -27,18 +29,28 @@ function statusLabel(status?: string) { } } +function formatAdminTime(iso: string) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString('zh-CN', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + }); +} + export default function AdminCommentsPage() { const nav = useNavigate(); const { ready } = useAdminGuard(); const [tab, setTab] = useState('pending'); const [comments, setComments] = useState([]); + const [trash, setTrash] = useState([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [pendingCount, setPendingCount] = useState(0); const [revComment, setRevComment] = useState(null); - const load = (p = page, st: Tab = tab) => { + const loadList = (p = page, st: Tab = tab) => { setLoading(true); api.adminComments({ page: p, status: st === 'pending' ? 'pending' : 'all' }) .then(d => { @@ -51,6 +63,28 @@ export default function AdminCommentsPage() { .finally(() => setLoading(false)); }; + const loadTrash = (p = page) => { + setLoading(true); + api.adminTrashComments({ page: p }) + .then(d => { + setTrash(d.comments ?? []); + setPage(d.page); + setTotalPages(d.total_pages); + }) + .catch(e => notify.error(e.message)) + .finally(() => setLoading(false)); + }; + + const load = (p = page, st: Tab = tab) => { + if (st === 'trash') loadTrash(p); + else loadList(p, st); + }; + + const switchTab = (next: Tab) => { + setTab(next); + setPage(1); + }; + useEffect(() => { if (ready) load(1, tab); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -81,42 +115,141 @@ export default function AdminCommentsPage() { const remove = async (id: number) => { try { await api.adminDeleteComment(id); - notify.success('评论已删除'); + notify.success('评论已移入回收站'); load(); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '删除失败'); } }; + const restore = async (id: number) => { + try { + await api.adminRestoreComment(id); + notify.success('评论已恢复'); + loadTrash(page); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '恢复失败'); + } + }; + + const purge = async (id: number) => { + try { + await api.adminPurgeComment(id); + notify.success('评论已永久删除'); + loadTrash(page); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '永久删除失败'); + } + }; + if (!ready) return null; return (

评论管理

-

审核普通用户评论;通过后公开,拒绝后仅作者可见并私信通知。

+

+ {tab === 'trash' + ? '回收站中的评论可恢复或永久删除;永久删除后不可撤销' + : '审核普通用户评论;通过后公开,拒绝后仅作者可见并私信通知。删除将移入回收站。'} +

+
{loading ? (
+ ) : tab === 'trash' ? ( + <> + + + + + + + + + + + + + + {trash.map(c => ( + + + + + + + + + + ))} + +
ID楼层帖子作者内容删除时间操作
{c.id}#{c.floor} + + + {c.user_id && c.user ? c.user.nickname : (c.guest_nick || '游客')} + {c.content}{formatAdminTime(c.deleted_at)} +
+ + + + + + + + 永久删除该评论? + + 将彻底清除该评论及其已删回复、修订与点赞,此操作不可恢复。 + + + + 取消 + purge(c.id)}>永久删除 + + + +
+
+ {trash.length === 0 &&
回收站为空
} + {totalPages > 1 && ( +
+ + {page} / {totalPages} + +
+ )} + ) : ( <> @@ -157,7 +290,7 @@ export default function AdminCommentsPage() { - +
{c.is_private ? : '—'}{new Date(c.created_at).toLocaleString('zh-CN')}{formatAdminTime(c.created_at)}
{(c.status === 'pending' || c.status === 'rejected') && ( @@ -175,12 +308,14 @@ export default function AdminCommentsPage() { - 确定删除该评论? - 删除后不可恢复。 + 移入回收站? + + 将同时移入其下所有回复,可随时恢复;永久删除请到回收站操作。 + 取消 - remove(c.id)}>删除 + remove(c.id)}>移入回收站 diff --git a/frontend/src/utils/comment.ts b/frontend/src/utils/comment.ts index 55ed653..9734fcb 100644 --- a/frontend/src/utils/comment.ts +++ b/frontend/src/utils/comment.ts @@ -22,6 +22,22 @@ export function isGuestComment(c: Comment): boolean { return !c.user_id || c.user_id === 0; } +/** 收集评论及其 reply_to 后代的 ID(含自身) */ +export function collectCommentSubtreeIds(comments: Comment[], rootId: number): Set { + const ids = new Set([rootId]); + let changed = true; + while (changed) { + changed = false; + for (const c of comments) { + if (!ids.has(c.id) && c.reply_to != null && ids.has(c.reply_to)) { + ids.add(c.id); + changed = true; + } + } + } + return ids; +} + /** 构建嵌套评论树(优先 thread_parent_id,回退 reply_to) */ export function buildCommentTree(comments: Comment[]): CommentNode[] { const map = new Map(); diff --git a/frontend/src/utils/enhanceCodeBlocks.ts b/frontend/src/utils/enhanceCodeBlocks.ts index c86c80e..aebb0b7 100644 --- a/frontend/src/utils/enhanceCodeBlocks.ts +++ b/frontend/src/utils/enhanceCodeBlocks.ts @@ -40,9 +40,47 @@ function readDisplayOptions(pre: Element) { }; } +/** + * 在换行处闭合并重开跨行 ,使每行 HTML 片段自包含。 + * hljs token 常跨多行,直接按 \\n 切开会破坏标签导致行号布局叠字。 + */ +function balanceHighlightLines(highlightedHtml: string): string[] { + const openTags: string[] = []; + let balanced = ''; + const tokenRe = /(]*>)|(<\/span>)|(\n)/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = tokenRe.exec(highlightedHtml)) !== null) { + balanced += highlightedHtml.slice(lastIndex, match.index); + lastIndex = tokenRe.lastIndex; + + if (match[3] !== undefined) { + // 换行:先闭合当前栈,再于下一行重开 + for (let i = openTags.length - 1; i >= 0; i--) balanced += ''; + balanced += '\n'; + for (const tag of openTags) balanced += tag; + continue; + } + + if (match[2] !== undefined) { + openTags.pop(); + balanced += match[2]; + continue; + } + + // 开标签 + openTags.push(match[1]); + balanced += match[1]; + } + + balanced += highlightedHtml.slice(lastIndex); + return balanced.split('\n'); +} + /** 为高亮后的 HTML 按行包一层,便于行号与折叠计数 */ function wrapCodeLines(highlightedHtml: string, withLineNumbers: boolean): string { - const lines = highlightedHtml.split('\n'); + const lines = balanceHighlightLines(highlightedHtml); return lines .map((line, i) => { const num = i + 1; diff --git a/frontend/src/utils/markdownContent.ts b/frontend/src/utils/markdownContent.ts index debe829..d91d000 100644 --- a/frontend/src/utils/markdownContent.ts +++ b/frontend/src/utils/markdownContent.ts @@ -3,6 +3,7 @@ import { marked } from 'marked'; import DOMPurify from 'dompurify'; import { POST_CONTENT_PURIFY_CONFIG } from './postContent'; import { parseFenceInfo, formatFenceInfo } from './codeBlockOptions'; +import { mapOutsideFences, wrapFencedCode } from './markdownFences'; const GATED_BLOCK_RE = /<(members-only|reply-only)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi; @@ -80,7 +81,7 @@ function addTurndownContentRules(service: TurndownService): void { const collapsed = pre.getAttribute('data-collapsed') === 'true' || wrap?.getAttribute('data-collapsed') === 'true'; const info = formatFenceInfo({ language, lineNumbers, collapsed }); - return `\n\n\`\`\`${info}\n${text}\n\`\`\`\n\n`; + return wrapFencedCode(info, text); }, }); @@ -284,10 +285,9 @@ function sanitizeContentHtml(html: string): string { /** 将行首空格转为不换行空格,避免 HTML 折叠缩进;跳过围栏代码块 */ function preserveLeadingIndent(markdown: string): string { - return markdown.split(/(```[\s\S]*?```)/g).map((part, index) => { - if (index % 2 === 1) return part; - return part.replace(/^( +)(?=\S)/gm, (_match, spaces: string) => '\u00A0'.repeat(spaces.length)); - }).join(''); + return mapOutsideFences(markdown, (outside) => + outside.replace(/^( +)(?=\S)/gm, (_match, spaces: string) => '\u00A0'.repeat(spaces.length)), + ); } /** 将普通 Markdown 片段转为 HTML */ diff --git a/frontend/src/utils/markdownFences.ts b/frontend/src/utils/markdownFences.ts new file mode 100644 index 0000000..274eb06 --- /dev/null +++ b/frontend/src/utils/markdownFences.ts @@ -0,0 +1,73 @@ +/** CommonMark 围栏:开围栏行(最多 3 空格缩进 + 至少 3 个反引号) */ +const OPEN_FENCE_RE = /^ {0,3}(`{3,})([^`\n]*)$/; +/** 闭围栏行:仅反引号与可选尾随空白 */ +const CLOSE_FENCE_RE = /^ {0,3}(`{3,})[ \t]*$/; + +/** 正文中最长连续反引号数;外层围栏需至少 longest+1(且 ≥ 3) */ +export function fenceLengthForContent(text: string): number { + let longest = 0; + let run = 0; + for (let i = 0; i < text.length; i++) { + if (text[i] === '`') { + run += 1; + if (run > longest) longest = run; + } else { + run = 0; + } + } + return Math.max(3, longest + 1); +} + +/** 用足够长的围栏包裹代码正文(info 为语言/选项串,可为空) */ +export function wrapFencedCode(info: string, text: string): string { + const len = fenceLengthForContent(text); + const fence = '`'.repeat(len); + const open = info ? `${fence}${info}` : fence; + return `\n\n${open}\n${text}\n${fence}\n\n`; +} + +/** + * 按行识别围栏块;仅对围栏外文本调用 fn。 + * 闭合条件:行首闭围栏长度 ≥ 开围栏(CommonMark)。 + */ +export function mapOutsideFences(markdown: string, fn: (outside: string) => string): string { + const lines = markdown.split('\n'); + const out: string[] = []; + let i = 0; + let outsideBuf: string[] = []; + + const flushOutside = () => { + if (outsideBuf.length === 0) return; + out.push(fn(outsideBuf.join('\n'))); + outsideBuf = []; + }; + + while (i < lines.length) { + const openMatch = lines[i].match(OPEN_FENCE_RE); + if (!openMatch) { + outsideBuf.push(lines[i]); + i += 1; + continue; + } + + flushOutside(); + const openLen = openMatch[1].length; + const fenceLines = [lines[i]]; + i += 1; + + while (i < lines.length) { + fenceLines.push(lines[i]); + const closeMatch = lines[i].match(CLOSE_FENCE_RE); + if (closeMatch && closeMatch[1].length >= openLen) { + i += 1; + break; + } + i += 1; + } + + out.push(fenceLines.join('\n')); + } + + flushOutside(); + return out.join('\n'); +} diff --git a/handler/api.go b/handler/api.go index e783d63..4ac16a7 100644 --- a/handler/api.go +++ b/handler/api.go @@ -365,14 +365,53 @@ func (h *Handlers) APIAdminRejectComment(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "已拒绝该评论并通知作者", "status": model.ContentStatusRejected}) } -// APIAdminDeleteComment 管理员删除评论 +// APIAdminDeleteComment 管理员软删除评论(进入回收站) func (h *Handlers) APIAdminDeleteComment(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 64) if err := h.Comment.AdminDelete(uint(id)); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"message": "评论已删除"}) + c.JSON(http.StatusOK, gin.H{"message": "评论已移入回收站"}) +} + +// APIAdminTrashComments 评论回收站列表 +func (h *Handlers) APIAdminTrashComments(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + keyword := strings.TrimSpace(c.Query("keyword")) + comments, total, err := h.Comment.ListTrash(page, size, keyword) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if comments == nil { + comments = []service.TrashCommentItem{} + } + c.JSON(http.StatusOK, gin.H{ + "comments": comments, "total": total, "page": page, + "total_pages": calcTotalPages(total, size), + }) +} + +// APIAdminRestoreComment 从回收站恢复评论 +func (h *Handlers) APIAdminRestoreComment(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.Comment.Restore(uint(id)); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "评论已恢复"}) +} + +// APIAdminPurgeComment 永久删除回收站评论 +func (h *Handlers) APIAdminPurgeComment(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.Comment.Purge(uint(id)); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "评论已永久删除"}) } // APIAdminCommentRevisions 管理员查看评论编辑历史 diff --git a/handler/handlers.go b/handler/handlers.go index 64bf271..a60bb1c 100644 --- a/handler/handlers.go +++ b/handler/handlers.go @@ -484,7 +484,7 @@ func (h *Handlers) APIDeleteComment(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"message": "评论已删除"}) + c.JSON(http.StatusOK, gin.H{"message": "评论已移入回收站"}) } func (h *Handlers) APIUpdateComment(c *gin.Context) { diff --git a/router/router.go b/router/router.go index 0f9b759..e8fae43 100644 --- a/router/router.go +++ b/router/router.go @@ -204,9 +204,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { adminAPI.GET("/reports", h.APIAdminReports) adminAPI.POST("/reports/:id/handle", h.APIAdminHandleReport) adminAPI.GET("/comments", h.APIAdminComments) + adminAPI.GET("/comments/trash", h.APIAdminTrashComments) adminAPI.GET("/comments/:id/revisions", h.APIAdminCommentRevisions) adminAPI.POST("/comments/:id/approve", h.APIAdminApproveComment) adminAPI.POST("/comments/:id/reject", h.APIAdminRejectComment) + adminAPI.POST("/comments/:id/restore", h.APIAdminRestoreComment) + adminAPI.DELETE("/comments/:id/purge", h.APIAdminPurgeComment) adminAPI.DELETE("/comments/:id", h.APIAdminDeleteComment) adminAPI.GET("/users", h.APIAdminUsers) adminAPI.POST("/users/:id/ban", h.APIAdminBanUser) diff --git a/service/comment.go b/service/comment.go index 025efac..8b9c3ce 100644 --- a/service/comment.go +++ b/service/comment.go @@ -395,12 +395,137 @@ func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration return content, enteredPending, nil } +// collectReplySubtreeIDs 沿 reply_to BFS 收集子树 ID(含 rootID) +// softDeletedOnly 为 true 时仅收集已软删节点(用于回收站恢复/永久删除) +func collectReplySubtreeIDs(db *gorm.DB, rootID uint, softDeletedOnly bool) ([]uint, error) { + q := db + if softDeletedOnly { + q = db.Unscoped() + } + ids := []uint{rootID} + seen := map[uint]struct{}{rootID: {}} + frontier := []uint{rootID} + for len(frontier) > 0 { + childQ := q.Model(&model.Comment{}).Select("id").Where("reply_to IN ?", frontier) + if softDeletedOnly { + childQ = childQ.Where("deleted_at IS NOT NULL") + } + var children []model.Comment + if err := childQ.Find(&children).Error; err != nil { + return nil, err + } + frontier = frontier[:0] + for _, c := range children { + if _, ok := seen[c.ID]; ok { + continue + } + seen[c.ID] = struct{}{} + ids = append(ids, c.ID) + frontier = append(frontier, c.ID) + } + } + return ids, nil +} + +// AdminDelete 软删除评论及其回复树(进入回收站);修订与点赞保留以便恢复 func (s *CommentService) AdminDelete(commentID uint) error { + var root model.Comment + if err := model.DB.First(&root, commentID).Error; err != nil { + return ErrCommentNotFound + } + ids, err := collectReplySubtreeIDs(model.DB, commentID, false) + if err != nil { + return err + } + return model.DB.Where("id IN ?", ids).Delete(&model.Comment{}).Error +} + +// TrashCommentItem 评论回收站列表项 +type TrashCommentItem struct { + model.Comment + DeletedAt time.Time `json:"deleted_at"` +} + +// ListTrash 列出已软删评论(不含随帖子一并删除的评论,那些在帖子回收站处理) +func (s *CommentService) ListTrash(page, size int, keyword string) ([]TrashCommentItem, int64, error) { + if page < 1 { + page = 1 + } + size = s.settings.NormalizePageSize(size) + db := model.DB.Unscoped().Model(&model.Comment{}). + Where("comments.deleted_at IS NOT NULL"). + Joins("JOIN posts ON posts.id = comments.post_id AND posts.deleted_at IS NULL"). + Preload("User").Preload("Post") + if keyword != "" { + kw, err := s.settings.NormalizeSearchKeyword(keyword) + if err != nil { + return nil, 0, err + } + like := "%" + kw + "%" + db = db.Where("comments.content LIKE ? OR posts.title LIKE ?", like, like) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var comments []model.Comment + if err := db.Order("comments.deleted_at DESC").Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil { + return nil, 0, err + } + out := make([]TrashCommentItem, len(comments)) + for i, c := range comments { + out[i] = TrashCommentItem{Comment: c} + if c.DeletedAt.Valid { + out[i].DeletedAt = c.DeletedAt.Time + } + } + return out, total, nil +} + +// Restore 从回收站恢复评论及其已软删的回复树 +func (s *CommentService) Restore(commentID uint) error { + var comment model.Comment + if err := model.DB.Unscoped().First(&comment, commentID).Error; err != nil { + return ErrCommentNotFound + } + if !comment.DeletedAt.Valid { + return errors.New("评论未被删除") + } + // 所属帖子必须仍存在且未删除 + var post model.Post + if err := model.DB.First(&post, comment.PostID).Error; err != nil { + return errors.New("所属帖子不存在或已在回收站,请先恢复帖子") + } + ids, err := collectReplySubtreeIDs(model.DB, commentID, true) + if err != nil { + return err + } + return model.DB.Unscoped().Model(&model.Comment{}). + Where("id IN ?", ids). + Update("deleted_at", nil).Error +} + +// Purge 永久删除回收站中的评论及其已软删回复(含修订、点赞) +func (s *CommentService) Purge(commentID uint) error { + var comment model.Comment + if err := model.DB.Unscoped().First(&comment, commentID).Error; err != nil { + return ErrCommentNotFound + } + if !comment.DeletedAt.Valid { + return errors.New("仅可彻底删除回收站中的评论,请先删除评论") + } + ids, err := collectReplySubtreeIDs(model.DB, commentID, true) + if err != nil { + return err + } return model.DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("comment_id = ?", commentID).Delete(&model.CommentRevision{}).Error; err != nil { + if err := tx.Where("comment_id IN ?", ids).Delete(&model.CommentRevision{}).Error; err != nil { return err } - return tx.Delete(&model.Comment{}, commentID).Error + if err := tx.Where("comment_id IN ?", ids).Delete(&model.CommentLike{}).Error; err != nil { + return err + } + return tx.Unscoped().Where("id IN ?", ids).Delete(&model.Comment{}).Error }) }