From 04fd4d50d05f705337120ad4bed0914aee66fed2 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Mon, 3 Aug 2026 14:48:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(mini-user):=20=E9=97=A8=E5=BA=97=E6=A0=B8?= =?UTF-8?q?=E9=94=80=E8=B5=B0=E9=A9=AC=E7=81=AF=E5=85=BC=E5=AE=B9=20H5=20?= =?UTF-8?q?=E4=B8=8E=E5=BE=AE=E4=BF=A1=E5=B0=8F=E7=A8=8B=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/components/StoreRedeemMarquee.tsx | 153 +++++++++++------- .../src/pages/store-detail/index.tsx | 27 +++- apps/mini-user/src/styles/store-detail.css | 41 ++++- 3 files changed, 154 insertions(+), 67 deletions(-) diff --git a/apps/mini-user/src/components/StoreRedeemMarquee.tsx b/apps/mini-user/src/components/StoreRedeemMarquee.tsx index 51056ca..5767e98 100644 --- a/apps/mini-user/src/components/StoreRedeemMarquee.tsx +++ b/apps/mini-user/src/components/StoreRedeemMarquee.tsx @@ -1,83 +1,124 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro from '@tarojs/taro'; +import Taro, { useReady } from '@tarojs/taro'; type StoreRedeemMarqueeProps = { lines: string[]; }; +/** 估算单行像素宽(12px 字号,DOM 测量失败时兜底) */ +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); +} + /** - * 横向走马灯(JS 位移)。 - * 微信小程序对 CSS max-content / translateX(-50%) 宽度计算不稳定,故不用纯 CSS 动画。 + * 门店核销动态走马灯(H5 + 微信小程序) + * + * - 用 Text 测自然内容宽(weapp 的 View 默认撑满父级,不能用来测宽) + * - 两段显式同宽 + 轨道宽 = 2×段宽,CSS translateX(-50%) 无缝循环 + * - 测量失败时用字宽估算,仍能滚动 */ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) { - const text = useMemo( - () => - lines - .map((s) => String(s || '').trim()) - .filter(Boolean) - .join('  '), - [lines], + 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 [offset, setOffset] = useState(0); - const halfWidthRef = useRef(0); - const measureIdRef = useRef(`m${Date.now().toString(36)}`); + + const [segWidth, setSegWidth] = useState(0); + + 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); + } + }); + }, [segment, measureId]); + + useReady(() => { + measure(); + }); useEffect(() => { - setOffset(0); - halfWidthRef.current = 0; - if (!text) return; - - const measure = () => { - Taro.createSelectorQuery() - .select(`#${measureIdRef.current}`) - .boundingClientRect() - .exec((res) => { - const width = Number(res?.[0]?.width || 0); - if (width > 0) halfWidthRef.current = width; - }); - }; - + if (!segment) { + setSegWidth(0); + return; + } + setSegWidth(0); measure(); - const t1 = setTimeout(measure, 80); - const t2 = setTimeout(measure, 320); + 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); }; - }, [text]); + }, [segment, measure]); - useEffect(() => { - if (!text) return; - const speedPxPerSec = 48; - const intervalMs = 32; - const step = (speedPxPerSec * intervalMs) / 1000; - const timer = setInterval(() => { - setOffset((prev) => { - const half = halfWidthRef.current || Math.max(text.length * 7, 120); - const next = prev - step; - return -next >= half ? 0 : next; - }); - }, intervalMs); - return () => clearInterval(timer); - }, [text]); + if (!segment) return null; - if (!text) return null; - - const segment = `${text}  `; + 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`, + } + : undefined; + const segStyle: CSSProperties | undefined = running + ? { width: `${segWidth}px` } + : undefined; return ( - - {segment} - - - {segment} - + + {segment} + + + {segment} + ); diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index 2aea632..0b03c8f 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -103,16 +103,29 @@ function formatRedeemAmountYuan(amount: number | string) { } function formatRecentRedeemLine(row: RecentRedeem) { - if (row.text?.trim()) return row.text.trim(); - return `${row.userLabel || '用户***'} ${formatRedeemTime(row.createdAt)} 核销${formatRedeemAmountYuan(row.amount)}元`; + try { + if (row.text?.trim()) return row.text.trim(); + const label = String(row.userLabel || '用户***').trim() || '用户***'; + const time = formatRedeemTime(row.createdAt); + const amount = formatRedeemAmountYuan(row.amount); + return `${label} ${time} 核销${amount}元`; + } catch { + return ''; + } } function normalizeRecentRedeems(payload: unknown): RecentRedeem[] { - if (Array.isArray(payload)) return payload as RecentRedeem[]; - if (payload && typeof payload === 'object') { - const list = (payload as { list?: unknown; items?: unknown }).list - ?? (payload as { items?: unknown }).items; - if (Array.isArray(list)) return list as RecentRedeem[]; + try { + if (Array.isArray(payload)) return payload as RecentRedeem[]; + if (payload && typeof payload === 'object') { + const list = + (payload as { list?: unknown; items?: unknown; data?: unknown }).list ?? + (payload as { items?: unknown }).items ?? + (payload as { data?: unknown }).data; + if (Array.isArray(list)) return list as RecentRedeem[]; + } + } catch { + /* ignore */ } return []; } diff --git a/apps/mini-user/src/styles/store-detail.css b/apps/mini-user/src/styles/store-detail.css index 7b63a7c..b096314 100644 --- a/apps/mini-user/src/styles/store-detail.css +++ b/apps/mini-user/src/styles/store-detail.css @@ -144,19 +144,52 @@ flex-wrap: nowrap; align-items: center; height: 20px; - will-change: transform; + transform: translateX(0); } -.store-detail-marquee-item { +.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-item-text { +.store-detail-marquee-text { + white-space: nowrap; font-size: 12px; line-height: 20px; color: #a61d24; - white-space: nowrap; +} + +@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%); + } } .store-detail-section {