fix(mini-user): 门店核销走马灯改用 JS 位移
微信端 CSS max-content/百分比 transform 不可靠;改为测量宽度后 translateX 滚动,并上移到店名下方。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user