import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { request } from '../lib/api'; 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 WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; const TIER_DURATION: Record = { 1: 3000, 2: 4500, 3: 6000 }; const SEED_IDS = new Set( ['seed-1', 'seed-2', 'seed-3', 'seed-4', 'seed-5', 'seed-6', 'seed-7', 'seed-8'], ); function seedPaidAt(h: number, m: number, s: number): string { const d = new Date(); d.setHours(h, m, s, 0); return d.toISOString(); } /** 发布会滚动列表垫底演示数据(不参与成交动效) */ const BIG_SCREEN_SEED_ORDERS: BigScreenOrder[] = [ { id: 'seed-1', orderNo: 'DK-DEMO-001', payAmount: 1288, items: '杜康君酿 · 浓香型 × 6瓶', createdAt: seedPaidAt(14, 28, 36), paidAt: seedPaidAt(14, 28, 36), userPhoneMasked: '138****8888', }, { id: 'seed-2', orderNo: 'DK-DEMO-002', payAmount: 688, items: '杜康君酿 · 浓香型 × 2瓶', createdAt: seedPaidAt(14, 22, 18), paidAt: seedPaidAt(14, 22, 18), userPhoneMasked: '139****6666', }, { id: 'seed-3', orderNo: 'DK-DEMO-003', payAmount: 1588, items: '杜康典藏 · 礼盒装 × 2套', createdAt: seedPaidAt(14, 15, 42), paidAt: seedPaidAt(14, 15, 42), userPhoneMasked: '136****9527', }, { id: 'seed-4', orderNo: 'DK-DEMO-004', payAmount: 520, items: '杜康特曲 · 500ml × 4瓶', createdAt: seedPaidAt(14, 8, 55), paidAt: seedPaidAt(14, 8, 55), userPhoneMasked: '135****3210', }, { id: 'seed-5', orderNo: 'DK-DEMO-005', payAmount: 268, items: '杜康君酿 · 浓香型 × 1瓶', createdAt: seedPaidAt(13, 56, 7), paidAt: seedPaidAt(13, 56, 7), userPhoneMasked: '137****1888', }, { id: 'seed-6', orderNo: 'DK-DEMO-006', payAmount: 999, items: '杜康窖藏 · 珍藏版 × 3瓶', createdAt: seedPaidAt(13, 48, 33), paidAt: seedPaidAt(13, 48, 33), userPhoneMasked: '133****7788', }, { id: 'seed-7', orderNo: 'DK-DEMO-007', payAmount: 188, items: '杜康小酒 · 品鉴装 × 2瓶', createdAt: seedPaidAt(13, 35, 21), paidAt: seedPaidAt(13, 35, 21), userPhoneMasked: '158****0099', }, { id: 'seed-8', orderNo: 'DK-DEMO-008', payAmount: 1200, items: '杜康君酿 · 浓香型 × 5瓶', createdAt: seedPaidAt(13, 22, 49), paidAt: seedPaidAt(13, 22, 49), userPhoneMasked: '186****5566', }, ]; function mergeDisplayItems(live: BigScreenOrder[]): BigScreenOrder[] { if (!live.length) return [...BIG_SCREEN_SEED_ORDERS]; return [...live, ...BIG_SCREEN_SEED_ORDERS]; } function isSeedOrder(order: BigScreenOrder): boolean { return SEED_IDS.has(order.id); } 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 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 duration = TIER_DURATION[tier]; const timer = window.setTimeout(() => onDoneRef.current(), duration); return () => window.clearTimeout(timer); }, [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 duration = TIER_DURATION[tier]; 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.0032 : 0.005) * dt; if (p.rain && (p.y > canvas.clientHeight + 30 || p.life <= 0) && now - started < duration - 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 [viewportH, setViewportH] = useState(0); useEffect(() => { for (const o of BIG_SCREEN_SEED_ORDERS) celebratedIdsRef.current.add(o.id); }, []); 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(() => { return request<{ items: BigScreenOrder[] }>('/admin/orders/big-screen?limit=2000') .then((d) => { const list = d.items ?? []; setItems(mergeDisplayItems(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 : '加载失败'); setItems(mergeDisplayItems([])); }); }, [enqueueNew]); useEffect(() => { void fetchData(); const id = setInterval(() => void fetchData(), POLL_MS); return () => clearInterval(id); }, [fetchData]); 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 = 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 latestLive = items.find((o) => !isSeedOrder(o)); const latestId = latestLive?.id; return (

杜康好客

· 发布会现场
实时成交 LIVE
下单金额 下单商品 付款时间 下单人
{items.length === 0 ? (
{loadError || '暂无订单'}
) : (
{trackItems.map((o, idx) => ( ))}
)}
{celebrate ? ( ) : null}
); }