- 新增 $white 语义变量 + --tk-font-display Token - 44 处 #fff → $white,2 处 background: #fff → $card - 14 处 border-radius 硬编码统一为 $r-xs/$r-lg/$r - 3 处 TSX inline 颜色提取为 SCSS 类(exchange/orders/action-inbox) - ErrorBoundary 重构:6 个 inline style → SCSS 类 + Design Token - 2 处离调色板颜色修正(#0284C7→$tx2, #94A3B8→$tx3) - 2 处静默 catch 块添加状态清理(article/health) - 趋势页补 Loading/EmptyState;咨询页 GuestGuard 统一 - 4 处 #FFFFFF → $white(mixins/index/exchange/variables)
215 lines
7.0 KiB
TypeScript
215 lines
7.0 KiB
TypeScript
import React, { useState, useCallback } from 'react';
|
||
import { View, Text } from '@tarojs/components';
|
||
import Taro, { useDidShow } from '@tarojs/taro';
|
||
import {
|
||
listProducts,
|
||
exchangeProduct,
|
||
} from '../../../services/points';
|
||
import type { PointsProduct } from '../../../services/points';
|
||
import { usePointsStore } from '../../../stores/points';
|
||
import Loading from '../../../components/Loading';
|
||
import { useElderClass } from '../../../hooks/useElderClass';
|
||
import './index.scss';
|
||
|
||
const TYPE_INITIAL: Record<string, string> = {
|
||
physical: '物',
|
||
service: '券',
|
||
privilege: '权',
|
||
};
|
||
|
||
const TYPE_LABEL: Record<string, string> = {
|
||
physical: '实物商品',
|
||
service: '服务券',
|
||
privilege: '权益卡',
|
||
};
|
||
|
||
const TYPE_CLASS: Record<string, string> = {
|
||
physical: 'product-icon-wrap--physical',
|
||
service: 'product-icon-wrap--service',
|
||
privilege: 'product-icon-wrap--privilege',
|
||
};
|
||
|
||
export default function ExchangeConfirm() {
|
||
const modeClass = useElderClass();
|
||
const [product, setProduct] = useState<PointsProduct | null>(null);
|
||
const { account, refresh: refreshPoints } = usePointsStore();
|
||
const [loading, setLoading] = useState(true);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
useDidShow(() => {
|
||
Taro.setNavigationBarTitle({ title: '确认兑换' });
|
||
loadData();
|
||
});
|
||
|
||
const loadData = useCallback(async () => {
|
||
const instance = Taro.getCurrentInstance();
|
||
const productId = instance.router?.params?.product_id;
|
||
if (!productId) {
|
||
Taro.showToast({ title: '参数错误', icon: 'none' });
|
||
setTimeout(() => Taro.navigateBack(), 1500);
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const [productRes] = await Promise.all([
|
||
listProducts({ page: 1, page_size: 100 }),
|
||
refreshPoints(),
|
||
]);
|
||
const found = productRes.data.find((p) => p.id === productId);
|
||
if (!found) {
|
||
Taro.showToast({ title: '商品不存在', icon: 'none' });
|
||
setTimeout(() => Taro.navigateBack(), 1500);
|
||
return;
|
||
}
|
||
setProduct(found);
|
||
} catch {
|
||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||
setTimeout(() => Taro.navigateBack(), 1500);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [refreshPoints]);
|
||
|
||
const balance = account?.balance ?? 0;
|
||
const cost = product?.points_cost ?? 0;
|
||
const insufficient = balance < cost;
|
||
|
||
const handleConfirm = useCallback(async () => {
|
||
if (!product || submitting) return;
|
||
|
||
if (insufficient) {
|
||
Taro.showToast({ title: '积分不足', icon: 'none' });
|
||
return;
|
||
}
|
||
|
||
const modalRes = await Taro.showModal({
|
||
title: '确认兑换',
|
||
content: `确定花费 ${cost} 积分兑换「${product.name}」吗?`,
|
||
});
|
||
if (!modalRes.confirm) return;
|
||
|
||
setSubmitting(true);
|
||
try {
|
||
const order = await exchangeProduct(product.id);
|
||
Taro.showToast({ title: '兑换成功', icon: 'success', duration: 2000 });
|
||
|
||
setTimeout(() => {
|
||
Taro.showModal({
|
||
title: '兑换成功',
|
||
content: `核销码: ${order.qr_code}\n请凭此码到前台核销`,
|
||
showCancel: false,
|
||
confirmText: '查看订单',
|
||
success: () => {
|
||
Taro.navigateTo({
|
||
url: `/pages/pkg-mall/orders/index`,
|
||
});
|
||
},
|
||
});
|
||
}, 2000);
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : '兑换失败';
|
||
if (msg.includes('余额不足') || msg.includes('insufficient')) {
|
||
Taro.showToast({ title: '积分不足', icon: 'none' });
|
||
} else {
|
||
Taro.showToast({ title: msg, icon: 'none' });
|
||
}
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}, [product, submitting, insufficient, cost]);
|
||
|
||
if (loading) {
|
||
return (
|
||
<View className={`exchange-page ${modeClass}`}>
|
||
<Loading />
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const productType = product?.product_type || 'physical';
|
||
const initial = TYPE_INITIAL[productType] || '礼';
|
||
const typeLabel = TYPE_LABEL[productType] || '商品';
|
||
const iconCls = TYPE_CLASS[productType] || 'product-icon-wrap--service';
|
||
|
||
return (
|
||
<View className={`exchange-page ${modeClass}`}>
|
||
{/* 商品预览卡片 */}
|
||
<View className='product-card'>
|
||
<View className={`product-icon-wrap ${iconCls}`}>
|
||
<Text className='product-icon-char'>{initial}</Text>
|
||
</View>
|
||
<View className='product-meta'>
|
||
<Text className='product-name'>{product?.name || ''}</Text>
|
||
<Text className='product-type-tag'>{typeLabel}</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 兑换明细 */}
|
||
<View className='detail-section'>
|
||
<Text className='detail-section-title'>兑换明细</Text>
|
||
<View className='detail-card'>
|
||
<View className='detail-row'>
|
||
<Text className='detail-label'>所需积分</Text>
|
||
<Text className='detail-value detail-cost'>{cost.toLocaleString()}</Text>
|
||
</View>
|
||
<View className='detail-row'>
|
||
<Text className='detail-label'>当前余额</Text>
|
||
<Text
|
||
className={`detail-value ${insufficient ? 'detail-insufficient' : 'detail-sufficient'}`}
|
||
>
|
||
{balance.toLocaleString()}
|
||
</Text>
|
||
</View>
|
||
{insufficient && (
|
||
<View className='detail-row'>
|
||
<Text className='detail-label'>差额</Text>
|
||
<Text className='detail-value detail-insufficient'>
|
||
-{(cost - balance).toLocaleString()}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
<View className='detail-row last'>
|
||
<Text className='detail-label'>库存</Text>
|
||
<Text className='detail-value'>
|
||
{product && product.stock > 0 ? `剩余 ${product.stock} 件` : '已兑完'}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 温馨提示 */}
|
||
<View className='notice-section'>
|
||
<Text className='notice-title'>温馨提示</Text>
|
||
<Text className='notice-text'>
|
||
兑换成功后将生成核销码,请凭核销码到前台核销领取。
|
||
</Text>
|
||
<Text className='notice-text'>积分一经兑换不可退回。</Text>
|
||
</View>
|
||
|
||
{/* 底部操作 */}
|
||
<View className='exchange-footer'>
|
||
<View className='footer-cost'>
|
||
<Text className='footer-cost-label'>合计</Text>
|
||
<Text className='footer-cost-num'>{cost.toLocaleString()}</Text>
|
||
<Text className='footer-cost-unit'>积分</Text>
|
||
</View>
|
||
<View
|
||
className={`confirm-btn ${insufficient || (product?.stock ?? 0) <= 0 || submitting ? 'disabled' : ''}`}
|
||
onClick={insufficient || (product?.stock ?? 0) <= 0 || submitting ? undefined : handleConfirm}
|
||
>
|
||
<Text className='confirm-btn-text'>
|
||
{submitting
|
||
? '兑换中...'
|
||
: insufficient
|
||
? '积分不足'
|
||
: (product?.stock ?? 0) <= 0
|
||
? '已兑完'
|
||
: '确认兑换'}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|