diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index e4cc089..972e896 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -43,6 +43,12 @@ type Store = { category?: { name: string } | null; }; +type RecentRedeem = { + userLabel: string; + amount: number; + createdAt: string; +}; + function uniqueUrls(urls: Array) { const seen = new Set(); const out: string[] = []; @@ -68,10 +74,40 @@ function fullAddress(store: Store) { return `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim(); } +/** 与历史记录一致:2026-08-03 15:14:30(Asia/Shanghai) */ +function formatRedeemTime(input?: string | null) { + const d = input ? new Date(input) : new Date(); + if (Number.isNaN(d.getTime())) return '—'; + 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')}`; +} + +function formatRedeemAmountYuan(amount: number) { + if (!Number.isFinite(amount)) return '0'; + if (Number.isInteger(amount)) return String(amount); + return amount.toFixed(2).replace(/\.?0+$/, ''); +} + +function formatRecentRedeemLine(row: RecentRedeem) { + return `${row.userLabel || '用户***'} ${formatRedeemTime(row.createdAt)} 核销${formatRedeemAmountYuan(Number(row.amount))}元`; +} + export default function StoreDetailPage() { const router = useRouter(); const storeId = router.params.id ?? ''; const [store, setStore] = useState(null); + const [recentRedeems, setRecentRedeems] = useState([]); const [headerSolid, setHeaderSolid] = useState(false); usePageScroll(({ scrollTop }) => { @@ -86,6 +122,9 @@ export default function StoreDetailPage() { setStore(null); toast('门店不存在或暂不可见'); }); + request(`/stores/${storeId}/recent-redeems?limit=20`) + .then((list) => setRecentRedeems(Array.isArray(list) ? list : [])) + .catch(() => setRecentRedeems([])); }); const sharePayload = useMemo( @@ -101,6 +140,11 @@ export default function StoreDetailPage() { [store, storeId], ); + const marqueeText = useMemo(() => { + if (!recentRedeems.length) return ''; + return recentRedeems.map(formatRecentRedeemLine).join('  '); + }, [recentRedeems]); + useShareAppMessage(() => toWeappShareMessage(sharePayload)); useShareTimeline(() => ({ title: sharePayload.title || DEFAULT_SHARE_TITLE, @@ -181,6 +225,9 @@ export default function StoreDetailPage() { }).catch(() => toast('无法预览图片')); } + // 文案过短时放慢动画,过长时加快一点(仍循环) + const marqueeDurationSec = Math.max(18, Math.min(60, Math.round(marqueeText.length / 2.2))); + return ( @@ -240,6 +287,20 @@ 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 1320dff..b7b1815 100644 --- a/apps/mini-user/src/styles/store-detail.css +++ b/apps/mini-user/src/styles/store-detail.css @@ -127,6 +127,42 @@ font-size: 11px; } +.store-detail-marquee { + margin: 0 var(--space-page) 12px; + padding: 8px 12px; + border-radius: var(--radius-lg); + background: rgba(166, 29, 36, 0.06); + overflow: hidden; + white-space: nowrap; +} + +.store-detail-marquee-track { + display: inline-flex; + align-items: center; + width: max-content; + animation-name: store-detail-marquee-scroll; + animation-timing-function: linear; + animation-iteration-count: infinite; + will-change: transform; +} + +.store-detail-marquee-text, +.store-detail-marquee-gap { + flex-shrink: 0; + font-size: 12px; + line-height: 1.4; + color: #666; +} + +@keyframes store-detail-marquee-scroll { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} + .store-detail-section { background: var(--color-card); margin: 0 var(--space-page) 16px; diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index f485792..536c270 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -38,6 +38,15 @@ type TokenPayload = { allocations?: Array<{ couponId: string; amount: number }>; }; +/** C 端公示:用户138****5678 / 用户*** */ +function maskRedeemUserLabel(phone?: string | null): string { + const digits = String(phone || '').replace(/\D/g, ''); + if (digits.length >= 7) { + return `用户${digits.slice(0, 3)}****${digits.slice(-4)}`; + } + return '用户***'; +} + type PendingSnapshot = TokenPayload & { redeemType: 'DIRECT' | 'COUPON'; }; @@ -1131,6 +1140,22 @@ export class RedeemService { }; } + /** C 端门店详情走马灯:脱敏用户 + 时间 + 金额 */ + async listPublicStoreRecentRedeems(storeId: bigint, limit = 20) { + const take = Math.min(Math.max(limit, 1), 50); + const list = await this.prisma.redeemRecord.findMany({ + where: { storeId }, + orderBy: { createdAt: 'desc' }, + take, + include: { user: { select: { phone: true } } }, + }); + return list.map((r) => ({ + userLabel: maskRedeemUserLabel(r.user?.phone), + amount: Number(r.amount), + createdAt: r.createdAt.toISOString(), + })); + } + async submitRating(userId: bigint, body: { redeemRecordId: string; serviceScore: number; envScore: number }) { const record = await this.prisma.redeemRecord.findFirst({ where: { id: BigInt(body.redeemRecordId), userId }, diff --git a/server/dukang-api/src/modules/store/store.controller.ts b/server/dukang-api/src/modules/store/store.controller.ts index 4e26add..b993f4f 100644 --- a/server/dukang-api/src/modules/store/store.controller.ts +++ b/server/dukang-api/src/modules/store/store.controller.ts @@ -12,7 +12,10 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator'; @Controller('stores') export class PublicStoreController { - constructor(private readonly storeService: StoreService) {} + constructor( + private readonly storeService: StoreService, + private readonly redeemService: RedeemService, + ) {} @Get() @UseGuards(OptionalJwtAuthGuard) @@ -28,6 +31,24 @@ export class PublicStoreController { return this.storeService.listOpenStores(cityCode, userLat, userLng, { phone: viewerPhone }); } + @Get(':id/recent-redeems') + @UseGuards(OptionalJwtAuthGuard) + async recentRedeems( + @CurrentUser() user: AuthUser | undefined, + @Param('id') id: string, + @Query('limit') limit?: string, + ) { + const viewerPhone = await this.resolveViewerPhone(user); + // 与详情同权:白名单门店对不可见用户返回空(不泄露存在核销) + try { + await this.storeService.getStore(BigInt(id), { phone: viewerPhone }); + } catch { + return []; + } + const n = limit != null && limit !== '' ? Number(limit) : 20; + return this.redeemService.listPublicStoreRecentRedeems(BigInt(id), Number.isFinite(n) ? n : 20); + } + @Get(':id') @UseGuards(OptionalJwtAuthGuard) async detail(@CurrentUser() user: AuthUser | undefined, @Param('id') id: string) {