支持评论点赞与举报,并统一帖子/评论的举报入口交互。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -107,7 +107,7 @@ export const api = {
|
||||
}>(`/api/admin/reports${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
adminHandleReport: (id: number, body: {
|
||||
action: 'dismiss' | 'resolve' | 'reject_post';
|
||||
action: 'dismiss' | 'resolve' | 'reject_post' | 'reject_comment';
|
||||
handle_note?: string;
|
||||
reject_reason?: string;
|
||||
}) =>
|
||||
@@ -333,11 +333,16 @@ export const api = {
|
||||
captcha: () => request<{ id: string; image: string }>('/api/captcha'),
|
||||
logout: () => request('/api/logout', { method: 'POST' }),
|
||||
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }),
|
||||
likeComment: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/comments/${id}/like`, { method: 'POST' }),
|
||||
favorite: (id: number) => request<{ favorited: boolean }>(`/api/posts/${id}/favorite`, { method: 'POST' }),
|
||||
reportPost: (id: number, body: { reason: ReportReason; detail?: string }) =>
|
||||
request<{ message: string; report: PostReport }>(`/api/posts/${id}/report`, {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}),
|
||||
reportComment: (id: number, body: { reason: ReportReason; detail?: string }) =>
|
||||
request<{ message: string; report: PostReport }>(`/api/comments/${id}/report`, {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}),
|
||||
messageConversations: (params?: { page?: number; size?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
|
||||
@@ -130,6 +130,8 @@ export interface Comment {
|
||||
is_private?: boolean;
|
||||
status?: 'pending' | 'published' | 'rejected' | string;
|
||||
content_hidden?: boolean;
|
||||
like_count?: number;
|
||||
liked?: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
user?: User;
|
||||
@@ -392,10 +394,11 @@ export interface MessageConversation {
|
||||
export type ReportReason = 'spam' | 'abuse' | 'illegal' | 'irrelevant' | 'other';
|
||||
export type ReportStatus = 'pending' | 'resolved' | 'dismissed';
|
||||
|
||||
/** 帖子举报 */
|
||||
/** 帖子/评论举报(有 comment_id 时为评论举报) */
|
||||
export interface PostReport {
|
||||
id: number;
|
||||
post_id: number;
|
||||
comment_id?: number;
|
||||
reporter_id: number;
|
||||
reason: ReportReason | string;
|
||||
detail: string;
|
||||
@@ -405,6 +408,7 @@ export interface PostReport {
|
||||
created_at: string;
|
||||
handled_at?: string;
|
||||
post?: PostItem;
|
||||
comment?: Comment;
|
||||
reporter?: User;
|
||||
handler?: User;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Check, Clock, History, MessageSquare, X, Pencil, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Check, Clock, History, MessageSquare, X, Pencil, Trash2,
|
||||
ThumbsUp, MoreHorizontal, Flag,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Comment, User } from '../api/types';
|
||||
import type { Comment, ReportReason, User } from '../api/types';
|
||||
import { api } from '../api/client';
|
||||
import CommentContent from './CommentContent';
|
||||
import CommentRevisionDialog from './CommentRevisionDialog';
|
||||
import {
|
||||
@@ -15,6 +19,22 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { notify } from '@/lib/notify';
|
||||
import {
|
||||
commentNick,
|
||||
commentInitial,
|
||||
@@ -24,9 +44,11 @@ import {
|
||||
type CommentNode,
|
||||
} from '../utils/comment';
|
||||
import { isTimeDiffSignificant } from '../utils/content';
|
||||
import { REPORT_REASON_OPTIONS } from '../utils/report';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { Tooltip } from './ui/Tooltip';
|
||||
import UserLink from './UserLink';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function isCommentAuthor(c: Comment, user?: User | null): boolean {
|
||||
return !!user && c.user_id > 0 && c.user_id === user.id;
|
||||
@@ -56,6 +78,8 @@ interface ItemProps {
|
||||
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
|
||||
onDelete: (comment: Comment) => Promise<void>;
|
||||
onApprove?: (comment: Comment) => Promise<void>;
|
||||
onRequireLogin?: (actionLabel: string) => void;
|
||||
onLikeUpdate?: (commentId: number, liked: boolean, likeCount: number) => void;
|
||||
renderReplyBox?: (comment: Comment) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -74,6 +98,8 @@ function CommentItem({
|
||||
onSaveEdit,
|
||||
onDelete,
|
||||
onApprove,
|
||||
onRequireLogin,
|
||||
onLikeUpdate,
|
||||
renderReplyBox,
|
||||
}: ItemProps) {
|
||||
const { limits } = useForumLimits();
|
||||
@@ -105,16 +131,29 @@ function CommentItem({
|
||||
&& (c.status === 'pending' || c.status === 'rejected')
|
||||
&& !!onApprove;
|
||||
const showEdited = !hidden && !!c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at);
|
||||
const canReport = !hidden && !isEditing && !isCommentAuthor(c, currentUser);
|
||||
const [editText, setEditText] = useState(c.content);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [approving, setApproving] = useState(false);
|
||||
const [liking, setLiking] = useState(false);
|
||||
const [liked, setLiked] = useState(!!c.liked);
|
||||
const [likeCount, setLikeCount] = useState(c.like_count ?? 0);
|
||||
const [revOpen, setRevOpen] = useState(false);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [reportReason, setReportReason] = useState<ReportReason>('spam');
|
||||
const [reportDetail, setReportDetail] = useState('');
|
||||
const [reporting, setReporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) setEditText(c.content);
|
||||
}, [isEditing, c.content, c.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setLiked(!!c.liked);
|
||||
setLikeCount(c.like_count ?? 0);
|
||||
}, [c.id, c.liked, c.like_count]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const next = editText.trim();
|
||||
if (!next) return;
|
||||
@@ -126,6 +165,51 @@ function CommentItem({
|
||||
}
|
||||
};
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!currentUser) {
|
||||
onRequireLogin?.('点赞');
|
||||
return;
|
||||
}
|
||||
if (liking) return;
|
||||
setLiking(true);
|
||||
try {
|
||||
const r = await api.likeComment(c.id);
|
||||
setLiked(r.liked);
|
||||
setLikeCount(r.like_count);
|
||||
onLikeUpdate?.(c.id, r.liked, r.like_count);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '点赞失败');
|
||||
} finally {
|
||||
setLiking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openReport = () => {
|
||||
if (!currentUser) {
|
||||
onRequireLogin?.('举报');
|
||||
return;
|
||||
}
|
||||
setReportReason('spam');
|
||||
setReportDetail('');
|
||||
setReportOpen(true);
|
||||
};
|
||||
|
||||
const handleReport = async () => {
|
||||
setReporting(true);
|
||||
try {
|
||||
const r = await api.reportComment(c.id, {
|
||||
reason: reportReason,
|
||||
detail: reportDetail.trim() || undefined,
|
||||
});
|
||||
notify.success(r.message);
|
||||
setReportOpen(false);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '举报失败');
|
||||
} finally {
|
||||
setReporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`floor-${c.floor}`}
|
||||
@@ -168,10 +252,18 @@ function CommentItem({
|
||||
) : (
|
||||
<span className="waline-comment-author">{nick}</span>
|
||||
)}
|
||||
{!c.reply_to && (
|
||||
<span className="waline-comment-floor" aria-label={`第 ${c.floor} 楼`}>
|
||||
#{c.floor}
|
||||
</span>
|
||||
{!hidden && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn('waline-comment-like', liked && 'is-liked')}
|
||||
disabled={liking}
|
||||
aria-label={liked ? '取消点赞' : '点赞'}
|
||||
aria-pressed={liked}
|
||||
onClick={handleLike}
|
||||
>
|
||||
<ThumbsUp size={14} strokeWidth={2} />
|
||||
<span>{likeCount}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -295,12 +387,70 @@ function CommentItem({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
{canReport && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="waline-comment-reply-btn waline-comment-more"
|
||||
aria-label="更多操作"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="report-more-menu">
|
||||
<DropdownMenuItem
|
||||
className="report-more-menu__item"
|
||||
onSelect={openReport}
|
||||
>
|
||||
<Flag size={14} />
|
||||
举报
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<CommentRevisionDialog open={revOpen} onOpenChange={setRevOpen} comment={c} />
|
||||
)}
|
||||
|
||||
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>举报评论</DialogTitle>
|
||||
<DialogDescription>请选择原因,管理员将尽快处理。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="pm-compose-fields">
|
||||
<label className="pm-field">
|
||||
<span>举报原因</span>
|
||||
<select
|
||||
value={reportReason}
|
||||
onChange={(e) => setReportReason(e.target.value as ReportReason)}
|
||||
>
|
||||
{REPORT_REASON_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="pm-field">
|
||||
<span>补充说明(可选)</span>
|
||||
<textarea
|
||||
value={reportDetail}
|
||||
onChange={(e) => setReportDetail(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
placeholder="补充更多细节…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReportOpen(false)}>取消</Button>
|
||||
<Button loading={reporting} onClick={handleReport}>提交举报</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isReplying && renderReplyBox && (
|
||||
<div id={`reply-box-${c.id}`} className="comment-box-wrap inline">
|
||||
{renderReplyBox(c)}
|
||||
@@ -325,6 +475,8 @@ function CommentItem({
|
||||
onSaveEdit={onSaveEdit}
|
||||
onDelete={onDelete}
|
||||
onApprove={onApprove}
|
||||
onRequireLogin={onRequireLogin}
|
||||
onLikeUpdate={onLikeUpdate}
|
||||
renderReplyBox={renderReplyBox}
|
||||
/>
|
||||
))}
|
||||
@@ -348,6 +500,8 @@ interface Props {
|
||||
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
|
||||
onDelete: (comment: Comment) => Promise<void>;
|
||||
onApprove?: (comment: Comment) => Promise<void>;
|
||||
onRequireLogin?: (actionLabel: string) => void;
|
||||
onLikeUpdate?: (commentId: number, liked: boolean, likeCount: number) => void;
|
||||
renderReplyBox?: (comment: Comment) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -365,6 +519,8 @@ export default function CommentThreadList({
|
||||
onSaveEdit,
|
||||
onDelete,
|
||||
onApprove,
|
||||
onRequireLogin,
|
||||
onLikeUpdate,
|
||||
renderReplyBox,
|
||||
}: Props) {
|
||||
const tree = buildCommentTree(comments);
|
||||
@@ -386,6 +542,8 @@ export default function CommentThreadList({
|
||||
onSaveEdit={onSaveEdit}
|
||||
onDelete={onDelete}
|
||||
onApprove={onApprove}
|
||||
onRequireLogin={onRequireLogin}
|
||||
onLikeUpdate={onLikeUpdate}
|
||||
renderReplyBox={renderReplyBox}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp } from 'lucide-react';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp, MoreHorizontal } from 'lucide-react';
|
||||
import FeaturedIcon from '@/components/FeaturedIcon';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -19,6 +19,12 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -683,18 +689,39 @@ export default function PostDetailPage() {
|
||||
<Star />
|
||||
{favorited ? '已收藏' : '收藏'}
|
||||
</Button>
|
||||
{user && user.id !== post.user_id && (
|
||||
<Button variant="outline" size="sm" onClick={() => setReportOpen(true)}>
|
||||
<Flag />
|
||||
举报
|
||||
{!user ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="post-detail-more-btn"
|
||||
aria-label="更多操作"
|
||||
onClick={() => requireLogin('举报')}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
)}
|
||||
{!user && (
|
||||
<Button variant="outline" size="sm" onClick={() => requireLogin('举报')}>
|
||||
<Flag />
|
||||
举报
|
||||
</Button>
|
||||
)}
|
||||
) : user.id !== post.user_id ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="post-detail-more-btn"
|
||||
aria-label="更多操作"
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="report-more-menu">
|
||||
<DropdownMenuItem
|
||||
className="report-more-menu__item"
|
||||
onSelect={() => setReportOpen(true)}
|
||||
>
|
||||
<Flag size={14} />
|
||||
举报
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{(isOwnerOrAdmin || canEdit || isAdmin) && (
|
||||
@@ -889,6 +916,12 @@ export default function PostDetailPage() {
|
||||
onSaveEdit={handleSaveComment}
|
||||
onDelete={handleDeleteComment}
|
||||
onApprove={user?.role === 'admin' ? handleApproveComment : undefined}
|
||||
onRequireLogin={requireLogin}
|
||||
onLikeUpdate={(commentId, liked, likeCount) => {
|
||||
setComments(list => list.map(item => (
|
||||
item.id === commentId ? { ...item, liked, like_count: likeCount } : item
|
||||
)));
|
||||
}}
|
||||
renderReplyBox={(c) => (
|
||||
<CommentBox
|
||||
key={c.id}
|
||||
|
||||
@@ -19,6 +19,17 @@ import { reportReasonLabel, reportStatusLabel } from '../../utils/report';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type StatusTab = 'pending' | 'resolved' | 'dismissed' | 'all';
|
||||
type HandleAction = 'dismiss' | 'resolve' | 'reject_post' | 'reject_comment';
|
||||
|
||||
function isCommentReport(r: PostReport) {
|
||||
return !!(r.comment_id && r.comment_id > 0);
|
||||
}
|
||||
|
||||
function commentExcerpt(r: PostReport) {
|
||||
const raw = (r.comment?.content || '').trim();
|
||||
if (!raw) return '';
|
||||
return raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
|
||||
}
|
||||
|
||||
export default function AdminReportsPage() {
|
||||
const nav = useNavigate();
|
||||
@@ -29,7 +40,7 @@ export default function AdminReportsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [active, setActive] = useState<PostReport | null>(null);
|
||||
const [action, setAction] = useState<'dismiss' | 'resolve' | 'reject_post' | null>(null);
|
||||
const [action, setAction] = useState<HandleAction | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -53,7 +64,7 @@ export default function AdminReportsPage() {
|
||||
load(1, status);
|
||||
}, [status, load]);
|
||||
|
||||
const openHandle = (rep: PostReport, act: 'dismiss' | 'resolve' | 'reject_post') => {
|
||||
const openHandle = (rep: PostReport, act: HandleAction) => {
|
||||
setActive(rep);
|
||||
setAction(act);
|
||||
setNote('');
|
||||
@@ -62,7 +73,7 @@ export default function AdminReportsPage() {
|
||||
|
||||
const submitHandle = async () => {
|
||||
if (!active || !action) return;
|
||||
if (action === 'reject_post' && !rejectReason.trim()) {
|
||||
if ((action === 'reject_post' || action === 'reject_comment') && !rejectReason.trim()) {
|
||||
notify.warning('请填写拒绝原因(将私信通知作者)');
|
||||
return;
|
||||
}
|
||||
@@ -71,7 +82,9 @@ export default function AdminReportsPage() {
|
||||
const r = await api.adminHandleReport(active.id, {
|
||||
action,
|
||||
handle_note: note.trim() || undefined,
|
||||
reject_reason: action === 'reject_post' ? rejectReason.trim() : undefined,
|
||||
reject_reason: (action === 'reject_post' || action === 'reject_comment')
|
||||
? rejectReason.trim()
|
||||
: undefined,
|
||||
});
|
||||
notify.success(r.message);
|
||||
setActive(null);
|
||||
@@ -84,6 +97,12 @@ export default function AdminReportsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openTarget = (r: PostReport) => {
|
||||
const floor = r.comment?.floor;
|
||||
const hash = floor && floor > 0 ? `#floor-${floor}` : '';
|
||||
nav(`/post/${r.post_id}${hash}`);
|
||||
};
|
||||
|
||||
const tabs: { key: StatusTab; label: string }[] = [
|
||||
{ key: 'pending', label: `待处理${pendingCount ? ` (${pendingCount})` : ''}` },
|
||||
{ key: 'resolved', label: '已处理' },
|
||||
@@ -94,7 +113,7 @@ export default function AdminReportsPage() {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<h1 className="admin-page-title">举报管理</h1>
|
||||
<p className="admin-page-desc">处理用户举报;拒绝帖子时将通过站内私信通知作者。</p>
|
||||
<p className="admin-page-desc">处理用户对帖子与评论的举报;拒绝时将通过站内私信通知作者。</p>
|
||||
|
||||
<div className="admin-tabs">
|
||||
{tabs.map((t) => (
|
||||
@@ -117,7 +136,7 @@ export default function AdminReportsPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>帖子</th>
|
||||
<th>目标</th>
|
||||
<th>原因</th>
|
||||
<th>举报人</th>
|
||||
<th>状态</th>
|
||||
@@ -126,44 +145,65 @@ export default function AdminReportsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.id}</td>
|
||||
<td className="max-w-[220px]">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-text-link truncate block max-w-full text-left"
|
||||
onClick={() => nav(`/post/${r.post_id}`)}
|
||||
>
|
||||
{r.post?.title || `帖子 #${r.post_id}`}
|
||||
</button>
|
||||
{r.detail && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{r.detail}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{reportReasonLabel(r.reason)}</td>
|
||||
<td>{r.reporter?.nickname || `#${r.reporter_id}`}</td>
|
||||
<td>
|
||||
<Badge variant={r.status === 'pending' ? 'orange' : r.status === 'resolved' ? 'green' : 'secondary'}>
|
||||
{reportStatusLabel(r.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-sm whitespace-nowrap">{formatTime(r.created_at)}</td>
|
||||
<td>
|
||||
{r.status === 'pending' ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={() => openHandle(r, 'dismiss')}>忽略</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openHandle(r, 'resolve')}>标记已处理</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => openHandle(r, 'reject_post')}>拒绝并通知</Button>
|
||||
{list.map((r) => {
|
||||
const commentRep = isCommentReport(r);
|
||||
const excerpt = commentRep ? commentExcerpt(r) : '';
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<td>{r.id}</td>
|
||||
<td className="max-w-[260px]">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<Badge variant={commentRep ? 'secondary' : 'outline'}>
|
||||
{commentRep ? '评论' : '帖子'}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{r.handle_note || '—'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="admin-text-link truncate block max-w-full text-left"
|
||||
onClick={() => openTarget(r)}
|
||||
>
|
||||
{r.post?.title || `帖子 #${r.post_id}`}
|
||||
{commentRep && r.comment?.floor ? ` · #${r.comment.floor}` : ''}
|
||||
</button>
|
||||
{excerpt && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{excerpt}</div>
|
||||
)}
|
||||
{r.detail && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{r.detail}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{reportReasonLabel(r.reason)}</td>
|
||||
<td>{r.reporter?.nickname || `#${r.reporter_id}`}</td>
|
||||
<td>
|
||||
<Badge variant={r.status === 'pending' ? 'orange' : r.status === 'resolved' ? 'green' : 'secondary'}>
|
||||
{reportStatusLabel(r.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-sm whitespace-nowrap">{formatTime(r.created_at)}</td>
|
||||
<td>
|
||||
{r.status === 'pending' ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={() => openHandle(r, 'dismiss')}>忽略</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openHandle(r, 'resolve')}>标记已处理</Button>
|
||||
{commentRep ? (
|
||||
<Button size="sm" variant="destructive" onClick={() => openHandle(r, 'reject_comment')}>
|
||||
拒绝该评论
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="destructive" onClick={() => openHandle(r, 'reject_post')}>
|
||||
拒绝并通知
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{r.handle_note || '—'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.length === 0 && <div className="admin-empty">暂无举报</div>}
|
||||
@@ -184,15 +224,18 @@ export default function AdminReportsPage() {
|
||||
{action === 'dismiss' && '忽略举报'}
|
||||
{action === 'resolve' && '标记已处理'}
|
||||
{action === 'reject_post' && '拒绝帖子并通知作者'}
|
||||
{action === 'reject_comment' && '拒绝评论并通知作者'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{action === 'reject_post'
|
||||
? '帖子将移入回收站,拒绝原因会通过站内私信发给作者;举报人也会收到处理结果通知。'
|
||||
: '举报人将收到处理结果的站内私信通知。'}
|
||||
? '帖子将标记为未通过,拒绝原因会通过站内私信发给作者;举报人也会收到处理结果通知。'
|
||||
: action === 'reject_comment'
|
||||
? '评论将标记为未通过,拒绝原因会通过站内私信发给评论作者;举报人也会收到处理结果通知。'
|
||||
: '举报人将收到处理结果的站内私信通知。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="pm-compose-fields">
|
||||
{action === 'reject_post' && (
|
||||
{(action === 'reject_post' || action === 'reject_comment') && (
|
||||
<label className="pm-field">
|
||||
<span>拒绝原因(发给作者)</span>
|
||||
<textarea
|
||||
@@ -218,7 +261,7 @@ export default function AdminReportsPage() {
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setAction(null); setActive(null); }}>取消</Button>
|
||||
<Button
|
||||
variant={action === 'reject_post' ? 'destructive' : 'default'}
|
||||
variant={action === 'reject_post' || action === 'reject_comment' ? 'destructive' : 'default'}
|
||||
loading={submitting}
|
||||
onClick={submitHandle}
|
||||
>
|
||||
|
||||
@@ -3766,6 +3766,38 @@ a.post-title:visited {
|
||||
border-top: 1px dashed var(--j13-border-light);
|
||||
}
|
||||
|
||||
.post-detail-more-btn {
|
||||
min-width: 36px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
/* 举报「更多」菜单:紧凑居中 */
|
||||
.report-more-menu {
|
||||
min-width: 0 !important;
|
||||
width: max-content;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.report-more-menu__item {
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
color: hsl(var(--destructive));
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.report-more-menu__item:focus,
|
||||
.report-more-menu__item:hover {
|
||||
color: hsl(var(--destructive));
|
||||
background: color-mix(in srgb, hsl(var(--destructive)) 10%, transparent);
|
||||
}
|
||||
|
||||
.report-more-menu__item svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.post-detail-content {
|
||||
font-size: 15.5px;
|
||||
line-height: 1.8;
|
||||
@@ -5328,6 +5360,7 @@ a.post-title:visited {
|
||||
font-weight: 400;
|
||||
color: var(--color-text-1);
|
||||
text-decoration: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
a.waline-comment-author:hover {
|
||||
@@ -5335,12 +5368,39 @@ a.waline-comment-author:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.waline-comment-floor {
|
||||
.waline-comment-like {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.waline-comment-like:hover {
|
||||
color: var(--j13-green);
|
||||
background: var(--j13-green-bg);
|
||||
}
|
||||
|
||||
.waline-comment-like.is-liked {
|
||||
color: var(--j13-green);
|
||||
}
|
||||
|
||||
.waline-comment-like:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.waline-comment-floor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.waline-comment-bubble {
|
||||
|
||||
Reference in New Issue
Block a user