a7a0a54688
Co-authored-by: Cursor <cursoragent@cursor.com>
177 lines
5.8 KiB
TypeScript
177 lines
5.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { View, Text, Input } from '@tarojs/components';
|
|
import Taro, { useRouter } from '@tarojs/taro';
|
|
import PageShell from '../../components/PageShell';
|
|
import SubPageHeader from '../../components/SubPageHeader';
|
|
import { goLogin } from '../../lib/auth-nav';
|
|
import { isLoggedIn, request, toast } from '../../lib/api';
|
|
import { formatMoney } from '../../lib/money';
|
|
|
|
type BenefitSummary = {
|
|
totalBalance: number;
|
|
maxRedeemAmount: number;
|
|
};
|
|
|
|
const MIN_REDEEM_AMOUNT = 0.01;
|
|
|
|
/**
|
|
* 核销金额输入清洗:
|
|
* - 只保留数字与一个小数点
|
|
* - 小数最多 2 位(再输入会被截断)
|
|
* - 去掉多余前导 0
|
|
*/
|
|
function sanitizeRedeemAmountInput(raw: string): string {
|
|
let next = String(raw ?? '').replace(/[^\d.]/g, '');
|
|
if (!next) return '';
|
|
|
|
const firstDot = next.indexOf('.');
|
|
if (firstDot >= 0) {
|
|
const intRaw = next.slice(0, firstDot).replace(/\D/g, '');
|
|
const decRaw = next
|
|
.slice(firstDot + 1)
|
|
.replace(/\D/g, '')
|
|
.slice(0, 2);
|
|
const intPart = intRaw.replace(/^0+(?=\d)/, '') || '0';
|
|
if (next.endsWith('.') && decRaw.length === 0) {
|
|
return `${intPart}.`;
|
|
}
|
|
if (decRaw.length > 0) {
|
|
return `${intPart}.${decRaw}`;
|
|
}
|
|
return intPart;
|
|
}
|
|
|
|
return next.replace(/^0+(?=\d)/, '');
|
|
}
|
|
|
|
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(() => sanitizeRedeemAmountInput(initialAmount));
|
|
/** 原生 input 在截断小数后偶发不同步,强制 remount 对齐受控值 */
|
|
const [inputKey, setInputKey] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const redeemableMax = couponId ? (couponBalance ?? 0) : balance;
|
|
|
|
useEffect(() => {
|
|
if (!isLoggedIn()) {
|
|
goLogin('/pages/redeem/index');
|
|
return;
|
|
}
|
|
request<BenefitSummary>('/benefit/summary')
|
|
.then((s) => setBalance(Number(s.maxRedeemAmount ?? s.totalBalance ?? 0)))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
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 < MIN_REDEEM_AMOUNT) return;
|
|
setAmount(sanitizeRedeemAmountInput(redeemableMax.toFixed(2)));
|
|
setInputKey((k) => k + 1);
|
|
}
|
|
|
|
function onAmountChange(raw: string) {
|
|
const next = sanitizeRedeemAmountInput(raw);
|
|
setAmount(next);
|
|
// 用户试图输入超过两位小数 / 非法字符时,强制刷新原生框显示
|
|
if (next !== raw) {
|
|
setInputKey((k) => k + 1);
|
|
}
|
|
}
|
|
|
|
async function submit() {
|
|
const value = Math.round(Number(amount) * 100) / 100;
|
|
if (!Number.isFinite(value) || value < MIN_REDEEM_AMOUNT) {
|
|
toast(`核销金额不能低于 ${MIN_REDEEM_AMOUNT.toFixed(2)} 元`);
|
|
return;
|
|
}
|
|
if (value > redeemableMax) {
|
|
toast(couponId ? '核销金额不能超过该权益可用余额' : '超出可用余额');
|
|
return;
|
|
}
|
|
|
|
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 (
|
|
<PageShell variant="sub" className="redeem-page">
|
|
<SubPageHeader title="权益核销" />
|
|
<View className="sub-page-body">
|
|
<View className="redeem-hero">
|
|
<Text className="redeem-hero-label">
|
|
{couponId ? '当前权益可用余额' : '可用余额'}
|
|
</Text>
|
|
<Text className="redeem-hero-amount">
|
|
¥{redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
|
|
</Text>
|
|
</View>
|
|
<View className="redeem-input-wrap">
|
|
<Input
|
|
key={inputKey}
|
|
className="redeem-input"
|
|
type="digit"
|
|
placeholder="输入核销金额"
|
|
placeholderClass="redeem-input-placeholder"
|
|
value={amount}
|
|
maxlength={12}
|
|
onInput={(e) => onAmountChange(e.detail.value)}
|
|
onBlur={(e) => onAmountChange(e.detail.value)}
|
|
style={{ textAlign: 'center' }}
|
|
/>
|
|
</View>
|
|
<View className="redeem-amount-foot">
|
|
<Text className="redeem-amount-hint">
|
|
最高可核销 ¥{formatMoney(redeemableMax)}
|
|
</Text>
|
|
<Text className="redeem-fill-max" onClick={fillMaxAmount}>
|
|
全部核销
|
|
</Text>
|
|
</View>
|
|
<View className="redeem-tips">
|
|
<Text className="redeem-tips-text">
|
|
核销金额最低 0.01 元,小数最多两位。不超过可用权益余额。核销码有效期 3 分钟,请到店出示给收银员扫码。
|
|
</Text>
|
|
</View>
|
|
<View
|
|
className={`redeem-submit${loading || redeemableMax < MIN_REDEEM_AMOUNT ? ' redeem-submit--disabled' : ''}`}
|
|
onClick={loading || redeemableMax < MIN_REDEEM_AMOUNT ? undefined : submit}
|
|
>
|
|
<Text>{loading ? '生成中...' : '生成核销码'}</Text>
|
|
</View>
|
|
</View>
|
|
</PageShell>
|
|
);
|
|
}
|