- 移除 28 个文件中不再需要的 import React (jsx: react-jsx) - 修复 catch(err: any) → catch(err: unknown) 类型收窄 - 修复 __wxConfig / CanvasRenderingContext2D 全局类型声明 - 修复 DTO 类型不匹配 (UpdateDialysisRecordReq, notificationService) - 移除 52 个未使用的 import/变量/常量 - 修复 services 类型 (auth.ts tenant_id, actionInbox boolean, device-sync)
217 lines
7.7 KiB
TypeScript
217 lines
7.7 KiB
TypeScript
import { useState, useCallback } from 'react';
|
||
import { View, Text } from '@tarojs/components';
|
||
import Taro from '@tarojs/taro';
|
||
import { usePageData } from '@/hooks/usePageData';
|
||
import {
|
||
getProduct,
|
||
listProducts,
|
||
exchangeProduct,
|
||
} from '../../../services/points';
|
||
import type { PointsProduct } from '../../../services/points';
|
||
import { usePointsStore } from '../../../stores/points';
|
||
import { useAuthStore } from '../../../stores/auth';
|
||
import Loading from '../../../components/Loading';
|
||
import { useElderClass } from '../../../hooks/useElderClass';
|
||
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
|
||
import PageShell from '@/components/ui/PageShell';
|
||
import './index.scss';
|
||
|
||
const TYPE_CHAR: Record<string, string> = {
|
||
physical: '物',
|
||
service: '券',
|
||
privilege: '权',
|
||
};
|
||
|
||
const TYPE_CLASS: Record<string, string> = {
|
||
physical: 'physical',
|
||
service: 'service',
|
||
privilege: 'privilege',
|
||
};
|
||
|
||
export default function ExchangeConfirm() {
|
||
const modeClass = useElderClass();
|
||
const [product, setProduct] = useState<PointsProduct | null>(null);
|
||
const account = usePointsStore((s) => s.account);
|
||
const refreshPoints = usePointsStore((s) => s.refresh);
|
||
const currentPatient = useAuthStore((s) => s.currentPatient);
|
||
const [loading, setLoading] = useState(true);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const { safeSetTimeout } = useSafeTimeout();
|
||
|
||
const loadData = useCallback(async () => {
|
||
const instance = Taro.getCurrentInstance();
|
||
const productId = instance.router?.params?.product_id;
|
||
if (!productId) {
|
||
Taro.showToast({ title: '参数错误', icon: 'none' });
|
||
safeSetTimeout(() => Taro.navigateBack(), 1500);
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
// 先尝试单商品接口,降级到列表查找
|
||
let found: PointsProduct | null = null;
|
||
try {
|
||
found = await getProduct(productId);
|
||
} catch (err) {
|
||
console.warn('[exchange] 单商品接口失败,降级列表查找:', err);
|
||
const productRes = await listProducts({ page: 1, page_size: 100 });
|
||
found = productRes.data.find((p) => p.id === productId) || null;
|
||
}
|
||
if (!found) {
|
||
Taro.showToast({ title: '商品不存在', icon: 'none' });
|
||
safeSetTimeout(() => Taro.navigateBack(), 1500);
|
||
return;
|
||
}
|
||
setProduct(found);
|
||
await refreshPoints();
|
||
} catch (err) {
|
||
console.warn('[exchange] 加载兑换页面失败:', err);
|
||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||
safeSetTimeout(() => Taro.navigateBack(), 1500);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [refreshPoints]);
|
||
|
||
usePageData(
|
||
useCallback(async () => {
|
||
Taro.setNavigationBarTitle({ title: '确认兑换' });
|
||
await loadData();
|
||
}, [loadData]),
|
||
{ throttleMs: 10000, enablePullDown: false },
|
||
);
|
||
|
||
const balance = account?.balance ?? 0;
|
||
const cost = product?.points_cost ?? 0;
|
||
const insufficient = balance < cost;
|
||
const remaining = balance - cost;
|
||
const productType = product?.product_type || 'physical';
|
||
const isService = productType === 'service';
|
||
const typeChar = TYPE_CHAR[productType] || '礼';
|
||
const typeCls = TYPE_CLASS[productType] || 'physical';
|
||
|
||
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 });
|
||
|
||
safeSetTimeout(() => {
|
||
if (isService && order.qr_code) {
|
||
Taro.showModal({
|
||
title: '兑换成功',
|
||
content: `核销码: ${order.qr_code}\n请凭此码到前台核销`,
|
||
showCancel: false,
|
||
confirmText: '查看订单',
|
||
success: () => {
|
||
Taro.redirectTo({ url: '/pages/pkg-mall/orders/index' });
|
||
},
|
||
});
|
||
} else {
|
||
Taro.redirectTo({ 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, isService]);
|
||
|
||
if (loading) {
|
||
return (
|
||
<PageShell className={modeClass}>
|
||
<Loading />
|
||
</PageShell>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<PageShell padding="md" safeBottom={false} scroll={false} className={`exchange-page ${modeClass}`}>
|
||
{/* 商品预览卡片 */}
|
||
<View className='exchange-product-card'>
|
||
<View className={`exchange-product-icon exchange-product-icon--${typeCls}`}>
|
||
<Text className='exchange-product-icon-char'>{typeChar}</Text>
|
||
</View>
|
||
<View className='exchange-product-meta'>
|
||
<Text className='exchange-product-name'>{product?.name || ''}</Text>
|
||
<Text className='exchange-product-points'>{cost.toLocaleString()} 积分</Text>
|
||
<Text className='exchange-product-qty'>×1</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 收货信息(实体商品) */}
|
||
{!isService && currentPatient && (
|
||
<View className='exchange-address-card'>
|
||
<View className='exchange-address-header'>
|
||
<Text className='exchange-address-title'>收货信息</Text>
|
||
<Text className='exchange-address-edit'>修改地址 ›</Text>
|
||
</View>
|
||
<Text className='exchange-address-name'>
|
||
{currentPatient.name}
|
||
</Text>
|
||
<Text className='exchange-address-detail'>请前往个人中心完善收货地址</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* 兑换明细 */}
|
||
<View className='exchange-detail-card'>
|
||
<Text className='exchange-detail-title'>兑换明细</Text>
|
||
<View className='exchange-detail-row'>
|
||
<Text className='exchange-detail-label'>商品积分</Text>
|
||
<Text className='exchange-detail-value'>{cost.toLocaleString()}</Text>
|
||
</View>
|
||
<View className='exchange-detail-row'>
|
||
<Text className='exchange-detail-label'>{isService ? '核销方式' : '运费'}</Text>
|
||
<Text className='exchange-detail-value'>{isService ? '到院核销' : '¥0.00'}</Text>
|
||
</View>
|
||
<View className='exchange-detail-row'>
|
||
<Text className='exchange-detail-label'>应扣积分</Text>
|
||
<Text className='exchange-detail-value exchange-detail-cost'>{cost.toLocaleString()}</Text>
|
||
</View>
|
||
<View className='exchange-detail-row last'>
|
||
<Text className='exchange-detail-label'>剩余积分</Text>
|
||
<Text className={`exchange-detail-value ${remaining >= 0 ? 'sufficient' : 'insufficient'}`}>
|
||
{remaining.toLocaleString()}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 确认兑换按钮 */}
|
||
<View
|
||
className={`exchange-confirm-btn ${insufficient || (product?.stock ?? 0) <= 0 || submitting ? 'disabled' : ''}`}
|
||
onClick={insufficient || (product?.stock ?? 0) <= 0 || submitting ? undefined : handleConfirm}
|
||
>
|
||
<Text className='exchange-confirm-text'>
|
||
{submitting
|
||
? '兑换中...'
|
||
: insufficient
|
||
? '积分不足'
|
||
: (product?.stock ?? 0) <= 0
|
||
? '已兑完'
|
||
: '确认兑换'}
|
||
</Text>
|
||
</View>
|
||
</PageShell>
|
||
);
|
||
}
|