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 }) {