merge(dev): 门店核销走马灯优化
CI / verify (push) Has been cancelled

This commit is contained in:
2026-08-03 16:34:36 +08:00
3 changed files with 88 additions and 129 deletions
@@ -1,41 +1,62 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import Taro, { useReady } from '@tarojs/taro'; import Taro from '@tarojs/taro';
type StoreRedeemMarqueeProps = { type StoreRedeemMarqueeProps = {
lines: string[]; lines: string[];
}; };
/** 飘动速度 px/s */ const FLY_SPEED = 56;
const FLY_SPEED = 58; const MIN_FLY_MS = 2400;
const MIN_FLY_MS = 2200;
const PAUSE_MIN_MS = 1000; const PAUSE_MIN_MS = 1000;
const PAUSE_MAX_MS = 5000; const PAUSE_MAX_MS = 5000;
const TICK_MS = 16;
function estimateTextWidth(text: string): number { /** 飘动终点:left 停在此处(略向左溢出 10px) */
let w = 0; const STOP_LEFT_PX = -10;
for (const ch of text) {
w += /[^\x00-\xff]/.test(ch) ? 12 : 7;
}
return Math.max(Math.ceil(w), 80);
}
function randomPauseMs() { function randomPauseMs() {
return PAUSE_MIN_MS + Math.floor(Math.random() * (PAUSE_MAX_MS - PAUSE_MIN_MS + 1)); return PAUSE_MIN_MS + Math.floor(Math.random() * (PAUSE_MAX_MS - PAUSE_MIN_MS + 1));
} }
function uid(prefix: string) { /** 容器宽兜底(不依赖 DOM 测量,小程序首帧即可用) */
return `${prefix}${Math.random().toString(36).slice(2, 10)}`; function getBoxWidthFallback(): number {
try {
const sys = Taro.getSystemInfoSync();
const screenW = Number(sys.windowWidth || sys.screenWidth || 375);
// 与 section 同宽:左右 var(--space-page)
return Math.max(220, Math.floor(screenW - 32));
} catch {
return 300;
}
} }
function createQuery() { function measureBoxWidth(selector: string, fallback: number): Promise<number> {
const page = Taro.getCurrentInstance().page; return new Promise((resolve) => {
return page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery(); Taro.nextTick(() => {
try {
const page = Taro.getCurrentInstance().page;
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
query
.select(selector)
.boundingClientRect()
.exec((res) => {
const w = Number(res?.[0]?.width || 0);
resolve(w > 8 ? Math.ceil(w) : fallback);
});
} catch {
resolve(fallback);
}
});
});
} }
/** /**
* 核销走马灯:单条从右向左位移飞出,间隔 1~5 秒随机再播下一条。 * 核销走马灯:单条从右向左位移飞出,间隔 1~5 秒随机再播下一条。
* H5 / 微信小程序均用 JS translateX,不依赖 CSS animation / Intl。 *
* 小程序注意:
* - 不用 useReady(子组件内不触发 → opacity 永远 0)
* - 不用 Text + transform(支持差),改用 View + left
* - 字宽用估算,避免屏外元素测宽失败
*/ */
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) { export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
const items = useMemo( const items = useMemo(
@@ -46,64 +67,19 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
[lines], [lines],
); );
const rootIdRef = useRef(uid('smr')); const rootIdRef = useRef(`smr${Math.random().toString(36).slice(2, 10)}`);
const textIdRef = useRef(uid('smt'));
const indexRef = useRef(0); const indexRef = useRef(0);
const boxWidthRef = useRef(0); const boxWidthRef = useRef(getBoxWidthFallback());
const itemsKey = items.join('\n'); const itemsKey = items.join('\n');
const [displayIndex, setDisplayIndex] = useState(0); const [displayIndex, setDisplayIndex] = useState(0);
const [offset, setOffset] = useState(9999); const [leftPx, setLeftPx] = useState(() => boxWidthRef.current);
const [ready, setReady] = useState(false);
const measureBox = () =>
new Promise<number>((resolve) => {
Taro.nextTick(() => {
try {
createQuery()
.select(`#${rootIdRef.current}`)
.boundingClientRect()
.exec((res) => {
const box = Number(res?.[0]?.width || 0);
const next = box > 8 ? box : boxWidthRef.current || 300;
boxWidthRef.current = next;
resolve(next);
});
} catch {
resolve(boxWidthRef.current || 300);
}
});
});
const measureText = (text: string) =>
new Promise<number>((resolve) => {
const fallback = estimateTextWidth(text);
Taro.nextTick(() => {
try {
createQuery()
.select(`#${textIdRef.current}`)
.boundingClientRect()
.exec((res) => {
const tw = Number(res?.[0]?.width || 0);
if (tw > 8 && tw < fallback * 3) resolve(Math.ceil(tw));
else resolve(fallback);
});
} catch {
resolve(fallback);
}
});
});
useReady(() => {
void measureBox().then(() => setReady(true));
});
useEffect(() => { useEffect(() => {
if (!items.length) return; if (!items.length) return;
let cancelled = false; let cancelled = false;
const waiters = new Set<ReturnType<typeof setTimeout>>(); const waiters = new Set<ReturnType<typeof setTimeout>>();
let rafId = 0;
let tickTimer: ReturnType<typeof setInterval> | undefined; let tickTimer: ReturnType<typeof setInterval> | undefined;
const sleep = (ms: number) => const sleep = (ms: number) =>
@@ -115,78 +91,59 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
waiters.add(id); waiters.add(id);
}); });
const clearAnim = () => { const clearTick = () => {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
if (tickTimer) { if (tickTimer) {
clearInterval(tickTimer); clearInterval(tickTimer);
tickTimer = undefined; tickTimer = undefined;
} }
}; };
const fly = (start: number, end: number, durationMs: number) => const fly = (from: number, to: number, durationMs: number) =>
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
const began = Date.now(); const began = Date.now();
setOffset(start); setLeftPx(from);
clearTick();
const step = () => { tickTimer = setInterval(() => {
if (cancelled) { if (cancelled) {
clearAnim(); clearTick();
resolve(); resolve();
return; return;
} }
const t = Math.min(1, (Date.now() - began) / durationMs); const t = Math.min(1, (Date.now() - began) / durationMs);
setOffset(start + (end - start) * t); setLeftPx(from + (to - from) * t);
if (t >= 1) { if (t >= 1) {
clearAnim(); clearTick();
resolve(); resolve();
return;
} }
if (typeof requestAnimationFrame === 'function') { }, TICK_MS);
rafId = requestAnimationFrame(step);
}
};
clearAnim();
if (typeof requestAnimationFrame === 'function') {
rafId = requestAnimationFrame(step);
} else {
tickTimer = setInterval(step, 32);
}
}); });
const loop = async () => { const loop = async () => {
indexRef.current = 0; indexRef.current = 0;
setDisplayIndex(0); setDisplayIndex(0);
await sleep(60);
const measured = await measureBoxWidth(`#${rootIdRef.current}`, boxWidthRef.current);
boxWidthRef.current = measured;
if (cancelled) return; if (cancelled) return;
while (!cancelled) { while (!cancelled && items.length) {
if (!items.length) break;
const idx = indexRef.current % items.length; const idx = indexRef.current % items.length;
const text = items[idx]; const box = boxWidthRef.current;
setDisplayIndex(idx); setDisplayIndex(idx);
setOffset(9999);
await sleep(48);
if (cancelled) break;
const box = await measureBox(); const from = box;
const textW = await measureText(text); const to = STOP_LEFT_PX;
if (cancelled) break; const distance = from - to;
const start = box;
const end = -textW;
const distance = start - end;
const durationMs = Math.max(MIN_FLY_MS, Math.round((distance / FLY_SPEED) * 1000)); const durationMs = Math.max(MIN_FLY_MS, Math.round((distance / FLY_SPEED) * 1000));
await fly(start, end, durationMs); setLeftPx(from);
await sleep(32);
if (cancelled) break;
await fly(from, to, durationMs);
if (cancelled) break; if (cancelled) break;
// 飞出后随机停留 1~5 秒,再播下一条
await sleep(randomPauseMs()); await sleep(randomPauseMs());
if (cancelled) break; if (cancelled) break;
@@ -198,7 +155,7 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
return () => { return () => {
cancelled = true; cancelled = true;
clearAnim(); clearTick();
waiters.forEach(clearTimeout); waiters.forEach(clearTimeout);
waiters.clear(); waiters.clear();
}; };
@@ -207,17 +164,13 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
if (!items.length) return null; if (!items.length) return null;
const current = items[displayIndex] || items[0]; const current = items[displayIndex] || items[0];
const textStyle: CSSProperties = { const innerStyle: CSSProperties = { left: `${leftPx}px` };
transform: `translateX(${offset}px)`,
WebkitTransform: `translateX(${offset}px)`,
opacity: ready ? 1 : 0,
};
return ( return (
<View id={rootIdRef.current} className="store-detail-marquee"> <View id={rootIdRef.current} className="store-detail-marquee">
<Text id={textIdRef.current} className="store-detail-marquee-text" style={textStyle}> <View className="store-detail-marquee-inner" style={innerStyle}>
{current} <Text className="store-detail-marquee-text">{current}</Text>
</Text> </View>
</View> </View>
); );
} }
@@ -342,8 +342,6 @@ export default function StoreDetailPage() {
<View className="store-detail-info-card"> <View className="store-detail-info-card">
<Text className="store-detail-name">{store.name}</Text> <Text className="store-detail-name">{store.name}</Text>
{marqueeLines.length > 0 ? <StoreRedeemMarquee lines={marqueeLines} /> : null}
<View className="store-detail-row"> <View className="store-detail-row">
<Text className="store-detail-meta store-detail-meta--flex"> <Text className="store-detail-meta store-detail-meta--flex">
{store.district ? `${store.district} · ` : ''} {store.district ? `${store.district} · ` : ''}
@@ -385,6 +383,12 @@ export default function StoreDetailPage() {
</View> </View>
</View> </View>
{marqueeLines.length > 0 ? (
<View className="store-detail-marquee-wrap">
<StoreRedeemMarquee key={marqueeLines.join('|')} lines={marqueeLines} />
</View>
) : null}
{intro ? ( {intro ? (
<View className="store-detail-section"> <View className="store-detail-section">
<Text className="store-detail-section-title"></Text> <Text className="store-detail-section-title"></Text>
+13 -11
View File
@@ -127,9 +127,13 @@
font-size: 11px; font-size: 11px;
} }
.store-detail-marquee-wrap {
margin: 0 var(--space-page) 12px;
}
.store-detail-marquee { .store-detail-marquee {
margin: 4px 0 12px; margin: 0;
padding: 10px 0; padding: 0;
border-radius: 8px; border-radius: 8px;
background: #fff7f6; background: #fff7f6;
border: 1px solid rgba(166, 29, 36, 0.12); border: 1px solid rgba(166, 29, 36, 0.12);
@@ -137,22 +141,20 @@
height: 40px; height: 40px;
box-sizing: border-box; box-sizing: border-box;
position: relative; position: relative;
display: flex; }
align-items: center;
.store-detail-marquee-inner {
position: absolute;
top: 50%;
margin-top: -10px;
white-space: nowrap;
} }
.store-detail-marquee-text { .store-detail-marquee-text {
position: absolute;
left: 0;
top: 50%;
margin-top: -10px;
display: inline-block;
white-space: nowrap; white-space: nowrap;
font-size: 12px; font-size: 12px;
line-height: 20px; line-height: 20px;
color: #a61d24; color: #a61d24;
will-change: transform;
pointer-events: none;
} }
.store-detail-section { .store-detail-section {