diff --git a/apps/mini-user/package.json b/apps/mini-user/package.json index f4c422d..c3f8418 100644 --- a/apps/mini-user/package.json +++ b/apps/mini-user/package.json @@ -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" }, diff --git a/apps/mini-user/src/app.tsx b/apps/mini-user/src/app.tsx index 3537d0a..85fa689 100644 --- a/apps/mini-user/src/app.tsx +++ b/apps/mini-user/src/app.tsx @@ -1,3 +1,4 @@ +import './lib/text-encoding-polyfill'; import { PropsWithChildren } from 'react'; import './app.css'; diff --git a/apps/mini-user/src/components/RedeemQrCode.tsx b/apps/mini-user/src/components/RedeemQrCode.tsx new file mode 100644 index 0000000..6de502a --- /dev/null +++ b/apps/mini-user/src/components/RedeemQrCode.tsx @@ -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 ( + + + {isWeapp ? ( + + ) : imgSrc ? ( + + ) : null} + + + ); +} diff --git a/apps/mini-user/src/lib/redeem-qr.ts b/apps/mini-user/src/lib/redeem-qr.ts new file mode 100644 index 0000000..decc453 --- /dev/null +++ b/apps/mini-user/src/lib/redeem-qr.ts @@ -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 { + 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; diff --git a/apps/mini-user/src/lib/text-encoding-polyfill.ts b/apps/mini-user/src/lib/text-encoding-polyfill.ts new file mode 100644 index 0000000..c952394 --- /dev/null +++ b/apps/mini-user/src/lib/text-encoding-polyfill.ts @@ -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 {}; diff --git a/apps/mini-user/src/pages/redeem-code/index.tsx b/apps/mini-user/src/pages/redeem-code/index.tsx index d3bc11a..741851b 100644 --- a/apps/mini-user/src/pages/redeem-code/index.tsx +++ b/apps/mini-user/src/pages/redeem-code/index.tsx @@ -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 | 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['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(`/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 ( - 核销金额 ¥{Number(amount).toFixed(2)} - {code} - 剩余有效时间 {mm}:{ss} - - 请向门店店员出示此码完成核销 - + 请向收银员出示此码 + + + + 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' redeem-timer--active' : ''}`}> + {formatTimer(timerSec)} + 失效倒计时 + + 待核销金额 + ¥ {formatMoney(amount)} + {token ? ( + + 核销码编号(供追查) + {token} + + ) : null} - - Taro.redirectTo({ - url: `/pages/redeem-success/index?amount=${amount}`, - }) - } - > - 模拟核销成功 + Taro.navigateBack()}> + 取消核销 diff --git a/apps/mini-user/src/pages/redeem-success/index.tsx b/apps/mini-user/src/pages/redeem-success/index.tsx index 59bd1cf..024be82 100644 --- a/apps/mini-user/src/pages/redeem-success/index.tsx +++ b/apps/mini-user/src/pages/redeem-success/index.tsx @@ -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(() => { + 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 ( @@ -15,13 +51,25 @@ export default function RedeemSuccessPage() { 核销成功 - - 已核销好客权益 ¥{Number(amount).toFixed(2)} - - Taro.switchTab({ url: '/pages/benefit/index' })} - > + ¥ {formatMoney(amount)} + 已在 {storeName} 完成核销 + + + + 核销门店 + {storeName} + + + 核销时间 + {redeemedAt} + + + 核销单号 + {redeemNo} + + + + 返回权益 (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>('/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() { - 可用余额 - ¥{balance.toFixed(2)} + + {couponId ? '当前权益可用余额' : '可用余额'} + + + ¥{redeemableMax > 0 ? formatMoney(redeemableMax) : '--'} + setAmount(e.detail.value)} /> + + + 最高可核销 ¥{formatMoney(redeemableMax)} + + + 全部核销 + + 直接核销:金额须大于 0 且不超过全部可用权益余额。核销码有效期 3 分钟,请到店出示。 - - 生成核销码 + + {loading ? '生成中...' : '生成核销码'} diff --git a/apps/mini-user/src/styles/redeem.css b/apps/mini-user/src/styles/redeem.css index ad8174f..b182831 100644 --- a/apps/mini-user/src/styles/redeem.css +++ b/apps/mini-user/src/styles/redeem.css @@ -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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d70f44..a817b48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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