feat: 帖子管理收进右上角菜单,手机端评论输入默认折叠

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 22:58:55 +08:00
parent 4dc3bbb13f
commit a83f8bd670
3 changed files with 420 additions and 124 deletions

View File

@@ -0,0 +1,189 @@
import {
Ban,
CircleCheck,
CircleHelp,
EllipsisVertical,
History,
Lock,
LockOpen,
MessageSquareOff,
Pencil,
Pin,
Sparkles,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { PostItem } from '../api/types';
export interface PostManageMenuProps {
post: PostItem;
isAdmin: boolean;
isOwnerOrAdmin: boolean;
canEdit: boolean;
isEdited: boolean;
isMobile?: boolean;
editRemaining?: string;
editBlockReason?: string;
deleting?: boolean;
onEdit: () => void;
onShowRevisions: () => void;
onToggleResolved: () => void;
onApprove: () => void;
onReject: () => void;
onFeature: () => void;
onPin: () => void;
onBoardPin: () => void;
onLock: () => void;
onCommentsLock: () => void;
onDelete: () => void;
}
/** 帖子右上角管理菜单:编辑、审核、置顶、锁定、删除等 */
export default function PostManageMenu({
post,
isAdmin,
isOwnerOrAdmin,
canEdit,
isEdited,
isMobile,
editRemaining,
editBlockReason,
deleting,
onEdit,
onShowRevisions,
onToggleResolved,
onApprove,
onReject,
onFeature,
onPin,
onBoardPin,
onLock,
onCommentsLock,
onDelete,
}: PostManageMenuProps) {
const showContent =
canEdit
|| (isOwnerOrAdmin && isEdited)
|| (isOwnerOrAdmin && post.post_type === 'question');
const hint = editRemaining || (!canEdit && isOwnerOrAdmin ? editBlockReason : '') || '';
if (!showContent && !isAdmin && !hint) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="post-manage-menu-trigger"
aria-label="管理帖子"
>
{!isMobile && <span></span>}
<EllipsisVertical size={16} aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="post-manage-menu">
{hint && (
<>
<DropdownMenuLabel className="post-manage-menu-hint">
{hint}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{showContent && (
<>
<DropdownMenuLabel></DropdownMenuLabel>
{canEdit && (
<DropdownMenuItem onSelect={onEdit}>
<Pencil size={14} aria-hidden />
</DropdownMenuItem>
)}
{isOwnerOrAdmin && isEdited && (
<DropdownMenuItem onSelect={onShowRevisions}>
<History size={14} aria-hidden />
</DropdownMenuItem>
)}
{isOwnerOrAdmin && post.post_type === 'question' && (
<DropdownMenuItem onSelect={onToggleResolved}>
{post.question_resolved
? <CircleHelp size={14} aria-hidden />
: <CircleCheck size={14} aria-hidden />}
{post.question_resolved ? '标为未解决' : '标为已解决'}
</DropdownMenuItem>
)}
{isAdmin && <DropdownMenuSeparator />}
</>
)}
{isAdmin && (
<>
<DropdownMenuLabel></DropdownMenuLabel>
{(post.status === 'pending' || post.status === 'rejected') && (
<DropdownMenuItem onSelect={onApprove}>
<CircleCheck size={14} aria-hidden />
</DropdownMenuItem>
)}
{post.status !== 'rejected' && (
<DropdownMenuItem onSelect={onReject}>
<Ban size={14} aria-hidden />
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuItem onSelect={onFeature}>
<Sparkles size={14} aria-hidden />
{post.featured ? '取消精华' : '设为精华'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onPin}>
<Pin size={14} aria-hidden />
{post.pinned ? '取消全局置顶' : '全局置顶'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onBoardPin}>
<Pin size={14} aria-hidden />
{post.board_pinned ? '取消板块置顶' : '板块置顶'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuItem onSelect={onLock}>
<Lock size={14} aria-hidden />
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCommentsLock}>
{post.comments_locked
? <LockOpen size={14} aria-hidden />
: <MessageSquareOff size={14} aria-hidden />}
{post.comments_locked ? '开放讨论' : '锁定讨论'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={deleting}
onSelect={onDelete}
>
<Trash2 size={14} aria-hidden />
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -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, LockOpen, MessageSquare, MessageSquareOff, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp, MoreHorizontal } from 'lucide-react'; import { ArrowLeft, ThumbsUp, Star, Lock, MessageSquare, MessageSquareOff, Flag, MoreHorizontal } from 'lucide-react';
import FeaturedIcon from '@/components/FeaturedIcon'; import FeaturedIcon from '@/components/FeaturedIcon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -16,7 +16,6 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { import {
DropdownMenu, DropdownMenu,
@@ -44,6 +43,7 @@ import PostBountyBanner from '../components/PostBountyBanner';
import { findCommentFloor } from '../utils/bounty'; import { findCommentFloor } from '../utils/bounty';
import PostLotteryCard from '../components/PostLotteryCard'; import PostLotteryCard from '../components/PostLotteryCard';
import PostRevisionPanel from '../components/PostRevisionPanel'; import PostRevisionPanel from '../components/PostRevisionPanel';
import PostManageMenu from '../components/PostManageMenu';
import ArticleOutline from '../components/ArticleOutline'; import ArticleOutline from '../components/ArticleOutline';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO'; import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
@@ -102,6 +102,8 @@ export default function PostDetailPage() {
const [editWindowHours, setEditWindowHours] = useState(0); const [editWindowHours, setEditWindowHours] = useState(0);
const [showRevisions, setShowRevisions] = useState(false); const [showRevisions, setShowRevisions] = useState(false);
const [deletingPost, setDeletingPost] = useState(false); const [deletingPost, setDeletingPost] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [composerOpen, setComposerOpen] = useState(false);
const [headings, setHeadings] = useState<PostHeading[]>([]); const [headings, setHeadings] = useState<PostHeading[]>([]);
const [reportOpen, setReportOpen] = useState(false); const [reportOpen, setReportOpen] = useState(false);
const [reportReason, setReportReason] = useState<ReportReason>('spam'); const [reportReason, setReportReason] = useState<ReportReason>('spam');
@@ -186,6 +188,7 @@ export default function PostDetailPage() {
} }
setReplyTo(null); setReplyTo(null);
setEditingCommentId(null); setEditingCommentId(null);
setComposerOpen(false);
setHeadings([]); setHeadings([]);
const seq = ++loadSeq.current; const seq = ++loadSeq.current;
setLoading(true); setLoading(true);
@@ -244,15 +247,54 @@ export default function PostDetailPage() {
} }
}, [postId]); }, [postId]);
const scrollToCommentBox = useCallback(() => { const focusCommentEditor = useCallback(() => {
const target = commentBoxRef.current || commentSectionRef.current; const root = commentBoxRef.current;
target?.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (!root) return;
window.setTimeout(() => { const editable = root.querySelector<HTMLElement>('.ProseMirror, [contenteditable="true"]');
const ta = commentBoxRef.current?.querySelector('textarea'); editable?.focus({ preventScroll: true });
ta?.focus({ preventScroll: true });
}, 320);
}, []); }, []);
const scrollToCommentBox = useCallback(() => {
setComposerOpen(true);
// 等折叠条展开后再滚入视口并聚焦 TipTap
window.setTimeout(() => {
const target = commentBoxRef.current || commentSectionRef.current;
target?.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(focusCommentEditor, 80);
}, 50);
}, [focusCommentEditor]);
// 手机端展开输入框后聚焦编辑器
useEffect(() => {
if (!composerOpen || !isMobile) return;
const timer = window.setTimeout(focusCommentEditor, 60);
return () => window.clearTimeout(timer);
}, [composerOpen, isMobile, focusCommentEditor]);
// 手机端:焦点离开评论输入区(含工具栏)后收起为「说点什么…」
useEffect(() => {
if (!isMobile || !composerOpen) return;
const root = commentBoxRef.current;
if (!root) return;
const onFocusOut = (e: FocusEvent) => {
const next = e.relatedTarget as Node | null;
if (next && root.contains(next)) return;
// 等焦点真正落定(工具栏 / Dialog / 系统弹窗)后再判断是否收起
window.setTimeout(() => {
const active = document.activeElement;
if (active && root.contains(active)) return;
// 代码块 Dialog 等浮层打开时不收起
if (document.querySelector('[role="dialog"][data-state="open"]')) return;
setComposerOpen(false);
}, 50);
};
root.addEventListener('focusout', onFocusOut);
return () => root.removeEventListener('focusout', onFocusOut);
}, [isMobile, composerOpen]);
const jumpToFloor = useCallback((floor: number) => { const jumpToFloor = useCallback((floor: number) => {
const el = document.getElementById(`floor-${floor}`); const el = document.getElementById(`floor-${floor}`);
if (!el) return false; if (!el) return false;
@@ -468,6 +510,7 @@ export default function PostDetailPage() {
}); });
setReplyTo(null); setReplyTo(null);
setSubmitCount(c => c + 1); setSubmitCount(c => c + 1);
if (isMobile) setComposerOpen(false);
notify.success(r.message || (r.status === 'pending' ? '评论已提交审核' : '评论成功')); notify.success(r.message || (r.status === 'pending' ? '评论已提交审核' : '评论成功'));
await Promise.all([reloadComments(), reloadPostContent()]); await Promise.all([reloadComments(), reloadPostContent()]);
setTimeout(() => jumpToFloor(r.floor), 100); setTimeout(() => jumpToFloor(r.floor), 100);
@@ -738,12 +781,38 @@ export default function PostDetailPage() {
<article className="page-wrap post-detail-page" ref={pageRef}> <article className="page-wrap post-detail-page" ref={pageRef}>
<div className="post-detail-header"> <div className="post-detail-header">
<div className="post-detail-nav"> <div className="post-detail-nav">
<Button variant="ghost" size="sm" onClick={() => nav(-1)}> <div className="post-detail-nav-left">
<ArrowLeft /> <Button variant="ghost" size="sm" onClick={() => nav(-1)}>
<ArrowLeft />
</Button>
{post.board && ( </Button>
<BoardBadge board={post.board} className="post-detail-board-tag" /> {post.board && (
<BoardBadge board={post.board} className="post-detail-board-tag" />
)}
</div>
{(isOwnerOrAdmin || canEdit || isAdmin) && (
<PostManageMenu
post={post}
isAdmin={isAdmin}
isOwnerOrAdmin={isOwnerOrAdmin}
canEdit={canEdit}
isEdited={isEdited}
isMobile={isMobile}
editRemaining={editRemaining}
editBlockReason={editBlockReason}
deleting={deletingPost}
onEdit={() => nav(`/post/${postId}/edit`)}
onShowRevisions={() => setShowRevisions(true)}
onToggleResolved={handleToggleResolved}
onApprove={handleApprove}
onReject={() => setRejectOpen(true)}
onFeature={handleFeature}
onPin={handlePin}
onBoardPin={handleBoardPin}
onLock={handleLock}
onCommentsLock={handleCommentsLock}
onDelete={() => setDeleteOpen(true)}
/>
)} )}
</div> </div>
@@ -943,101 +1012,32 @@ export default function PostDetailPage() {
</DropdownMenu> </DropdownMenu>
) : null} ) : null}
</div> </div>
{(isOwnerOrAdmin || canEdit || isAdmin) && (
<div className="post-detail-actions-manage">
{isOwnerOrAdmin && post.post_type === 'question' && (
<Button
variant={post.question_resolved ? 'outline' : 'default'}
size="sm"
onClick={handleToggleResolved}
>
{post.question_resolved ? <CircleHelp /> : <CircleCheck />}
{post.question_resolved ? '标为未解决' : '标为已解决'}
</Button>
)}
{canEdit && (
<Button variant="outline" size="sm" onClick={() => nav(`/post/${postId}/edit`)}>
<Pencil />
</Button>
)}
{isOwnerOrAdmin && isEdited && (
<Button variant="outline" size="sm" onClick={() => setShowRevisions(true)}>
<History />
</Button>
)}
{isAdmin && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={deletingPost}>
<Trash2 />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeletePost}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{editRemaining && (
<span className="post-detail-edit-hint">{editRemaining}</span>
)}
{isOwnerOrAdmin && !canEdit && editBlockReason && (
<span className="post-detail-edit-hint" title={editBlockReason}>
{editBlockReason}
</span>
)}
{isAdmin && (
<>
{(post.status === 'pending' || post.status === 'rejected') && (
<Button variant="default" size="sm" onClick={handleApprove}>
</Button>
)}
<Button variant="outline" size="sm" onClick={handleFeature}>
<Sparkles />
{post.featured ? '取消精华' : '设为精华'}
</Button>
<Button variant="outline" size="sm" onClick={handlePin}>
<Pin />
{post.pinned ? '取消全局置顶' : '全局置顶'}
</Button>
<Button variant="outline" size="sm" onClick={handleBoardPin}>
<Pin />
{post.board_pinned ? '取消板块置顶' : '板块置顶'}
</Button>
<Button variant="outline" size="sm" onClick={handleLock}>
<Lock />
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
</Button>
<Button variant="outline" size="sm" onClick={handleCommentsLock}>
{post.comments_locked ? <LockOpen /> : <MessageSquareOff />}
{post.comments_locked ? '开放讨论' : '锁定讨论'}
</Button>
{post.status !== 'rejected' && (
<Button variant="outline" size="sm" onClick={() => setRejectOpen(true)}>
<Ban />
</Button>
)}
</>
)}
</div>
)}
</div> </div>
</div> </div>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletingPost}></AlertDialogCancel>
<AlertDialogAction
disabled={deletingPost}
onClick={(e) => {
e.preventDefault();
void handleDeletePost();
}}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={reportOpen} onOpenChange={setReportOpen}> <Dialog open={reportOpen} onOpenChange={setReportOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -1122,7 +1122,45 @@ export default function PostDetailPage() {
</div> </div>
) : !replyTo && ( ) : !replyTo && (
<div className="comment-box-wrap" ref={commentBoxRef}> <div className="comment-box-wrap" ref={commentBoxRef}>
<CommentBox {...commentBoxProps} /> {isMobile && !composerOpen && (
<button
type="button"
className="comment-composer-collapsed"
onClick={() => {
if (!user) {
requireLogin('评论');
return;
}
setComposerOpen(true);
window.requestAnimationFrame(() => {
window.setTimeout(focusCommentEditor, 50);
});
}}
>
{user ? (
<>
<span className="comment-composer-collapsed-avatar" aria-hidden>
{user.avatar
? <img src={user.avatar} alt="" loading="lazy" decoding="async" />
: (user.nickname?.[0] || '?')}
</span>
<span className="comment-composer-collapsed-placeholder"></span>
</>
) : (
<span className="comment-composer-collapsed-placeholder"></span>
)}
</button>
)}
<div
className={
isMobile && !composerOpen
? 'comment-box-expanded-slot is-collapsed'
: 'comment-box-expanded-slot'
}
aria-hidden={isMobile && !composerOpen}
>
<CommentBox {...commentBoxProps} />
</div>
</div> </div>
)} )}

