init
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
const REDEEM_MAX_AMOUNT = 500;
|
||||
const REDEEM_TOKEN_TTL_SECONDS = 300;
|
||||
|
||||
type BenefitSummary = {
|
||||
totalBalance: number;
|
||||
maxRedeemAmount: number;
|
||||
activeCouponCount: number;
|
||||
};
|
||||
|
||||
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 RedeemPage() {
|
||||
const navigate = useNavigate();
|
||||
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
||||
const [amountInput, setAmountInput] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [token, setToken] = useState('');
|
||||
const [confirmAmount, setConfirmAmount] = useState(0);
|
||||
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<BenefitSummary>('USER_H5', '/benefit/summary').then(setSummary).catch(() => {});
|
||||
return () => stopTimer();
|
||||
}, []);
|
||||
|
||||
function stopTimer() {
|
||||
if (timerRef.current != null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
stopTimer();
|
||||
setModalOpen(false);
|
||||
setToken('');
|
||||
setTimerSec(REDEEM_TOKEN_TTL_SECONDS);
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
stopTimer();
|
||||
setTimerSec(REDEEM_TOKEN_TTL_SECONDS);
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setTimerSec((prev) => {
|
||||
if (prev <= 1) {
|
||||
stopTimer();
|
||||
window.setTimeout(() => {
|
||||
window.alert('核销码已失效,请重新生成');
|
||||
closeModal();
|
||||
}, 0);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function parseAmount() {
|
||||
const value = Number(amountInput);
|
||||
return Number.isFinite(value) ? Math.round(value * 100) / 100 : 0;
|
||||
}
|
||||
|
||||
function fillMaxAmount() {
|
||||
if (!summary) return;
|
||||
setAmountInput(formatMoney(summary.maxRedeemAmount));
|
||||
setMsg('');
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const amount = parseAmount();
|
||||
if (!summary) return;
|
||||
if (amount <= 0) {
|
||||
setMsg('请输入核销金额');
|
||||
return;
|
||||
}
|
||||
if (amount > summary.totalBalance) {
|
||||
setMsg('核销金额不能超过可用余额');
|
||||
return;
|
||||
}
|
||||
if (amount > REDEEM_MAX_AMOUNT) {
|
||||
setMsg(`单次核销不能超过 ¥${REDEEM_MAX_AMOUNT}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const data = await request<{ token: string; amount: number }>('USER_H5', '/redeem/tokens', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ amount }),
|
||||
});
|
||||
setToken(data.token);
|
||||
setConfirmAmount(data.amount);
|
||||
setModalOpen(true);
|
||||
startTimer();
|
||||
sessionStorage.setItem('redeemToken', data.token);
|
||||
sessionStorage.setItem('redeemAmount', String(data.amount));
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '生成失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const maxHint = summary ? Math.min(REDEEM_MAX_AMOUNT, summary.totalBalance) : REDEEM_MAX_AMOUNT;
|
||||
const qrUrl = token
|
||||
? `https://api.qrserver.com/v1/create-qr-code/?size=192x192&data=${encodeURIComponent(token)}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="redeem-page">
|
||||
<header className="redeem-header">
|
||||
<button type="button" className="redeem-header-btn" aria-label="返回" onClick={() => navigate(-1)}>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className="app-page-title">好客权益核销</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="redeem-header-btn"
|
||||
aria-label="帮助"
|
||||
onClick={() => window.alert('单次核销上限 ¥500,核销码 5 分钟内有效。')}
|
||||
>
|
||||
<span className="material-symbols-outlined">help_outline</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="redeem-main">
|
||||
<section className="redeem-balance-card">
|
||||
<div className="redeem-balance-pattern" aria-hidden />
|
||||
<div className="redeem-balance-inner">
|
||||
<span className="redeem-balance-label">当前好客权益可用余额</span>
|
||||
<div className="redeem-balance-amount">
|
||||
<span className="redeem-balance-symbol">¥</span>
|
||||
<span className="redeem-balance-value">
|
||||
{summary ? formatMoney(summary.totalBalance) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="redeem-form-card">
|
||||
<label className="redeem-form-label" htmlFor="redeem-amount">
|
||||
核销金额 (¥)
|
||||
</label>
|
||||
<div className="redeem-amount-row">
|
||||
<span className="redeem-amount-symbol">¥</span>
|
||||
<input
|
||||
id="redeem-amount"
|
||||
className="redeem-amount-input"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
value={amountInput}
|
||||
onChange={(e) => {
|
||||
setAmountInput(e.target.value);
|
||||
setMsg('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="redeem-amount-foot">
|
||||
<span>单次最高可核销 ¥{formatMoney(maxHint)}</span>
|
||||
<button type="button" className="redeem-fill-max" onClick={fillMaxAmount}>
|
||||
全部核销
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{msg && <p className="redeem-msg">{msg}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="redeem-submit-btn"
|
||||
disabled={loading || !summary || summary.totalBalance <= 0}
|
||||
onClick={submit}
|
||||
>
|
||||
<span className="material-symbols-outlined">qr_code_2</span>
|
||||
{loading ? '生成中...' : '生成核销码'}
|
||||
</button>
|
||||
|
||||
<div className="redeem-security">
|
||||
<span className="material-symbols-outlined">shield</span>
|
||||
<p>安全提示:请在核销前核实金额,核销码将在 5 分钟后自动失效。</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className={`redeem-modal${modalOpen ? ' open' : ''}`} aria-hidden={!modalOpen}>
|
||||
<div className="redeem-modal-backdrop" onClick={closeModal} aria-hidden />
|
||||
<div className={`redeem-modal-panel${modalOpen ? ' open' : ''}`}>
|
||||
<div className="redeem-modal-head">
|
||||
<h3>正在核销</h3>
|
||||
<p>请向收银员出示此码</p>
|
||||
</div>
|
||||
<div className="redeem-modal-body">
|
||||
<div className="redeem-qr-wrap">
|
||||
<div className="redeem-qr-box">
|
||||
{qrUrl ? (
|
||||
<img className="redeem-qr-img" src={qrUrl} alt="核销二维码" />
|
||||
) : (
|
||||
<div className="redeem-qr-placeholder" />
|
||||
)}
|
||||
<div className="redeem-qr-scanline" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' active' : ''}`}>
|
||||
<span className="redeem-timer-value">{formatTimer(timerSec)}</span>
|
||||
<span className="redeem-timer-label">失效倒计时</span>
|
||||
</div>
|
||||
<div className="redeem-modal-amount">
|
||||
<p>待核销金额</p>
|
||||
<p className="redeem-modal-amount-value">¥ {formatMoney(confirmAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="redeem-modal-cancel" onClick={closeModal}>
|
||||
取消核销
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user