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

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

View File

@@ -107,7 +107,7 @@ export const api = {
}>(`/api/admin/reports${qs ? `?${qs}` : ''}`);
},
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));

View File

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

View File

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

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

View File

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