优化帖子详情与列表展示,并新增问答帖类型与已解决状态。

统一标题锚点定位、编辑底栏固定与操作栏分层,列表徽章前置并区分置顶/问答配色。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-04 00:19:32 +08:00
parent 3aca5c42c5
commit b24f6c23ea
16 changed files with 696 additions and 239 deletions

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 } from 'lucide-react';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp } from 'lucide-react';
import FeaturedIcon from '@/components/FeaturedIcon';
import PinnedIcon from '@/components/PinnedIcon';
import { Button } from '@/components/ui/button';
@@ -216,6 +216,26 @@ export default function PostDetailPage() {
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
}, []);
/** 正文标题锚点:滚动容器是 pageRef原生 hash 定位无效,需手动滚 */
const jumpToHeadingHash = useCallback((hash: string, smooth = false) => {
const id = decodeURIComponent((hash || '').replace(/^#/, '')).trim();
if (!id || /^floor-\d+$/.test(id)) return false;
const el = document.getElementById(id);
if (!el) return false;
const root = pageRef.current;
const behavior: ScrollBehavior = smooth ? 'smooth' : 'auto';
if (root) {
const rootRect = root.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
root.scrollTo({ top: Math.max(0, top), behavior });
} else {
el.scrollIntoView({ behavior, block: 'start' });
}
return true;
}, []);
// 从 #floor-N 定位到对应评论(右栏最新评论等入口)
useEffect(() => {
if (loading || !post) return;
@@ -227,6 +247,32 @@ export default function PostDetailPage() {
return () => clearTimeout(t);
}, [loading, post, comments, location.hash, jumpToFloor]);
// 从 #heading-N或任意标题 id定位等正文进 DOM 后重试,避免首屏 hash 失效
useEffect(() => {
if (loading || !post) return;
const hash = location.hash;
if (!hash || /^#floor-\d+$/.test(hash)) return;
let cancelled = false;
let attempts = 0;
let timer = 0;
const tryJump = () => {
if (cancelled) return;
if (jumpToHeadingHash(hash, false)) return;
attempts += 1;
if (attempts < 30) {
timer = window.setTimeout(tryJump, 50);
}
};
timer = window.setTimeout(tryJump, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [loading, post, location.hash, jumpToHeadingHash, headings]);
const requireLogin = (actionLabel: string) => {
notify.warning(`登录后即可${actionLabel}`);
nav(loginPath(detailPath));
@@ -416,6 +462,20 @@ export default function PostDetailPage() {
}
};
const handleToggleResolved = async () => {
if (!post || post.post_type !== 'question') return;
const next = !post.question_resolved;
try {
const r = await api.setQuestionResolved(postId, next);
setPost(p => p ? { ...p, question_resolved: r.question_resolved } : p);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const handleApprove = async () => {
if (!post) return;
try {
@@ -488,10 +548,6 @@ export default function PostDetailPage() {
}
};
const jumpToComments = () => {
commentSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<article className="page-wrap post-detail-page" ref={pageRef}>
<div className="post-detail-header">
@@ -518,10 +574,21 @@ export default function PostDetailPage() {
<div className="post-detail-head">
<h1 className="post-detail-title">
{post.pinned && (
<span className="post-pin-badge post-pin-badge--icon post-pin-badge--detail" title="置顶">
<PinnedIcon size={16} />
</span>
)}
{post.featured && <FeaturedIcon className="mr-2" size={18} />}
{post.status === 'pending' && <Badge variant="orange" className="mr-2 align-middle"></Badge>}
{post.status === 'rejected' && <Badge variant="destructive" className="mr-2 align-middle"></Badge>}
{post.featured && <FeaturedIcon className="mr-2" size={18} />}
{post.pinned && <PinnedIcon className="mr-2" size={18} />}
{post.post_type === 'question' && (
<span
className={`post-qa-badge post-qa-badge--detail${post.question_resolved ? ' post-qa-badge--resolved' : ' post-qa-badge--open'}`}
>
{post.question_resolved ? '已解决' : '未解决'}
</span>
)}
{post.title}
</h1>
<div className="post-detail-author-row">
@@ -577,110 +644,121 @@ export default function PostDetailPage() {
/>
<div className="post-detail-actions">
<Button
variant={liked ? 'default' : 'outline'}
size="sm"
onClick={handleLike}
title={!user ? '登录后即可点赞' : undefined}
className={!user ? 'post-action-guest' : undefined}
>
<ThumbsUp />
{!user ? '登录后点赞' : `点赞 ${post.like_count}`}
</Button>
<Button
variant={favorited ? 'default' : 'outline'}
size="sm"
onClick={handleFavorite}
title={!user ? '登录后即可收藏' : undefined}
className={!user ? 'post-action-guest' : undefined}
>
<Star />
{!user ? '登录后收藏' : (favorited ? '已收藏' : '收藏')}
</Button>
<Button variant="outline" size="sm" onClick={jumpToComments}>
<MessageSquare />
{comments.length}
</Button>
{user && user.id !== post.user_id && (
<Button variant="outline" size="sm" onClick={() => setReportOpen(true)}>
<Flag />
<div className="post-detail-actions-primary">
<Button
variant={liked ? 'default' : 'outline'}
size="sm"
onClick={handleLike}
title={!user ? '登录后即可点赞' : undefined}
>
<ThumbsUp />
{post.like_count}
</Button>
)}
{!user && (
<Button variant="outline" size="sm" onClick={() => requireLogin('举报')}>
<Flag />
<Button
variant={favorited ? 'default' : 'outline'}
size="sm"
onClick={handleFavorite}
title={!user ? '登录后即可收藏' : undefined}
>
<Star />
{favorited ? '已收藏' : '收藏'}
</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}>
{user && user.id !== post.user_id && (
<Button variant="outline" size="sm" onClick={() => setReportOpen(true)}>
<Flag />
</Button>
)}
{!user && (
<Button variant="outline" size="sm" onClick={() => requireLogin('举报')}>
<Flag />
</Button>
)}
</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>
)}
<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={handleLock}>
<Lock />
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
</Button>
{post.status !== 'rejected' && (
<Button variant="outline" size="sm" onClick={() => setRejectOpen(true)}>
<Ban />
{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={handleLock}>
<Lock />
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
</Button>
{post.status !== 'rejected' && (
<Button variant="outline" size="sm" onClick={() => setRejectOpen(true)}>
<Ban />
</Button>
)}
</>
)}
</div>
)}
</div>
</div>