diff --git a/apps/mini-user/src/components/StoreRedeemMarquee.tsx b/apps/mini-user/src/components/StoreRedeemMarquee.tsx new file mode 100644 index 0000000..51056ca --- /dev/null +++ b/apps/mini-user/src/components/StoreRedeemMarquee.tsx @@ -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 ( + + + + {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 3d26dfc..6926edd 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -12,6 +12,7 @@ import PageShell from '../../components/PageShell'; import PageNavBar from '../../components/PageNavBar'; import ProductCarousel from '../../components/ProductCarousel'; import ShareNavButton from '../../components/ShareNavButton'; +import StoreRedeemMarquee from '../../components/StoreRedeemMarquee'; import WechatShareReady from '../../components/WechatShareReady'; import { request, toast } from '../../lib/api'; import { @@ -54,6 +55,7 @@ type RecentRedeem = { userLabel: string; amount: number | string; createdAt: string; + text?: string; }; function uniqueUrls(urls: Array) { @@ -119,6 +121,7 @@ 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)}元`; } @@ -234,10 +237,10 @@ export default function StoreDetailPage() { [store, storeId], ); - const marqueeText = useMemo(() => { - if (!recentRedeems.length) return ''; - return recentRedeems.map(formatRecentRedeemLine).join('  '); - }, [recentRedeems]); + const marqueeLines = useMemo( + () => recentRedeems.map(formatRecentRedeemLine).filter(Boolean), + [recentRedeems], + ); useShareAppMessage(() => toWeappShareMessage(sharePayload)); useShareTimeline(() => ({ @@ -321,8 +324,6 @@ export default function StoreDetailPage() { }).catch(() => toast('无法预览图片')); } - const marqueeDurationSec = Math.max(18, Math.min(60, Math.round((marqueeText.length || 24) / 2.2))); - return ( @@ -341,6 +342,8 @@ export default function StoreDetailPage() { {store.name} + {marqueeLines.length > 0 ? : null} + {store.district ? `${store.district} · ` : ''} @@ -382,20 +385,6 @@ export default function StoreDetailPage() { - {marqueeText ? ( - - - {marqueeText} - {marqueeText} - - - ) : null} - {intro ? ( 门店详情 diff --git a/apps/mini-user/src/styles/store-detail.css b/apps/mini-user/src/styles/store-detail.css index 5464554..7b63a7c 100644 --- a/apps/mini-user/src/styles/store-detail.css +++ b/apps/mini-user/src/styles/store-detail.css @@ -128,40 +128,35 @@ } .store-detail-marquee { - margin: 0 var(--space-page) 12px; - padding: 8px 0; - border-radius: var(--radius-lg); - background: rgba(166, 29, 36, 0.06); + margin: 4px 0 12px; + padding: 10px 0; + border-radius: 8px; + background: #fff7f6; + border: 1px solid rgba(166, 29, 36, 0.12); overflow: hidden; + height: 40px; + box-sizing: border-box; } .store-detail-marquee-track { display: flex; flex-direction: row; flex-wrap: nowrap; - width: max-content; - animation-name: store-detail-marquee-scroll; - animation-timing-function: linear; - animation-iteration-count: infinite; + align-items: center; + height: 20px; will-change: transform; } .store-detail-marquee-item { - flex: 0 0 auto; - padding: 0 24px; + flex: none; white-space: nowrap; - font-size: 12px; - line-height: 1.5; - color: #666; } -@keyframes store-detail-marquee-scroll { - from { - transform: translate3d(0, 0, 0); - } - to { - transform: translate3d(-50%, 0, 0); - } +.store-detail-marquee-item-text { + font-size: 12px; + line-height: 20px; + color: #a61d24; + white-space: nowrap; } .store-detail-section { diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index 536c270..c4ecd5c 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -1149,11 +1149,40 @@ export class RedeemService { take, include: { user: { select: { phone: true } } }, }); - return list.map((r) => ({ - userLabel: maskRedeemUserLabel(r.user?.phone), - amount: Number(r.amount), - createdAt: r.createdAt.toISOString(), - })); + + const fmtAmount = (n: number) => { + if (!Number.isFinite(n)) return '0'; + 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 }) {