支持评论点赞与举报,并统一帖子/评论的举报入口交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 01:06:02 +08:00
parent c05cc472cf
commit b075495540
13 changed files with 595 additions and 90 deletions

View File

@@ -107,7 +107,7 @@ export const api = {
}>(`/api/admin/reports${qs ? `?${qs}` : ''}`); }>(`/api/admin/reports${qs ? `?${qs}` : ''}`);
}, },
adminHandleReport: (id: number, body: { adminHandleReport: (id: number, body: {
action: 'dismiss' | 'resolve' | 'reject_post'; action: 'dismiss' | 'resolve' | 'reject_post' | 'reject_comment';
handle_note?: string; handle_note?: string;
reject_reason?: string; reject_reason?: string;
}) => }) =>
@@ -333,11 +333,16 @@ export const api = {
captcha: () => request<{ id: string; image: string }>('/api/captcha'), captcha: () => request<{ id: string; image: string }>('/api/captcha'),
logout: () => request('/api/logout', { method: 'POST' }), logout: () => request('/api/logout', { method: 'POST' }),
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }), like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }),
likeComment: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/comments/${id}/like`, { method: 'POST' }),
favorite: (id: number) => request<{ favorited: boolean }>(`/api/posts/${id}/favorite`, { method: 'POST' }), favorite: (id: number) => request<{ favorited: boolean }>(`/api/posts/${id}/favorite`, { method: 'POST' }),
reportPost: (id: number, body: { reason: ReportReason; detail?: string }) => reportPost: (id: number, body: { reason: ReportReason; detail?: string }) =>
request<{ message: string; report: PostReport }>(`/api/posts/${id}/report`, { request<{ message: string; report: PostReport }>(`/api/posts/${id}/report`, {
method: 'POST', body: JSON.stringify(body), method: 'POST', body: JSON.stringify(body),
}), }),
reportComment: (id: number, body: { reason: ReportReason; detail?: string }) =>
request<{ message: string; report: PostReport }>(`/api/comments/${id}/report`, {
method: 'POST', body: JSON.stringify(body),
}),
messageConversations: (params?: { page?: number; size?: number }) => { messageConversations: (params?: { page?: number; size?: number }) => {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params?.page) q.set('page', String(params.page)); if (params?.page) q.set('page', String(params.page));

View File

@@ -130,6 +130,8 @@ export interface Comment {
is_private?: boolean; is_private?: boolean;
status?: 'pending' | 'published' | 'rejected' | string; status?: 'pending' | 'published' | 'rejected' | string;
content_hidden?: boolean; content_hidden?: boolean;
like_count?: number;
liked?: boolean;
created_at: string; created_at: string;
updated_at?: string; updated_at?: string;
user?: User; user?: User;
@@ -392,10 +394,11 @@ export interface MessageConversation {
export type ReportReason = 'spam' | 'abuse' | 'illegal' | 'irrelevant' | 'other'; export type ReportReason = 'spam' | 'abuse' | 'illegal' | 'irrelevant' | 'other';
export type ReportStatus = 'pending' | 'resolved' | 'dismissed'; export type ReportStatus = 'pending' | 'resolved' | 'dismissed';
/** 帖子举报 */ /** 帖子/评论举报(有 comment_id 时为评论举报) */
export interface PostReport { export interface PostReport {
id: number; id: number;
post_id: number; post_id: number;
comment_id?: number;
reporter_id: number; reporter_id: number;
reason: ReportReason | string; reason: ReportReason | string;
detail: string; detail: string;
@@ -405,6 +408,7 @@ export interface PostReport {
created_at: string; created_at: string;
handled_at?: string; handled_at?: string;
post?: PostItem; post?: PostItem;
comment?: Comment;
reporter?: User; reporter?: User;
handler?: User; handler?: User;
} }

View File

@@ -1,7 +1,11 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Check, Clock, History, MessageSquare, X, Pencil, Trash2 } from 'lucide-react'; import {
Check, Clock, History, MessageSquare, X, Pencil, Trash2,
ThumbsUp, MoreHorizontal, Flag,
} from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { Comment, User } from '../api/types'; import type { Comment, ReportReason, User } from '../api/types';
import { api } from '../api/client';
import CommentContent from './CommentContent'; import CommentContent from './CommentContent';
import CommentRevisionDialog from './CommentRevisionDialog'; import CommentRevisionDialog from './CommentRevisionDialog';
import { import {
@@ -15,6 +19,22 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { notify } from '@/lib/notify';
import { import {
commentNick, commentNick,
commentInitial, commentInitial,
@@ -24,9 +44,11 @@ import {
type CommentNode, type CommentNode,
} from '../utils/comment'; } from '../utils/comment';
import { isTimeDiffSignificant } from '../utils/content'; import { isTimeDiffSignificant } from '../utils/content';
import { REPORT_REASON_OPTIONS } from '../utils/report';
import { useForumLimits } from '../hooks/useForumLimits'; import { useForumLimits } from '../hooks/useForumLimits';
import { Tooltip } from './ui/Tooltip'; import { Tooltip } from './ui/Tooltip';
import UserLink from './UserLink'; import UserLink from './UserLink';
import { cn } from '@/lib/utils';
function isCommentAuthor(c: Comment, user?: User | null): boolean { function isCommentAuthor(c: Comment, user?: User | null): boolean {
return !!user && c.user_id > 0 && c.user_id === user.id; return !!user && c.user_id > 0 && c.user_id === user.id;
@@ -56,6 +78,8 @@ interface ItemProps {
onSaveEdit: (comment: Comment, content: string) => Promise<void>; onSaveEdit: (comment: Comment, content: string) => Promise<void>;
onDelete: (comment: Comment) => Promise<void>; onDelete: (comment: Comment) => Promise<void>;
onApprove?: (comment: Comment) => Promise<void>; onApprove?: (comment: Comment) => Promise<void>;
onRequireLogin?: (actionLabel: string) => void;
onLikeUpdate?: (commentId: number, liked: boolean, likeCount: number) => void;
renderReplyBox?: (comment: Comment) => ReactNode; renderReplyBox?: (comment: Comment) => ReactNode;
} }
@@ -74,6 +98,8 @@ function CommentItem({
onSaveEdit, onSaveEdit,
onDelete, onDelete,
onApprove, onApprove,
onRequireLogin,
onLikeUpdate,
renderReplyBox, renderReplyBox,
}: ItemProps) { }: ItemProps) {
const { limits } = useForumLimits(); const { limits } = useForumLimits();
@@ -105,16 +131,29 @@ function CommentItem({
&& (c.status === 'pending' || c.status === 'rejected') && (c.status === 'pending' || c.status === 'rejected')
&& !!onApprove; && !!onApprove;
const showEdited = !hidden && !!c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at); const showEdited = !hidden && !!c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at);
const canReport = !hidden && !isEditing && !isCommentAuthor(c, currentUser);
const [editText, setEditText] = useState(c.content); const [editText, setEditText] = useState(c.content);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [approving, setApproving] = useState(false); const [approving, setApproving] = useState(false);
const [liking, setLiking] = useState(false);
const [liked, setLiked] = useState(!!c.liked);
const [likeCount, setLikeCount] = useState(c.like_count ?? 0);
const [revOpen, setRevOpen] = useState(false); const [revOpen, setRevOpen] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [reportReason, setReportReason] = useState<ReportReason>('spam');
const [reportDetail, setReportDetail] = useState('');
const [reporting, setReporting] = useState(false);
useEffect(() => { useEffect(() => {
if (isEditing) setEditText(c.content); if (isEditing) setEditText(c.content);
}, [isEditing, c.content, c.id]); }, [isEditing, c.content, c.id]);
useEffect(() => {
setLiked(!!c.liked);
setLikeCount(c.like_count ?? 0);
}, [c.id, c.liked, c.like_count]);
const handleSave = async () => { const handleSave = async () => {
const next = editText.trim(); const next = editText.trim();
if (!next) return; if (!next) return;
@@ -126,6 +165,51 @@ function CommentItem({
} }
}; };
const handleLike = async () => {
if (!currentUser) {
onRequireLogin?.('点赞');
return;
}
if (liking) return;
setLiking(true);
try {
const r = await api.likeComment(c.id);
setLiked(r.liked);
setLikeCount(r.like_count);
onLikeUpdate?.(c.id, r.liked, r.like_count);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '点赞失败');
} finally {
setLiking(false);
}
};
const openReport = () => {
if (!currentUser) {
onRequireLogin?.('举报');
return;
}
setReportReason('spam');
setReportDetail('');
setReportOpen(true);
};
const handleReport = async () => {
setReporting(true);
try {
const r = await api.reportComment(c.id, {
reason: reportReason,
detail: reportDetail.trim() || undefined,
});
notify.success(r.message);
setReportOpen(false);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '举报失败');
} finally {
setReporting(false);
}
};
return ( return (
<div <div
id={`floor-${c.floor}`} id={`floor-${c.floor}`}
@@ -168,10 +252,18 @@ function CommentItem({
) : ( ) : (
<span className="waline-comment-author">{nick}</span> <span className="waline-comment-author">{nick}</span>
)} )}
{!c.reply_to && ( {!hidden && (
<span className="waline-comment-floor" aria-label={`${c.floor}`}> <button
#{c.floor} type="button"
</span> className={cn('waline-comment-like', liked && 'is-liked')}
disabled={liking}
aria-label={liked ? '取消点赞' : '点赞'}
aria-pressed={liked}
onClick={handleLike}
>
<ThumbsUp size={14} strokeWidth={2} />
<span>{likeCount}</span>
</button>
)} )}
</div> </div>
@@ -295,12 +387,70 @@ function CommentItem({
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
)} )}
{canReport && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="waline-comment-reply-btn waline-comment-more"
aria-label="更多操作"
>
<MoreHorizontal size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="report-more-menu">
<DropdownMenuItem
className="report-more-menu__item"
onSelect={openReport}
>
<Flag size={14} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div> </div>
{isAdmin && ( {isAdmin && (
<CommentRevisionDialog open={revOpen} onOpenChange={setRevOpen} comment={c} /> <CommentRevisionDialog open={revOpen} onOpenChange={setRevOpen} comment={c} />
)} )}
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="pm-compose-fields">
<label className="pm-field">
<span></span>
<select
value={reportReason}
onChange={(e) => setReportReason(e.target.value as ReportReason)}
>
{REPORT_REASON_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<label className="pm-field">
<span></span>
<textarea
value={reportDetail}
onChange={(e) => setReportDetail(e.target.value)}
rows={4}
maxLength={500}
placeholder="补充更多细节…"
/>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setReportOpen(false)}></Button>
<Button loading={reporting} onClick={handleReport}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isReplying && renderReplyBox && ( {isReplying && renderReplyBox && (
<div id={`reply-box-${c.id}`} className="comment-box-wrap inline"> <div id={`reply-box-${c.id}`} className="comment-box-wrap inline">
{renderReplyBox(c)} {renderReplyBox(c)}
@@ -325,6 +475,8 @@ function CommentItem({
onSaveEdit={onSaveEdit} onSaveEdit={onSaveEdit}
onDelete={onDelete} onDelete={onDelete}
onApprove={onApprove} onApprove={onApprove}
onRequireLogin={onRequireLogin}
onLikeUpdate={onLikeUpdate}
renderReplyBox={renderReplyBox} renderReplyBox={renderReplyBox}
/> />
))} ))}
@@ -348,6 +500,8 @@ interface Props {
onSaveEdit: (comment: Comment, content: string) => Promise<void>; onSaveEdit: (comment: Comment, content: string) => Promise<void>;
onDelete: (comment: Comment) => Promise<void>; onDelete: (comment: Comment) => Promise<void>;
onApprove?: (comment: Comment) => Promise<void>; onApprove?: (comment: Comment) => Promise<void>;
onRequireLogin?: (actionLabel: string) => void;
onLikeUpdate?: (commentId: number, liked: boolean, likeCount: number) => void;
renderReplyBox?: (comment: Comment) => ReactNode; renderReplyBox?: (comment: Comment) => ReactNode;
} }
@@ -365,6 +519,8 @@ export default function CommentThreadList({
onSaveEdit, onSaveEdit,
onDelete, onDelete,
onApprove, onApprove,
onRequireLogin,
onLikeUpdate,
renderReplyBox, renderReplyBox,
}: Props) { }: Props) {
const tree = buildCommentTree(comments); const tree = buildCommentTree(comments);
@@ -386,6 +542,8 @@ export default function CommentThreadList({
onSaveEdit={onSaveEdit} onSaveEdit={onSaveEdit}
onDelete={onDelete} onDelete={onDelete}
onApprove={onApprove} onApprove={onApprove}
onRequireLogin={onRequireLogin}
onLikeUpdate={onLikeUpdate}
renderReplyBox={renderReplyBox} renderReplyBox={renderReplyBox}
/> />
))} ))}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom'; import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp } from 'lucide-react'; import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp, MoreHorizontal } from 'lucide-react';
import FeaturedIcon from '@/components/FeaturedIcon'; import FeaturedIcon from '@/components/FeaturedIcon';
import PinnedIcon from '@/components/PinnedIcon'; import PinnedIcon from '@/components/PinnedIcon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -19,6 +19,12 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -683,18 +689,39 @@ export default function PostDetailPage() {
<Star /> <Star />
{favorited ? '已收藏' : '收藏'} {favorited ? '已收藏' : '收藏'}
</Button> </Button>
{user && user.id !== post.user_id && ( {!user ? (
<Button variant="outline" size="sm" onClick={() => setReportOpen(true)}> <Button
<Flag /> variant="outline"
size="sm"
className="post-detail-more-btn"
aria-label="更多操作"
onClick={() => requireLogin('举报')}
>
<MoreHorizontal />
</Button> </Button>
)} ) : user.id !== post.user_id ? (
{!user && ( <DropdownMenu>
<Button variant="outline" size="sm" onClick={() => requireLogin('举报')}> <DropdownMenuTrigger asChild>
<Flag /> <Button
variant="outline"
size="sm"
className="post-detail-more-btn"
aria-label="更多操作"
>
<MoreHorizontal />
</Button> </Button>
)} </DropdownMenuTrigger>
<DropdownMenuContent align="center" className="report-more-menu">
<DropdownMenuItem
className="report-more-menu__item"
onSelect={() => setReportOpen(true)}
>
<Flag size={14} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div> </div>
{(isOwnerOrAdmin || canEdit || isAdmin) && ( {(isOwnerOrAdmin || canEdit || isAdmin) && (
@@ -889,6 +916,12 @@ export default function PostDetailPage() {
onSaveEdit={handleSaveComment} onSaveEdit={handleSaveComment}
onDelete={handleDeleteComment} onDelete={handleDeleteComment}
onApprove={user?.role === 'admin' ? handleApproveComment : undefined} onApprove={user?.role === 'admin' ? handleApproveComment : undefined}
onRequireLogin={requireLogin}
onLikeUpdate={(commentId, liked, likeCount) => {
setComments(list => list.map(item => (
item.id === commentId ? { ...item, liked, like_count: likeCount } : item
)));
}}
renderReplyBox={(c) => ( renderReplyBox={(c) => (
<CommentBox <CommentBox
key={c.id} key={c.id}

View File

@@ -19,6 +19,17 @@ import { reportReasonLabel, reportStatusLabel } from '../../utils/report';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type StatusTab = 'pending' | 'resolved' | 'dismissed' | 'all'; type StatusTab = 'pending' | 'resolved' | 'dismissed' | 'all';
type HandleAction = 'dismiss' | 'resolve' | 'reject_post' | 'reject_comment';
function isCommentReport(r: PostReport) {
return !!(r.comment_id && r.comment_id > 0);
}
function commentExcerpt(r: PostReport) {
const raw = (r.comment?.content || '').trim();
if (!raw) return '';
return raw.length > 80 ? `${raw.slice(0, 80)}` : raw;
}
export default function AdminReportsPage() { export default function AdminReportsPage() {
const nav = useNavigate(); const nav = useNavigate();
@@ -29,7 +40,7 @@ export default function AdminReportsPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [active, setActive] = useState<PostReport | null>(null); const [active, setActive] = useState<PostReport | null>(null);
const [action, setAction] = useState<'dismiss' | 'resolve' | 'reject_post' | null>(null); const [action, setAction] = useState<HandleAction | null>(null);
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const [rejectReason, setRejectReason] = useState(''); const [rejectReason, setRejectReason] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -53,7 +64,7 @@ export default function AdminReportsPage() {
load(1, status); load(1, status);
}, [status, load]); }, [status, load]);
const openHandle = (rep: PostReport, act: 'dismiss' | 'resolve' | 'reject_post') => { const openHandle = (rep: PostReport, act: HandleAction) => {
setActive(rep); setActive(rep);
setAction(act); setAction(act);
setNote(''); setNote('');
@@ -62,7 +73,7 @@ export default function AdminReportsPage() {
const submitHandle = async () => { const submitHandle = async () => {
if (!active || !action) return; if (!active || !action) return;
if (action === 'reject_post' && !rejectReason.trim()) { if ((action === 'reject_post' || action === 'reject_comment') && !rejectReason.trim()) {
notify.warning('请填写拒绝原因(将私信通知作者)'); notify.warning('请填写拒绝原因(将私信通知作者)');
return; return;
} }
@@ -71,7 +82,9 @@ export default function AdminReportsPage() {
const r = await api.adminHandleReport(active.id, { const r = await api.adminHandleReport(active.id, {
action, action,
handle_note: note.trim() || undefined, handle_note: note.trim() || undefined,
reject_reason: action === 'reject_post' ? rejectReason.trim() : undefined, reject_reason: (action === 'reject_post' || action === 'reject_comment')
? rejectReason.trim()
: undefined,
}); });
notify.success(r.message); notify.success(r.message);
setActive(null); setActive(null);
@@ -84,6 +97,12 @@ export default function AdminReportsPage() {
} }
}; };
const openTarget = (r: PostReport) => {
const floor = r.comment?.floor;
const hash = floor && floor > 0 ? `#floor-${floor}` : '';
nav(`/post/${r.post_id}${hash}`);
};
const tabs: { key: StatusTab; label: string }[] = [ const tabs: { key: StatusTab; label: string }[] = [
{ key: 'pending', label: `待处理${pendingCount ? ` (${pendingCount})` : ''}` }, { key: 'pending', label: `待处理${pendingCount ? ` (${pendingCount})` : ''}` },
{ key: 'resolved', label: '已处理' }, { key: 'resolved', label: '已处理' },
@@ -94,7 +113,7 @@ export default function AdminReportsPage() {
return ( return (
<div className="admin-page"> <div className="admin-page">
<h1 className="admin-page-title"></h1> <h1 className="admin-page-title"></h1>
<p className="admin-page-desc"></p> <p className="admin-page-desc"></p>
<div className="admin-tabs"> <div className="admin-tabs">
{tabs.map((t) => ( {tabs.map((t) => (
@@ -117,7 +136,7 @@ export default function AdminReportsPage() {
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
@@ -126,17 +145,29 @@ export default function AdminReportsPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{list.map((r) => ( {list.map((r) => {
const commentRep = isCommentReport(r);
const excerpt = commentRep ? commentExcerpt(r) : '';
return (
<tr key={r.id}> <tr key={r.id}>
<td>{r.id}</td> <td>{r.id}</td>
<td className="max-w-[220px]"> <td className="max-w-[260px]">
<div className="flex items-center gap-1.5 mb-0.5">
<Badge variant={commentRep ? 'secondary' : 'outline'}>
{commentRep ? '评论' : '帖子'}
</Badge>
</div>
<button <button
type="button" type="button"
className="admin-text-link truncate block max-w-full text-left" className="admin-text-link truncate block max-w-full text-left"
onClick={() => nav(`/post/${r.post_id}`)} onClick={() => openTarget(r)}
> >
{r.post?.title || `帖子 #${r.post_id}`} {r.post?.title || `帖子 #${r.post_id}`}
{commentRep && r.comment?.floor ? ` · #${r.comment.floor}` : ''}
</button> </button>
{excerpt && (
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{excerpt}</div>
)}
{r.detail && ( {r.detail && (
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{r.detail}</div> <div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{r.detail}</div>
)} )}
@@ -154,7 +185,15 @@ export default function AdminReportsPage() {
<div className="flex gap-1 flex-wrap"> <div className="flex gap-1 flex-wrap">
<Button size="sm" variant="outline" onClick={() => openHandle(r, 'dismiss')}></Button> <Button size="sm" variant="outline" onClick={() => openHandle(r, 'dismiss')}></Button>
<Button size="sm" variant="outline" onClick={() => openHandle(r, 'resolve')}></Button> <Button size="sm" variant="outline" onClick={() => openHandle(r, 'resolve')}></Button>
<Button size="sm" variant="destructive" onClick={() => openHandle(r, 'reject_post')}></Button> {commentRep ? (
<Button size="sm" variant="destructive" onClick={() => openHandle(r, 'reject_comment')}>
</Button>
) : (
<Button size="sm" variant="destructive" onClick={() => openHandle(r, 'reject_post')}>
</Button>
)}
</div> </div>
) : ( ) : (
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-sm">
@@ -163,7 +202,8 @@ export default function AdminReportsPage() {
)} )}
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
{list.length === 0 && <div className="admin-empty"></div>} {list.length === 0 && <div className="admin-empty"></div>}
@@ -184,15 +224,18 @@ export default function AdminReportsPage() {
{action === 'dismiss' && '忽略举报'} {action === 'dismiss' && '忽略举报'}
{action === 'resolve' && '标记已处理'} {action === 'resolve' && '标记已处理'}
{action === 'reject_post' && '拒绝帖子并通知作者'} {action === 'reject_post' && '拒绝帖子并通知作者'}
{action === 'reject_comment' && '拒绝评论并通知作者'}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{action === 'reject_post' {action === 'reject_post'
? '帖子将移入回收站,拒绝原因会通过站内私信发给作者;举报人也会收到处理结果通知。' ? '帖子将标记为未通过,拒绝原因会通过站内私信发给作者;举报人也会收到处理结果通知。'
: action === 'reject_comment'
? '评论将标记为未通过,拒绝原因会通过站内私信发给评论作者;举报人也会收到处理结果通知。'
: '举报人将收到处理结果的站内私信通知。'} : '举报人将收到处理结果的站内私信通知。'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="pm-compose-fields"> <div className="pm-compose-fields">
{action === 'reject_post' && ( {(action === 'reject_post' || action === 'reject_comment') && (
<label className="pm-field"> <label className="pm-field">
<span></span> <span></span>
<textarea <textarea
@@ -218,7 +261,7 @@ export default function AdminReportsPage() {
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => { setAction(null); setActive(null); }}></Button> <Button variant="outline" onClick={() => { setAction(null); setActive(null); }}></Button>
<Button <Button
variant={action === 'reject_post' ? 'destructive' : 'default'} variant={action === 'reject_post' || action === 'reject_comment' ? 'destructive' : 'default'}
loading={submitting} loading={submitting}
onClick={submitHandle} onClick={submitHandle}
> >

View File

@@ -3766,6 +3766,38 @@ a.post-title:visited {
border-top: 1px dashed var(--j13-border-light); border-top: 1px dashed var(--j13-border-light);
} }
.post-detail-more-btn {
min-width: 36px;
padding-inline: 10px;
}
/* 举报「更多」菜单:紧凑居中 */
.report-more-menu {
min-width: 0 !important;
width: max-content;
padding: 4px;
}
.report-more-menu__item {
justify-content: center;
gap: 6px;
padding: 6px 12px;
color: hsl(var(--destructive));
font-size: 13px;
cursor: pointer;
}
.report-more-menu__item:focus,
.report-more-menu__item:hover {
color: hsl(var(--destructive));
background: color-mix(in srgb, hsl(var(--destructive)) 10%, transparent);
}
.report-more-menu__item svg {
width: 14px;
height: 14px;
}
.post-detail-content { .post-detail-content {
font-size: 15.5px; font-size: 15.5px;
line-height: 1.8; line-height: 1.8;
@@ -5328,6 +5360,7 @@ a.post-title:visited {
font-weight: 400; font-weight: 400;
color: var(--color-text-1); color: var(--color-text-1);
text-decoration: none; text-decoration: none;
min-width: 0;
} }
a.waline-comment-author:hover { a.waline-comment-author:hover {
@@ -5335,12 +5368,39 @@ a.waline-comment-author:hover {
text-decoration: none; text-decoration: none;
} }
.waline-comment-floor { .waline-comment-like {
margin-left: auto; margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: none;
padding: 2px 4px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--color-text-3); color: var(--color-text-3);
cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
border-radius: 6px;
transition: color 0.15s, background 0.15s;
}
.waline-comment-like:hover {
color: var(--j13-green);
background: var(--j13-green-bg);
}
.waline-comment-like.is-liked {
color: var(--j13-green);
}
.waline-comment-like:disabled {
opacity: 0.6;
cursor: wait;
}
.waline-comment-floor {
display: none;
} }
.waline-comment-bubble { .waline-comment-bubble {

View File

@@ -370,6 +370,16 @@ func (h *Handlers) APIToggleLike(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"liked": liked, "like_count": post.LikeCount}) c.JSON(http.StatusOK, gin.H{"liked": liked, "like_count": post.LikeCount})
} }
func (h *Handlers) APIToggleCommentLike(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
liked, likeCount, err := h.Comment.ToggleLike(h.currentUserID(c), uint(id))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"liked": liked, "like_count": likeCount})
}
func (h *Handlers) APIToggleFavorite(c *gin.Context) { func (h *Handlers) APIToggleFavorite(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64) id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
faved, err := h.Post.ToggleFavorite(h.currentUserID(c), uint(id)) faved, err := h.Post.ToggleFavorite(h.currentUserID(c), uint(id))

View File

@@ -29,6 +29,25 @@ func (h *Handlers) APICreatePostReport(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "举报已提交,感谢反馈", "report": rep}) c.JSON(http.StatusOK, gin.H{"message": "举报已提交,感谢反馈", "report": rep})
} }
// APICreateCommentReport 举报评论
func (h *Handlers) APICreateCommentReport(c *gin.Context) {
commentID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Reason string `json:"reason"`
Detail string `json:"detail"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
rep, err := h.Report.CreateCommentReport(h.currentUserID(c), uint(commentID), req.Reason, req.Detail)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "举报已提交,感谢反馈", "report": rep})
}
// APIAdminReports 举报列表 // APIAdminReports 举报列表
func (h *Handlers) APIAdminReports(c *gin.Context) { func (h *Handlers) APIAdminReports(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))

View File

@@ -35,7 +35,7 @@ func InitDB(dbPath string) error {
if err := db.AutoMigrate( if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{}, &User{}, &Board{}, &Post{}, &Comment{},
&PostLike{}, &PostFavorite{}, &PostRevision{}, &CommentRevision{}, &ForumSetting{}, &PostLike{}, &CommentLike{}, &PostFavorite{}, &PostRevision{}, &CommentRevision{}, &ForumSetting{},
&OAuthClient{}, &OAuthAuthCode{}, &OAuthClient{}, &OAuthAuthCode{},
&GiteaRepo{}, &GiteaRepo{},
&PrivateMessage{}, &PostReport{}, &PrivateMessage{}, &PostReport{},

View File

@@ -130,6 +130,7 @@ type Comment struct {
GuestURL string `gorm:"size:256" json:"guest_url,omitempty"` GuestURL string `gorm:"size:256" json:"guest_url,omitempty"`
IsPrivate bool `gorm:"default:false" json:"is_private"` IsPrivate bool `gorm:"default:false" json:"is_private"`
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
LikeCount int `gorm:"default:0" json:"like_count"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
@@ -140,6 +141,7 @@ type Comment struct {
// ThreadParentID 嵌套展示用父评论(父评论不可见时回挂到最近可见祖先) // ThreadParentID 嵌套展示用父评论(父评论不可见时回挂到最近可见祖先)
ThreadParentID *uint `gorm:"-" json:"thread_parent_id,omitempty"` ThreadParentID *uint `gorm:"-" json:"thread_parent_id,omitempty"`
ContentHidden bool `gorm:"-" json:"content_hidden"` ContentHidden bool `gorm:"-" json:"content_hidden"`
Liked bool `gorm:"-" json:"liked"`
} }
// PostLike 帖子点赞 // PostLike 帖子点赞
@@ -150,6 +152,14 @@ type PostLike struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// CommentLike 评论点赞
type CommentLike struct {
ID uint `gorm:"primaryKey" json:"id"`
CommentID uint `gorm:"uniqueIndex:idx_comment_user;not null" json:"comment_id"`
UserID uint `gorm:"uniqueIndex:idx_comment_user;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
}
// PostFavorite 帖子收藏 // PostFavorite 帖子收藏
type PostFavorite struct { type PostFavorite struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
@@ -203,10 +213,11 @@ const (
ReportReasonOther = "other" ReportReasonOther = "other"
) )
// PostReport 帖子举报 // PostReport 帖子/评论举报CommentID 有值时为评论举报)
type PostReport struct { type PostReport struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"` PostID uint `gorm:"index;not null" json:"post_id"`
CommentID *uint `gorm:"index" json:"comment_id,omitempty"`
ReporterID uint `gorm:"index;not null" json:"reporter_id"` ReporterID uint `gorm:"index;not null" json:"reporter_id"`
Reason string `gorm:"size:32;not null" json:"reason"` Reason string `gorm:"size:32;not null" json:"reason"`
Detail string `gorm:"size:1000" json:"detail"` Detail string `gorm:"size:1000" json:"detail"`
@@ -217,6 +228,7 @@ type PostReport struct {
HandledAt *time.Time `json:"handled_at,omitempty"` HandledAt *time.Time `json:"handled_at,omitempty"`
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"` Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
Comment *Comment `gorm:"foreignKey:CommentID" json:"comment,omitempty"`
Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"` Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"`
Handler *User `gorm:"foreignKey:HandlerID" json:"handler,omitempty"` Handler *User `gorm:"foreignKey:HandlerID" json:"handler,omitempty"`
} }

View File

@@ -48,7 +48,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
postSvc := service.NewPostService(filter, settingsSvc) postSvc := service.NewPostService(filter, settingsSvc)
commentSvc := service.NewCommentService(filter, settingsSvc) commentSvc := service.NewCommentService(filter, settingsSvc)
messageSvc := service.NewMessageService(filter, settingsSvc) messageSvc := service.NewMessageService(filter, settingsSvc)
reportSvc := service.NewReportService(filter, settingsSvc, messageSvc, postSvc) reportSvc := service.NewReportService(filter, settingsSvc, messageSvc, postSvc, commentSvc)
backupSvc := service.NewBackupService(cfg.DBPath(), cfg.DataDir) backupSvc := service.NewBackupService(cfg.DBPath(), cfg.DataDir)
limiter := service.NewRateLimiter(settingsSvc) limiter := service.NewRateLimiter(settingsSvc)
captchaSvc := service.NewCaptchaService() captchaSvc := service.NewCaptchaService()
@@ -155,6 +155,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.POST("/messages/conversations/:peerId/read", h.APIMarkConversationRead) api.POST("/messages/conversations/:peerId/read", h.APIMarkConversationRead)
api.POST("/messages", middleware.RateLimitMiddleware(limiter, "message"), h.APISendMessage) api.POST("/messages", middleware.RateLimitMiddleware(limiter, "message"), h.APISendMessage)
api.POST("/messages/read-all", h.APIMarkAllMessagesRead) api.POST("/messages/read-all", h.APIMarkAllMessagesRead)
api.POST("/comments/:id/like", h.APIToggleCommentLike)
api.POST("/comments/:id/report", middleware.RateLimitMiddleware(limiter, "report"), h.APICreateCommentReport)
api.DELETE("/comments/:id", h.APIDeleteComment) api.DELETE("/comments/:id", h.APIDeleteComment)
api.PUT("/comments/:id", h.APIUpdateComment) api.PUT("/comments/:id", h.APIUpdateComment)
} }

View File

@@ -140,6 +140,7 @@ func (s *CommentService) ListByPost(postID, viewerID uint, isAdmin bool, postAut
rt.ContentHidden = true rt.ContentHidden = true
} }
} }
s.fillLiked(visible, viewerID)
return visible, nil return visible, nil
} }
@@ -259,6 +260,64 @@ func (s *CommentService) GetByID(id uint) (*model.Comment, error) {
return &c, nil return &c, nil
} }
// fillLiked 批量标记当前用户是否已点赞
func (s *CommentService) fillLiked(comments []model.Comment, viewerID uint) {
if viewerID == 0 || len(comments) == 0 {
return
}
ids := make([]uint, 0, len(comments))
for _, c := range comments {
ids = append(ids, c.ID)
}
var likes []model.CommentLike
model.DB.Where("user_id = ? AND comment_id IN ?", viewerID, ids).Find(&likes)
likedSet := make(map[uint]struct{}, len(likes))
for _, l := range likes {
likedSet[l.CommentID] = struct{}{}
}
for i := range comments {
_, comments[i].Liked = likedSet[comments[i].ID]
}
}
// ToggleLike 切换评论点赞
func (s *CommentService) ToggleLike(userID, commentID uint) (liked bool, likeCount int, err error) {
var comment model.Comment
if err := model.DB.Select("id", "like_count").First(&comment, commentID).Error; err != nil {
return false, 0, ErrCommentNotFound
}
var like model.CommentLike
result := model.DB.Where("comment_id = ? AND user_id = ?", commentID, userID).Limit(1).Find(&like)
if result.Error != nil {
return false, 0, result.Error
}
if result.RowsAffected > 0 {
if err := model.DB.Delete(&like).Error; err != nil {
return false, 0, err
}
model.DB.Model(&model.Comment{}).Where("id = ?", commentID).UpdateColumn("like_count", gorm.Expr("CASE WHEN like_count > 0 THEN like_count - 1 ELSE 0 END"))
_ = model.DB.Select("like_count").First(&comment, commentID)
return false, comment.LikeCount, nil
}
like = model.CommentLike{CommentID: commentID, UserID: userID}
if err := model.DB.Create(&like).Error; err != nil {
return false, 0, err
}
model.DB.Model(&model.Comment{}).Where("id = ?", commentID).UpdateColumn("like_count", gorm.Expr("like_count + 1"))
_ = model.DB.Select("like_count").First(&comment, commentID)
return true, comment.LikeCount, nil
}
// IsLiked 用户是否已点赞该评论
func (s *CommentService) IsLiked(userID, commentID uint) bool {
if userID == 0 || commentID == 0 {
return false
}
var count int64
model.DB.Model(&model.CommentLike{}).Where("comment_id = ? AND user_id = ?", commentID, userID).Count(&count)
return count > 0
}
// PendingCommentCount 待审评论数 // PendingCommentCount 待审评论数
func (s *CommentService) PendingCommentCount() (int64, error) { func (s *CommentService) PendingCommentCount() (int64, error) {
var n int64 var n int64

View File

@@ -13,8 +13,9 @@ import (
var ( var (
ErrReportNotFound = errors.New("举报不存在") ErrReportNotFound = errors.New("举报不存在")
ErrReportAlreadyExists = errors.New("你已举报过该帖子,请等待处理") ErrReportAlreadyExists = errors.New("你已举报过该内容,请等待处理")
ErrCannotReportOwnPost = errors.New("不能举报自己的帖子") ErrCannotReportOwnPost = errors.New("不能举报自己的帖子")
ErrCannotReportOwnComment = errors.New("不能举报自己的评论")
) )
type ReportService struct { type ReportService struct {
@@ -22,6 +23,7 @@ type ReportService struct {
settings *ForumSettingsService settings *ForumSettingsService
messages *MessageService messages *MessageService
posts *PostService posts *PostService
comments *CommentService
} }
func NewReportService( func NewReportService(
@@ -29,8 +31,9 @@ func NewReportService(
settings *ForumSettingsService, settings *ForumSettingsService,
messages *MessageService, messages *MessageService,
posts *PostService, posts *PostService,
comments *CommentService,
) *ReportService { ) *ReportService {
return &ReportService{filter: filter, settings: settings, messages: messages, posts: posts} return &ReportService{filter: filter, settings: settings, messages: messages, posts: posts, comments: comments}
} }
func normalizeReportReason(reason string) (string, error) { func normalizeReportReason(reason string) (string, error) {
@@ -107,6 +110,52 @@ func (s *ReportService) Create(reporterID, postID uint, reason, detail string) (
return rep, nil return rep, nil
} }
// CreateCommentReport 用户举报评论
func (s *ReportService) CreateCommentReport(reporterID, commentID uint, reason, detail string) (*model.PostReport, error) {
reason, err := normalizeReportReason(reason)
if err != nil {
return nil, err
}
detail = strings.TrimSpace(detail)
if utf8.RuneCountInString(detail) > 500 {
return nil, errors.New("补充说明过长")
}
if s.filter != nil && detail != "" {
detail = s.filter.Filter(detail)
}
comment, err := s.comments.GetByID(commentID)
if err != nil {
return nil, err
}
if comment.UserID > 0 && comment.UserID == reporterID {
return nil, ErrCannotReportOwnComment
}
var existing int64
model.DB.Model(&model.PostReport{}).
Where("comment_id = ? AND reporter_id = ? AND status = ?", commentID, reporterID, model.ReportStatusPending).
Count(&existing)
if existing > 0 {
return nil, ErrReportAlreadyExists
}
cid := commentID
rep := &model.PostReport{
PostID: comment.PostID,
CommentID: &cid,
ReporterID: reporterID,
Reason: reason,
Detail: detail,
Status: model.ReportStatusPending,
}
if err := model.DB.Create(rep).Error; err != nil {
return nil, err
}
_ = model.DB.Preload("Post").Preload("Comment").Preload("Reporter").First(rep, rep.ID).Error
return rep, nil
}
type ReportListQuery struct { type ReportListQuery struct {
Status string Status string
Page int Page int
@@ -133,7 +182,9 @@ func (s *ReportService) ListAdmin(q ReportListQuery) ([]model.PostReport, int64,
var list []model.PostReport var list []model.PostReport
err := db.Preload("Post", func(tx *gorm.DB) *gorm.DB { err := db.Preload("Post", func(tx *gorm.DB) *gorm.DB {
return tx.Unscoped() return tx.Unscoped()
}).Preload("Post.User").Preload("Reporter").Preload("Handler"). }).Preload("Post.User").Preload("Comment", func(tx *gorm.DB) *gorm.DB {
return tx.Unscoped()
}).Preload("Comment.User").Preload("Reporter").Preload("Handler").
Order("CASE WHEN status = 'pending' THEN 0 ELSE 1 END, id DESC"). Order("CASE WHEN status = 'pending' THEN 0 ELSE 1 END, id DESC").
Offset((q.Page - 1) * q.Size). Offset((q.Page - 1) * q.Size).
Limit(q.Size). Limit(q.Size).
@@ -153,9 +204,9 @@ func (s *ReportService) PendingCount() (int64, error) {
type HandleReportInput struct { type HandleReportInput struct {
ReportID uint ReportID uint
HandlerID uint HandlerID uint
Action string // dismiss | resolve | reject_post Action string // dismiss | resolve | reject_post | reject_comment
HandleNote string HandleNote string
RejectReason string // reject_post 时必填,发给作者 RejectReason string // reject_post / reject_comment 时发给作者
} }
// Handle 处理举报 // Handle 处理举报
@@ -163,6 +214,8 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
var rep model.PostReport var rep model.PostReport
if err := model.DB.Preload("Post", func(tx *gorm.DB) *gorm.DB { if err := model.DB.Preload("Post", func(tx *gorm.DB) *gorm.DB {
return tx.Unscoped() return tx.Unscoped()
}).Preload("Comment", func(tx *gorm.DB) *gorm.DB {
return tx.Unscoped()
}).First(&rep, in.ReportID).Error; err != nil { }).First(&rep, in.ReportID).Error; err != nil {
return nil, ErrReportNotFound return nil, ErrReportNotFound
} }
@@ -188,6 +241,13 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
postTitle = rep.Post.Title postTitle = rep.Post.Title
authorID = rep.Post.UserID authorID = rep.Post.UserID
} }
isCommentReport := rep.CommentID != nil && *rep.CommentID > 0
commentAuthorID := uint(0)
commentFloor := 0
if isCommentReport && rep.Comment != nil {
commentAuthorID = rep.Comment.UserID
commentFloor = rep.Comment.Floor
}
switch in.Action { switch in.Action {
case "dismiss": case "dismiss":
@@ -195,6 +255,9 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
case "resolve": case "resolve":
rep.Status = model.ReportStatusResolved rep.Status = model.ReportStatusResolved
case "reject_post": case "reject_post":
if isCommentReport {
return nil, errors.New("评论举报请使用「拒绝该评论」")
}
reason := strings.TrimSpace(in.RejectReason) reason := strings.TrimSpace(in.RejectReason)
if reason == "" { if reason == "" {
return nil, errors.New("请填写拒绝原因(将私信通知作者)") return nil, errors.New("请填写拒绝原因(将私信通知作者)")
@@ -221,6 +284,37 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
&rid, &rid,
) )
} }
case "reject_comment":
if !isCommentReport {
return nil, errors.New("仅评论举报可拒绝评论")
}
reason := strings.TrimSpace(in.RejectReason)
if reason == "" {
return nil, errors.New("请填写拒绝原因(将私信通知作者)")
}
if utf8.RuneCountInString(reason) > 1000 {
return nil, errors.New("拒绝原因过长")
}
if err := s.comments.SetStatus(*rep.CommentID, model.ContentStatusRejected); err != nil {
return nil, err
}
rep.Status = model.ReportStatusResolved
if note == "" {
rep.HandleNote = "已拒绝该评论并通知作者"
}
if commentAuthorID > 0 {
pid := postID
rid := rep.ID
body := fmt.Sprintf("你在帖子《%s》下的评论#%d未通过审核。\n\n原因\n%s", postTitle, commentFloor, reason)
_, _ = s.messages.SendSystem(
commentAuthorID,
fmt.Sprintf("评论未通过审核 · 《%s》", postTitle),
body,
model.MessageKindReject,
&pid,
&rid,
)
}
default: default:
return nil, errors.New("无效的处理操作") return nil, errors.New("无效的处理操作")
} }
@@ -232,16 +326,20 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
// 通知举报人处理结果 // 通知举报人处理结果
resultText := "已忽略" resultText := "已忽略"
if rep.Status == model.ReportStatusResolved { if rep.Status == model.ReportStatusResolved {
if in.Action == "reject_post" { switch in.Action {
case "reject_post":
resultText = "已核实并下架该帖" resultText = "已核实并下架该帖"
} else { case "reject_comment":
resultText = "已核实并处理该评论"
default:
resultText = "已处理" resultText = "已处理"
} }
} }
content := fmt.Sprintf( targetDesc := fmt.Sprintf("帖子《%s》#%d", postTitle, postID)
"你举报的帖子《%s》#%d已处理%s。", if isCommentReport {
postTitle, postID, resultText, targetDesc = fmt.Sprintf("帖子《%s》下的评论#%d", postTitle, commentFloor)
) }
content := fmt.Sprintf("你举报的%s已处理%s。", targetDesc, resultText)
if note != "" { if note != "" {
content += "\n\n管理员备注\n" + note content += "\n\n管理员备注\n" + note
} }
@@ -258,6 +356,8 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
_ = model.DB.Preload("Post", func(tx *gorm.DB) *gorm.DB { _ = model.DB.Preload("Post", func(tx *gorm.DB) *gorm.DB {
return tx.Unscoped() return tx.Unscoped()
}).Preload("Comment", func(tx *gorm.DB) *gorm.DB {
return tx.Unscoped()
}).Preload("Reporter").Preload("Handler").First(&rep, rep.ID).Error }).Preload("Reporter").Preload("Handler").First(&rep, rep.ID).Error
return &rep, nil return &rep, nil
} }