支持评论点赞与举报,并统一帖子/评论的举报入口交互。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -107,7 +107,7 @@ export const api = {
|
||||
}>(`/api/admin/reports${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
adminHandleReport: (id: number, body: {
|
||||
action: 'dismiss' | 'resolve' | 'reject_post';
|
||||
action: 'dismiss' | 'resolve' | 'reject_post' | 'reject_comment';
|
||||
handle_note?: string;
|
||||
reject_reason?: string;
|
||||
}) =>
|
||||
@@ -333,11 +333,16 @@ export const api = {
|
||||
captcha: () => request<{ id: string; image: string }>('/api/captcha'),
|
||||
logout: () => request('/api/logout', { 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' }),
|
||||
reportPost: (id: number, body: { reason: ReportReason; detail?: string }) =>
|
||||
request<{ message: string; report: PostReport }>(`/api/posts/${id}/report`, {
|
||||
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 }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
|
||||
@@ -130,6 +130,8 @@ export interface Comment {
|
||||
is_private?: boolean;
|
||||
status?: 'pending' | 'published' | 'rejected' | string;
|
||||
content_hidden?: boolean;
|
||||
like_count?: number;
|
||||
liked?: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
user?: User;
|
||||
@@ -392,10 +394,11 @@ export interface MessageConversation {
|
||||
export type ReportReason = 'spam' | 'abuse' | 'illegal' | 'irrelevant' | 'other';
|
||||
export type ReportStatus = 'pending' | 'resolved' | 'dismissed';
|
||||
|
||||
/** 帖子举报 */
|
||||
/** 帖子/评论举报(有 comment_id 时为评论举报) */
|
||||
export interface PostReport {
|
||||
id: number;
|
||||
post_id: number;
|
||||
comment_id?: number;
|
||||
reporter_id: number;
|
||||
reason: ReportReason | string;
|
||||
detail: string;
|
||||
@@ -405,6 +408,7 @@ export interface PostReport {
|
||||
created_at: string;
|
||||
handled_at?: string;
|
||||
post?: PostItem;
|
||||
comment?: Comment;
|
||||
reporter?: User;
|
||||
handler?: User;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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 { Comment, User } from '../api/types';
|
||||
import type { Comment, ReportReason, User } from '../api/types';
|
||||
import { api } from '../api/client';
|
||||
import CommentContent from './CommentContent';
|
||||
import CommentRevisionDialog from './CommentRevisionDialog';
|
||||
import {
|
||||
@@ -15,6 +19,22 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} 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 {
|
||||
commentNick,
|
||||
commentInitial,
|
||||
@@ -24,9 +44,11 @@ import {
|
||||
type CommentNode,
|
||||
} from '../utils/comment';
|
||||
import { isTimeDiffSignificant } from '../utils/content';
|
||||
import { REPORT_REASON_OPTIONS } from '../utils/report';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { Tooltip } from './ui/Tooltip';
|
||||
import UserLink from './UserLink';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function isCommentAuthor(c: Comment, user?: User | null): boolean {
|
||||
return !!user && c.user_id > 0 && c.user_id === user.id;
|
||||
@@ -56,6 +78,8 @@ interface ItemProps {
|
||||
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
|
||||
onDelete: (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;
|
||||
}
|
||||
|
||||
@@ -74,6 +98,8 @@ function CommentItem({
|
||||
onSaveEdit,
|
||||
onDelete,
|
||||
onApprove,
|
||||
onRequireLogin,
|
||||
onLikeUpdate,
|
||||
renderReplyBox,
|
||||
}: ItemProps) {
|
||||
const { limits } = useForumLimits();
|
||||
@@ -105,16 +131,29 @@ function CommentItem({
|
||||
&& (c.status === 'pending' || c.status === 'rejected')
|
||||
&& !!onApprove;
|
||||
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 [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = 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 [reportOpen, setReportOpen] = useState(false);
|
||||
const [reportReason, setReportReason] = useState<ReportReason>('spam');
|
||||
const [reportDetail, setReportDetail] = useState('');
|
||||
const [reporting, setReporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) setEditText(c.content);
|
||||
}, [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 next = editText.trim();
|
||||
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 (
|
||||
<div
|
||||
id={`floor-${c.floor}`}
|
||||
@@ -168,10 +252,18 @@ function CommentItem({
|
||||
) : (
|
||||
<span className="waline-comment-author">{nick}</span>
|
||||
)}
|
||||
{!c.reply_to && (
|
||||
<span className="waline-comment-floor" aria-label={`第 ${c.floor} 楼`}>
|
||||
#{c.floor}
|
||||
</span>
|
||||
{!hidden && (
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
|
||||
@@ -295,12 +387,70 @@ function CommentItem({
|
||||
</AlertDialogContent>
|
||||
</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>
|
||||
|
||||
{isAdmin && (
|
||||
<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 && (
|
||||
<div id={`reply-box-${c.id}`} className="comment-box-wrap inline">
|
||||
{renderReplyBox(c)}
|
||||
@@ -325,6 +475,8 @@ function CommentItem({
|
||||
onSaveEdit={onSaveEdit}
|
||||
onDelete={onDelete}
|
||||
onApprove={onApprove}
|
||||
onRequireLogin={onRequireLogin}
|
||||
onLikeUpdate={onLikeUpdate}
|
||||
renderReplyBox={renderReplyBox}
|
||||
/>
|
||||
))}
|
||||
@@ -348,6 +500,8 @@ interface Props {
|
||||
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
|
||||
onDelete: (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;
|
||||
}
|
||||
|
||||
@@ -365,6 +519,8 @@ export default function CommentThreadList({
|
||||
onSaveEdit,
|
||||
onDelete,
|
||||
onApprove,
|
||||
onRequireLogin,
|
||||
onLikeUpdate,
|
||||
renderReplyBox,
|
||||
}: Props) {
|
||||
const tree = buildCommentTree(comments);
|
||||
@@ -386,6 +542,8 @@ export default function CommentThreadList({
|
||||
onSaveEdit={onSaveEdit}
|
||||
onDelete={onDelete}
|
||||
onApprove={onApprove}
|
||||
onRequireLogin={onRequireLogin}
|
||||
onLikeUpdate={onLikeUpdate}
|
||||
renderReplyBox={renderReplyBox}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
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 PinnedIcon from '@/components/PinnedIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -19,6 +19,12 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -683,18 +689,39 @@ export default function PostDetailPage() {
|
||||
<Star />
|
||||
{favorited ? '已收藏' : '收藏'}
|
||||
</Button>
|
||||
{user && user.id !== post.user_id && (
|
||||
<Button variant="outline" size="sm" onClick={() => setReportOpen(true)}>
|
||||
<Flag />
|
||||
举报
|
||||
{!user ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="post-detail-more-btn"
|
||||
aria-label="更多操作"
|
||||
onClick={() => requireLogin('举报')}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
)}
|
||||
{!user && (
|
||||
<Button variant="outline" size="sm" onClick={() => requireLogin('举报')}>
|
||||
<Flag />
|
||||
举报
|
||||
</Button>
|
||||
)}
|
||||
) : user.id !== post.user_id ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="post-detail-more-btn"
|
||||
aria-label="更多操作"
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</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>
|
||||
|
||||
{(isOwnerOrAdmin || canEdit || isAdmin) && (
|
||||
@@ -889,6 +916,12 @@ export default function PostDetailPage() {
|
||||
onSaveEdit={handleSaveComment}
|
||||
onDelete={handleDeleteComment}
|
||||
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) => (
|
||||
<CommentBox
|
||||
key={c.id}
|
||||
|
||||
@@ -19,6 +19,17 @@ import { reportReasonLabel, reportStatusLabel } from '../../utils/report';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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() {
|
||||
const nav = useNavigate();
|
||||
@@ -29,7 +40,7 @@ export default function AdminReportsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 [rejectReason, setRejectReason] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -53,7 +64,7 @@ export default function AdminReportsPage() {
|
||||
load(1, status);
|
||||
}, [status, load]);
|
||||
|
||||
const openHandle = (rep: PostReport, act: 'dismiss' | 'resolve' | 'reject_post') => {
|
||||
const openHandle = (rep: PostReport, act: HandleAction) => {
|
||||
setActive(rep);
|
||||
setAction(act);
|
||||
setNote('');
|
||||
@@ -62,7 +73,7 @@ export default function AdminReportsPage() {
|
||||
|
||||
const submitHandle = async () => {
|
||||
if (!active || !action) return;
|
||||
if (action === 'reject_post' && !rejectReason.trim()) {
|
||||
if ((action === 'reject_post' || action === 'reject_comment') && !rejectReason.trim()) {
|
||||
notify.warning('请填写拒绝原因(将私信通知作者)');
|
||||
return;
|
||||
}
|
||||
@@ -71,7 +82,9 @@ export default function AdminReportsPage() {
|
||||
const r = await api.adminHandleReport(active.id, {
|
||||
action,
|
||||
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);
|
||||
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 }[] = [
|
||||
{ key: 'pending', label: `待处理${pendingCount ? ` (${pendingCount})` : ''}` },
|
||||
{ key: 'resolved', label: '已处理' },
|
||||
@@ -94,7 +113,7 @@ export default function AdminReportsPage() {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<h1 className="admin-page-title">举报管理</h1>
|
||||
<p className="admin-page-desc">处理用户举报;拒绝帖子时将通过站内私信通知作者。</p>
|
||||
<p className="admin-page-desc">处理用户对帖子与评论的举报;拒绝时将通过站内私信通知作者。</p>
|
||||
|
||||
<div className="admin-tabs">
|
||||
{tabs.map((t) => (
|
||||
@@ -117,7 +136,7 @@ export default function AdminReportsPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>帖子</th>
|
||||
<th>目标</th>
|
||||
<th>原因</th>
|
||||
<th>举报人</th>
|
||||
<th>状态</th>
|
||||
@@ -126,44 +145,65 @@ export default function AdminReportsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.id}</td>
|
||||
<td className="max-w-[220px]">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-text-link truncate block max-w-full text-left"
|
||||
onClick={() => nav(`/post/${r.post_id}`)}
|
||||
>
|
||||
{r.post?.title || `帖子 #${r.post_id}`}
|
||||
</button>
|
||||
{r.detail && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{r.detail}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{reportReasonLabel(r.reason)}</td>
|
||||
<td>{r.reporter?.nickname || `#${r.reporter_id}`}</td>
|
||||
<td>
|
||||
<Badge variant={r.status === 'pending' ? 'orange' : r.status === 'resolved' ? 'green' : 'secondary'}>
|
||||
{reportStatusLabel(r.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-sm whitespace-nowrap">{formatTime(r.created_at)}</td>
|
||||
<td>
|
||||
{r.status === 'pending' ? (
|
||||
<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, 'resolve')}>标记已处理</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => openHandle(r, 'reject_post')}>拒绝并通知</Button>
|
||||
{list.map((r) => {
|
||||
const commentRep = isCommentReport(r);
|
||||
const excerpt = commentRep ? commentExcerpt(r) : '';
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<td>{r.id}</td>
|
||||
<td className="max-w-[260px]">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<Badge variant={commentRep ? 'secondary' : 'outline'}>
|
||||
{commentRep ? '评论' : '帖子'}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{r.handle_note || '—'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="admin-text-link truncate block max-w-full text-left"
|
||||
onClick={() => openTarget(r)}
|
||||
>
|
||||
{r.post?.title || `帖子 #${r.post_id}`}
|
||||
{commentRep && r.comment?.floor ? ` · #${r.comment.floor}` : ''}
|
||||
</button>
|
||||
{excerpt && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{excerpt}</div>
|
||||
)}
|
||||
{r.detail && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{r.detail}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{reportReasonLabel(r.reason)}</td>
|
||||
<td>{r.reporter?.nickname || `#${r.reporter_id}`}</td>
|
||||
<td>
|
||||
<Badge variant={r.status === 'pending' ? 'orange' : r.status === 'resolved' ? 'green' : 'secondary'}>
|
||||
{reportStatusLabel(r.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-sm whitespace-nowrap">{formatTime(r.created_at)}</td>
|
||||
<td>
|
||||
{r.status === 'pending' ? (
|
||||
<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, 'resolve')}>标记已处理</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>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{r.handle_note || '—'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.length === 0 && <div className="admin-empty">暂无举报</div>}
|
||||
@@ -184,15 +224,18 @@ export default function AdminReportsPage() {
|
||||
{action === 'dismiss' && '忽略举报'}
|
||||
{action === 'resolve' && '标记已处理'}
|
||||
{action === 'reject_post' && '拒绝帖子并通知作者'}
|
||||
{action === 'reject_comment' && '拒绝评论并通知作者'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{action === 'reject_post'
|
||||
? '帖子将移入回收站,拒绝原因会通过站内私信发给作者;举报人也会收到处理结果通知。'
|
||||
: '举报人将收到处理结果的站内私信通知。'}
|
||||
? '帖子将标记为未通过,拒绝原因会通过站内私信发给作者;举报人也会收到处理结果通知。'
|
||||
: action === 'reject_comment'
|
||||
? '评论将标记为未通过,拒绝原因会通过站内私信发给评论作者;举报人也会收到处理结果通知。'
|
||||
: '举报人将收到处理结果的站内私信通知。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="pm-compose-fields">
|
||||
{action === 'reject_post' && (
|
||||
{(action === 'reject_post' || action === 'reject_comment') && (
|
||||
<label className="pm-field">
|
||||
<span>拒绝原因(发给作者)</span>
|
||||
<textarea
|
||||
@@ -218,7 +261,7 @@ export default function AdminReportsPage() {
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setAction(null); setActive(null); }}>取消</Button>
|
||||
<Button
|
||||
variant={action === 'reject_post' ? 'destructive' : 'default'}
|
||||
variant={action === 'reject_post' || action === 'reject_comment' ? 'destructive' : 'default'}
|
||||
loading={submitting}
|
||||
onClick={submitHandle}
|
||||
>
|
||||
|
||||
@@ -3766,6 +3766,38 @@ a.post-title:visited {
|
||||
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 {
|
||||
font-size: 15.5px;
|
||||
line-height: 1.8;
|
||||
@@ -5328,6 +5360,7 @@ a.post-title:visited {
|
||||
font-weight: 400;
|
||||
color: var(--color-text-1);
|
||||
text-decoration: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
a.waline-comment-author:hover {
|
||||
@@ -5335,12 +5368,39 @@ a.waline-comment-author:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.waline-comment-floor {
|
||||
.waline-comment-like {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
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 {
|
||||
|
||||
@@ -370,6 +370,16 @@ func (h *Handlers) APIToggleLike(c *gin.Context) {
|
||||
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) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
faved, err := h.Post.ToggleFavorite(h.currentUserID(c), uint(id))
|
||||
|
||||
@@ -29,6 +29,25 @@ func (h *Handlers) APICreatePostReport(c *gin.Context) {
|
||||
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 举报列表
|
||||
func (h *Handlers) APIAdminReports(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
|
||||
@@ -35,7 +35,7 @@ func InitDB(dbPath string) error {
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&User{}, &Board{}, &Post{}, &Comment{},
|
||||
&PostLike{}, &PostFavorite{}, &PostRevision{}, &CommentRevision{}, &ForumSetting{},
|
||||
&PostLike{}, &CommentLike{}, &PostFavorite{}, &PostRevision{}, &CommentRevision{}, &ForumSetting{},
|
||||
&OAuthClient{}, &OAuthAuthCode{},
|
||||
&GiteaRepo{},
|
||||
&PrivateMessage{}, &PostReport{},
|
||||
|
||||
@@ -130,6 +130,7 @@ type Comment struct {
|
||||
GuestURL string `gorm:"size:256" json:"guest_url,omitempty"`
|
||||
IsPrivate bool `gorm:"default:false" json:"is_private"`
|
||||
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"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
@@ -140,6 +141,7 @@ type Comment struct {
|
||||
// ThreadParentID 嵌套展示用父评论(父评论不可见时回挂到最近可见祖先)
|
||||
ThreadParentID *uint `gorm:"-" json:"thread_parent_id,omitempty"`
|
||||
ContentHidden bool `gorm:"-" json:"content_hidden"`
|
||||
Liked bool `gorm:"-" json:"liked"`
|
||||
}
|
||||
|
||||
// PostLike 帖子点赞
|
||||
@@ -150,6 +152,14 @@ type PostLike struct {
|
||||
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 帖子收藏
|
||||
type PostFavorite struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
@@ -203,10 +213,11 @@ const (
|
||||
ReportReasonOther = "other"
|
||||
)
|
||||
|
||||
// PostReport 帖子举报
|
||||
// PostReport 帖子/评论举报(CommentID 有值时为评论举报)
|
||||
type PostReport struct {
|
||||
ID uint `gorm:"primaryKey" json:"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"`
|
||||
Reason string `gorm:"size:32;not null" json:"reason"`
|
||||
Detail string `gorm:"size:1000" json:"detail"`
|
||||
@@ -216,9 +227,10 @@ type PostReport struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
HandledAt *time.Time `json:"handled_at,omitempty"`
|
||||
|
||||
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
|
||||
Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"`
|
||||
Handler *User `gorm:"foreignKey:HandlerID" json:"handler,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"`
|
||||
Handler *User `gorm:"foreignKey:HandlerID" json:"handler,omitempty"`
|
||||
}
|
||||
|
||||
// Media 上传媒体索引(真实文件在本地 uploads 或 S3;本表供后台列表与统计)
|
||||
|
||||
@@ -48,7 +48,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
postSvc := service.NewPostService(filter, settingsSvc)
|
||||
commentSvc := service.NewCommentService(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)
|
||||
limiter := service.NewRateLimiter(settingsSvc)
|
||||
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", middleware.RateLimitMiddleware(limiter, "message"), h.APISendMessage)
|
||||
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.PUT("/comments/:id", h.APIUpdateComment)
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ func (s *CommentService) ListByPost(postID, viewerID uint, isAdmin bool, postAut
|
||||
rt.ContentHidden = true
|
||||
}
|
||||
}
|
||||
s.fillLiked(visible, viewerID)
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
@@ -259,6 +260,64 @@ func (s *CommentService) GetByID(id uint) (*model.Comment, error) {
|
||||
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 待审评论数
|
||||
func (s *CommentService) PendingCommentCount() (int64, error) {
|
||||
var n int64
|
||||
|
||||
@@ -12,9 +12,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrReportNotFound = errors.New("举报不存在")
|
||||
ErrReportAlreadyExists = errors.New("你已举报过该帖子,请等待处理")
|
||||
ErrCannotReportOwnPost = errors.New("不能举报自己的帖子")
|
||||
ErrReportNotFound = errors.New("举报不存在")
|
||||
ErrReportAlreadyExists = errors.New("你已举报过该内容,请等待处理")
|
||||
ErrCannotReportOwnPost = errors.New("不能举报自己的帖子")
|
||||
ErrCannotReportOwnComment = errors.New("不能举报自己的评论")
|
||||
)
|
||||
|
||||
type ReportService struct {
|
||||
@@ -22,6 +23,7 @@ type ReportService struct {
|
||||
settings *ForumSettingsService
|
||||
messages *MessageService
|
||||
posts *PostService
|
||||
comments *CommentService
|
||||
}
|
||||
|
||||
func NewReportService(
|
||||
@@ -29,8 +31,9 @@ func NewReportService(
|
||||
settings *ForumSettingsService,
|
||||
messages *MessageService,
|
||||
posts *PostService,
|
||||
comments *CommentService,
|
||||
) *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) {
|
||||
@@ -107,6 +110,52 @@ func (s *ReportService) Create(reporterID, postID uint, reason, detail string) (
|
||||
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 {
|
||||
Status string
|
||||
Page int
|
||||
@@ -133,7 +182,9 @@ func (s *ReportService) ListAdmin(q ReportListQuery) ([]model.PostReport, int64,
|
||||
var list []model.PostReport
|
||||
err := db.Preload("Post", func(tx *gorm.DB) *gorm.DB {
|
||||
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").
|
||||
Offset((q.Page - 1) * q.Size).
|
||||
Limit(q.Size).
|
||||
@@ -151,11 +202,11 @@ func (s *ReportService) PendingCount() (int64, error) {
|
||||
}
|
||||
|
||||
type HandleReportInput struct {
|
||||
ReportID uint
|
||||
HandlerID uint
|
||||
Action string // dismiss | resolve | reject_post
|
||||
HandleNote string
|
||||
RejectReason string // reject_post 时必填,发给作者
|
||||
ReportID uint
|
||||
HandlerID uint
|
||||
Action string // dismiss | resolve | reject_post | reject_comment
|
||||
HandleNote string
|
||||
RejectReason string // reject_post / reject_comment 时发给作者
|
||||
}
|
||||
|
||||
// Handle 处理举报
|
||||
@@ -163,6 +214,8 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
|
||||
var rep model.PostReport
|
||||
if err := model.DB.Preload("Post", func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Unscoped()
|
||||
}).Preload("Comment", func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Unscoped()
|
||||
}).First(&rep, in.ReportID).Error; err != nil {
|
||||
return nil, ErrReportNotFound
|
||||
}
|
||||
@@ -188,6 +241,13 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
|
||||
postTitle = rep.Post.Title
|
||||
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 {
|
||||
case "dismiss":
|
||||
@@ -195,6 +255,9 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
|
||||
case "resolve":
|
||||
rep.Status = model.ReportStatusResolved
|
||||
case "reject_post":
|
||||
if isCommentReport {
|
||||
return nil, errors.New("评论举报请使用「拒绝该评论」")
|
||||
}
|
||||
reason := strings.TrimSpace(in.RejectReason)
|
||||
if reason == "" {
|
||||
return nil, errors.New("请填写拒绝原因(将私信通知作者)")
|
||||
@@ -221,6 +284,37 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
|
||||
&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:
|
||||
return nil, errors.New("无效的处理操作")
|
||||
}
|
||||
@@ -232,16 +326,20 @@ func (s *ReportService) Handle(in HandleReportInput) (*model.PostReport, error)
|
||||
// 通知举报人处理结果
|
||||
resultText := "已忽略"
|
||||
if rep.Status == model.ReportStatusResolved {
|
||||
if in.Action == "reject_post" {
|
||||
switch in.Action {
|
||||
case "reject_post":
|
||||
resultText = "已核实并下架该帖"
|
||||
} else {
|
||||
case "reject_comment":
|
||||
resultText = "已核实并处理该评论"
|
||||
default:
|
||||
resultText = "已处理"
|
||||
}
|
||||
}
|
||||
content := fmt.Sprintf(
|
||||
"你举报的帖子《%s》(#%d)已处理:%s。",
|
||||
postTitle, postID, resultText,
|
||||
)
|
||||
targetDesc := fmt.Sprintf("帖子《%s》(#%d)", postTitle, postID)
|
||||
if isCommentReport {
|
||||
targetDesc = fmt.Sprintf("帖子《%s》下的评论(#%d)", postTitle, commentFloor)
|
||||
}
|
||||
content := fmt.Sprintf("你举报的%s已处理:%s。", targetDesc, resultText)
|
||||
if 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 {
|
||||
return tx.Unscoped()
|
||||
}).Preload("Comment", func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Unscoped()
|
||||
}).Preload("Reporter").Preload("Handler").First(&rep, rep.ID).Error
|
||||
return &rep, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user