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

@@ -0,0 +1,182 @@
import { useState } from 'react';
import { CheckCircle2, Coins } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem } from '../api/types';
interface Props {
post: PostItem;
isOwnerOrAdmin: boolean;
isAdmin?: boolean;
/** 是否可取消悬赏(楼主有他人回复时为 false;管理员为 true) */
canRefund?: boolean;
refundBlockReason?: string;
eligibleReplyCount?: number;
onUpdate?: () => void;
/** 跳转到被采纳的评论楼层 */
onJumpToAwarded?: () => void;
/** 被采纳评论是否仍在当前评论列表中 */
canJumpToAwarded?: boolean;
}
/** 悬赏帖状态卡 */
export default function PostBountyBanner({
post,
isOwnerOrAdmin,
isAdmin = false,
canRefund = true,
refundBlockReason,
eligibleReplyCount = 0,
onUpdate,
onJumpToAwarded,
canJumpToAwarded,
}: Props) {
const [refundOpen, setRefundOpen] = useState(false);
const [refunding, setRefunding] = useState(false);
if (post.post_type !== 'bounty') return null;
const points = post.bounty_points ?? 0;
const open = post.bounty_status === 'open' && points > 0;
const awarded = post.bounty_status === 'awarded';
const refunded = post.bounty_status === 'refunded';
if (refunded && !isOwnerOrAdmin) return null;
const showRefundButton = isOwnerOrAdmin && canRefund;
const ownerRefundBlocked = isOwnerOrAdmin && !isAdmin && !canRefund;
const confirmRefund = async () => {
setRefunding(true);
try {
await api.bountyRefund(post.id);
notify.success('悬赏已退回');
setRefundOpen(false);
onUpdate?.();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
} finally {
setRefunding(false);
}
};
const refundDialogDescription = isAdmin && eligibleReplyCount > 0
? `将强制取消悬赏并把 ${points} 积分退回楼主账户。当前已有 ${eligibleReplyCount} 条他人回复,请确认已审慎处理。`
: `确定取消悬赏并将 ${points} 积分退回你的账户?取消后他人将无法再参与本帖悬赏。`;
if (open) {
let ownerHint = '在评论上点击「采纳」发放积分';
if (ownerRefundBlocked) {
ownerHint = refundBlockReason || '已有用户回复,请从评论中采纳发放积分';
}
return (
<>
<section className="post-bounty post-bounty--open" aria-label="悬赏">
<div className="post-bounty__head">
<div className="post-bounty__lead">
<Coins size={18} className="post-bounty__icon" aria-hidden />
<div className="post-bounty__titles">
<strong className="post-bounty__title">悬赏进行中</strong>
<span className="post-bounty__points">{points} 积分</span>
</div>
</div>
{showRefundButton && (
<div className="post-bounty__actions">
<Button type="button" variant="outline" size="sm" onClick={() => setRefundOpen(true)}>
取消悬赏
</Button>
</div>
)}
</div>
<p className="post-bounty__hint">
{isOwnerOrAdmin
? ownerHint
: `回复本帖,优质回答可获得这 ${points} 积分`}
</p>
</section>
{showRefundButton && (
<AlertDialog
open={refundOpen}
onOpenChange={(next) => { if (!next && !refunding) setRefundOpen(false); }}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{isAdmin && eligibleReplyCount > 0 ? '强制取消悬赏?' : '取消悬赏?'}</AlertDialogTitle>
<AlertDialogDescription>
{refundDialogDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={refunding}>返回</AlertDialogCancel>
<AlertDialogAction
disabled={refunding}
onClick={(e) => {
e.preventDefault();
void confirmRefund();
}}
>
{refunding ? '退回中…' : '确认取消'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
);
}
if (awarded) {
return (
<section className="post-bounty post-bounty--awarded" aria-label="悬赏已采纳">
<div className="post-bounty__head">
<div className="post-bounty__lead">
<CheckCircle2 size={18} className="post-bounty__icon" aria-hidden />
<div className="post-bounty__titles">
<strong className="post-bounty__title">已采纳</strong>
<span className="post-bounty__subtitle">
{points} 积分已发放给被采纳的回复
</span>
</div>
</div>
</div>
{canJumpToAwarded && onJumpToAwarded && (
<div className="post-bounty__actions post-bounty__actions--inline">
<button type="button" className="post-bounty__jump" onClick={onJumpToAwarded}>
查看被采纳的回复
</button>
</div>
)}
</section>
);
}
if (refunded) {
return (
<section className="post-bounty post-bounty--refunded" aria-label="悬赏已退回">
<div className="post-bounty__head">
<div className="post-bounty__lead">
<Coins size={18} className="post-bounty__icon" aria-hidden />
<div className="post-bounty__titles">
<strong className="post-bounty__title">悬赏已取消</strong>
<span className="post-bounty__subtitle">积分已退回</span>
</div>
</div>
</div>
</section>
);
}
return null;
}