From 666bcb8633802e270e9ddbd5e0f7313d42bf57f0 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Mon, 3 Aug 2026 15:12:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E7=AB=AF=E9=97=A8?= =?UTF-8?q?=E5=BA=97=E6=A0=B8=E9=94=80=E8=AE=B0=E5=BD=95=E8=B5=B0=E9=A9=AC?= =?UTF-8?q?=E7=81=AF=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/StoreRedeemMarquee.tsx | 278 ++++++++++++------ apps/mini-user/src/styles/store-detail.css | 53 +--- 2 files changed, 196 insertions(+), 135 deletions(-) diff --git a/apps/mini-user/src/components/StoreRedeemMarquee.tsx b/apps/mini-user/src/components/StoreRedeemMarquee.tsx index 5767e98..eeeb542 100644 --- a/apps/mini-user/src/components/StoreRedeemMarquee.tsx +++ b/apps/mini-user/src/components/StoreRedeemMarquee.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react'; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { View, Text } from '@tarojs/components'; import Taro, { useReady } from '@tarojs/taro'; @@ -6,7 +6,12 @@ type StoreRedeemMarqueeProps = { lines: string[]; }; -/** 估算单行像素宽(12px 字号,DOM 测量失败时兜底) */ +/** 飘动速度 px/s */ +const FLY_SPEED = 58; +const MIN_FLY_MS = 2200; +const PAUSE_MIN_MS = 1000; +const PAUSE_MAX_MS = 5000; + function estimateTextWidth(text: string): number { let w = 0; for (const ch of text) { @@ -15,111 +20,204 @@ function estimateTextWidth(text: string): number { return Math.max(Math.ceil(w), 80); } +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)}`; +} + +function createQuery() { + const page = Taro.getCurrentInstance().page; + return page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery(); +} + /** - * 门店核销动态走马灯(H5 + 微信小程序) - * - * - 用 Text 测自然内容宽(weapp 的 View 默认撑满父级,不能用来测宽) - * - 两段显式同宽 + 轨道宽 = 2×段宽,CSS translateX(-50%) 无缝循环 - * - 测量失败时用字宽估算,仍能滚动 + * 核销走马灯:单条从右向左位移飞出,间隔 1~5 秒随机再播下一条。 + * H5 / 微信小程序均用 JS translateX,不依赖 CSS animation / Intl。 */ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) { - const segment = useMemo(() => { - const joined = lines - .map((s) => String(s || '').trim()) - .filter(Boolean) - .join('  '); - return joined ? `${joined}  ` : ''; - }, [lines]); - - const measureId = useMemo( - () => `sm${Math.random().toString(36).slice(2, 10)}`, - [segment], + const items = useMemo( + () => + lines + .map((s) => String(s || '').trim()) + .filter(Boolean), + [lines], ); - const [segWidth, setSegWidth] = useState(0); + const rootIdRef = useRef(uid('smr')); + const textIdRef = useRef(uid('smt')); + const indexRef = useRef(0); + const boxWidthRef = useRef(0); + const itemsKey = items.join('\n'); - const measure = useCallback(() => { - if (!segment) { - setSegWidth(0); - return; - } - const fallback = estimateTextWidth(segment); - Taro.nextTick(() => { - try { - Taro.createSelectorQuery() - .select(`#${measureId}`) - .boundingClientRect() - .exec((res) => { - const w = Number(res?.[0]?.width || 0); - // weapp 若误测成容器宽,会接近视口;用估算兜底纠正 - if (w > 8 && w < fallback * 2.5) { - setSegWidth(Math.ceil(w)); - } else { - setSegWidth(fallback); - } - }); - } catch { - setSegWidth(fallback); - } + 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); + } + }); }); - }, [segment, measureId]); useReady(() => { - measure(); + void measureBox().then(() => setReady(true)); }); useEffect(() => { - if (!segment) { - setSegWidth(0); - return; - } - setSegWidth(0); - measure(); - const t1 = setTimeout(measure, 100); - const t2 = setTimeout(measure, 350); - const t3 = setTimeout(() => { - setSegWidth((prev) => (prev > 0 ? prev : estimateTextWidth(segment))); - }, 600); - return () => { - clearTimeout(t1); - clearTimeout(t2); - clearTimeout(t3); - }; - }, [segment, measure]); + if (!items.length) return; - if (!segment) return null; + let cancelled = false; + const waiters = new Set>(); + let rafId = 0; + let tickTimer: ReturnType | undefined; - const running = segWidth > 0; - const durationSec = Math.max(10, Math.min(90, segWidth / 42)); - const trackStyle: CSSProperties | undefined = running - ? { - width: `${segWidth * 2}px`, - animationDuration: `${durationSec}s`, - // 微信 / Safari - WebkitAnimationDuration: `${durationSec}s`, + const sleep = (ms: number) => + new Promise((resolve) => { + const id = setTimeout(() => { + waiters.delete(id); + resolve(); + }, ms); + waiters.add(id); + }); + + const clearAnim = () => { + if (rafId) { + cancelAnimationFrame(rafId); + rafId = 0; } - : undefined; - const segStyle: CSSProperties | undefined = running - ? { width: `${segWidth}px` } - : undefined; + if (tickTimer) { + clearInterval(tickTimer); + tickTimer = undefined; + } + }; + + const fly = (start: number, end: number, durationMs: number) => + new Promise((resolve) => { + const began = Date.now(); + setOffset(start); + + const step = () => { + if (cancelled) { + clearAnim(); + resolve(); + return; + } + const t = Math.min(1, (Date.now() - began) / durationMs); + setOffset(start + (end - start) * t); + if (t >= 1) { + clearAnim(); + resolve(); + return; + } + if (typeof requestAnimationFrame === 'function') { + rafId = requestAnimationFrame(step); + } + }; + + clearAnim(); + if (typeof requestAnimationFrame === 'function') { + rafId = requestAnimationFrame(step); + } else { + tickTimer = setInterval(step, 32); + } + }); + + const loop = async () => { + indexRef.current = 0; + setDisplayIndex(0); + await sleep(60); + if (cancelled) return; + + while (!cancelled) { + if (!items.length) break; + + const idx = indexRef.current % items.length; + const text = items[idx]; + + 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 durationMs = Math.max(MIN_FLY_MS, Math.round((distance / FLY_SPEED) * 1000)); + + await fly(start, end, durationMs); + if (cancelled) break; + + // 飞出后随机停留 1~5 秒,再播下一条 + await sleep(randomPauseMs()); + if (cancelled) break; + + indexRef.current = (idx + 1) % items.length; + } + }; + + void loop(); + + return () => { + cancelled = true; + clearAnim(); + waiters.forEach(clearTimeout); + waiters.clear(); + }; + }, [itemsKey, items]); + + 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, + }; return ( - - - - {segment} - - - {segment} - - + + + {current} + ); } diff --git a/apps/mini-user/src/styles/store-detail.css b/apps/mini-user/src/styles/store-detail.css index b096314..05824d0 100644 --- a/apps/mini-user/src/styles/store-detail.css +++ b/apps/mini-user/src/styles/store-detail.css @@ -136,60 +136,23 @@ overflow: hidden; height: 40px; box-sizing: border-box; -} - -.store-detail-marquee-track { + position: relative; display: flex; - flex-direction: row; - flex-wrap: nowrap; align-items: center; - height: 20px; - transform: translateX(0); -} - -.store-detail-marquee-track.is-running { - animation-name: store-detail-marquee-scroll; - animation-timing-function: linear; - animation-iteration-count: infinite; - -webkit-animation-name: store-detail-marquee-scroll; - -webkit-animation-timing-function: linear; - -webkit-animation-iteration-count: infinite; -} - -.store-detail-marquee-seg { - display: inline-block; - flex: none; - box-sizing: content-box; - white-space: nowrap; - overflow: hidden; - vertical-align: top; } .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; -} - -@keyframes store-detail-marquee-scroll { - 0% { - transform: translateX(0); - } - 100% { - transform: translateX(-50%); - } -} - -@-webkit-keyframes store-detail-marquee-scroll { - 0% { - -webkit-transform: translateX(0); - transform: translateX(0); - } - 100% { - -webkit-transform: translateX(-50%); - transform: translateX(-50%); - } + will-change: transform; + pointer-events: none; } .store-detail-section {