10 Commits

Author SHA1 Message Date
jacy f941dcf072 Merge pull request 'Dev' (#27) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/27
2026-08-20 15:06:24 +08:00
jacy e8b585e623 Merge pull request '增加音效逻辑' (#26) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/26
2026-08-20 15:06:07 +08:00
jacy ee493823bf 增加音效逻辑
CI / verify (pull_request) Has been cancelled
2026-08-20 15:04:54 +08:00
jacy 5702266276 Merge pull request 'Dev jacy' (#25) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/25
2026-08-20 13:43:29 +08:00
jacy 3382d36a6c Merge pull request 'Dev jacy 大屏优化' (#24) from dev_jacy into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/24
2026-08-20 13:43:06 +08:00
jacy 8b5bb6a981 大屏显示优化
CI / verify (pull_request) Has been cancelled
2026-08-20 13:41:50 +08:00
jacy 0a36188b35 调整优化大屏 2026-08-20 12:55:11 +08:00
jacy 9b8e3f1347 Merge pull request 'v3.5.2 大屏调整' (#23) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/23
2026-08-20 12:01:21 +08:00
jacy a8a37fbc4a Merge pull request 'v3.5.2 大屏调整' (#22) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/22
2026-08-20 12:00:57 +08:00
jacy 6cf66c722c v3.5.2 大屏调整
CI / verify (pull_request) Has been cancelled
2026-08-20 11:58:15 +08:00
7 changed files with 138 additions and 135 deletions
+19 -15
View File
@@ -291,26 +291,21 @@ body:has(.big-screen-page),
z-index: 1; z-index: 1;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0 8px; padding: 0;
overflow: hidden; overflow: hidden;
} }
.big-screen-list-head, .big-screen-list-head,
.big-screen-row { .big-screen-row {
display: grid; display: grid;
grid-template-columns: 208px 1fr 160px 200px; grid-template-columns: minmax(180px, 18%) minmax(0, 1fr) minmax(140px, 16%) minmax(160px, 18%);
align-items: center; align-items: center;
column-gap: 16px; column-gap: 16px;
} width: 100%;
box-sizing: border-box;
.big-screen-list-head {
flex: 0 0 auto;
padding: 0 28px 10px 40px;
font-size: 15px;
color: rgba(170, 200, 230, 0.55);
letter-spacing: 2px;
} }
.big-screen-list-body { .big-screen-list-body {
@@ -457,10 +452,20 @@ body:has(.big-screen-page),
} }
.big-screen-empty { .big-screen-empty {
position: absolute;
left: 0;
right: 0;
/* 全屏高度黄金分割点(≈0.382),略偏上 */
top: 38.2%;
transform: translateY(-50%);
z-index: 2;
text-align: center; text-align: center;
color: rgba(255, 255, 255, 0.45); color: rgba(145, 190, 230, 0.42);
padding: 48px 20px; padding: 0 40px;
font-size: 18px; font-size: 60px;
letter-spacing: 6px;
font-weight: 400;
pointer-events: none;
} }
.big-screen-fx { .big-screen-fx {
@@ -713,9 +718,8 @@ body:has(.big-screen-page),
.big-screen-clock { .big-screen-clock {
font-size: 56px; font-size: 56px;
} }
.big-screen-list-head,
.big-screen-row { .big-screen-row {
grid-template-columns: 172px 1fr 120px 160px; grid-template-columns: minmax(140px, 20%) minmax(0, 1fr) minmax(110px, 16%) minmax(130px, 18%);
font-size: 16px; font-size: 16px;
} }
.big-screen-amount--6d { .big-screen-amount--6d {
+108 -117
View File
@@ -1,5 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { request } from '../lib/api'; 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 = { export type BigScreenOrder = {
id: string; id: string;
@@ -16,102 +19,60 @@ type AmountTier = 1 | 2 | 3;
const POLL_MS = 3000; const POLL_MS = 3000;
const ROW_MS = 2500; const ROW_MS = 2500;
/** 不足此条数时重复填充,保证滚动连贯 */
const MIN_SCROLL_ROWS = 10;
const FX_DURATION_MS = 10_000;
const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
const TIER_DURATION: Record<AmountTier, number> = { 1: 3000, 2: 4500, 3: 6000 };
const SEED_IDS = new Set( const CELEBRATE_SFX: Record<AmountTier, string> = {
['seed-1', 'seed-2', 'seed-3', 'seed-4', 'seed-5', 'seed-6', 'seed-7', 'seed-8'], 1: sfxTier1,
); 2: sfxTier2,
3: sfxTier3,
};
function seedPaidAt(h: number, m: number, s: number): string { /** 浏览器自动播放策略:需用户手势后才能出声 */
const d = new Date(); let audioUnlocked = false;
d.setHours(h, m, s, 0); const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null;
return d.toISOString();
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 {
const BIG_SCREEN_SEED_ORDERS: BigScreenOrder[] = [ if (!sharedAudio) return () => undefined;
{ try {
id: 'seed-1', sharedAudio.pause();
orderNo: 'DK-DEMO-001', sharedAudio.currentTime = 0;
payAmount: 1288, sharedAudio.src = CELEBRATE_SFX[tier];
items: '杜康君酿 · 浓香型 × 6瓶', sharedAudio.volume = tier === 3 ? 0.85 : tier === 2 ? 0.75 : 0.65;
createdAt: seedPaidAt(14, 28, 36), sharedAudio.muted = false;
paidAt: seedPaidAt(14, 28, 36), void sharedAudio.play().catch(() => {
userPhoneMasked: '138****8888', /* 未解锁时静默失败 */
}, });
{ } catch {
id: 'seed-2', /* ignore */
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];
} }
return () => {
function isSeedOrder(order: BigScreenOrder): boolean { try {
return SEED_IDS.has(order.id); sharedAudio.pause();
sharedAudio.currentTime = 0;
} catch {
/* ignore */
}
};
} }
function pad(n: number) { function pad(n: number) {
@@ -134,11 +95,20 @@ function formatOrderTime(iso: string): string {
} }
} }
// function formatAmount(n: number): string {
// return Number(n || 0).toLocaleString('zh-CN', {
// minimumFractionDigits: 0,
// maximumFractionDigits: 0,
// });
// }
function formatAmount(n: number): string { function formatAmount(n: number): string {
return Number(n || 0).toLocaleString('zh-CN', { const num = Number(n || 0);
minimumFractionDigits: 0, // 先保留两位小数,再分割处理
maximumFractionDigits: 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 { function amountCssClass(payAmount: number, base: string): string {
@@ -264,9 +234,13 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v
const [displayAmount, setDisplayAmount] = useState(tier === 3 ? 0 : order.payAmount); const [displayAmount, setDisplayAmount] = useState(tier === 3 ? 0 : order.payAmount);
useEffect(() => { useEffect(() => {
const duration = TIER_DURATION[tier]; const timer = window.setTimeout(() => onDoneRef.current(), FX_DURATION_MS);
const timer = window.setTimeout(() => onDoneRef.current(), duration);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [order.id]);
useEffect(() => {
const stop = playCelebrateSfx(tier);
return stop;
}, [order.id, tier]); }, [order.id, tier]);
useEffect(() => { useEffect(() => {
@@ -299,7 +273,6 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v
let raf = 0; let raf = 0;
let last = performance.now(); let last = performance.now();
const started = performance.now(); const started = performance.now();
const duration = TIER_DURATION[tier];
const gravity = tier === 3 ? 0.18 : 0.14; const gravity = tier === 3 ? 0.18 : 0.14;
const loop = (now: number) => { const loop = (now: number) => {
const dt = Math.min(32, now - last) / 16.6; const dt = Math.min(32, now - last) / 16.6;
@@ -310,8 +283,12 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v
p.x += p.vx * dt; p.x += p.vx * dt;
p.y += p.vy * dt; p.y += p.vy * dt;
p.rot += p.vr * dt; p.rot += p.vr * dt;
p.life -= (tier === 3 ? 0.0032 : 0.005) * dt; p.life -= (tier === 3 ? 0.002 : 0.003) * dt;
if (p.rain && (p.y > canvas.clientHeight + 30 || p.life <= 0) && now - started < duration - 400) { if (
p.rain &&
(p.y > canvas.clientHeight + 30 || p.life <= 0) &&
now - started < FX_DURATION_MS - 400
) {
p.x = Math.random() * canvas.clientWidth; p.x = Math.random() * canvas.clientWidth;
p.y = -16 - Math.random() * 60; p.y = -16 - Math.random() * 60;
p.vx = (Math.random() - 0.5) * 1.4; p.vx = (Math.random() - 0.5) * 1.4;
@@ -374,12 +351,9 @@ export default function BigScreenPage() {
const queueRef = useRef<BigScreenOrder[]>([]); const queueRef = useRef<BigScreenOrder[]>([]);
const celebratingRef = useRef(false); const celebratingRef = useRef(false);
const viewportRef = useRef<HTMLDivElement | null>(null); const viewportRef = useRef<HTMLDivElement | null>(null);
const dayKeyRef = useRef('');
const [viewportH, setViewportH] = useState(0); const [viewportH, setViewportH] = useState(0);
useEffect(() => {
for (const o of BIG_SCREEN_SEED_ORDERS) celebratedIdsRef.current.add(o.id);
}, []);
const playNext = useCallback(() => { const playNext = useCallback(() => {
const next = queueRef.current.shift() ?? null; const next = queueRef.current.shift() ?? null;
celebratingRef.current = !!next; celebratingRef.current = !!next;
@@ -404,10 +378,22 @@ export default function BigScreenPage() {
); );
const fetchData = useCallback(() => { 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') return request<{ items: BigScreenOrder[] }>('/admin/orders/big-screen?limit=2000')
.then((d) => { .then((d) => {
const list = d.items ?? []; const list = d.items ?? [];
setItems(mergeDisplayItems(list)); setItems(list);
setLoadError(''); setLoadError('');
const seen = seenIdsRef.current; const seen = seenIdsRef.current;
if (!seen) { if (!seen) {
@@ -421,7 +407,6 @@ export default function BigScreenPage() {
}) })
.catch((e) => { .catch((e) => {
setLoadError(e instanceof Error ? e.message : '加载失败'); setLoadError(e instanceof Error ? e.message : '加载失败');
setItems(mergeDisplayItems([]));
}); });
}, [enqueueNew]); }, [enqueueNew]);
@@ -431,6 +416,17 @@ export default function BigScreenPage() {
return () => clearInterval(id); return () => clearInterval(id);
}, [fetchData]); }, [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(() => { useEffect(() => {
const html = document.documentElement; const html = document.documentElement;
const prevHtml = html.style.overflow; const prevHtml = html.style.overflow;
@@ -457,7 +453,7 @@ export default function BigScreenPage() {
if (!items.length) return []; if (!items.length) return [];
const rowH = 80; const rowH = 80;
const visible = Math.max(1, Math.ceil((viewportH || 480) / rowH)); const visible = Math.max(1, Math.ceil((viewportH || 480) / rowH));
const minCount = visible + 1; const minCount = Math.max(MIN_SCROLL_ROWS, visible + 1);
const unit: BigScreenOrder[] = []; const unit: BigScreenOrder[] = [];
while (unit.length < minCount) unit.push(...items); while (unit.length < minCount) unit.push(...items);
return unit; return unit;
@@ -466,8 +462,7 @@ export default function BigScreenPage() {
const trackItems = useMemo(() => unitItems.concat(unitItems), [unitItems]); const trackItems = useMemo(() => unitItems.concat(unitItems), [unitItems]);
const marqueeMs = Math.max(unitItems.length, 1) * ROW_MS; const marqueeMs = Math.max(unitItems.length, 1) * ROW_MS;
const rolling = items.length > 0 && unitItems.length > 0; const rolling = items.length > 0 && unitItems.length > 0;
const latestLive = items.find((o) => !isSeedOrder(o)); const latestId = items[0]?.id;
const latestId = latestLive?.id;
return ( return (
<div className="big-screen-page"> <div className="big-screen-page">
@@ -496,16 +491,8 @@ export default function BigScreenPage() {
<LiveClock /> <LiveClock />
<div className="big-screen-list"> <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}> <div className="big-screen-list-body" ref={viewportRef}>
{items.length === 0 ? ( {items.length === 0 ? null : (
<div className="big-screen-empty">{loadError || '暂无订单'}</div>
) : (
<div <div
className={`big-screen-track${rolling ? ' is-rolling' : ''}${paused ? ' is-paused' : ''}`} className={`big-screen-track${rolling ? ' is-rolling' : ''}${paused ? ' is-paused' : ''}`}
style={rolling ? ({ ['--marquee-ms']: `${marqueeMs}ms` } as CSSProperties) : undefined} style={rolling ? ({ ['--marquee-ms']: `${marqueeMs}ms` } as CSSProperties) : undefined}
@@ -522,6 +509,10 @@ export default function BigScreenPage() {
</div> </div>
</div> </div>
{items.length === 0 ? (
<div className="big-screen-empty">{loadError || '当日暂无订单'}</div>
) : null}
{celebrate ? ( {celebrate ? (
<CelebrateFx <CelebrateFx
key={celebrate.id} key={celebrate.id}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.mp3' {
const src: string;
export default src;
}
@@ -25,13 +25,15 @@ export class AdminOrdersService {
private readonly adminRedeemService: AdminRedeemService, private readonly adminRedeemService: AdminRedeemService,
) {} ) {}
/** v3.5.1 #1:发布会大屏,仅已付款订单;按付款时间倒序 */ /** v3.5.1 #1:发布会大屏,仅当日已付款订单;按付款时间倒序 */
async listBigScreen(limit?: string) { async listBigScreen(limit?: string) {
const take = Math.min(Math.max(Number(limit) || 2000, 1), 2000); const take = Math.min(Math.max(Number(limit) || 2000, 1), 2000);
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const orders = await this.prisma.order.findMany({ const orders = await this.prisma.order.findMany({
where: { where: {
payAmount: { gte: 100 },
payStatus: 'PAID', payStatus: 'PAID',
paidAt: { gte: startOfToday },
}, },
orderBy: [{ paidAt: 'desc' }, { createdAt: 'desc' }], orderBy: [{ paidAt: 'desc' }, { createdAt: 'desc' }],
take, take,