diff --git a/apps/mini-user/src/components/StoreRedeemMarquee.tsx b/apps/mini-user/src/components/StoreRedeemMarquee.tsx index eeeb542..f8f1de7 100644 --- a/apps/mini-user/src/components/StoreRedeemMarquee.tsx +++ b/apps/mini-user/src/components/StoreRedeemMarquee.tsx @@ -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 { + 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((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((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>(); - let rafId = 0; let tickTimer: ReturnType | 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((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 ( - - {current} - + + {current} + ); } diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index a482e62..3d31d91 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -342,8 +342,6 @@ export default function StoreDetailPage() { {store.name} - {marqueeLines.length > 0 ? : null} - {store.district ? `${store.district} · ` : ''} @@ -385,6 +383,12 @@ export default function StoreDetailPage() { + {marqueeLines.length > 0 ? ( + + + + ) : null} + {intro ? ( 门店详情 diff --git a/apps/mini-user/src/styles/store-detail.css b/apps/mini-user/src/styles/store-detail.css index 05824d0..06ec28c 100644 --- a/apps/mini-user/src/styles/store-detail.css +++ b/apps/mini-user/src/styles/store-detail.css @@ -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 {