View File

@@ -4308,12 +4308,40 @@ a.post-title:visited {
.post-detail-nav { .post-detail-nav {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 8px; gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.post-detail-nav-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.post-detail-board-tag { margin: 0 !important; } .post-detail-board-tag { margin: 0 !important; }
.post-manage-menu-trigger {
flex-shrink: 0;
gap: 4px;
color: var(--color-text-2);
}
.post-manage-menu {
max-height: 70vh;
overflow-y: auto;
min-width: 11.5rem;
}
.post-manage-menu-hint {
font-weight: 400 !important;
font-size: 12px !important;
color: var(--color-text-3) !important;
white-space: normal;
line-height: 1.4;
}
.post-detail-head { margin-bottom: 12px; } .post-detail-head { margin-bottom: 12px; }
.post-detail-title { .post-detail-title {
@@ -4379,12 +4407,6 @@ a.post-title:visited {
font-size: 0.875rem; font-size: 0.875rem;
} }
.post-detail-edit-hint {
font-size: 12px;
color: var(--color-text-3);
align-self: center;
}
.post-revision-overlay { .post-revision-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -4960,8 +4982,7 @@ a.post-title:visited {
position: relative; position: relative;
} }
.post-detail-actions-primary, .post-detail-actions-primary {
.post-detail-actions-manage {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
@@ -4969,12 +4990,6 @@ a.post-title:visited {
gap: 8px; gap: 8px;
} }
.post-detail-actions-manage {
width: 100%;
padding-top: 12px;
border-top: 1px dashed var(--j13-border-light);
}
.post-detail-more-btn { .post-detail-more-btn {
min-width: 36px; min-width: 36px;
padding-inline: 10px; padding-inline: 10px;
@@ -6215,6 +6230,61 @@ a.post-title:visited {
flex-shrink: 0; flex-shrink: 0;
} }
.comment-box-expanded-slot.is-collapsed {
display: none;
}
.comment-composer-collapsed {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 44px;
padding: 8px 12px;
border: 1px solid var(--j13-border-light);
border-radius: 999px;
background: var(--j13-bg-workspace, hsl(var(--muted)));
color: var(--color-text-3);
font-size: 14px;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.comment-composer-collapsed:hover {
border-color: color-mix(in srgb, var(--j13-green) 40%, var(--j13-border-light));
background: var(--j13-bg-surface);
}
.comment-composer-collapsed-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
background: color-mix(in srgb, var(--j13-green) 18%, transparent);
color: var(--j13-green);
font-size: 12px;
font-weight: 600;
}
.comment-composer-collapsed-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.comment-composer-collapsed-placeholder {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-box-wrap.inline { .comment-box-wrap.inline {
padding: 12px 0 4px; padding: 12px 0 4px;
border-bottom: none; border-bottom: none;
@@ -7096,8 +7166,7 @@ a.waline-comment-author:hover {
.post-detail-header { padding: 12px 14px 16px; } .post-detail-header { padding: 12px 14px 16px; }
.post-detail-title { font-size: 17px; } .post-detail-title { font-size: 17px; }
.post-detail-actions { margin-top: 20px; padding-top: 16px; gap: 10px; } .post-detail-actions { margin-top: 20px; padding-top: 16px; gap: 10px; }
.post-detail-actions-primary, .post-detail-actions-primary { gap: 8px; }
.post-detail-actions-manage { gap: 8px; }
.comment-section-bar { padding: 12px 14px 10px; } .comment-section-bar { padding: 12px 14px 10px; }
.comment-section-bar::after { left: 14px; width: 32px; } .comment-section-bar::after { left: 14px; width: 32px; }
.comment-section:hover .comment-section-bar::after { width: 46px; } .comment-section:hover .comment-section-bar::after { width: 46px; }