支持评论点赞与举报,并统一帖子/评论的举报入口交互。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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"
|
||||||
</Button>
|
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>
|
</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}
|
||||||
|
|||||||
@@ -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,44 +145,65 @@ export default function AdminReportsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{list.map((r) => (
|
{list.map((r) => {
|
||||||
<tr key={r.id}>
|
const commentRep = isCommentReport(r);
|
||||||
<td>{r.id}</td>
|
const excerpt = commentRep ? commentExcerpt(r) : '';
|
||||||
<td className="max-w-[220px]">
|
return (
|
||||||
<button
|
<tr key={r.id}>
|
||||||
type="button"
|
<td>{r.id}</td>
|
||||||
className="admin-text-link truncate block max-w-full text-left"
|
<td className="max-w-[260px]">
|
||||||
onClick={() => nav(`/post/${r.post_id}`)}
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
>
|
<Badge variant={commentRep ? 'secondary' : 'outline'}>
|
||||||
{r.post?.title || `帖子 #${r.post_id}`}
|
{commentRep ? '评论' : '帖子'}
|
||||||
</button>
|
</Badge>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<button
|
||||||
<span className="text-muted-foreground text-sm">
|
type="button"
|
||||||
{r.handle_note || '—'}
|
className="admin-text-link truncate block max-w-full text-left"
|
||||||
</span>
|
onClick={() => openTarget(r)}
|
||||||
)}
|
>
|
||||||
</td>
|
{r.post?.title || `帖子 #${r.post_id}`}
|
||||||
</tr>
|
{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>
|
</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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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{},
|
||||||
|
|||||||
@@ -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"`
|
||||||
@@ -216,9 +227,10 @@ type PostReport struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
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"`
|
||||||
Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"`
|
Comment *Comment `gorm:"foreignKey:CommentID" json:"comment,omitempty"`
|
||||||
Handler *User `gorm:"foreignKey:HandlerID" json:"handler,omitempty"`
|
Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"`
|
||||||
|
Handler *User `gorm:"foreignKey:HandlerID" json:"handler,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Media 上传媒体索引(真实文件在本地 uploads 或 S3;本表供后台列表与统计)
|
// Media 上传媒体索引(真实文件在本地 uploads 或 S3;本表供后台列表与统计)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ 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).
|
||||||
@@ -151,11 +202,11 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user