diff --git a/frontend/src/components/PostManageMenu.tsx b/frontend/src/components/PostManageMenu.tsx new file mode 100644 index 0000000..46e2e16 --- /dev/null +++ b/frontend/src/components/PostManageMenu.tsx @@ -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 ( + + + + + + {hint && ( + <> + + {hint} + + + + )} + + {showContent && ( + <> + 内容 + {canEdit && ( + + + 编辑 + + )} + {isOwnerOrAdmin && isEdited && ( + + + 编辑历史 + + )} + {isOwnerOrAdmin && post.post_type === 'question' && ( + + {post.question_resolved + ? + : } + {post.question_resolved ? '标为未解决' : '标为已解决'} + + )} + {isAdmin && } + + )} + + {isAdmin && ( + <> + 审核 + {(post.status === 'pending' || post.status === 'rejected') && ( + + + 通过审核 + + )} + {post.status !== 'rejected' && ( + + + 拒绝并通知 + + )} + + + 展示 + + + {post.featured ? '取消精华' : '设为精华'} + + + + {post.pinned ? '取消全局置顶' : '全局置顶'} + + + + {post.board_pinned ? '取消板块置顶' : '板块置顶'} + + + + 讨论 + + + {post.edit_locked ? '解锁编辑' : '锁定编辑'} + + + {post.comments_locked + ? + : } + {post.comments_locked ? '开放讨论' : '锁定讨论'} + + + + 危险 + + + 删除 + + + )} + + + ); +} diff --git a/frontend/src/pages/PostDetailPage.tsx b/frontend/src/pages/PostDetailPage.tsx index 849a096..4e49643 100644 --- a/frontend/src/pages/PostDetailPage.tsx +++ b/frontend/src/pages/PostDetailPage.tsx @@ -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, 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 { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -16,7 +16,6 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger, } from '@/components/ui/alert-dialog'; import { DropdownMenu, @@ -44,6 +43,7 @@ import PostBountyBanner from '../components/PostBountyBanner'; import { findCommentFloor } from '../utils/bounty'; import PostLotteryCard from '../components/PostLotteryCard'; import PostRevisionPanel from '../components/PostRevisionPanel'; +import PostManageMenu from '../components/PostManageMenu'; import ArticleOutline from '../components/ArticleOutline'; import { useAuth } from '../hooks/useAuth'; import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO'; @@ -102,6 +102,8 @@ export default function PostDetailPage() { const [editWindowHours, setEditWindowHours] = useState(0); const [showRevisions, setShowRevisions] = useState(false); const [deletingPost, setDeletingPost] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [composerOpen, setComposerOpen] = useState(false); const [headings, setHeadings] = useState([]); const [reportOpen, setReportOpen] = useState(false); const [reportReason, setReportReason] = useState('spam'); @@ -186,6 +188,7 @@ export default function PostDetailPage() { } setReplyTo(null); setEditingCommentId(null); + setComposerOpen(false); setHeadings([]); const seq = ++loadSeq.current; setLoading(true); @@ -244,15 +247,54 @@ export default function PostDetailPage() { } }, [postId]); - const scrollToCommentBox = useCallback(() => { - const target = commentBoxRef.current || commentSectionRef.current; - target?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - window.setTimeout(() => { - const ta = commentBoxRef.current?.querySelector('textarea'); - ta?.focus({ preventScroll: true }); - }, 320); + const focusCommentEditor = useCallback(() => { + const root = commentBoxRef.current; + if (!root) return; + const editable = root.querySelector('.ProseMirror, [contenteditable="true"]'); + editable?.focus({ preventScroll: true }); }, []); + 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 el = document.getElementById(`floor-${floor}`); if (!el) return false; @@ -468,6 +510,7 @@ export default function PostDetailPage() { }); setReplyTo(null); setSubmitCount(c => c + 1); + if (isMobile) setComposerOpen(false); notify.success(r.message || (r.status === 'pending' ? '评论已提交审核' : '评论成功')); await Promise.all([reloadComments(), reloadPostContent()]); setTimeout(() => jumpToFloor(r.floor), 100); @@ -738,12 +781,38 @@ export default function PostDetailPage() {
- - {post.board && ( - +
+ + {post.board && ( + + )} +
+ {(isOwnerOrAdmin || canEdit || isAdmin) && ( + 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)} + /> )}
@@ -943,101 +1012,32 @@ export default function PostDetailPage() { ) : null}
- - {(isOwnerOrAdmin || canEdit || isAdmin) && ( -
- {isOwnerOrAdmin && post.post_type === 'question' && ( - - )} - {canEdit && ( - - )} - {isOwnerOrAdmin && isEdited && ( - - )} - {isAdmin && ( - - - - - - - 确定删除该帖子? - - 帖子与评论将移入回收站,可在后台恢复或永久删除。普通用户不可自行删除内容。 - - - - 取消 - 删除 - - - - )} - {editRemaining && ( - {editRemaining} - )} - {isOwnerOrAdmin && !canEdit && editBlockReason && ( - - {editBlockReason} - - )} - {isAdmin && ( - <> - {(post.status === 'pending' || post.status === 'rejected') && ( - - )} - - - - - - {post.status !== 'rejected' && ( - - )} - - )} -
- )} + + + + 确定删除该帖子? + + 帖子与评论将移入回收站,可在后台恢复或永久删除。普通用户不可自行删除内容。 + + + + 取消 + { + e.preventDefault(); + void handleDeletePost(); + }} + > + 删除 + + + + + @@ -1122,7 +1122,45 @@ export default function PostDetailPage() { ) : !replyTo && (
- + {isMobile && !composerOpen && ( + + )} +
+ +
)} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 82e0b87..68bdc11 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -4308,12 +4308,40 @@ a.post-title:visited { .post-detail-nav { display: flex; align-items: center; + justify-content: space-between; gap: 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-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-title { @@ -4379,12 +4407,6 @@ a.post-title:visited { font-size: 0.875rem; } -.post-detail-edit-hint { - font-size: 12px; - color: var(--color-text-3); - align-self: center; -} - .post-revision-overlay { position: fixed; inset: 0; @@ -4960,8 +4982,7 @@ a.post-title:visited { position: relative; } -.post-detail-actions-primary, -.post-detail-actions-manage { +.post-detail-actions-primary { display: flex; flex-wrap: wrap; justify-content: center; @@ -4969,12 +4990,6 @@ a.post-title:visited { gap: 8px; } -.post-detail-actions-manage { - width: 100%; - padding-top: 12px; - border-top: 1px dashed var(--j13-border-light); -} - .post-detail-more-btn { min-width: 36px; padding-inline: 10px; @@ -6215,6 +6230,61 @@ a.post-title:visited { 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 { padding: 12px 0 4px; border-bottom: none; @@ -7096,8 +7166,7 @@ a.waline-comment-author:hover { .post-detail-header { padding: 12px 14px 16px; } .post-detail-title { font-size: 17px; } .post-detail-actions { margin-top: 20px; padding-top: 16px; gap: 10px; } - .post-detail-actions-primary, - .post-detail-actions-manage { gap: 8px; } + .post-detail-actions-primary { gap: 8px; } .comment-section-bar { padding: 12px 14px 10px; } .comment-section-bar::after { left: 14px; width: 32px; } .comment-section:hover .comment-section-bar::after { width: 46px; }