小程序二维码功能
This commit is contained in:
@@ -1,54 +1,157 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import RedeemQrCode from '../../components/RedeemQrCode';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { toast } from '../../lib/api';
|
||||
import { request, toast } from '../../lib/api';
|
||||
|
||||
const POLL_INTERVAL_MS = 2500;
|
||||
const LAST_REDEEM_RECORD_KEY = 'lastRedeemRecordId';
|
||||
const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
|
||||
|
||||
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) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatTimer(seconds: number) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function RedeemCodePage() {
|
||||
const router = useRouter();
|
||||
const amount = router.params.amount ?? '0';
|
||||
const [seconds, setSeconds] = useState(180);
|
||||
const code = `DK${String(Date.now()).slice(-8)}`;
|
||||
const token = decodeURIComponent(router.params.token ?? '');
|
||||
const amount = Number(router.params.amount ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setSeconds((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(timer);
|
||||
toast('核销码已过期');
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const successHandled = useRef(false);
|
||||
|
||||
const stopTimer = useCallback(() => {
|
||||
if (timerRef.current != null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const mm = String(Math.floor(seconds / 60)).padStart(2, '0');
|
||||
const ss = String(seconds % 60).padStart(2, '0');
|
||||
const handleRedeemExpired = useCallback(() => {
|
||||
if (successHandled.current) return;
|
||||
successHandled.current = true;
|
||||
stopTimer();
|
||||
toast('核销码已失效,请重新生成');
|
||||
setTimeout(() => {
|
||||
Taro.navigateBack();
|
||||
}, 1500);
|
||||
}, [stopTimer]);
|
||||
|
||||
const handleRedeemSuccess = useCallback(
|
||||
(record: NonNullable<Extract<RedeemTokenStatus, { status: 'CONSUMED' }>['record']>) => {
|
||||
if (successHandled.current) return;
|
||||
successHandled.current = true;
|
||||
stopTimer();
|
||||
Taro.setStorageSync(LAST_REDEEM_RECORD_KEY, record.id);
|
||||
Taro.setStorageSync(LAST_REDEEM_RESULT_KEY, JSON.stringify(record));
|
||||
Taro.redirectTo({
|
||||
url: `/pages/redeem-success/index?amount=${record.amount}`,
|
||||
});
|
||||
},
|
||||
[stopTimer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
toast('核销码无效,请重新生成');
|
||||
Taro.navigateBack();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
successHandled.current = false;
|
||||
setTimerSec(REDEEM_TOKEN_TTL_SECONDS);
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimerSec((prev) => {
|
||||
if (prev <= 1) {
|
||||
stopTimer();
|
||||
setTimeout(() => handleRedeemExpired(), 0);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => stopTimer();
|
||||
}, [token, stopTimer, handleRedeemExpired]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const status = await request<RedeemTokenStatus>(`/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 = setInterval(() => void pollStatus(), POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(pollId);
|
||||
};
|
||||
}, [token, handleRedeemSuccess, handleRedeemExpired]);
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="redeem-code-page">
|
||||
<SubPageHeader title="核销码" />
|
||||
<View className="sub-page-body">
|
||||
<View className="redeem-code-panel">
|
||||
<Text className="u-muted">核销金额 ¥{Number(amount).toFixed(2)}</Text>
|
||||
<Text className="redeem-code-value">{code}</Text>
|
||||
<Text className="redeem-code-timer">剩余有效时间 {mm}:{ss}</Text>
|
||||
<Text className="u-muted" style={{ display: 'block', marginTop: 16 }}>
|
||||
请向门店店员出示此码完成核销
|
||||
</Text>
|
||||
<Text className="redeem-code-head">请向收银员出示此码</Text>
|
||||
<View className="redeem-qr-wrap">
|
||||
<RedeemQrCode token={token} />
|
||||
</View>
|
||||
<View className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' redeem-timer--active' : ''}`}>
|
||||
<Text className="redeem-timer-value">{formatTimer(timerSec)}</Text>
|
||||
<Text className="redeem-timer-label">失效倒计时</Text>
|
||||
</View>
|
||||
<Text className="u-muted">待核销金额</Text>
|
||||
<Text className="redeem-code-amount">¥ {formatMoney(amount)}</Text>
|
||||
{token ? (
|
||||
<View className="redeem-code-token-wrap">
|
||||
<Text className="redeem-code-token-label">核销码编号(供追查)</Text>
|
||||
<Text className="redeem-code-token">{token}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View
|
||||
className="redeem-submit"
|
||||
onClick={() =>
|
||||
Taro.redirectTo({
|
||||
url: `/pages/redeem-success/index?amount=${amount}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text>模拟核销成功</Text>
|
||||
<View className="redeem-cancel-btn" onClick={() => Taro.navigateBack()}>
|
||||
<Text>取消核销</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
|
||||
Reference in New Issue
Block a user