/* global React */
/* Polls · 用户端投票模块
- PollsListPage: 当前 active 投票一览
- PollDetailPage: 投票详情(二元 → BinaryPollLayout 左右对垒;多选 → MultiChoicePollLayout 列表)
- PollCountdown: 倒计时(本地计算,不依赖服务器同步)
- 用 polling(每 2.5s 一次)拉最新结果,投票结束后停轮询
- 短链 /v/:code 解析在 index.html boot 时完成,把 code 传进来 */
const { useState: uP, useEffect: uPE, useRef: uPR, useMemo: uPM } = React;
// ---------- 倒计时(本地计算,不依赖服务器)----------
function PollCountdown({ endsAt, onZero }) {
const [left, setLeft] = uP(() => Math.max(0, Math.floor((new Date(endsAt) - Date.now()) / 1000)));
uPE(() => {
if (!endsAt) return;
const t = setInterval(() => {
const s = Math.max(0, Math.floor((new Date(endsAt) - Date.now()) / 1000));
setLeft(s);
if (s === 0 && onZero) onZero();
}, 1000);
return () => clearInterval(t);
}, [endsAt]);
if (!endsAt) return null;
const m = Math.floor(left / 60), s = left % 60;
const urgent = left <= 30;
return (
余
{String(m).padStart(2, '0')}
:
{String(s).padStart(2, '0')}
);
}
// ---------- 进度条(平滑 transition)----------
function PollBar({ pct, side = 'left', highlight }) {
const cls = `bx-poll-bar bx-poll-bar--${side} ${highlight ? 'is-highlight' : ''}`;
return (
);
}
// ---------- 二元投票:左右对垒 ----------
function BinaryPollLayout({ poll, onVote, voting, voteError }) {
const [a, b] = poll.options;
const total = poll.total_weight || 0;
const pctA = total > 0 ? Math.round(a.vote_weight_sum / total * 100) : 0;
const pctB = total > 0 ? Math.round(b.vote_weight_sum / total * 100) : 0;
const myOpt = poll.my_vote && poll.my_vote.option_id;
const closed = poll.status === 'closed';
const winner = closed && a.vote_weight_sum !== b.vote_weight_sum
? (a.vote_weight_sum > b.vote_weight_sum ? a.id : b.id) : null;
const Side = ({ opt, side, pct }) => {
const mine = myOpt === opt.id;
const winning = winner === opt.id;
const cls = `bx-poll-side bx-poll-side--${side} ${mine ? 'is-mine' : ''} ${winning ? 'is-winner' : ''}`;
return (
{opt.text}
{pct}%
{opt.vote_count} 票 · 加权 {opt.vote_weight_sum}
{!myOpt && !closed && (
)}
{mine &&
您选了此项}
{winning &&
陛下钦点}
);
};
return (
VS
{voteError && {voteError}
}
);
}
// ---------- 多选投票:垂直列表 ----------
function MultiChoicePollLayout({ poll, onVote, voting, voteError }) {
const total = poll.total_weight || 0;
const myOpt = poll.my_vote && poll.my_vote.option_id;
const closed = poll.status === 'closed';
const ranked = [...poll.options].sort((x, y) => y.vote_weight_sum - x.vote_weight_sum);
const winnerWeight = closed && ranked.length ? ranked[0].vote_weight_sum : -1;
return (
{poll.options.map(opt => {
const pct = total > 0 ? Math.round(opt.vote_weight_sum / total * 100) : 0;
const mine = myOpt === opt.id;
const winning = closed && opt.vote_weight_sum === winnerWeight && winnerWeight > 0;
const cls = `bx-poll-row ${mine ? 'is-mine' : ''} ${winning ? 'is-winner' : ''}`;
return (
{opt.text}
{pct}%
{opt.vote_count} 票 · 加权 {opt.vote_weight_sum}
{mine && 您选了}
{winning && 陛下钦点}
{!myOpt && !closed && (
)}
);
})}
{voteError &&
{voteError}
}
);
}
// ---------- 详情页 ----------
function PollDetailPage({ pollId, code, user, onLogin, onBack, onListReturn }) {
const [poll, setPoll] = uP(null);
const [loading, setLoading] = uP(true);
const [error, setError] = uP(null);
const [voting, setVoting] = uP(false);
const [voteError, setVoteError] = uP(null);
const reusedNotedRef = uPR(false);
// 首次加载 + polling — 基于 fetchOne 返回的最新 data 决定要不要继续轮询。
// 不依赖 poll 状态(state 在闭包里会停留在 effect 启动时的值,导致 closed
// 后还在 fetch 的 bug)。
uPE(() => {
let cancelled = false;
let timer = null;
async function fetchOne() {
if (!window.bxApi) return null;
try {
const data = code
? await window.bxApi.pollByCode(code)
: await window.bxApi.getPoll(pollId);
if (cancelled) return null;
setPoll(data);
setLoading(false);
// 决断 7 提示:code 复用历史 → 一次性 toast
if (data.code_reused && !reusedNotedRef.current) {
reusedNotedRef.current = true;
window.showToast && window.showToast({
type: 'info',
msg: '此投票码曾被用过 · 您看到的是最新一场投票',
duration: 5000,
});
}
return data;
} catch (e) {
if (cancelled) return null;
setError(e.message || '获取投票失败');
setLoading(false);
return null;
}
}
// 拉一次,仅在 active 时继续轮询;closed/pending 拉完即停。
(async function loop() {
const data = await fetchOne();
if (cancelled) return;
if (data && data.status === 'active') {
timer = setTimeout(loop, 2500);
}
})();
return () => { cancelled = true; if (timer) clearTimeout(timer); };
}, [pollId, code]);
async function handleVote(optionId) {
if (!user) { onLogin && onLogin(); return; }
if (!poll || voting) return;
setVoting(true); setVoteError(null);
try {
const r = await window.bxApi.vote(poll.id, optionId);
setPoll(r.poll);
window.showToast && window.showToast({
type: 'success',
msg: `已投票 · 您的权重 ${r.weight}`,
});
} catch (e) {
const msg = e.message || '投票失败';
setVoteError(msg);
window.showToast && window.showToast({ type: 'error', msg });
} finally {
setVoting(false);
}
}
if (loading) return
;
if (error || !poll) {
return (
);
}
const isPending = poll.status === 'pending';
const isActive = poll.status === 'active';
const isClosed = poll.status === 'closed';
return (
{isActive ? '进行中' : isClosed ? '已结束' : '尚未开始'}
{poll.title}
{poll.description &&
{poll.description}
}
{isActive &&
}
{isClosed && 投票已结束 · 总参与 {poll.total_votes} 人 · 加权 {poll.total_weight}}
{isPending && 尚未开始,请稍候}
{isPending ? (
) : poll.poll_type === 'binary'
?
: }
{!user && isActive && (
点此登录后即可参与投票 →
)}
);
}
// ---------- 列表页 ----------
function PollsListPage({ user, onOpen, onLogin }) {
const [polls, setPolls] = uP([]);
const [loading, setLoading] = uP(true);
const [error, setError] = uP(null);
uPE(() => {
let cancelled = false;
async function load() {
if (!window.bxApi) { if (!cancelled) setLoading(false); return; } // 离线/mock:静默显示空态,不报丑陋的 TypeError
try {
const r = await window.bxApi.activePolls();
if (cancelled) return;
setPolls(r.items || []);
} catch (e) {
if (cancelled) return;
setError(e.message || '加载失败');
} finally {
if (!cancelled) setLoading(false);
}
}
load();
const t = setInterval(load, 8000); // 列表页每 8s 轮一次,温和
return () => { cancelled = true; clearInterval(t); };
}, []);
if (loading) return ;
if (!polls.length) {
return (
);
}
return (
PUBLIC POLLS · 朝议
朝议中
陛下正在征询爱卿之意。
{polls.map(p => (
onOpen('poll', p.id)}>
{p.poll_type === 'binary' ? '二选一' : '多选一'}
{p.title}
{p.description &&
{p.description}
}
{p.options.length} 项 · 已投 {p.total_votes} 人
码 {p.code}
))}
);
}
// 暴露到 window 给 index.html 用
Object.assign(window, { PollsListPage, PollDetailPage, PollCountdown });