fix(mini-user): 门店核销走马灯兼容 H5 与微信小程序

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 14:48:28 +08:00
parent 2088a1ee19
commit 04fd4d50d0
3 changed files with 154 additions and 67 deletions
@@ -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 { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro'; import Taro, { useReady } from '@tarojs/taro';
type StoreRedeemMarqueeProps = { type StoreRedeemMarqueeProps = {
lines: string[]; 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 位移)。 * 门店核销动态走马灯(H5 + 微信小程序)
* 微信小程序对 CSS max-content / translateX(-50%) 宽度计算不稳定,故不用纯 CSS 动画。 *
* - 用 Text 测自然内容宽(weapp 的 View 默认撑满父级,不能用来测宽)
* - 两段显式同宽 + 轨道宽 = 2×段宽,CSS translateX(-50%) 无缝循环
* - 测量失败时用字宽估算,仍能滚动
*/ */
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) { export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
const text = useMemo( const segment = useMemo(() => {
() => const joined = lines
lines .map((s) => String(s || '').trim())
.map((s) => String(s || '').trim()) .filter(Boolean)
.filter(Boolean) .join('  ');
.join('  '), return joined ? `${joined}  ` : '';
[lines], }, [lines]);
const measureId = useMemo(
() => `sm${Math.random().toString(36).slice(2, 10)}`,
[segment],
); );
const [offset, setOffset] = useState(0);
const halfWidthRef = useRef(0); const [segWidth, setSegWidth] = useState(0);
const measureIdRef = useRef(`m${Date.now().toString(36)}`);
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(() => { useEffect(() => {
setOffset(0); if (!segment) {
halfWidthRef.current = 0; setSegWidth(0);
if (!text) return; return;
}
const measure = () => { setSegWidth(0);
Taro.createSelectorQuery()
.select(`#${measureIdRef.current}`)
.boundingClientRect()
.exec((res) => {
const width = Number(res?.[0]?.width || 0);
if (width > 0) halfWidthRef.current = width;
});
};
measure(); measure();
const t1 = setTimeout(measure, 80); const t1 = setTimeout(measure, 100);
const t2 = setTimeout(measure, 320); const t2 = setTimeout(measure, 350);
const t3 = setTimeout(() => {
setSegWidth((prev) => (prev > 0 ? prev : estimateTextWidth(segment)));
}, 600);
return () => { return () => {
clearTimeout(t1); clearTimeout(t1);
clearTimeout(t2); clearTimeout(t2);
clearTimeout(t3);
}; };
}, [text]); }, [segment, measure]);
useEffect(() => { if (!segment) return null;
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 (!text) return null; const running = segWidth > 0;
const durationSec = Math.max(10, Math.min(90, segWidth / 42));
const segment = `${text}  `; 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 ( return (
<View className="store-detail-marquee"> <View className="store-detail-marquee">
<View <View
className="store-detail-marquee-track" className={`store-detail-marquee-track${running ? ' is-running' : ''}`}
style={{ transform: `translateX(${offset}px)` }} style={trackStyle}
> >
<View id={measureIdRef.current} className="store-detail-marquee-item"> <Text
<Text className="store-detail-marquee-item-text">{segment}</Text> id={measureId}
</View> className="store-detail-marquee-seg store-detail-marquee-text"
<View className="store-detail-marquee-item"> style={segStyle}
<Text className="store-detail-marquee-item-text">{segment}</Text> >
</View> {segment}
</Text>
<Text className="store-detail-marquee-seg store-detail-marquee-text" style={segStyle}>
{segment}
</Text>
</View> </View>
</View> </View>
); );
@@ -103,16 +103,29 @@ function formatRedeemAmountYuan(amount: number | string) {
} }
function formatRecentRedeemLine(row: RecentRedeem) { function formatRecentRedeemLine(row: RecentRedeem) {
if (row.text?.trim()) return row.text.trim(); try {
return `${row.userLabel || '用户***'} ${formatRedeemTime(row.createdAt)} 核销${formatRedeemAmountYuan(row.amount)}`; 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[] { function normalizeRecentRedeems(payload: unknown): RecentRedeem[] {
if (Array.isArray(payload)) return payload as RecentRedeem[]; try {
if (payload && typeof payload === 'object') { if (Array.isArray(payload)) return payload as RecentRedeem[];
const list = (payload as { list?: unknown; items?: unknown }).list if (payload && typeof payload === 'object') {
?? (payload as { items?: unknown }).items; const list =
if (Array.isArray(list)) return list as RecentRedeem[]; (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 []; return [];
} }
+37 -4
View File
@@ -144,19 +144,52 @@
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; align-items: center;
height: 20px; 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; flex: none;
box-sizing: content-box;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
vertical-align: top;
} }
.store-detail-marquee-item-text { .store-detail-marquee-text {
white-space: nowrap;
font-size: 12px; font-size: 12px;
line-height: 20px; line-height: 20px;
color: #a61d24; 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 { .store-detail-section {