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

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

View File

@@ -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}

View File

@@ -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}
>