小程序二维码功能
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
"@tarojs/runtime": "4.2.0",
|
||||
"@tarojs/shared": "4.2.0",
|
||||
"@tarojs/taro": "4.2.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import './lib/text-encoding-polyfill';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import './app.css';
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Canvas } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import {
|
||||
REDEEM_QR_DISPLAY_SIZE,
|
||||
buildRedeemQrDataUrl,
|
||||
drawRedeemQrOnCanvas,
|
||||
} from '../lib/redeem-qr';
|
||||
|
||||
const CANVAS_ID = 'redeem-qr-canvas';
|
||||
|
||||
type RedeemQrCodeProps = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
function drawOnWeappCanvas(token: string) {
|
||||
const page = Taro.getCurrentInstance().page;
|
||||
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
|
||||
query
|
||||
.select(`#${CANVAS_ID}`)
|
||||
.fields({ node: true, size: true })
|
||||
.exec((res) => {
|
||||
const item = res[0] as { node?: WechatMiniprogram.Canvas; width?: number; height?: number } | undefined;
|
||||
const canvas = item?.node;
|
||||
if (!canvas) return;
|
||||
|
||||
const layoutW = item.width || REDEEM_QR_DISPLAY_SIZE;
|
||||
const layoutH = item.height || REDEEM_QR_DISPLAY_SIZE;
|
||||
const drawSize = Math.min(layoutW, layoutH);
|
||||
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
const dpr = Taro.getSystemInfoSync().pixelRatio || 2;
|
||||
canvas.width = layoutW * dpr;
|
||||
canvas.height = layoutH * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
drawRedeemQrOnCanvas(ctx, token, drawSize);
|
||||
});
|
||||
}
|
||||
|
||||
export default function RedeemQrCode({ token }: RedeemQrCodeProps) {
|
||||
const [imgSrc, setImgSrc] = useState('');
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setImgSrc('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWeapp) {
|
||||
const timer = setTimeout(() => drawOnWeappCanvas(token), 120);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void buildRedeemQrDataUrl(token).then((url) => {
|
||||
if (!cancelled) setImgSrc(url);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, isWeapp]);
|
||||
|
||||
return (
|
||||
<View className="redeem-qr-box">
|
||||
<View className="redeem-qr-placeholder" />
|
||||
{isWeapp ? (
|
||||
<Canvas type="2d" id={CANVAS_ID} canvasId={CANVAS_ID} className="redeem-qr-canvas" />
|
||||
) : imgSrc ? (
|
||||
<View className="redeem-qr-img" style={{ backgroundImage: `url(${imgSrc})` }} />
|
||||
) : null}
|
||||
<View className="redeem-qr-scanline" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import './text-encoding-polyfill';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
const QR_SIZE = 240;
|
||||
const QR_OPTIONS = {
|
||||
width: QR_SIZE * 2,
|
||||
margin: 0,
|
||||
color: { dark: '#1f1a17', light: '#ffffff' },
|
||||
} as const;
|
||||
|
||||
/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用) */
|
||||
export function drawRedeemQrOnCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
token: string,
|
||||
sizePx = QR_SIZE,
|
||||
) {
|
||||
const qr = QRCode.create(token, { errorCorrectionLevel: 'M' });
|
||||
const count = qr.modules.size;
|
||||
const cell = sizePx / count;
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, sizePx, sizePx);
|
||||
ctx.fillStyle = '#1f1a17';
|
||||
for (let row = 0; row < count; row++) {
|
||||
for (let col = 0; col < count; col++) {
|
||||
if (qr.modules.get(row, col)) {
|
||||
ctx.fillRect(col * cell, row * cell, cell, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** H5:Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */
|
||||
export async function buildRedeemQrDataUrl(token: string): Promise<string> {
|
||||
try {
|
||||
return await QRCode.toDataURL(token, QR_OPTIONS);
|
||||
} catch {
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const REDEEM_QR_DISPLAY_SIZE = QR_SIZE;
|
||||
@@ -0,0 +1,52 @@
|
||||
/** 微信小程序基础库未内置 TextEncoder,qrcode 库依赖它编码 payload */
|
||||
function installTextEncodingPolyfill() {
|
||||
const root = (typeof globalThis !== 'undefined'
|
||||
? globalThis
|
||||
: typeof global !== 'undefined'
|
||||
? global
|
||||
: typeof wx !== 'undefined'
|
||||
? wx
|
||||
: {}) as typeof globalThis & { TextEncoder?: typeof TextEncoder };
|
||||
|
||||
if (typeof root.TextEncoder !== 'undefined') return;
|
||||
|
||||
class MiniTextEncoder implements TextEncoder {
|
||||
readonly encoding = 'utf-8';
|
||||
|
||||
encode(input?: string): Uint8Array {
|
||||
const str = input ?? '';
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let code = str.charCodeAt(i);
|
||||
if (code < 0x80) {
|
||||
bytes.push(code);
|
||||
} else if (code < 0x800) {
|
||||
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
|
||||
} else if (code >= 0xd800 && code <= 0xdbff && i + 1 < str.length) {
|
||||
const next = str.charCodeAt(i + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
i += 1;
|
||||
code = 0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00);
|
||||
bytes.push(
|
||||
0xf0 | (code >> 18),
|
||||
0x80 | ((code >> 12) & 0x3f),
|
||||
0x80 | ((code >> 6) & 0x3f),
|
||||
0x80 | (code & 0x3f),
|
||||
);
|
||||
} else {
|
||||
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
||||
}
|
||||
} else {
|
||||
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
root.TextEncoder = MiniTextEncoder as unknown as typeof TextEncoder;
|
||||
}
|
||||
|
||||
installTextEncodingPolyfill();
|
||||
|
||||
export {};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -54,6 +54,19 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.redeem-amount-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0 var(--space-page) 12px;
|
||||
}
|
||||
|
||||
.redeem-fill-max {
|
||||
font-size: 13px;
|
||||
color: var(--color-heritage-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.redeem-submit {
|
||||
margin: 24px var(--space-page);
|
||||
height: 48px;
|
||||
@@ -67,6 +80,25 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.redeem-submit--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.redeem-cancel-btn {
|
||||
margin: 0 var(--space-page) 24px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-heritage-red);
|
||||
border: 1px solid var(--color-heritage-red);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.redeem-code-panel {
|
||||
margin: 32px var(--space-page);
|
||||
padding: 32px 20px;
|
||||
@@ -76,20 +108,162 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.redeem-code-value {
|
||||
.redeem-code-head {
|
||||
display: block;
|
||||
font-family: var(--font-headline);
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-heritage-red);
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
color: var(--color-subtle-gray);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.redeem-code-timer {
|
||||
.redeem-qr-wrap {
|
||||
padding: 12px;
|
||||
border: 4px solid rgba(166, 29, 36, 0.1);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: 0 auto 20px;
|
||||
width: 240px;
|
||||
max-width: calc(100% - 40px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.redeem-qr-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: 100%;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.redeem-qr-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.redeem-qr-img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.redeem-qr-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
background: var(--color-surface-container);
|
||||
}
|
||||
|
||||
.redeem-qr-scanline {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 3;
|
||||
height: 3px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(166, 29, 36, 0) 0%,
|
||||
rgba(166, 29, 36, 0.85) 50%,
|
||||
rgba(166, 29, 36, 0) 100%
|
||||
);
|
||||
box-shadow: 0 0 12px rgba(166, 29, 36, 0.55);
|
||||
pointer-events: none;
|
||||
animation: redeem-scan 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes redeem-scan {
|
||||
0%,
|
||||
100% {
|
||||
top: 0;
|
||||
opacity: 0.1;
|
||||
}
|
||||
50% {
|
||||
top: 95%;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.redeem-timer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.redeem-timer--active {
|
||||
animation: redeem-pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes redeem-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(166, 29, 36, 0.2);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(166, 29, 36, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(166, 29, 36, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.redeem-timer-value {
|
||||
font-family: var(--font-headline);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--color-ink-black);
|
||||
}
|
||||
|
||||
.redeem-timer-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-subtle-gray);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.redeem-code-amount {
|
||||
display: block;
|
||||
font-family: var(--font-headline);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-heritage-red);
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
|
||||
.redeem-code-token-wrap {
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-surface-container);
|
||||
}
|
||||
|
||||
.redeem-code-token-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--color-subtle-gray);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.redeem-code-token {
|
||||
display: block;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
color: var(--color-on-surface);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.redeem-success-icon {
|
||||
@@ -119,7 +293,46 @@
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--color-subtle-gray);
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.redeem-success-amount {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-family: var(--font-headline);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-heritage-red);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.redeem-success-details {
|
||||
margin: 0 var(--space-page) 24px;
|
||||
padding: 16px;
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.redeem-success-detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid var(--color-surface-container);
|
||||
}
|
||||
|
||||
.redeem-success-detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.redeem-success-mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ledger-item {
|
||||
|
||||
Generated
+3
@@ -310,6 +310,9 @@ importers:
|
||||
element-china-area-data:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
|
||||
Reference in New Issue
Block a user