核销成功提示

This commit is contained in:
2026-07-07 11:46:12 +08:00
parent 1dcc9aa1f4
commit e25b208545
6 changed files with 339 additions and 32 deletions
+81 -10
View File
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { request } from '../lib/api'; import { request } from '../lib/api';
const REDEEM_TOKEN_TTL_SECONDS = 300; const REDEEM_TOKEN_TTL_SECONDS = 300;
const POLL_INTERVAL_MS = 2500;
type BenefitSummary = { type BenefitSummary = {
totalBalance: number; totalBalance: number;
@@ -10,6 +11,21 @@ type BenefitSummary = {
activeCouponCount: number; activeCouponCount: number;
}; };
type RedeemTokenStatus =
| { status: 'PENDING'; expireInSeconds: number; amount: number }
| {
status: 'CONSUMED';
record: {
id: string;
redeemNo: string;
amount: number;
storeId: string;
storeName: string;
createdAt: string;
};
}
| { status: 'EXPIRED' };
function formatMoney(amount: number) { function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
@@ -37,10 +53,9 @@ export default function RedeemPage() {
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS); const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
const timerRef = useRef<number | null>(null); const timerRef = useRef<number | null>(null);
const presetApplied = useRef(false); const presetApplied = useRef(false);
const successHandled = useRef(false);
const redeemableMax = couponId const redeemableMax = couponId ? (couponBalance ?? 0) : (summary?.totalBalance ?? 0);
? (couponBalance ?? 0)
: (summary?.totalBalance ?? 0);
useEffect(() => { useEffect(() => {
request<BenefitSummary>('USER_H5', '/benefit/summary').then(setSummary).catch(() => {}); request<BenefitSummary>('USER_H5', '/benefit/summary').then(setSummary).catch(() => {});
@@ -77,12 +92,41 @@ export default function RedeemPage() {
} }
} }
function closeModal() { const closeModal = useCallback(() => {
stopTimer(); stopTimer();
setModalOpen(false); setModalOpen(false);
setToken(''); setToken('');
setTimerSec(REDEEM_TOKEN_TTL_SECONDS); setTimerSec(REDEEM_TOKEN_TTL_SECONDS);
} successHandled.current = false;
}, []);
const handleRedeemExpired = useCallback(() => {
if (successHandled.current) return;
successHandled.current = true;
closeModal();
sessionStorage.removeItem('redeemToken');
sessionStorage.removeItem('redeemAmount');
setMsg('核销码已失效,请重新生成');
}, [closeModal]);
const handleRedeemSuccess = useCallback(
(record: NonNullable<Extract<RedeemTokenStatus, { status: 'CONSUMED' }>['record']>) => {
if (successHandled.current) return;
successHandled.current = true;
stopTimer();
setModalOpen(false);
setToken('');
sessionStorage.setItem('lastRedeemRecordId', record.id);
sessionStorage.setItem('lastRedeemResult', JSON.stringify(record));
sessionStorage.removeItem('redeemToken');
sessionStorage.removeItem('redeemAmount');
navigate('/redeem/success', {
replace: true,
state: { record },
});
},
[navigate],
);
function startTimer() { function startTimer() {
stopTimer(); stopTimer();
@@ -91,10 +135,7 @@ export default function RedeemPage() {
setTimerSec((prev) => { setTimerSec((prev) => {
if (prev <= 1) { if (prev <= 1) {
stopTimer(); stopTimer();
window.setTimeout(() => { window.setTimeout(() => handleRedeemExpired(), 0);
window.alert('核销码已失效,请重新生成');
closeModal();
}, 0);
return 0; return 0;
} }
return prev - 1; return prev - 1;
@@ -102,6 +143,35 @@ export default function RedeemPage() {
}, 1000); }, 1000);
} }
useEffect(() => {
if (!modalOpen || !token) return undefined;
let cancelled = false;
async function pollStatus() {
try {
const status = await request<RedeemTokenStatus>('USER_H5', `/redeem/tokens/${token}/status`);
if (cancelled || successHandled.current) return;
if (status.status === 'CONSUMED' && status.record) {
handleRedeemSuccess(status.record);
} else if (status.status === 'EXPIRED') {
handleRedeemExpired();
} else if (status.status === 'PENDING' && status.expireInSeconds > 0) {
setTimerSec((prev) => Math.min(prev, status.expireInSeconds));
}
} catch {
/* 轮询失败忽略,下次重试 */
}
}
void pollStatus();
const pollId = window.setInterval(() => void pollStatus(), POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(pollId);
};
}, [modalOpen, token, handleRedeemSuccess, handleRedeemExpired]);
function parseAmount() { function parseAmount() {
const value = Number(amountInput); const value = Number(amountInput);
return Number.isFinite(value) ? Math.round(value * 100) / 100 : 0; return Number.isFinite(value) ? Math.round(value * 100) / 100 : 0;
@@ -129,6 +199,7 @@ export default function RedeemPage() {
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
successHandled.current = false;
try { try {
const body: { amount: number; couponId?: string } = { amount }; const body: { amount: number; couponId?: string } = { amount };
if (couponId) body.couponId = couponId; if (couponId) body.couponId = couponId;
+91 -16
View File
@@ -1,53 +1,128 @@
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api'; import { request } from '../lib/api';
type RedeemRecord = {
id: string;
redeemNo: string;
amount: number;
storeId: string;
storeName: string;
createdAt: string;
};
function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function StarRating({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (score: number) => void;
}) {
return (
<div className="redeem-rating-row">
<span className="redeem-rating-label">{label}</span>
<div className="redeem-star-row" role="group" aria-label={label}>
{[1, 2, 3, 4, 5].map((score) => (
<button
key={score}
type="button"
className={`redeem-star-btn${score <= value ? ' active' : ''}`}
aria-label={`${score}`}
onClick={() => onChange(score)}
>
</button>
))}
</div>
</div>
);
}
export default function RedeemSuccessPage() { export default function RedeemSuccessPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [serviceScore, setServiceScore] = useState(5); const [serviceScore, setServiceScore] = useState(5);
const [envScore, setEnvScore] = useState(5); const [envScore, setEnvScore] = useState(5);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const record = useMemo<RedeemRecord | null>(() => {
const fromState = (location.state as { record?: RedeemRecord })?.record;
if (fromState) return fromState;
try {
const cached = sessionStorage.getItem('lastRedeemResult');
return cached ? (JSON.parse(cached) as RedeemRecord) : null;
} catch {
return null;
}
}, [location.state]);
const amount = Number(record?.amount ?? 0);
const storeName = record?.storeName || '门店';
const redeemNo = record?.redeemNo || '—';
const redeemedAt = record?.createdAt
? new Date(record.createdAt).toLocaleString('zh-CN')
: new Date().toLocaleString('zh-CN');
async function submit() { async function submit() {
setLoading(true); setLoading(true);
try { try {
const redeemRecordId = sessionStorage.getItem('lastRedeemRecordId'); const redeemRecordId = record?.id || sessionStorage.getItem('lastRedeemRecordId');
if (redeemRecordId) { if (redeemRecordId) {
await request('USER_H5', '/redeem/ratings', { await request('USER_H5', '/redeem/ratings', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
redeemRecordId, redeemRecordId,
serviceScore, serviceScore,
environmentScore: envScore, envScore,
}), }),
}); });
} }
} catch { } catch {
/* preV1: allow navigate even if rating fails */ /* 评价失败不阻塞返回 */
} finally { } finally {
setLoading(false); setLoading(false);
navigate('/benefit'); sessionStorage.removeItem('lastRedeemRecordId');
sessionStorage.removeItem('lastRedeemResult');
navigate('/benefit', { replace: true });
} }
} }
return ( return (
<div className="page-no-tab redeem-success-page"> <div className="page-no-tab redeem-success-page">
<PageHeader title="核销成功" onBack={() => navigate('/benefit')} /> <PageHeader title="核销成功" onBack={() => navigate('/benefit', { replace: true })} />
<div style={{ textAlign: 'center', padding: '24px 0' }}> <div className="redeem-success-hero">
<div className="success-icon"></div> <div className="success-icon"></div>
<h2 className="headline-lg text-primary"></h2> <h2 className="headline-lg text-primary"></h2>
<p className="text-variant body-md" style={{ marginTop: 8 }}></p> <p className="redeem-success-amount">¥ {formatMoney(amount)}</p>
<p className="text-variant body-md"> {storeName} </p>
</div> </div>
<div className="card">
<div className="form-group"> <div className="card redeem-success-details">
<label> (1-5)</label> <div className="redeem-success-detail-row">
<input type="number" min={1} max={5} value={serviceScore} onChange={(e) => setServiceScore(Number(e.target.value))} /> <span></span>
<span>{storeName}</span>
</div> </div>
<div className="form-group"> <div className="redeem-success-detail-row">
<label> (1-5)</label> <span></span>
<input type="number" min={1} max={5} value={envScore} onChange={(e) => setEnvScore(Number(e.target.value))} /> <span>{redeemedAt}</span>
</div> </div>
<div className="redeem-success-detail-row">
<span></span>
<span className="redeem-success-mono">{redeemNo}</span>
</div>
</div>
<div className="card redeem-success-rating">
<p className="redeem-success-rating-title"></p>
<StarRating label="服务态度" value={serviceScore} onChange={setServiceScore} />
<StarRating label="用餐环境" value={envScore} onChange={setEnvScore} />
<button type="button" className="btn btn-primary btn-block" disabled={loading} onClick={submit}> <button type="button" className="btn btn-primary btn-block" disabled={loading} onClick={submit}>
{loading ? '提交中...' : '完成'} {loading ? '提交中...' : '完成'}
</button> </button>
+89
View File
@@ -1223,6 +1223,95 @@
font-weight: 700; font-weight: 700;
} }
.redeem-success-page {
padding-bottom: 24px;
}
.redeem-success-hero {
text-align: center;
padding: 24px 16px 8px;
}
.redeem-success-amount {
margin-top: 12px;
font-size: 28px;
font-weight: 700;
color: var(--color-primary, #a02d30);
}
.redeem-success-details {
margin: 16px;
}
.redeem-success-detail-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
font-size: 14px;
}
.redeem-success-detail-row:last-child {
border-bottom: none;
}
.redeem-success-detail-row span:first-child {
color: var(--color-on-surface-variant, #666);
flex-shrink: 0;
}
.redeem-success-detail-row span:last-child {
text-align: right;
word-break: break-all;
}
.redeem-success-mono {
font-family: ui-monospace, monospace;
font-size: 13px;
}
.redeem-success-rating {
margin: 0 16px;
}
.redeem-success-rating-title {
margin: 0 0 16px;
font-weight: 600;
text-align: center;
}
.redeem-rating-row {
margin-bottom: 20px;
}
.redeem-rating-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: var(--color-on-surface-variant, #666);
}
.redeem-star-row {
display: flex;
justify-content: center;
gap: 8px;
}
.redeem-star-btn {
border: none;
background: none;
font-size: 32px;
line-height: 1;
color: #ddd;
cursor: pointer;
padding: 0;
}
.redeem-star-btn.active {
color: #ffc107;
}
/* ── 登录页(stitch user_用户登录页_C端小程序) ── */ /* ── 登录页(stitch user_用户登录页_C端小程序) ── */
.login-page { .login-page {
min-height: 100vh; min-height: 100vh;
+2
View File
@@ -81,3 +81,5 @@ export const CLIENT_APP_ACTOR_MAP: Record<ClientApp, ActorType> = {
export const REDEEM_DIRECT_LIMIT_POLICY = 'TOTAL_ACTIVE_BALANCE'; export const REDEEM_DIRECT_LIMIT_POLICY = 'TOTAL_ACTIVE_BALANCE';
export const REDEEM_DOCUMENT_LIMIT_POLICY = 'DOCUMENT_BALANCE'; export const REDEEM_DOCUMENT_LIMIT_POLICY = 'DOCUMENT_BALANCE';
export const REDEEM_TOKEN_TTL_SECONDS = 300; export const REDEEM_TOKEN_TTL_SECONDS = 300;
/** 核销成功后供用户端轮询结果,略长于 token TTL */
export const REDEEM_RESULT_TTL_SECONDS = 600;
@@ -18,6 +18,11 @@ export class UserRedeemController {
return this.redeemService.getToken(token); return this.redeemService.getToken(token);
} }
@Get('tokens/:token/status')
getTokenStatus(@CurrentUser() user: AuthUser, @Param('token') token: string) {
return this.redeemService.getTokenStatus(token, user.actorId);
}
@Post('ratings') @Post('ratings')
rating(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) { rating(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
return this.redeemService.submitRating(user.actorId, body as never); return this.redeemService.submitRating(user.actorId, body as never);
@@ -10,7 +10,7 @@ import {
validateRedeemAmount, validateRedeemAmount,
allocateBenefitCoupons, allocateBenefitCoupons,
} from '@dukang/domain'; } from '@dukang/domain';
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types'; import { REDEEM_RESULT_TTL_SECONDS, REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { RedisService } from '../../common/redis/redis.service'; import { RedisService } from '../../common/redis/redis.service';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@@ -26,6 +26,16 @@ type TokenPayload = {
allocations?: Array<{ couponId: string; amount: number }>; allocations?: Array<{ couponId: string; amount: number }>;
}; };
type RedeemResultPayload = {
recordId: string;
redeemNo: string;
userId: string;
amount: number;
storeId: string;
storeName: string;
createdAt: string;
};
@Injectable() @Injectable()
export class RedeemService { export class RedeemService {
constructor( constructor(
@@ -93,6 +103,41 @@ export class RedeemService {
return cached; return cached;
} }
async getTokenStatus(token: string, userId: bigint) {
const result = await this.redis.getJson<RedeemResultPayload>(`redeem:result:${token}`);
if (result) {
if (result.userId !== userId.toString()) {
throw new NotFoundException('核销码不存在');
}
return {
status: 'CONSUMED' as const,
record: {
id: result.recordId,
redeemNo: result.redeemNo,
amount: result.amount,
storeId: result.storeId,
storeName: result.storeName,
createdAt: result.createdAt,
},
};
}
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
if (cached) {
if (cached.userId !== userId.toString()) {
throw new NotFoundException('核销码不存在');
}
const ttl = await this.redis.ttl(`redeem:token:${token}`);
return {
status: 'PENDING' as const,
expireInSeconds: ttl > 0 ? ttl : 0,
amount: cached.amount,
};
}
return { status: 'EXPIRED' as const };
}
async previewRedeem(storeAccountId: bigint, token: string) { async previewRedeem(storeAccountId: bigint, token: string) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({ const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId }, where: { id: storeAccountId },
@@ -223,17 +268,37 @@ export class RedeemService {
throw e; throw e;
} }
await this.redis.setJson(
`redeem:result:${body.token}`,
{
recordId: record.id.toString(),
redeemNo: record.redeemNo,
userId: cached.userId,
amount,
storeId: account.storeId.toString(),
storeName: account.store.name,
createdAt: record.createdAt.toISOString(),
} satisfies RedeemResultPayload,
REDEEM_RESULT_TTL_SECONDS,
);
await this.redis.del(`redeem:token:${body.token}`); await this.redis.del(`redeem:token:${body.token}`);
const redeemExtra = {
redeemRecordId: record.id.toString(),
storeId: account.storeId.toString(),
amount,
};
this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', { this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', {
eventName: 'benefit_redeem_success', eventName: 'benefit_redeem_success',
refType: 'STORE', refType: 'STORE',
refId: account.storeId, refId: account.storeId,
extraJson: { extraJson: redeemExtra,
redeemRecordId: record.id.toString(), });
storeId: account.storeId.toString(), this.analyticsService.trackOneSafe(BigInt(cached.userId), 'USER_H5', {
amount, eventName: 'benefit_redeem_success',
}, refType: 'STORE',
refId: account.storeId,
extraJson: redeemExtra,
}); });
return serializeBigInt(record); return serializeBigInt(record);