merge(dev): 核销时间格式与门店走马灯
CI / verify (push) Has been cancelled

This commit is contained in:
2026-08-03 14:20:36 +08:00
5 changed files with 151 additions and 12 deletions
@@ -20,27 +20,23 @@ function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** 中国时区展示:2026年8月3日 13点45分 */
/** 与权益「历史记录」一致:2026-08-03 13:53:03Asia/Shanghai */
function formatChinaDateTime(input?: string | null) {
const d = input ? new Date(input) : new Date();
if (Number.isNaN(d.getTime())) return '—';
const parts = new Intl.DateTimeFormat('zh-CN', {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '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 ?? '';
const year = get('year');
const month = String(Number(get('month')));
const day = String(Number(get('day')));
const hour = String(Number(get('hour')));
const minute = get('minute').padStart(2, '0');
return `${year}${month}${day}${hour}${minute}`;
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
}
function StarRating({
@@ -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:30Asia/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) {