支持评论点赞与举报,并统一帖子/评论的举报入口交互。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user