feat(posts): 类型帖截止时间、抽奖奖品表与悬赏发帖体验

统一投票/悬赏/抽奖截止与状态展示,抽奖支持多奖品行;发帖页悬赏积分对齐表格输入,并在余额不足时于底部就地提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 10:21:05 +08:00
parent 6b772380c9
commit b2d3d6dd26
23 changed files with 2711 additions and 1007 deletions

View File

@@ -1,13 +1,12 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Gift, Trophy, CheckCircle2, Timer, BarChart3, Coins, CircleHelp } from "lucide-react";
import {
apiVotePoll,
apiClosePoll,
apiRefundBounty,
apiCloseLottery,
apiDrawLottery,
apiSolveQuestion,
apiReopenQuestion,
@@ -32,12 +31,13 @@ export default function PostInteractPanels({
lottery?: LotteryState | null;
loggedIn: boolean;
}) {
// 问答状态改在正文下方展示(未解决 / 已解决资料卡),此处不再放
if (!poll && !bounty && !lottery) return null;
// 问答 / 已采纳悬赏改在正文下方 AcceptedAnswerCard,此处不再放
const showBounty = !!bounty && !bounty.accepted_comment_id;
if (!poll && !showBounty && !lottery) return null;
return (
<div className="j13-interact-below space-y-4">
{poll && <PollPanel postId={postId} state={poll} loggedIn={loggedIn} />}
{bounty && <BountyPanel postId={postId} state={bounty} />}
{showBounty && <BountyPanel postId={postId} state={bounty!} />}
{lottery && <LotteryPanel postId={postId} state={lottery} />}
</div>
);
@@ -52,6 +52,129 @@ function formatEndsAt(iso?: string) {
}
}
/** 底栏右侧:进行中显示截止时间,结束后显示结束时间 */
function TypedFootTime({
endsAt,
endedAt,
ended,
}: {
endsAt?: string;
endedAt?: string;
ended?: boolean;
}) {
if (ended) {
const t = endedAt || endsAt;
if (!t) return null;
return <span className="j13-typed-foot-time">结束时间: {formatEndsAt(t)}</span>;
}
if (!endsAt) return null;
return <span className="j13-typed-foot-time">截止时间: {formatEndsAt(endsAt)}</span>;
}
function pad2(n: number) {
return String(Math.max(0, n)).padStart(2, "0");
}
function remainParts(endsAtMs: number, now: number) {
const ms = Math.max(0, endsAtMs - now);
const totalSec = Math.floor(ms / 1000);
return {
days: Math.floor(totalSec / 86400),
hours: Math.floor((totalSec % 86400) / 3600),
mins: Math.floor((totalSec % 3600) / 60),
secs: totalSec % 60,
expired: ms <= 0,
};
}
/** 未截止倒计时:最小单位秒,每秒刷新 */
function DeadlineCountdown({
endsAt,
activeLabel = "距截止还剩",
expiredLabel = "已到截止时间",
expiredHint = "等待处理",
}: {
endsAt: string;
activeLabel?: string;
expiredLabel?: string;
expiredHint?: string;
}) {
const endsMs = Date.parse(endsAt);
const [now, setNow] = useState<number | null>(null);
useEffect(() => {
if (!Number.isFinite(endsMs)) return;
const tick = () => setNow(Date.now());
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [endsMs]);
if (!Number.isFinite(endsMs)) return null;
if (now === null) {
return (
<div className="j13-deadline-countdown" aria-hidden="true">
<div className="j13-deadline-countdown-label">
<Timer size={15} strokeWidth={2.25} aria-hidden />
{activeLabel}
</div>
<div className="j13-deadline-countdown-digits">
{["天", "时", "分", "秒"].map((suf) => (
<span key={suf} className={`j13-deadline-countdown-unit${suf === "秒" ? " is-sec" : ""}`}>
<span className="j13-deadline-countdown-num tabular-nums">--</span>
<span className="j13-deadline-countdown-suf">{suf}</span>
</span>
))}
</div>
<p className="j13-deadline-countdown-ends">截止于 {formatEndsAt(endsAt)}</p>
</div>
);
}
const r = remainParts(endsMs, now);
return (
<div
className={`j13-deadline-countdown${r.expired ? " is-expired" : ""}`}
role="timer"
aria-live="polite"
aria-atomic="true"
>
<div className="j13-deadline-countdown-label">
<Timer size={15} strokeWidth={2.25} aria-hidden />
{r.expired ? expiredLabel : activeLabel}
</div>
{r.expired ? (
<p className="j13-deadline-countdown-expired">{expiredHint}</p>
) : (
<div
className="j13-deadline-countdown-digits"
aria-label={`还剩 ${r.days} 天 ${r.hours} 时 ${r.mins} 分 ${r.secs} 秒`}
>
<span className="j13-deadline-countdown-unit">
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.days)}</span>
<span className="j13-deadline-countdown-suf">天</span>
</span>
<span className="j13-deadline-countdown-unit">
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.hours)}</span>
<span className="j13-deadline-countdown-suf">时</span>
</span>
<span className="j13-deadline-countdown-unit">
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.mins)}</span>
<span className="j13-deadline-countdown-suf">分</span>
</span>
<span className="j13-deadline-countdown-unit is-sec">
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.secs)}</span>
<span className="j13-deadline-countdown-suf">秒</span>
</span>
</div>
)}
<p className="j13-deadline-countdown-ends">截止于 {formatEndsAt(endsAt)}</p>
</div>
);
}
/** 正文下方的问答状态卡(未解决 / 仅标记已解决);有采纳答案时改用 AcceptedAnswerCard */
export function QuestionStatusCard({ postId, state }: { postId: number; state: QuestionState }) {
const router = useRouter();
@@ -70,57 +193,80 @@ export function QuestionStatusCard({ postId, state }: { postId: number; state: Q
}
};
const statusBadge = state.solved
? { cls: "badge-status badge-status-solved", label: "已解决" }
: { cls: "badge-status badge-status-unsolved", label: "未解决" };
const footLeft = state.solved
? state.accepted_comment_id
? "已采纳答案"
: "已标记解决"
: "等待解答";
return (
<section
className={`j13-interact-card j13-question-below${state.solved ? " is-solved" : " is-unsolved"}`}
aria-label={`问答 · ${state.solved ? "已解决" : "未解决"}`}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-[15px] font-medium" style={{ color: "var(--ink)" }}>
问答 · {state.solved ? "已解决" : "未解决"}
<section className="j13-interact-card j13-typed-card" aria-label={`问答 · ${statusBadge.label}`}>
<div className="j13-typed-head">
<div className="j13-typed-head-main">
<h2 className="j13-typed-title is-question">
<CircleHelp size={16} strokeWidth={2.25} aria-hidden />
问答
</h2>
<p className="meta text-[12.5px] mt-1">
{state.solved
? state.accepted_comment_id
? (
<>
已采纳{" "}
<a
href={`#comment-${state.accepted_comment_id}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
#{state.accepted_floor || "?"}
</a>
</>
)
: "作者已标记为已解决"
: "可在评论旁采纳答案,或手动标记已解决"}
</p>
</div>
<div className="flex flex-wrap gap-2">
{state.can_solve && (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => void act(() => apiSolveQuestion(String(postId)), "已标记为已解决")}
>
标记已解决
</button>
)}
{state.can_reopen && (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => void act(() => apiReopenQuestion(String(postId)), "已重新打开")}
>
重新打开
</button>
)}
<span className={statusBadge.cls}>{statusBadge.label}</span>
</div>
{(state.can_solve || state.can_reopen) && (
<div className="j13-typed-actions">
{state.can_solve && (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => void act(() => apiSolveQuestion(String(postId)), "已标记为已解决")}
>
标记已解决
</button>
)}
{state.can_reopen && (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => void act(() => apiReopenQuestion(String(postId)), "已重新打开")}
>
重新打开
</button>
)}
</div>
)}
</div>
<p className="j13-typed-meta">
{state.solved
? state.accepted_comment_id
? (
<>
已采纳{" "}
<a
href={`#comment-${state.accepted_comment_id}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
#{state.accepted_floor || "?"}
</a>
</>
)
: "作者已标记为已解决"
: "可在评论旁采纳答案,或手动标记已解决"}
</p>
<div className="j13-typed-foot">
<span className="j13-typed-foot-status">
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
{footLeft}
</span>
<TypedFootTime
ended={state.solved}
endedAt={state.solved_at || state.accepted_answer?.created_at}
/>
</div>
</section>
);
@@ -139,6 +285,7 @@ function PollPanel({
const [sel, setSel] = useState<number[]>(state.my_options || []);
const [busy, setBusy] = useState(false);
const showResults = state.closed || (state.my_options && state.my_options.length > 0);
const hasVoted = (state.my_options?.length ?? 0) > 0;
const toggle = (idx: number) => {
if (!state.can_vote) return;
@@ -183,19 +330,56 @@ function PollPanel({
}
};
const statusBadge = state.closed
? { cls: "badge-status badge-status-closed", label: "已结束" }
: { cls: "badge-status badge-status-open", label: "进行中" };
const footLeft = state.closed
? "已结束投票"
: hasVoted
? "你已完成投票"
: "投票进行中";
return (
<section className="j13-interact-card">
<div className="flex items-center justify-between gap-3 mb-3">
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>
投票{state.multi ? "(多选)" : "(单选)"}
{state.anonymous ? " · 匿名" : " · 实名"}
</h2>
<span className="meta text-[12.5px]">
{state.voters} 人参与 · {state.total_votes} 票
{state.closed ? " · 已结束" : ""}
</span>
<section className="j13-interact-card j13-typed-card" aria-label="投票">
<div className="j13-typed-head">
<div className="j13-typed-head-main">
<h2 className="j13-typed-title is-poll">
<BarChart3 size={16} strokeWidth={2.25} aria-hidden />
投票
</h2>
<span className={statusBadge.cls}>{statusBadge.label}</span>
</div>
{state.can_close && (
<div className="j13-typed-actions">
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => void close()}
>
结束投票
</button>
</div>
)}
</div>
<ul className="space-y-2">
<p className="j13-typed-meta">
{state.multi ? "多选" : "单选"}
{state.anonymous ? " · 匿名" : " · 实名"}
{" · "}
{state.voters} 人参与 · {state.total_votes} 票
</p>
{!state.closed && state.ends_at ? (
<DeadlineCountdown
endsAt={state.ends_at}
expiredLabel="已到截止时间"
expiredHint="投票即将关闭"
/>
) : null}
<ul className="j13-poll-options">
{state.options.map((o) => {
const checked = sel.includes(o.index);
const namedVoters = !state.anonymous && showResults ? o.voters || [] : [];
@@ -205,43 +389,32 @@ function PollPanel({
type="button"
disabled={!state.can_vote || busy}
onClick={() => toggle(o.index)}
className="w-full text-left rounded-[12px] px-3 py-2.5 relative overflow-hidden transition-colors"
style={{
border: `1px solid ${checked ? "var(--accent)" : "var(--line)"}`,
background: checked ? "var(--accent-soft)" : "var(--panel)",
}}
className={`j13-poll-option${checked ? " is-on" : ""}`}
>
{showResults && (
<span
className="absolute inset-y-0 left-0 opacity-30"
style={{
width: `${o.percent}%`,
background: "var(--accent)",
}}
className="j13-poll-option-bar"
style={{ width: `${o.percent}%` }}
aria-hidden
/>
)}
<span className="relative flex items-center justify-between gap-3">
<span className="text-[14px]" style={{ color: "var(--ink)" }}>
<span className="j13-poll-option-row">
<span className="j13-poll-option-text">
{state.can_vote ? (checked ? "✓ " : "") : ""}
{o.text}
</span>
{showResults && (
<span className="meta tabular-nums text-[12.5px] shrink-0">
<span className="j13-poll-option-stat tabular-nums">
{o.votes} · {o.percent}%
</span>
)}
</span>
</button>
{namedVoters.length > 0 && (
<ul className="mt-1.5 flex flex-wrap gap-1.5 px-1">
<ul className="j13-poll-voters">
{namedVoters.map((v) => (
<li key={v.id}>
<Link
href={`/u/${v.id}`}
className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[12px]"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Link href={`/u/${v.id}`} className="j13-poll-voter">
<Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} />
{v.nickname || v.username}
</Link>
@@ -253,106 +426,121 @@ function PollPanel({
);
})}
</ul>
<div className="mt-3 flex flex-wrap gap-2">
{state.can_vote && (
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void vote()}>
提交投票
</button>
)}
{!loggedIn && !state.closed && (
<Link href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} className="btn btn-line btn-sm">
登录后投票
</Link>
)}
{state.can_close && (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => void close()}
>
结束投票
</button>
)}
{(state.can_vote || (!loggedIn && !state.closed)) && (
<div className="j13-typed-actions-row">
{state.can_vote && (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busy}
onClick={() => void vote()}
>
提交投票
</button>
)}
{!loggedIn && !state.closed && (
<Link
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
className="btn btn-line btn-sm"
>
登录后投票
</Link>
)}
</div>
)}
<div className="j13-typed-foot">
<span className="j13-typed-foot-status">
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
{footLeft}
</span>
<TypedFootTime
ended={state.closed}
endsAt={state.ends_at}
endedAt={state.closed_at}
/>
</div>
</section>
);
}
function BountyPanel({ postId, state }: { postId: number; state: BountyState }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
function BountyPanel({ state }: { postId: number; state: BountyState }) {
const statusBadge = state.accepted_comment_id
? { cls: "badge-status badge-status-solved", label: "已采纳" }
: state.expired || state.refunded
? { cls: "badge-status badge-status-closed", label: "已过期" }
: { cls: "badge-status badge-status-open", label: "进行中" };
const refund = async () => {
if (!confirming) {
setConfirming(true);
window.setTimeout(() => setConfirming(false), 3000);
return;
}
setBusy(true);
try {
const res = await apiRefundBounty(String(postId));
if (res.post) {
toast("悬赏已退回", "ok");
router.refresh();
} else toast(res.error || "退回失败", "error");
} finally {
setBusy(false);
setConfirming(false);
}
};
const footLeft = state.accepted_comment_id
? "悬赏已完成"
: state.expired || state.refunded
? state.can_accept
? "已过期退回 · 仍可采纳支付"
: "悬赏已过期退回"
: "悬赏进行中";
const statusText = state.expired
? "已过期,积分已自动退回"
: state.refunded
? "已退回给发帖人"
: state.accepted_comment_id
? null
const bodyHint = state.accepted_answer
? "悬赏积分已发放给采纳答案"
: state.accepted_comment_id
? null
: state.expired || state.refunded
? state.can_accept
? "到期未采纳已自动退回;采纳将从余额扣除,支付后不可撤回"
: "积分已自动退回发帖人"
: state.escrowed
? "积分已托管,采纳优质回答后发放"
? "积分已托管,采纳优质回答后发放;到期未采纳自动退回"
: "悬赏未托管";
return (
<section className="j13-interact-card">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>
悬赏 {state.points} 积分
<section className="j13-interact-card j13-typed-card" aria-label="悬赏">
<div className="j13-typed-head">
<div className="j13-typed-head-main">
<h2 className="j13-typed-title is-bounty">
<Coins size={16} strokeWidth={2.25} aria-hidden />
悬赏
</h2>
<p className="meta text-[12.5px] mt-1">
{state.accepted_answer
? "悬赏积分已发放给采纳答案"
: state.accepted_comment_id
? (
<>
已采纳{" "}
<a
href={`#comment-${state.accepted_comment_id}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
#{state.accepted_floor || "?"}
</a>
</>
)
: statusText}
{state.ends_at && !state.settled ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
</p>
<span className={statusBadge.cls}>{statusBadge.label}</span>
</div>
{state.can_refund && (
<button
type="button"
className={`btn btn-sm ${confirming ? "btn-primary" : "btn-line"}`}
disabled={busy}
onClick={() => void refund()}
</div>
<p className="j13-typed-meta">
<span className="j13-bounty-points tabular-nums">{state.points} 积分</span>
{bodyHint ? ` · ${bodyHint}` : null}
{state.can_accept ? " · 在评论旁点击「采纳」发放积分" : null}
</p>
{!state.accepted_comment_id && state.ends_at ? (
<DeadlineCountdown
endsAt={state.ends_at}
expiredLabel="已到截止时间"
expiredHint={state.can_accept ? "已自动退回,仍可采纳并支付" : "等待处理"}
/>
) : null}
{state.accepted_comment_id > 0 && (
<p className="j13-typed-meta">
已采纳{" "}
<a
href={`#comment-${state.accepted_comment_id}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
{confirming ? "再点确认退回" : "退回悬赏"}
</button>
)}
{state.can_accept && (
<p className="meta text-[12.5px]">在评论旁点击「采纳」发放积分</p>
)}
#{state.accepted_floor || "?"}
</a>
</p>
)}
<div className="j13-typed-foot">
<span className="j13-typed-foot-status">
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
{footLeft}
</span>
<TypedFootTime
ended={!!state.accepted_comment_id || state.expired || state.refunded}
endsAt={state.ends_at}
endedAt={state.ends_at}
/>
</div>
</section>
);
@@ -381,35 +569,28 @@ function LotteryPanel({
}
};
const prizes = state.prizes?.length
? state.prizes
: state.slots > 0
? [{ name: "奖品", count: state.slots }]
: [];
const statusBadge = state.drawn
? { cls: "badge-status badge-status-closed", label: "已开奖" }
: { cls: "badge-status badge-status-open", label: "进行中" };
return (
<section className="j13-interact-card">
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
<div>
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>
抽奖 · {state.slots} 个名额
<section className="j13-interact-card j13-typed-card" aria-label="抽奖">
<div className="j13-typed-head">
<div className="j13-typed-head-main">
<h2 className="j13-typed-title is-lottery">
<Gift size={16} strokeWidth={2.25} aria-hidden />
抽奖
</h2>
<p className="meta text-[12.5px] mt-1">
已有 {state.entry_count} 名回帖用户参与
{state.drawn ? " · 已开奖" : state.closed ? " · 已截止" : ""}
{state.eligible && !state.drawn ? " · 你已在奖池中" : ""}
{state.ends_at && !state.drawn ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
</p>
{!state.drawn && (
<p className="meta text-[12px] mt-1">对本帖发表评论即可参与(楼主除外)</p>
)}
<span className={statusBadge.cls}>{statusBadge.label}</span>
</div>
<div className="flex flex-wrap gap-2">
{state.can_close && (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => void act(() => apiCloseLottery(String(postId)), "已截止")}
>
提前截止
</button>
)}
{state.can_draw && (
{state.can_draw && (
<div className="j13-typed-actions">
<button
type="button"
className="btn btn-primary btn-sm"
@@ -418,38 +599,83 @@ function LotteryPanel({
>
开奖
</button>
)}
</div>
</div>
)}
</div>
{(state.can_draw || (state.eligible && !state.drawn)) && (
<p className="j13-typed-meta">
{state.can_draw ? `已有 ${state.entry_count} 名主评用户入池` : null}
{state.can_draw && state.eligible && !state.drawn ? " · " : null}
{state.eligible && !state.drawn ? "你已在奖池中" : null}
</p>
)}
{!state.drawn && state.ends_at ? (
<DeadlineCountdown
endsAt={state.ends_at}
expiredLabel="已到截止时间"
expiredHint="等待开奖"
/>
) : null}
{prizes.length > 0 && (
<ul className="j13-lottery-prize-row" aria-label="奖品">
{prizes.map((prize, i) => (
<li key={`${prize.name}-${i}`}>
<div className="j13-lottery-prize-chip" title={`${prize.name} ×${prize.count}`}>
<Trophy size={13} strokeWidth={2.25} aria-hidden />
<span className="j13-lottery-prize-chip-text">
{prize.name}
<span className="j13-lottery-prize-chip-count">×{prize.count}</span>
</span>
</div>
</li>
))}
</ul>
)}
{state.drawn && (
<div>
<p className="font-semibold text-[13px] mb-2" style={{ color: "var(--ink)" }}>
{state.winners.length > 0 ? "中奖名单" : "无人回帖,开奖结果为空"}
</p>
{state.winners.length > 0 && (
<ul className="flex flex-wrap gap-3">
<>
<p className="j13-lottery-winners-label">中奖名单</p>
{state.winners.length > 0 ? (
<ul className="j13-lottery-winner-row">
{state.winners.map((w) => (
<li key={w.id}>
<span className="badge-winner inline-flex items-center gap-2 !py-1.5 !px-2.5 !text-[13px] !font-medium">
<Link href={`/u/${w.id}`} className="inline-flex items-center gap-2 min-w-0">
<Avatar name={w.nickname || w.username} src={w.avatar || undefined} size={22} />
<span className="truncate">{w.nickname || w.username}</span>
</Link>
{w.comment_id && w.floor ? (
<a
href={`#comment-${w.comment_id}`}
className="opacity-90 hover:underline shrink-0"
>
#{w.floor}
</a>
) : null}
</span>
<li key={`${w.id}-${w.comment_id || 0}`}>
<div className="j13-lottery-winner">
<Avatar
name={w.nickname || w.username}
src={w.avatar || undefined}
size={36}
className="j13-lottery-winner-avatar"
/>
<div className="j13-lottery-winner-body">
<Link href={`/u/${w.id}`} className="j13-lottery-winner-name">
{w.nickname || w.username}
</Link>
{w.prize_name ? (
<span className="j13-lottery-winner-prize">{w.prize_name}</span>
) : null}
</div>
</div>
</li>
))}
</ul>
) : (
<p className="j13-typed-empty">无人回帖,开奖结果为空</p>
)}
</div>
</>
)}
<div className="j13-typed-foot">
<span className="j13-typed-foot-status">
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
{state.drawn ? "开奖完成" : "抽奖进行中"}
</span>
{state.ends_at ? (
<span className="j13-typed-foot-time">截止时间: {formatEndsAt(state.ends_at)}</span>
) : null}
</div>
</section>
);
}