feat(store): 门店详情核销记录走马灯
新增 GET /stores/:id/recent-redeems,C 端在门店详情区上方循环展示脱敏核销动态。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -43,6 +43,12 @@ type Store = {
|
||||
category?: { name: string } | null;
|
||||
};
|
||||
|
||||
type RecentRedeem = {
|
||||
userLabel: string;
|
||||
amount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function uniqueUrls(urls: Array<string | null | undefined>) {
|
||||
const seen = new Set<string>();
|
||||
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<Store | null>(null);
|
||||
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
|
||||
usePageScroll(({ scrollTop }) => {
|
||||
@@ -86,6 +122,9 @@ export default function StoreDetailPage() {
|
||||
setStore(null);
|
||||
toast('门店不存在或暂不可见');
|
||||
});
|
||||
request<RecentRedeem[]>(`/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 (
|
||||
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
@@ -240,6 +287,20 @@ export default function StoreDetailPage() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{marqueeText ? (
|
||||
<View className="store-detail-marquee" aria-hidden>
|
||||
<View
|
||||
className="store-detail-marquee-track"
|
||||
style={{ animationDuration: `${marqueeDurationSec}s` }}
|
||||
>
|
||||
<Text className="store-detail-marquee-text">{marqueeText}</Text>
|
||||
<Text className="store-detail-marquee-gap"> </Text>
|
||||
<Text className="store-detail-marquee-text">{marqueeText}</Text>
|
||||
<Text className="store-detail-marquee-gap"> </Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{intro ? (
|
||||
<View className="store-detail-section">
|
||||
<Text className="store-detail-section-title">门店详情</Text>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user