feat(mp+health): 小程序分包迁移 + 积分商城后台列表 API
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 小程序页面迁移到 pkg-health/pkg-mall/pkg-profile 分包目录
- 删除旧 pages/health/input、pages/mall/detail 等旧路径
- 导航路径更新为分包路径(/pages/pkg-mall/exchange/index 等)
- TrendChart 组件优化
- 后台添加 admin_list_products API(支持查看已下架商品)
- config/index.ts 添加 defineConstants 环境变量
- mp e2e check-readiness 路径修正
This commit is contained in:
iven
2026-04-29 07:29:49 +08:00
parent 9015a2b85e
commit cb6f5cc651
32 changed files with 229 additions and 516 deletions

View File

@@ -0,0 +1,215 @@
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 './index.scss';
const TYPE_INITIAL: Record<string, string> = {
physical: '物',
service: '券',
privilege: '权',
};
const TYPE_LABEL: Record<string, string> = {
physical: '实物商品',
service: '服务券',
privilege: '权益卡',
};
const TYPE_COLOR: Record<string, string> = {
physical: '#5B7A5E',
service: '#C4623A',
privilege: '#8B3E1F',
};
export default function ExchangeConfirm() {
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'>
<Loading />
</View>
);
}
const productType = product?.product_type || 'physical';
const initial = TYPE_INITIAL[productType] || '礼';
const typeLabel = TYPE_LABEL[productType] || '商品';
const typeColor = TYPE_COLOR[productType] || '#C4623A';
return (
<View className='exchange-page'>
{/* 商品预览卡片 */}
<View className='product-card'>
<View
className='product-icon-wrap'
style={{ backgroundColor: typeColor }}
>
<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>
);
}