535 lines
17 KiB
TypeScript
535 lines
17 KiB
TypeScript
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<AmountTier, number> = { 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 (
|
||
<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 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 (
|
||
<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 [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 (
|
||
<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-head">
|
||
<span>下单金额</span>
|
||
<span>下单商品</span>
|
||
<span>付款时间</span>
|
||
<span>下单人</span>
|
||
</div>
|
||
<div className="big-screen-list-body" ref={viewportRef}>
|
||
{items.length === 0 ? (
|
||
<div className="big-screen-empty">{loadError || '暂无订单'}</div>
|
||
) : (
|
||
<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>
|
||
|
||
{celebrate ? (
|
||
<CelebrateFx
|
||
key={celebrate.id}
|
||
order={celebrate}
|
||
onDone={playNext}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|