import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { request } from '../lib/api'; import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3'; import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3'; import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3'; export type BigScreenOrder = { id: string; orderNo: string; payAmount: number; items: string; /** 展示用时间(付款成功时间) */ createdAt: string; paidAt?: string; userPhoneMasked: string | null; }; type AmountTier = 1 | 2 | 3; const POLL_MS = 3000; const ROW_MS = 2500; /** 不足此条数时重复填充,保证滚动连贯 */ const MIN_SCROLL_ROWS = 10; const FX_DURATION_MS = 10_000; const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; const CELEBRATE_SFX: Record = { 1: sfxTier1, 2: sfxTier2, 3: sfxTier3, }; /** 浏览器自动播放策略:需用户手势后才能出声 */ let audioUnlocked = false; const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null; function unlockCelebrateAudio() { if (audioUnlocked || !sharedAudio) return; sharedAudio.muted = true; sharedAudio.src = CELEBRATE_SFX[1]; void sharedAudio .play() .then(() => { sharedAudio.pause(); sharedAudio.currentTime = 0; sharedAudio.muted = false; audioUnlocked = true; }) .catch(() => { /* 等待下次手势 */ }); } function playCelebrateSfx(tier: AmountTier): () => void { if (!sharedAudio) return () => undefined; try { sharedAudio.pause(); sharedAudio.currentTime = 0; sharedAudio.src = CELEBRATE_SFX[tier]; sharedAudio.volume = tier === 3 ? 0.85 : tier === 2 ? 0.75 : 0.65; sharedAudio.muted = false; void sharedAudio.play().catch(() => { /* 未解锁时静默失败 */ }); } catch { /* ignore */ } return () => { try { sharedAudio.pause(); sharedAudio.currentTime = 0; } catch { /* ignore */ } }; } function pad(n: number) { return String(n).padStart(2, '0'); } function formatClock(d: Date) { return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } function formatDateLine(d: Date) { return `${d.getFullYear()} / ${pad(d.getMonth() + 1)} / ${pad(d.getDate())} ${WEEKDAYS[d.getDay()]}`; } function formatOrderTime(iso: string): string { try { return formatClock(new Date(iso)); } catch { return iso; } } // function formatAmount(n: number): string { // return Number(n || 0).toLocaleString('zh-CN', { // minimumFractionDigits: 0, // maximumFractionDigits: 0, // }); // } function formatAmount(n: number): string { const num = Number(n || 0); // 先保留两位小数,再分割处理 const fixed = num.toFixed(2); const [intStr, decStr] = fixed.split('.'); const intFormatted = Number(intStr).toLocaleString('zh-CN'); return decStr === '00' ? intFormatted : `${intFormatted}.${decStr}`; } function amountCssClass(payAmount: number, base: string): string { const wide = Math.floor(Math.abs(Number(payAmount || 0))) >= 100_000; return wide ? `${base} ${base}--6d` : base; } export function amountTier(payAmount: number): AmountTier { if (payAmount >= 1000) return 3; if (payAmount >= 500) return 2; return 1; } function LiveClock() { const [now, setNow] = useState(() => new Date()); useEffect(() => { const id = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(id); }, []); return (
{formatClock(now)}
{formatDateLine(now)}
); } function OrderRow({ order, latest, }: { order: BigScreenOrder; latest?: boolean; }) { const tier = amountTier(order.payAmount); return (
{latest ? : null} ¥ {formatAmount(order.payAmount)} {order.items || '—'} {formatOrderTime(order.paidAt ?? order.createdAt)} {order.userPhoneMasked || '—'}
); } type Particle = { x: number; y: number; vx: number; vy: number; rot: number; vr: number; w: number; h: number; color: string; life: number; kind: 'rect' | 'ribbon' | 'spark'; rain?: boolean; }; const TIER_COLORS: Record = { 1: ['#e6f7ff', '#91d5ff', '#40a9ff', '#ffffff', '#69c0ff'], 2: ['#fff1b8', '#ffe58f', '#ffd666', '#fffbe6', '#ffe7ba'], 3: ['#ffd666', '#faad14', '#ffec3d', '#fff1b8', '#ff4d4f', '#ff7a45', '#ffffff'], }; function spawnParticles(tier: AmountTier, w: number, h: number): Particle[] { const colors = TIER_COLORS[tier]; const count = tier === 3 ? 180 : tier === 2 ? 110 : 70; const out: Particle[] = []; const cx = w / 2; const cy = h * 0.42; for (let i = 0; i < count; i++) { const angle = Math.random() * Math.PI * 2; const speed = (tier === 3 ? 8 : tier === 2 ? 6 : 4) * (0.4 + Math.random()); const kind: Particle['kind'] = tier === 3 && Math.random() < 0.25 ? 'ribbon' : Math.random() < 0.2 ? 'spark' : 'rect'; out.push({ x: cx + (Math.random() - 0.5) * 80, y: cy + (Math.random() - 0.5) * 40, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - (tier === 3 ? 6 : 3), rot: Math.random() * 360, vr: (Math.random() - 0.5) * 18, w: kind === 'ribbon' ? 10 + Math.random() * 16 : kind === 'spark' ? 2 : 6 + Math.random() * 8, h: kind === 'ribbon' ? 28 + Math.random() * 24 : kind === 'spark' ? 10 + Math.random() * 8 : 4 + Math.random() * 6, color: colors[Math.floor(Math.random() * colors.length)], life: 1, kind, }); } if (tier >= 2) { for (let i = 0; i < (tier === 3 ? 80 : 40); i++) { out.push({ x: Math.random() * w, y: -20 - Math.random() * 80, vx: (Math.random() - 0.5) * 1.4, vy: 3 + Math.random() * 5, rot: Math.random() * 360, vr: (Math.random() - 0.5) * 10, w: 5 + Math.random() * 8, h: 10 + Math.random() * 14, color: colors[Math.floor(Math.random() * colors.length)], life: 1, kind: 'rect', rain: true, }); } } return out; } function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => void }) { const canvasRef = useRef(null); const onDoneRef = useRef(onDone); onDoneRef.current = onDone; const tier = amountTier(order.payAmount); const [displayAmount, setDisplayAmount] = useState(tier === 3 ? 0 : order.payAmount); useEffect(() => { const timer = window.setTimeout(() => onDoneRef.current(), FX_DURATION_MS); return () => window.clearTimeout(timer); }, [order.id]); useEffect(() => { const stop = playCelebrateSfx(tier); return stop; }, [order.id, tier]); useEffect(() => { if (tier !== 3) return; const start = performance.now(); const dur = 900; let raf = 0; const tick = (now: number) => { const p = Math.min(1, (now - start) / dur); const eased = 1 - (1 - p) ** 3; setDisplayAmount(Math.round(order.payAmount * eased)); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [order.payAmount, order.id, tier]); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const resize = () => { canvas.width = canvas.clientWidth * devicePixelRatio; canvas.height = canvas.clientHeight * devicePixelRatio; ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); }; resize(); const particles = spawnParticles(tier, canvas.clientWidth, canvas.clientHeight); let raf = 0; let last = performance.now(); const started = performance.now(); const gravity = tier === 3 ? 0.18 : 0.14; const loop = (now: number) => { const dt = Math.min(32, now - last) / 16.6; last = now; ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); for (const p of particles) { p.vy += gravity * dt; p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.vr * dt; p.life -= (tier === 3 ? 0.002 : 0.003) * dt; if ( p.rain && (p.y > canvas.clientHeight + 30 || p.life <= 0) && now - started < FX_DURATION_MS - 400 ) { p.x = Math.random() * canvas.clientWidth; p.y = -16 - Math.random() * 60; p.vx = (Math.random() - 0.5) * 1.4; p.vy = 3 + Math.random() * 5; p.life = 1; } if (p.life <= 0) continue; ctx.save(); ctx.translate(p.x, p.y); ctx.rotate((p.rot * Math.PI) / 180); ctx.globalAlpha = Math.max(0, p.life); ctx.fillStyle = p.color; if (p.kind === 'spark') { ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); } else { ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); } ctx.restore(); } raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); const onResize = () => resize(); window.addEventListener('resize', onResize); return () => { cancelAnimationFrame(raf); window.removeEventListener('resize', onResize); }; }, [order.id, tier]); return (
{tier === 3 ?
: null} {tier === 3 ?
: null} {tier >= 2 ?
: null} {tier >= 2 ?
: null}
{tier === 3 ? '高额成交' : tier === 2 ? '大额成交' : '新成交'}
¥ {formatAmount(displayAmount)}
{order.items || '—'}
{order.userPhoneMasked || '—'} {formatOrderTime(order.paidAt ?? order.createdAt)}
); } export default function BigScreenPage() { const [items, setItems] = useState([]); const [loadError, setLoadError] = useState(''); const [paused, setPaused] = useState(false); const [celebrate, setCelebrate] = useState(null); const seenIdsRef = useRef | null>(null); const celebratedIdsRef = useRef>(new Set()); const queueRef = useRef([]); const celebratingRef = useRef(false); const viewportRef = useRef(null); const dayKeyRef = useRef(''); const [viewportH, setViewportH] = useState(0); const playNext = useCallback(() => { const next = queueRef.current.shift() ?? null; celebratingRef.current = !!next; setCelebrate(next); setPaused(!!next); }, []); const enqueueNew = useCallback( (fresh: BigScreenOrder[]) => { const toCelebrate = fresh.filter((o) => !celebratedIdsRef.current.has(o.id)); if (!toCelebrate.length) return; for (const o of toCelebrate) celebratedIdsRef.current.add(o.id); const ranked = [...toCelebrate].sort((a, b) => { const td = amountTier(b.payAmount) - amountTier(a.payAmount); if (td !== 0) return td; return +new Date(b.paidAt ?? b.createdAt) - +new Date(a.paidAt ?? a.createdAt); }); queueRef.current.push(...ranked); if (!celebratingRef.current) playNext(); }, [playNext], ); const fetchData = useCallback(() => { const now = new Date(); const todayKey = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; if (dayKeyRef.current && dayKeyRef.current !== todayKey) { seenIdsRef.current = null; celebratedIdsRef.current = new Set(); queueRef.current = []; celebratingRef.current = false; setCelebrate(null); setPaused(false); } dayKeyRef.current = todayKey; return request<{ items: BigScreenOrder[] }>('/admin/orders/big-screen?limit=2000') .then((d) => { const list = d.items ?? []; setItems(list); setLoadError(''); const seen = seenIdsRef.current; if (!seen) { seenIdsRef.current = new Set(list.map((o) => o.id)); for (const o of list) celebratedIdsRef.current.add(o.id); return; } const fresh = list.filter((o) => !seen.has(o.id)); for (const o of fresh) seen.add(o.id); enqueueNew(fresh); }) .catch((e) => { setLoadError(e instanceof Error ? e.message : '加载失败'); }); }, [enqueueNew]); useEffect(() => { void fetchData(); const id = setInterval(() => void fetchData(), POLL_MS); return () => clearInterval(id); }, [fetchData]); useEffect(() => { unlockCelebrateAudio(); const unlock = () => unlockCelebrateAudio(); window.addEventListener('pointerdown', unlock, { once: true }); window.addEventListener('keydown', unlock, { once: true }); return () => { window.removeEventListener('pointerdown', unlock); window.removeEventListener('keydown', unlock); }; }, []); useEffect(() => { const html = document.documentElement; const prevHtml = html.style.overflow; const prevBody = document.body.style.overflow; html.style.overflow = 'hidden'; document.body.style.overflow = 'hidden'; return () => { html.style.overflow = prevHtml; document.body.style.overflow = prevBody; }; }, []); useEffect(() => { const el = viewportRef.current; if (!el) return; const measure = () => setViewportH(el.clientHeight); measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, []); const unitItems = useMemo(() => { if (!items.length) return []; const rowH = 80; const visible = Math.max(1, Math.ceil((viewportH || 480) / rowH)); const minCount = Math.max(MIN_SCROLL_ROWS, visible + 1); const unit: BigScreenOrder[] = []; while (unit.length < minCount) unit.push(...items); return unit; }, [items, viewportH]); const trackItems = useMemo(() => unitItems.concat(unitItems), [unitItems]); const marqueeMs = Math.max(unitItems.length, 1) * ROW_MS; const rolling = items.length > 0 && unitItems.length > 0; const latestId = items[0]?.id; return (

杜康好客

· 发布会现场
实时成交 LIVE
{items.length === 0 ? null : (
{trackItems.map((o, idx) => ( ))}
)}
{items.length === 0 ? (
{loadError || '当日暂无订单'}
) : null} {celebrate ? ( ) : null}
); }