fix(mini-user): 门店核销走马灯改用 JS 位移

微信端 CSS max-content/百分比 transform 不可靠;改为测量宽度后 translateX 滚动,并上移到店名下方。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 14:33:31 +08:00
parent fd05621660
commit 021b9e0329
4 changed files with 142 additions and 45 deletions
@@ -0,0 +1,84 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
type StoreRedeemMarqueeProps = {
lines: string[];
};
/**
* 横向走马灯(JS 位移)。
* 微信小程序对 CSS max-content / translateX(-50%) 宽度计算不稳定,故不用纯 CSS 动画。
*/
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
const text = useMemo(
() =>
lines
.map((s) => String(s || '').trim())
.filter(Boolean)
.join('  '),
[lines],
);
const [offset, setOffset] = useState(0);
const halfWidthRef = useRef(0);
const measureIdRef = useRef(`m${Date.now().toString(36)}`);
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;
});
};
measure();
const t1 = setTimeout(measure, 80);
const t2 = setTimeout(measure, 320);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [text]);
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 (!text) return null;
const segment = `${text}  `;
return (
<View className="store-detail-marquee">
<View
className="store-detail-marquee-track"
style={{ transform: `translateX(${offset}px)` }}
>
<View id={measureIdRef.current} className="store-detail-marquee-item">
<Text className="store-detail-marquee-item-text">{segment}</Text>
</View>
<View className="store-detail-marquee-item">
<Text className="store-detail-marquee-item-text">{segment}</Text>
</View>
</View>
</View>
);
}
@@ -12,6 +12,7 @@ import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar'; import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel'; import ProductCarousel from '../../components/ProductCarousel';
import ShareNavButton from '../../components/ShareNavButton'; import ShareNavButton from '../../components/ShareNavButton';
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { import {
@@ -54,6 +55,7 @@ type RecentRedeem = {
userLabel: string; userLabel: string;
amount: number | string; amount: number | string;
createdAt: string; createdAt: string;
text?: string;
}; };
function uniqueUrls(urls: Array<string | null | undefined>) { function uniqueUrls(urls: Array<string | null | undefined>) {
@@ -119,6 +121,7 @@ function formatRedeemAmountYuan(amount: number | string) {
} }
function formatRecentRedeemLine(row: RecentRedeem) { function formatRecentRedeemLine(row: RecentRedeem) {
if (row.text?.trim()) return row.text.trim();
return `${row.userLabel || '用户***'} ${formatRedeemTime(row.createdAt)} 核销${formatRedeemAmountYuan(row.amount)}`; return `${row.userLabel || '用户***'} ${formatRedeemTime(row.createdAt)} 核销${formatRedeemAmountYuan(row.amount)}`;
} }
@@ -234,10 +237,10 @@ export default function StoreDetailPage() {
[store, storeId], [store, storeId],
); );
const marqueeText = useMemo(() => { const marqueeLines = useMemo(
if (!recentRedeems.length) return ''; () => recentRedeems.map(formatRecentRedeemLine).filter(Boolean),
return recentRedeems.map(formatRecentRedeemLine).join('  '); [recentRedeems],
}, [recentRedeems]); );
useShareAppMessage(() => toWeappShareMessage(sharePayload)); useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({ useShareTimeline(() => ({
@@ -321,8 +324,6 @@ export default function StoreDetailPage() {
}).catch(() => toast('无法预览图片')); }).catch(() => toast('无法预览图片'));
} }
const marqueeDurationSec = Math.max(18, Math.min(60, Math.round((marqueeText.length || 24) / 2.2)));
return ( return (
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter> <PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
<WechatShareReady payload={sharePayload} /> <WechatShareReady payload={sharePayload} />
@@ -341,6 +342,8 @@ export default function StoreDetailPage() {
<View className="store-detail-info-card"> <View className="store-detail-info-card">
<Text className="store-detail-name">{store.name}</Text> <Text className="store-detail-name">{store.name}</Text>
{marqueeLines.length > 0 ? <StoreRedeemMarquee lines={marqueeLines} /> : null}
<View className="store-detail-row"> <View className="store-detail-row">
<Text className="store-detail-meta store-detail-meta--flex"> <Text className="store-detail-meta store-detail-meta--flex">
{store.district ? `${store.district} · ` : ''} {store.district ? `${store.district} · ` : ''}
@@ -382,20 +385,6 @@ export default function StoreDetailPage() {
</View> </View>
</View> </View>
{marqueeText ? (
<View className="store-detail-marquee">
<View
className="store-detail-marquee-track"
style={{
animationDuration: `${marqueeDurationSec}s`,
}}
>
<View className="store-detail-marquee-item">{marqueeText}</View>
<View className="store-detail-marquee-item">{marqueeText}</View>
</View>
</View>
) : null}
{intro ? ( {intro ? (
<View className="store-detail-section"> <View className="store-detail-section">
<Text className="store-detail-section-title"></Text> <Text className="store-detail-section-title"></Text>
+15 -20
View File
@@ -128,40 +128,35 @@
} }
.store-detail-marquee { .store-detail-marquee {
margin: 0 var(--space-page) 12px; margin: 4px 0 12px;
padding: 8px 0; padding: 10px 0;
border-radius: var(--radius-lg); border-radius: 8px;
background: rgba(166, 29, 36, 0.06); background: #fff7f6;
border: 1px solid rgba(166, 29, 36, 0.12);
overflow: hidden; overflow: hidden;
height: 40px;
box-sizing: border-box;
} }
.store-detail-marquee-track { .store-detail-marquee-track {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
width: max-content; align-items: center;
animation-name: store-detail-marquee-scroll; height: 20px;
animation-timing-function: linear;
animation-iteration-count: infinite;
will-change: transform; will-change: transform;
} }
.store-detail-marquee-item { .store-detail-marquee-item {
flex: 0 0 auto; flex: none;
padding: 0 24px;
white-space: nowrap; white-space: nowrap;
font-size: 12px;
line-height: 1.5;
color: #666;
} }
@keyframes store-detail-marquee-scroll { .store-detail-marquee-item-text {
from { font-size: 12px;
transform: translate3d(0, 0, 0); line-height: 20px;
} color: #a61d24;
to { white-space: nowrap;
transform: translate3d(-50%, 0, 0);
}
} }
.store-detail-section { .store-detail-section {
@@ -1149,11 +1149,40 @@ export class RedeemService {
take, take,
include: { user: { select: { phone: true } } }, include: { user: { select: { phone: true } } },
}); });
return list.map((r) => ({
userLabel: maskRedeemUserLabel(r.user?.phone), const fmtAmount = (n: number) => {
amount: Number(r.amount), if (!Number.isFinite(n)) return '0';
createdAt: r.createdAt.toISOString(), if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
})); return n.toFixed(2).replace(/\.?0+$/, '');
};
const fmtTime = (d: Date) => {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(d);
const get = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((p) => p.type === type)?.value ?? '';
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
};
return list.map((r) => {
const userLabel = maskRedeemUserLabel(r.user?.phone);
const amount = Number(r.amount);
const createdAt = fmtTime(r.createdAt);
return {
userLabel,
amount,
createdAt,
/** 前端可直接展示 */
text: `${userLabel} ${createdAt} 核销${fmtAmount(amount)}`,
};
});
} }
async submitRating(userId: bigint, body: { redeemRecordId: string; serviceScore: number; envScore: number }) { async submitRating(userId: bigint, body: { redeemRecordId: string; serviceScore: number; envScore: number }) {