feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 06:23:54 +08:00
parent df19752a1e
commit 2208af7070
80 changed files with 9620 additions and 641 deletions

View File

@@ -34,11 +34,15 @@ import {
} from '@/components/ui/dialog';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem, Comment, ReportReason } from '../api/types';
import type { PostItem, Comment, ReportReason, PollView, PostLotteryView } from '../api/types';
import { REPORT_REASON_OPTIONS } from '../utils/report';
import CommentThreadList from '../components/CommentThreadList';
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
import PostContent from '../components/PostContent';
import PostPollCard from '../components/PostPollCard';
import PostBountyBanner from '../components/PostBountyBanner';
import { findCommentFloor } from '../utils/bounty';
import PostLotteryCard from '../components/PostLotteryCard';
import PostRevisionPanel from '../components/PostRevisionPanel';
import ArticleOutline from '../components/ArticleOutline';
import { useAuth } from '../hooks/useAuth';
@@ -81,6 +85,8 @@ export default function PostDetailPage() {
const { setPostOutline, isMobile } = useOutletContext<LayoutCtx>();
const [post, setPost] = useState<PostItem | null>(null);
const [poll, setPoll] = useState<PollView | null>(null);
const [lottery, setLottery] = useState<PostLotteryView | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [liked, setLiked] = useState(false);
const [favorited, setFavorited] = useState(false);
@@ -104,6 +110,11 @@ export default function PostDetailPage() {
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [rejecting, setRejecting] = useState(false);
const [bountyAwardTarget, setBountyAwardTarget] = useState<number | null>(null);
const [bountyAwarding, setBountyAwarding] = useState(false);
const [bountyCanRefund, setBountyCanRefund] = useState(true);
const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState('');
const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(0);
const pageRef = useRef<HTMLDivElement>(null);
const commentSectionRef = useRef<HTMLDivElement>(null);
@@ -189,12 +200,17 @@ export default function PostDetailPage() {
]);
if (seq !== loadSeq.current) return;
setPost(detail.post);
setPoll(detail.poll ?? null);
setLottery(detail.lottery ?? null);
setLiked(detail.liked);
setFavorited(detail.favorited);
setCanEdit(detail.can_edit ?? false);
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
setEditBlockReason(detail.edit_block_reason ?? '');
setEditWindowHours(detail.post_edit_window_hours ?? 0);
setBountyCanRefund(detail.bounty_can_refund ?? true);
setBountyRefundBlockReason(detail.bounty_refund_block_reason ?? '');
setBountyEligibleReplyCount(detail.bounty_eligible_reply_count ?? 0);
setComments(Array.isArray(comm.comments) ? comm.comments : []);
void refresh();
} catch {
@@ -218,6 +234,11 @@ export default function PostDetailPage() {
try {
const detail = await api.post(postId, { skipView: true });
setPost(detail.post);
setPoll(detail.poll ?? null);
setLottery(detail.lottery ?? null);
setBountyCanRefund(detail.bounty_can_refund ?? true);
setBountyRefundBlockReason(detail.bounty_refund_block_reason ?? '');
setBountyEligibleReplyCount(detail.bounty_eligible_reply_count ?? 0);
} catch {
// 评论已成功,正文刷新失败不阻断
}
@@ -547,6 +568,32 @@ export default function PostDetailPage() {
? formatEditRemaining(post.created_at, editWindowHours)
: '';
const handleBountyAward = (commentId: number) => {
setBountyAwardTarget(commentId);
};
const confirmBountyAward = async () => {
if (bountyAwardTarget == null) return;
setBountyAwarding(true);
try {
await api.bountyAward(post.id, bountyAwardTarget);
notify.success('悬赏已发放');
setBountyAwardTarget(null);
await reloadPostContent();
await reloadComments();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
} finally {
setBountyAwarding(false);
}
};
const awardedCommentFloor = findCommentFloor(comments, post.bounty_comment_id);
const canJumpToAwarded = awardedCommentFloor != null;
const handleJumpToAwarded = () => {
if (awardedCommentFloor != null) jumpToFloor(awardedCommentFloor);
};
const handlePin = async () => {
if (!post) return;
try {
@@ -729,6 +776,20 @@ export default function PostDetailPage() {
{post.question_resolved ? '已解决' : '未解决'}
</span>
)}
{post.post_type === 'poll' && (
<span className="post-type-badge post-type-badge--poll"></span>
)}
{post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 && (
<span className="post-bounty-badge post-bounty-badge--open post-bounty-badge--detail"> {post.bounty_points}</span>
)}
{post.post_type === 'bounty' && post.bounty_status === 'awarded' && (
<span className="post-bounty-badge post-bounty-badge--awarded post-bounty-badge--detail"></span>
)}
{post.post_type === 'lottery' && (
<span className="post-type-badge post-type-badge--lottery">
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
</span>
)}
{post.title}
</h1>
<div className="post-detail-author-row">
@@ -791,6 +852,34 @@ export default function PostDetailPage() {
</details>
)}
{poll && (
<PostPollCard
postId={post.id}
poll={poll}
isOwnerOrAdmin={isOwnerOrAdmin}
onUpdate={setPoll}
/>
)}
<PostBountyBanner
post={post}
isOwnerOrAdmin={isOwnerOrAdmin}
isAdmin={isAdmin}
canRefund={bountyCanRefund}
refundBlockReason={bountyRefundBlockReason}
eligibleReplyCount={bountyEligibleReplyCount}
onUpdate={reloadPostContent}
onJumpToAwarded={handleJumpToAwarded}
canJumpToAwarded={canJumpToAwarded}
/>
{lottery && (
<PostLotteryCard
postId={post.id}
lottery={lottery}
isOwnerOrAdmin={isOwnerOrAdmin}
onUpdate={v => { setLottery(v); void reloadPostContent(); }}
/>
)}
<PostContent
html={post.content || ''}
isLoggedIn={!!user}
@@ -1078,10 +1167,42 @@ export default function PostDetailPage() {
inline
/>
)}
bountyAward={post.post_type === 'bounty' ? {
open: post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0,
awardedCommentId: post.bounty_comment_id,
postAuthorId: post.user_id,
canAward: isOwnerOrAdmin,
onAward: handleBountyAward,
} : undefined}
/>
)}
</div>
</div>
<AlertDialog
open={bountyAwardTarget != null}
onOpenChange={(open) => { if (!open && !bountyAwarding) setBountyAwardTarget(null); }}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{post.bounty_points ?? 0}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bountyAwarding}></AlertDialogCancel>
<AlertDialogAction
disabled={bountyAwarding}
onClick={(e) => {
e.preventDefault();
void confirmBountyAward();
}}
>
{bountyAwarding ? '发放中…' : '确认采纳'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<InFlowSiteFooter />
</article>
);