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