/* 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 (
投票码 {poll.code}
{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 (
onOpen('home')}/>
); } 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 });