@@ -20,27 +20,23 @@ function formatMoney(amount: number) {
|
|||||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 中国时区展示:2026年8月3日 13点45分 */
|
/** 与权益「历史记录」一致:2026-08-03 13:53:03(Asia/Shanghai) */
|
||||||
function formatChinaDateTime(input?: string | null) {
|
function formatChinaDateTime(input?: string | null) {
|
||||||
const d = input ? new Date(input) : new Date();
|
const d = input ? new Date(input) : new Date();
|
||||||
if (Number.isNaN(d.getTime())) return '—';
|
if (Number.isNaN(d.getTime())) return '—';
|
||||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
timeZone: 'Asia/Shanghai',
|
timeZone: 'Asia/Shanghai',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'numeric',
|
month: '2-digit',
|
||||||
day: 'numeric',
|
day: '2-digit',
|
||||||
hour: 'numeric',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
hour12: false,
|
hour12: false,
|
||||||
}).formatToParts(d);
|
}).formatToParts(d);
|
||||||
const get = (type: Intl.DateTimeFormatPartTypes) =>
|
const get = (type: Intl.DateTimeFormatPartTypes) =>
|
||||||
parts.find((p) => p.type === type)?.value ?? '';
|
parts.find((p) => p.type === type)?.value ?? '';
|
||||||
const year = get('year');
|
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
|
||||||
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}分`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StarRating({
|
function StarRating({
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ type Store = {
|
|||||||
category?: { name: string } | null;
|
category?: { name: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RecentRedeem = {
|
||||||
|
userLabel: string;
|
||||||
|
amount: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
function uniqueUrls(urls: Array<string | null | undefined>) {
|
function uniqueUrls(urls: Array<string | null | undefined>) {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
@@ -68,10 +74,40 @@ function fullAddress(store: Store) {
|
|||||||
return `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
|
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() {
|
export default function StoreDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const storeId = router.params.id ?? '';
|
const storeId = router.params.id ?? '';
|
||||||
const [store, setStore] = useState<Store | null>(null);
|
const [store, setStore] = useState<Store | null>(null);
|
||||||
|
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
|
||||||
const [headerSolid, setHeaderSolid] = useState(false);
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
|
|
||||||
usePageScroll(({ scrollTop }) => {
|
usePageScroll(({ scrollTop }) => {
|
||||||
@@ -86,6 +122,9 @@ export default function StoreDetailPage() {
|
|||||||
setStore(null);
|
setStore(null);
|
||||||
toast('门店不存在或暂不可见');
|
toast('门店不存在或暂不可见');
|
||||||
});
|
});
|
||||||
|
request<RecentRedeem[]>(`/stores/${storeId}/recent-redeems?limit=20`)
|
||||||
|
.then((list) => setRecentRedeems(Array.isArray(list) ? list : []))
|
||||||
|
.catch(() => setRecentRedeems([]));
|
||||||
});
|
});
|
||||||
|
|
||||||
const sharePayload = useMemo(
|
const sharePayload = useMemo(
|
||||||
@@ -101,6 +140,11 @@ export default function StoreDetailPage() {
|
|||||||
[store, storeId],
|
[store, storeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const marqueeText = useMemo(() => {
|
||||||
|
if (!recentRedeems.length) return '';
|
||||||
|
return recentRedeems.map(formatRecentRedeemLine).join(' ');
|
||||||
|
}, [recentRedeems]);
|
||||||
|
|
||||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||||
useShareTimeline(() => ({
|
useShareTimeline(() => ({
|
||||||
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||||
@@ -181,6 +225,9 @@ export default function StoreDetailPage() {
|
|||||||
}).catch(() => toast('无法预览图片'));
|
}).catch(() => toast('无法预览图片'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 文案过短时放慢动画,过长时加快一点(仍循环)
|
||||||
|
const marqueeDurationSec = Math.max(18, Math.min(60, Math.round(marqueeText.length / 2.2)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
|
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
|
||||||
<WechatShareReady payload={sharePayload} />
|
<WechatShareReady payload={sharePayload} />
|
||||||
@@ -240,6 +287,20 @@ export default function StoreDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
</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 ? (
|
{intro ? (
|
||||||
<View className="store-detail-section">
|
<View className="store-detail-section">
|
||||||
<Text className="store-detail-section-title">门店详情</Text>
|
<Text className="store-detail-section-title">门店详情</Text>
|
||||||
|
|||||||
@@ -127,6 +127,42 @@
|
|||||||
font-size: 11px;
|
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 {
|
.store-detail-section {
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
margin: 0 var(--space-page) 16px;
|
margin: 0 var(--space-page) 16px;
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ type TokenPayload = {
|
|||||||
allocations?: Array<{ couponId: string; amount: number }>;
|
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 & {
|
type PendingSnapshot = TokenPayload & {
|
||||||
redeemType: 'DIRECT' | 'COUPON';
|
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 }) {
|
async submitRating(userId: bigint, body: { redeemRecordId: string; serviceScore: number; envScore: number }) {
|
||||||
const record = await this.prisma.redeemRecord.findFirst({
|
const record = await this.prisma.redeemRecord.findFirst({
|
||||||
where: { id: BigInt(body.redeemRecordId), userId },
|
where: { id: BigInt(body.redeemRecordId), userId },
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
|||||||
|
|
||||||
@Controller('stores')
|
@Controller('stores')
|
||||||
export class PublicStoreController {
|
export class PublicStoreController {
|
||||||
constructor(private readonly storeService: StoreService) {}
|
constructor(
|
||||||
|
private readonly storeService: StoreService,
|
||||||
|
private readonly redeemService: RedeemService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(OptionalJwtAuthGuard)
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
@@ -28,6 +31,24 @@ export class PublicStoreController {
|
|||||||
return this.storeService.listOpenStores(cityCode, userLat, userLng, { phone: viewerPhone });
|
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')
|
@Get(':id')
|
||||||
@UseGuards(OptionalJwtAuthGuard)
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
async detail(@CurrentUser() user: AuthUser | undefined, @Param('id') id: string) {
|
async detail(@CurrentUser() user: AuthUser | undefined, @Param('id') id: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user