Files
dukang/apps/admin-web/src/pages/BigScreenPage.tsx
T
jacy ee493823bf
CI / verify (pull_request) Has been cancelled
增加音效逻辑
2026-08-20 15:04:54 +08:00

526 lines
17 KiB
TypeScript

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<AmountTier, string> = {
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 (
<div className="big-screen-hero">
<div className="big-screen-clock">{formatClock(now)}</div>
<div className="big-screen-date">{formatDateLine(now)}</div>
</div>
);
}
function OrderRow({
order,
latest,
}: {
order: BigScreenOrder;
latest?: boolean;
}) {
const tier = amountTier(order.payAmount);
return (
<div
className={`big-screen-row big-screen-row--t${tier}${latest ? ' is-latest' : ''}`}
>
{latest ? <span className="big-screen-row-mark" /> : null}
<span className={amountCssClass(order.payAmount, 'big-screen-amount')}>
¥ {formatAmount(order.payAmount)}
</span>
<span className="big-screen-items">{order.items || '—'}</span>
<span className="big-screen-time">{formatOrderTime(order.paidAt ?? order.createdAt)}</span>
<span className="big-screen-phone">{order.userPhoneMasked || '—'}</span>
</div>
);
}
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<AmountTier, string[]> = {
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<HTMLCanvasElement | null>(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 (
<div className={`big-screen-fx big-screen-fx--t${tier}`} role="presentation">
<canvas ref={canvasRef} className="big-screen-fx-canvas" />
{tier === 3 ? <div className="big-screen-fx-shock" /> : null}
{tier === 3 ? <div className="big-screen-fx-shock big-screen-fx-shock--late" /> : null}
{tier >= 2 ? <div className="big-screen-fx-sweep" /> : null}
{tier >= 2 ? <div className="big-screen-fx-sweep big-screen-fx-sweep--alt" /> : null}
<div className={`big-screen-fx-card big-screen-fx-card--t${tier}`}>
<div className="big-screen-fx-kicker">{tier === 3 ? '高额成交' : tier === 2 ? '大额成交' : '新成交'}</div>
<div className={amountCssClass(displayAmount || order.payAmount, 'big-screen-fx-amount')}>
¥ {formatAmount(displayAmount)}
</div>
<div className="big-screen-fx-items">{order.items || '—'}</div>
<div className="big-screen-fx-meta">
<span>{order.userPhoneMasked || '—'}</span>
<span>{formatOrderTime(order.paidAt ?? order.createdAt)}</span>
</div>
</div>
</div>
);
}
export default function BigScreenPage() {
const [items, setItems] = useState<BigScreenOrder[]>([]);
const [loadError, setLoadError] = useState('');
const [paused, setPaused] = useState(false);
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
const seenIdsRef = useRef<Set<string> | null>(null);
const celebratedIdsRef = useRef<Set<string>>(new Set());
const queueRef = useRef<BigScreenOrder[]>([]);
const celebratingRef = useRef(false);
const viewportRef = useRef<HTMLDivElement | null>(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 (
<div className="big-screen-page">
<div className="big-screen-stars" aria-hidden />
<div className="big-screen-frame" aria-hidden>
<span className="big-screen-corner big-screen-corner--tl" />
<span className="big-screen-corner big-screen-corner--tr" />
<span className="big-screen-corner big-screen-corner--bl" />
<span className="big-screen-corner big-screen-corner--br" />
</div>
<header className="big-screen-header">
<div className="big-screen-brand">
<h1 className="big-screen-title">杜康好客</h1>
<span className="big-screen-subtitle">· 发布会现场</span>
</div>
<div className="big-screen-live-wrap">
<span className="big-screen-live-label">实时成交</span>
<span className="big-screen-live">
<span className="big-screen-live-dot" />
LIVE
</span>
</div>
</header>
<LiveClock />
<div className="big-screen-list">
<div className="big-screen-list-body" ref={viewportRef}>
{items.length === 0 ? null : (
<div
className={`big-screen-track${rolling ? ' is-rolling' : ''}${paused ? ' is-paused' : ''}`}
style={rolling ? ({ ['--marquee-ms']: `${marqueeMs}ms` } as CSSProperties) : undefined}
>
{trackItems.map((o, idx) => (
<OrderRow
key={`${o.id}-${idx}`}
order={o}
latest={idx % items.length === 0 && o.id === latestId}
/>
))}
</div>
)}
</div>
</div>
{items.length === 0 ? (
<div className="big-screen-empty">{loadError || '当日暂无订单'}</div>
) : null}
{celebrate ? (
<CelebrateFx
key={celebrate.id}
order={celebrate}
onDone={playNext}
/>
) : null}
</div>
);
}