小程序二维码功能

This commit is contained in:
2026-07-12 23:59:52 +08:00
parent 1bd152073a
commit f2cdc0222d
10 changed files with 658 additions and 63 deletions
+137 -34
View File
@@ -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>
@@ -1,11 +1,47 @@
import { useMemo } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
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 });
}
export default function RedeemSuccessPage() {
const router = useRouter();
const amount = router.params.amount ?? '0';
const record = useMemo<RedeemRecord | null>(() => {
try {
const cached = Taro.getStorageSync(LAST_REDEEM_RESULT_KEY);
return cached ? (JSON.parse(String(cached)) as RedeemRecord) : null;
} catch {
return null;
}
}, []);
const amount = Number(record?.amount ?? router.params.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');
function goBenefit() {
Taro.removeStorageSync(LAST_REDEEM_RESULT_KEY);
Taro.switchTab({ url: '/pages/benefit/index' });
}
return (
<PageShell variant="sub" className="redeem-success-page">
@@ -15,13 +51,25 @@ export default function RedeemSuccessPage() {
<Text></Text>
</View>
<Text className="redeem-success-title"></Text>
<Text className="redeem-success-desc">
¥{Number(amount).toFixed(2)}
</Text>
<View
className="redeem-submit"
onClick={() => Taro.switchTab({ url: '/pages/benefit/index' })}
>
<Text className="redeem-success-amount">¥ {formatMoney(amount)}</Text>
<Text className="redeem-success-desc"> {storeName} </Text>
<View className="redeem-success-details">
<View className="redeem-success-detail-row">
<Text></Text>
<Text>{storeName}</Text>
</View>
<View className="redeem-success-detail-row">
<Text></Text>
<Text>{redeemedAt}</Text>
</View>
<View className="redeem-success-detail-row">
<Text></Text>
<Text className="redeem-success-mono">{redeemNo}</Text>
</View>
</View>
<View className="redeem-submit" onClick={goBenefit}>
<Text></Text>
</View>
<View
+68 -11
View File
@@ -11,11 +11,20 @@ type BenefitSummary = {
maxRedeemAmount: number;
};
function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function RedeemPage() {
const router = useRouter();
const couponId = router.params.couponId;
const initialAmount = router.params.amount ?? '';
const [balance, setBalance] = useState(0);
const [couponBalance, setCouponBalance] = useState<number | null>(null);
const [amount, setAmount] = useState(initialAmount);
const [loading, setLoading] = useState(false);
const redeemableMax = couponId ? (couponBalance ?? 0) : balance;
useEffect(() => {
if (!isLoggedIn()) {
@@ -27,19 +36,52 @@ export default function RedeemPage() {
.catch(() => {});
}, []);
function submit() {
const value = Number(amount);
useEffect(() => {
if (!couponId) {
setCouponBalance(null);
return;
}
request<Array<{ id: string; balance: number }>>('/benefit/coupons')
.then((list) => {
const found = list.find((c) => String(c.id) === couponId);
if (found) setCouponBalance(Number(found.balance));
})
.catch(() => {});
}, [couponId]);
function fillMaxAmount() {
if (redeemableMax <= 0) return;
setAmount(String(redeemableMax));
}
async function submit() {
const value = Math.round(Number(amount) * 100) / 100;
if (!(value > 0)) {
toast('请输入核销金额');
return;
}
if (value > balance && balance > 0) {
toast('超出可用余额');
if (value > redeemableMax) {
toast(couponId ? '核销金额不能超过该权益可用余额' : '超出可用余额');
return;
}
Taro.navigateTo({
url: `/pages/redeem-code/index?amount=${value}`,
});
setLoading(true);
try {
const body: { amount: number; couponId?: string } = { amount: value };
if (couponId) body.couponId = couponId;
const data = await request<{ token: string; amount: number }>('/redeem/tokens', {
method: 'POST',
data: body,
});
Taro.navigateTo({
url: `/pages/redeem-code/index?token=${encodeURIComponent(data.token)}&amount=${data.amount}`,
});
} catch (e) {
toast(e instanceof Error ? e.message : '生成失败');
} finally {
setLoading(false);
}
}
return (
@@ -47,8 +89,12 @@ export default function RedeemPage() {
<SubPageHeader title="权益核销" />
<View className="sub-page-body">
<View className="redeem-hero">
<Text className="redeem-hero-label"></Text>
<Text className="redeem-hero-amount">¥{balance.toFixed(2)}</Text>
<Text className="redeem-hero-label">
{couponId ? '当前权益可用余额' : '可用余额'}
</Text>
<Text className="redeem-hero-amount">
¥{redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
</Text>
</View>
<View className="redeem-input-wrap">
<Input
@@ -59,11 +105,22 @@ export default function RedeemPage() {
onInput={(e) => setAmount(e.detail.value)}
/>
</View>
<View className="redeem-amount-foot">
<Text className="redeem-tips" style={{ margin: 0 }}>
¥{formatMoney(redeemableMax)}
</Text>
<Text className="redeem-fill-max" onClick={fillMaxAmount}>
</Text>
</View>
<Text className="redeem-tips">
0 3
</Text>
<View className="redeem-submit" onClick={submit}>
<Text></Text>
<View
className={`redeem-submit${loading || redeemableMax <= 0 ? ' redeem-submit--disabled' : ''}`}
onClick={loading || redeemableMax <= 0 ? undefined : submit}
>
<Text>{loading ? '生成中...' : '生成核销码'}</Text>
</View>
</View>
</PageShell>