支持评论级联软删与后台回收站,并完善 Markdown 代码围栏嵌套。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 }) => {
|
||||
|
||||
@@ -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 管道表;源码侧始终带表头分隔行 */
|
||||
|
||||
@@ -368,7 +368,9 @@ function CommentItem({
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
||||
<AlertDialogDescription>删除后不可恢复。</AlertDialogDescription>
|
||||
<AlertDialogDescription>
|
||||
将同时移入回收站其下所有回复,可在后台恢复或永久删除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Tab>('pending');
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [trash, setTrash] = useState<TrashComment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [revComment, setRevComment] = useState<Comment | null>(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 (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>评论管理</h1>
|
||||
<p>审核普通用户评论;通过后公开,拒绝后仅作者可见并私信通知。</p>
|
||||
<p>
|
||||
{tab === 'trash'
|
||||
? '回收站中的评论可恢复或永久删除;永久删除后不可撤销'
|
||||
: '审核普通用户评论;通过后公开,拒绝后仅作者可见并私信通知。删除将移入回收站。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
className={cn('admin-tab', tab === 'pending' && 'active')}
|
||||
onClick={() => setTab('pending')}
|
||||
onClick={() => switchTab('pending')}
|
||||
>
|
||||
待审核{pendingCount > 0 ? ` (${pendingCount})` : ''}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('admin-tab', tab === 'all' && 'active')}
|
||||
onClick={() => setTab('all')}
|
||||
onClick={() => switchTab('all')}
|
||||
>
|
||||
全部评论
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-selected={tab === 'trash'}
|
||||
className={cn('admin-tab', tab === 'trash' && 'active')}
|
||||
onClick={() => switchTab('trash')}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden />
|
||||
回收站
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
) : tab === 'trash' ? (
|
||||
<>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>楼层</th>
|
||||
<th>帖子</th>
|
||||
<th>作者</th>
|
||||
<th>内容</th>
|
||||
<th>删除时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trash.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.id}</td>
|
||||
<td>#{c.floor}</td>
|
||||
<td>
|
||||
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${c.post_id}`)}>
|
||||
{c.post?.title ?? `#${c.post_id}`}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{c.user_id && c.user ? c.user.nickname : (c.guest_nick || '游客')}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate">{c.content}</td>
|
||||
<td className="text-sm whitespace-nowrap">{formatAdminTime(c.deleted_at)}</td>
|
||||
<td>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="outline" onClick={() => restore(c.id)}>
|
||||
<RotateCcw size={14} /> 恢复
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-destructive">永久删除</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>永久删除该评论?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将彻底清除该评论及其已删回复、修订与点赞,此操作不可恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => purge(c.id)}>永久删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{trash.length === 0 && <div className="admin-empty">回收站为空</div>}
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => loadTrash(page - 1)}>上一页</Button>
|
||||
<span>{page} / {totalPages}</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => loadTrash(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<table className="admin-table">
|
||||
@@ -157,7 +290,7 @@ export default function AdminCommentsPage() {
|
||||
</Badge>
|
||||
</td>
|
||||
<td>{c.is_private ? <Badge variant="secondary">是</Badge> : '—'}</td>
|
||||
<td>{new Date(c.created_at).toLocaleString('zh-CN')}</td>
|
||||
<td>{formatAdminTime(c.created_at)}</td>
|
||||
<td>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(c.status === 'pending' || c.status === 'rejected') && (
|
||||
@@ -175,12 +308,14 @@ export default function AdminCommentsPage() {
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
||||
<AlertDialogDescription>删除后不可恢复。</AlertDialogDescription>
|
||||
<AlertDialogTitle>移入回收站?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将同时移入其下所有回复,可随时恢复;永久删除请到回收站操作。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => remove(c.id)}>删除</AlertDialogAction>
|
||||
<AlertDialogAction onClick={() => remove(c.id)}>移入回收站</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -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<number> {
|
||||
const ids = new Set<number>([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<number, CommentNode>();
|
||||
|
||||
@@ -40,9 +40,47 @@ function readDisplayOptions(pre: Element) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在换行处闭合并重开跨行 <span>,使每行 HTML 片段自包含。
|
||||
* hljs token 常跨多行,直接按 \\n 切开会破坏标签导致行号布局叠字。
|
||||
*/
|
||||
function balanceHighlightLines(highlightedHtml: string): string[] {
|
||||
const openTags: string[] = [];
|
||||
let balanced = '';
|
||||
const tokenRe = /(<span\b[^>]*>)|(<\/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 += '</span>';
|
||||
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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
73
frontend/src/utils/markdownFences.ts
Normal file
73
frontend/src/utils/markdownFences.ts
Normal file
@@ -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');
|
||||
}
|
||||
@@ -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 管理员查看评论编辑历史
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
return model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("comment_id = ?", commentID).Delete(&model.CommentRevision{}).Error; err != nil {
|
||||
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 tx.Delete(&model.Comment{}, commentID).Error
|
||||
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 IN ?", ids).Delete(&model.CommentRevision{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user