feat: 帖子管理收进右上角菜单,手机端评论输入默认折叠
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
189
frontend/src/components/PostManageMenu.tsx
Normal file
189
frontend/src/components/PostManageMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user