Files
jiang13-bbs/frontend/components/PostInteractPanels.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

695 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
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,
apiDrawLottery,
apiSolveQuestion,
apiReopenQuestion,
type PollState,
type BountyState,
type LotteryState,
type QuestionState,
} from "@/lib/api";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import { useAllowComments } from "@/components/CommentsPolicyProvider";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
export default function PostInteractPanels({
postId,
poll,
bounty,
lottery,
loggedIn,
}: {
postId: number;
poll?: PollState | null;
bounty?: BountyState | null;
lottery?: LotteryState | null;
loggedIn: boolean;
}) {
// 问答 / 已采纳悬赏改在正文下方 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} />}
{showBounty && <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 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 allowComments = useAllowComments();
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);
}
};
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-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>
<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 && allowComments
? (
<>
已采纳{" "}
<a
href={commentAnchor(
state.accepted_floor,
state.accepted_answer?.is_root ?? true,
state.accepted_comment_id
)}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
#{state.accepted_floor || "?"}
</a>
</>
)
: "作者已标记为已解决"
: allowComments
? "可在评论旁采纳答案,或手动标记已解决"
: "可由作者标记为已解决"}
</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>
);
}
function PollPanel({
postId,
state,
loggedIn,
}: {
postId: number;
state: PollState;
loggedIn: boolean;
}) {
const router = useRouter();
const urlStyle = useUrlStyle();
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;
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);
}
};
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 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>
<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 || [] : [];
return (
<li key={o.index}>
<button
type="button"
disabled={!state.can_vote || busy}
onClick={() => toggle(o.index)}
className={`j13-poll-option${checked ? " is-on" : ""}`}
>
{showResults && (
<span
className="j13-poll-option-bar"
style={{ width: `${o.percent}%` }}
aria-hidden
/>
)}
<span className="j13-poll-option-row">
<span className="j13-poll-option-text">
{state.can_vote ? (checked ? "✓ " : "") : ""}
{o.text}
</span>
{showResults && (
<span className="j13-poll-option-stat tabular-nums">
{o.votes} · {o.percent}%
</span>
)}
</span>
</button>
{namedVoters.length > 0 && (
<ul className="j13-poll-voters">
{namedVoters.map((v) => (
<li key={v.id}>
<Link href={userPath(urlStyle, v.id)} className="j13-poll-voter">
<Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} />
{v.nickname || v.username}
</Link>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
{(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(postPath(urlStyle, 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({ state }: { postId: number; state: BountyState }) {
const allowComments = useAllowComments();
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 footLeft = state.accepted_comment_id
? "悬赏已完成"
: state.expired || state.refunded
? state.can_accept
? "已过期退回 · 仍可采纳支付"
: "悬赏已过期退回"
: "悬赏进行中";
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 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>
<span className={statusBadge.cls}>{statusBadge.label}</span>
</div>
</div>
<p className="j13-typed-meta">
<span className="j13-bounty-points tabular-nums">{state.points} 积分</span>
{bodyHint ? ` · ${bodyHint}` : null}
{state.can_accept && allowComments ? " · 在评论旁点击「采纳」发放积分" : null}
</p>
{!state.accepted_comment_id && state.ends_at ? (
<DeadlineCountdown
endsAt={state.ends_at}
expiredLabel="已到截止时间"
expiredHint={state.can_accept ? "已自动退回,仍可采纳并支付" : "等待处理"}
/>
) : null}
{allowComments && state.accepted_comment_id > 0 && (
<p className="j13-typed-meta">
已采纳{" "}
<a
href={commentAnchor(
state.accepted_floor,
state.accepted_answer?.is_root ?? true,
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.accepted_comment_id || state.expired || state.refunded}
endsAt={state.ends_at}
endedAt={state.ends_at}
/>
</div>
</section>
);
}
function LotteryPanel({
postId,
state,
}: {
postId: number;
state: LotteryState;
}) {
const router = useRouter();
const urlStyle = useUrlStyle();
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);
}
};
const prizes = state.prizes?.length ? state.prizes : [];
const statusBadge = state.drawn
? { cls: "badge-status badge-status-closed", label: "已开奖" }
: { cls: "badge-status badge-status-open", label: "进行中" };
return (
<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>
<span className={statusBadge.cls}>{statusBadge.label}</span>
</div>
{state.can_draw && (
<div className="j13-typed-actions">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busy}
onClick={() => void act(() => apiDrawLottery(String(postId)), "开奖完成")}
>
开奖
</button>
</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 && (
<>
<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}-${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={userPath(urlStyle, 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 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>
);
}