fix(mini-user): 门店核销走马灯兼容 H5 与微信小程序
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user