feat(mp+health): 小程序分包迁移 + 积分商城后台列表 API
- 小程序页面迁移到 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:
218
apps/miniprogram/src/pages/pkg-mall/exchange/index.scss
Normal file
218
apps/miniprogram/src/pages/pkg-mall/exchange/index.scss
Normal file
@@ -0,0 +1,218 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
@mixin serif-number {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@mixin section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@mixin tag($bg, $color) {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
background: $bg;
|
||||
color: $color;
|
||||
}
|
||||
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.exchange-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: 140px;
|
||||
}
|
||||
|
||||
/* ===== 商品预览 ===== */
|
||||
.product-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 32px 24px;
|
||||
background: $card;
|
||||
margin: 20px 24px 16px;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.product-icon-wrap {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
border-radius: $r;
|
||||
@include flex-center;
|
||||
margin-right: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-icon-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 52px;
|
||||
font-weight: bold;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.product-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-type-tag {
|
||||
@include tag($pri-l, $pri-d);
|
||||
}
|
||||
|
||||
/* ===== 兑换明细 ===== */
|
||||
.detail-section {
|
||||
padding: 0 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
box-shadow: $shadow-sm;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&.last {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 28px;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
@include serif-number;
|
||||
font-size: 28px;
|
||||
color: $tx;
|
||||
font-weight: bold;
|
||||
|
||||
&.detail-cost {
|
||||
color: $pri;
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
&.detail-sufficient {
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&.detail-insufficient {
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 温馨提示 ===== */
|
||||
.notice-section {
|
||||
background: $card;
|
||||
padding: 24px;
|
||||
margin: 0 24px;
|
||||
border-radius: $r;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
@include section-title;
|
||||
font-size: 28px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.notice-text {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
display: block;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ===== 底部操作栏 ===== */
|
||||
.exchange-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
background: $card;
|
||||
box-shadow: 0 -2px 12px rgba(45, 42, 38, 0.06);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.footer-cost {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.footer-cost-label {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.footer-cost-num {
|
||||
@include serif-number;
|
||||
font-size: 38px;
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.footer-cost-unit {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: $pri;
|
||||
padding: 20px 48px;
|
||||
border-radius: $r-pill;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&.disabled {
|
||||
background: $bd;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-btn-text {
|
||||
font-size: 30px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
215
apps/miniprogram/src/pages/pkg-mall/exchange/index.tsx
Normal file
215
apps/miniprogram/src/pages/pkg-mall/exchange/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user