feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
421
frontend/components/PostInteractPanels.tsx
Normal file
421
frontend/components/PostInteractPanels.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
apiVotePoll,
|
||||
apiClosePoll,
|
||||
apiRefundBounty,
|
||||
apiCloseLottery,
|
||||
apiDrawLottery,
|
||||
apiSolveQuestion,
|
||||
apiReopenQuestion,
|
||||
type PollState,
|
||||
type BountyState,
|
||||
type LotteryState,
|
||||
type QuestionState,
|
||||
} from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "@/components/Avatar";
|
||||
|
||||
export default function PostInteractPanels({
|
||||
postId,
|
||||
question,
|
||||
poll,
|
||||
bounty,
|
||||
lottery,
|
||||
loggedIn,
|
||||
}: {
|
||||
postId: number;
|
||||
question?: QuestionState | null;
|
||||
poll?: PollState | null;
|
||||
bounty?: BountyState | null;
|
||||
lottery?: LotteryState | null;
|
||||
loggedIn: boolean;
|
||||
}) {
|
||||
if (!question && !poll && !bounty && !lottery) return null;
|
||||
return (
|
||||
<div className="space-y-4 mb-8">
|
||||
{question && <QuestionPanel postId={postId} state={question} />}
|
||||
{poll && <PollPanel postId={postId} state={poll} loggedIn={loggedIn} />}
|
||||
{bounty && <BountyPanel postId={postId} state={bounty} />}
|
||||
{lottery && <LotteryPanel postId={postId} state={lottery} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatEndsAt(iso?: string) {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
return new Date(iso).toLocaleString("zh-CN");
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function QuestionPanel({ postId, state }: { postId: number; state: QuestionState }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const act = async (fn: () => Promise<{ post?: unknown; error?: string }>, ok: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fn();
|
||||
if (res.post) {
|
||||
toast(ok, "ok");
|
||||
router.refresh();
|
||||
} else toast(res.error || "操作失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
问答 · {state.solved ? "已解决" : "未解决"}
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
{state.solved
|
||||
? state.accepted_comment_id
|
||||
? `已采纳评论 #${state.accepted_comment_id}`
|
||||
: "作者已标记为已解决"
|
||||
: "可在评论旁采纳答案,或手动标记已解决"}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PollPanel({
|
||||
postId,
|
||||
state,
|
||||
loggedIn,
|
||||
}: {
|
||||
postId: number;
|
||||
state: PollState;
|
||||
loggedIn: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
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 toggle = (idx: number) => {
|
||||
if (!state.can_vote) return;
|
||||
if (state.multi) {
|
||||
setSel((prev) =>
|
||||
prev.includes(idx) ? prev.filter((x) => x !== idx) : [...prev, idx]
|
||||
);
|
||||
} else {
|
||||
setSel([idx]);
|
||||
}
|
||||
};
|
||||
|
||||
const vote = async () => {
|
||||
if (sel.length === 0) {
|
||||
toast("请先选择选项", "error");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiVotePoll(String(postId), sel);
|
||||
if (res.post) {
|
||||
toast("投票成功", "ok");
|
||||
router.refresh();
|
||||
} else toast(res.error || "投票失败", "error");
|
||||
} catch {
|
||||
toast("投票失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const close = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiClosePoll(String(postId));
|
||||
if (res.post) {
|
||||
toast("已结束投票", "ok");
|
||||
router.refresh();
|
||||
} else toast(res.error || "操作失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<h2 className="font-extrabold 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>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{state.options.map((o) => {
|
||||
const checked = sel.includes(o.index);
|
||||
const namedVoters = !state.anonymous && showResults ? o.voters || [] : [];
|
||||
return (
|
||||
<li key={o.index}>
|
||||
<button
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
{showResults && (
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 opacity-30"
|
||||
style={{
|
||||
width: `${o.percent}%`,
|
||||
background: "var(--accent)",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span className="relative flex items-center justify-between gap-3">
|
||||
<span className="font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
{state.can_vote ? (checked ? "✓ " : "") : ""}
|
||||
{o.text}
|
||||
</span>
|
||||
{showResults && (
|
||||
<span className="meta tabular-nums text-[12.5px] shrink-0">
|
||||
{o.votes} · {o.percent}%
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{namedVoters.length > 0 && (
|
||||
<ul className="mt-1.5 flex flex-wrap gap-1.5 px-1">
|
||||
{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] font-semibold"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} />
|
||||
{v.nickname || v.username}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BountyPanel({ postId, state }: { postId: number; state: BountyState }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
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 statusText = state.expired
|
||||
? "已过期,积分已自动退回"
|
||||
: state.refunded
|
||||
? "已退回给发帖人"
|
||||
: state.accepted_comment_id
|
||||
? `已采纳评论 #${state.accepted_comment_id}`
|
||||
: state.escrowed
|
||||
? "积分已托管,采纳优质回答后发放"
|
||||
: "悬赏未托管";
|
||||
|
||||
return (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
悬赏 {state.points} 积分
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
{statusText}
|
||||
{state.ends_at && !state.settled ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{state.can_refund && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${confirming ? "btn-primary" : "btn-line"}`}
|
||||
disabled={busy}
|
||||
onClick={() => void refund()}
|
||||
>
|
||||
{confirming ? "再点确认退回" : "退回悬赏"}
|
||||
</button>
|
||||
)}
|
||||
{state.can_accept && (
|
||||
<p className="meta text-[12.5px]">在评论旁点击「采纳」发放积分</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LotteryPanel({
|
||||
postId,
|
||||
state,
|
||||
}: {
|
||||
postId: number;
|
||||
state: LotteryState;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const act = async (fn: () => Promise<{ post?: unknown; error?: string }>, ok: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fn();
|
||||
if (res.post) {
|
||||
toast(ok, "ok");
|
||||
router.refresh();
|
||||
} else toast(res.error || "操作失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
抽奖 · {state.slots} 个名额
|
||||
</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>
|
||||
)}
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => apiDrawLottery(String(postId)), "开奖完成")}
|
||||
>
|
||||
开奖
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{state.drawn && (
|
||||
<div>
|
||||
<p className="font-bold text-[13px] mb-2" style={{ color: "var(--ink)" }}>
|
||||
{state.winners.length > 0 ? "中奖名单" : "无人回帖,开奖结果为空"}
|
||||
</p>
|
||||
{state.winners.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-3">
|
||||
{state.winners.map((w) => (
|
||||
<li key={w.id}>
|
||||
<Link
|
||||
href={`/u/${w.id}`}
|
||||
className="inline-flex items-center gap-2 px-2.5 py-1.5 rounded-full"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<Avatar name={w.nickname || w.username} src={w.avatar || undefined} size={22} />
|
||||
<span className="text-[13px] font-bold">{w.nickname || w.username}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user