496 lines
19 KiB
TypeScript
496 lines
19 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { Button, Card, Empty, Input, Select, Space, Spin, Tag, Modal, Form, message } from 'antd';
|
||
import { SearchOutlined } from '@ant-design/icons';
|
||
import { PointsMallAPI, PointsMallCategory, PointsMallOverview, PointsMallProduct, PointsMallProductsResponse } from '../api';
|
||
|
||
type SortKey = 'popular' | 'price_asc' | 'price_desc' | 'newest';
|
||
|
||
const MOCK_OVERVIEW: PointsMallOverview = {
|
||
me: { points: 1280, level: 'Lv.2' },
|
||
categories: [
|
||
{ id: 'all', name: '全部', sort: 0 },
|
||
{ id: 'digital', name: '虚拟权益', sort: 1 },
|
||
{ id: 'tool', name: '工具周边', sort: 2 },
|
||
{ id: 'gift', name: '礼品卡券', sort: 3 },
|
||
{ id: 'limited', name: '限时活动', sort: 4 }
|
||
],
|
||
announcements: [],
|
||
banners: [
|
||
{ id: 'b1', title: '本期活动', subtitle: 'Up to 25% Off', imageUrl: '', linkUrl: '' }
|
||
],
|
||
promoEntries: [
|
||
{ id: 'p1', title: '促销活动', subtitle: '本周精选', linkUrl: '' },
|
||
{ id: 'p2', title: '积分任务', subtitle: '快速涨积分', linkUrl: '' }
|
||
]
|
||
};
|
||
|
||
const MOCK_PRODUCTS: PointsMallProduct[] = Array.from({ length: 12 }).map((_, i) => ({
|
||
id: String(i + 1),
|
||
categoryId: i % 2 === 0 ? 'digital' : 'tool',
|
||
name: `商品 ${i + 1}`,
|
||
subtitle: '这里是商品简短描述',
|
||
coverUrl: '',
|
||
pointsPrice: 199 + i * 10,
|
||
stock: 99,
|
||
sold: 12 + i,
|
||
tags: i % 3 === 0 ? ['限时'] : i % 3 === 1 ? ['热卖'] : []
|
||
}));
|
||
|
||
interface ExchangeFormValues {
|
||
recipientName: string;
|
||
phone: string;
|
||
province: string;
|
||
city: string;
|
||
district: string;
|
||
address: string;
|
||
zipCode?: string;
|
||
}
|
||
|
||
export default function PointsMallPage() {
|
||
const [overviewLoading, setOverviewLoading] = useState(false);
|
||
const [productsLoading, setProductsLoading] = useState(false);
|
||
const [overview, setOverview] = useState<PointsMallOverview | null>(null);
|
||
const [categories, setCategories] = useState<PointsMallCategory[]>([]);
|
||
|
||
const [categoryId, setCategoryId] = useState<string>('all');
|
||
const [q, setQ] = useState('');
|
||
const [sort, setSort] = useState<SortKey>('popular');
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(24);
|
||
const [productsRes, setProductsRes] = useState<PointsMallProductsResponse | null>(null);
|
||
|
||
const [exchangeModalVisible, setExchangeModalVisible] = useState(false);
|
||
const [selectedProduct, setSelectedProduct] = useState<PointsMallProduct | null>(null);
|
||
const [pendingOrderId, setPendingOrderId] = useState<string | null>(null);
|
||
const [exchangeLoading, setExchangeLoading] = useState(false);
|
||
const [form] = Form.useForm<ExchangeFormValues>();
|
||
|
||
const loadOverview = async () => {
|
||
setOverviewLoading(true);
|
||
try {
|
||
const data = await PointsMallAPI.overview();
|
||
setOverview(data);
|
||
setCategories(data.categories || []);
|
||
if (!data.categories?.some((c) => c.id === categoryId) && data.categories?.[0]?.id) {
|
||
setCategoryId(data.categories[0].id);
|
||
}
|
||
} catch {
|
||
message.error('获取积分信息失败,请稍后重试');
|
||
setOverview({
|
||
...MOCK_OVERVIEW,
|
||
me: { points: 0, level: 'Lv.0' }
|
||
});
|
||
setCategories(MOCK_OVERVIEW.categories);
|
||
} finally {
|
||
setOverviewLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadProducts = async () => {
|
||
setProductsLoading(true);
|
||
try {
|
||
const res = await PointsMallAPI.products({
|
||
categoryId: categoryId === 'all' ? undefined : categoryId,
|
||
q,
|
||
sort,
|
||
page,
|
||
pageSize
|
||
});
|
||
setProductsRes(res);
|
||
} catch {
|
||
const filtered = MOCK_PRODUCTS.filter((p) => (categoryId === 'all' ? true : p.categoryId === categoryId))
|
||
.filter((p) => (q ? (p.name + p.subtitle).toLowerCase().includes(q.toLowerCase()) : true));
|
||
setProductsRes({
|
||
page,
|
||
pageSize,
|
||
total: filtered.length,
|
||
items: filtered.slice((page - 1) * pageSize, page * pageSize)
|
||
});
|
||
} finally {
|
||
setProductsLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadOverview();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadProducts();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [categoryId, q, sort, page, pageSize]);
|
||
|
||
const banner = overview?.banners?.[0];
|
||
const promoEntries = overview?.promoEntries || [];
|
||
|
||
const products = productsRes?.items || [];
|
||
const total = productsRes?.total || 0;
|
||
|
||
const handleExchangeClick = async (product: PointsMallProduct) => {
|
||
if (userPoints < product.pointsPrice) return;
|
||
|
||
setExchangeLoading(true);
|
||
try {
|
||
const res = await PointsMallAPI.exchangePrepare(product.id);
|
||
setSelectedProduct(product);
|
||
setPendingOrderId(res.orderId);
|
||
setExchangeModalVisible(true);
|
||
form.resetFields();
|
||
setOverview((prev) => {
|
||
if (!prev) return prev;
|
||
return { ...prev, me: { ...prev.me, points: res.remainingPoints } };
|
||
});
|
||
message.success('积分扣减成功,请填写收件信息完成兑换');
|
||
} catch (e: any) {
|
||
message.error(e?.message || '兑换失败,请稍后重试');
|
||
} finally {
|
||
setExchangeLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleExchangeSubmit = async () => {
|
||
if (!selectedProduct || !pendingOrderId) return;
|
||
|
||
try {
|
||
await form.validateFields();
|
||
setExchangeLoading(true);
|
||
|
||
await PointsMallAPI.exchangeSubmitShipping(pendingOrderId, form.getFieldsValue());
|
||
|
||
message.success('兑换成功!我们将尽快为您安排发货');
|
||
setExchangeModalVisible(false);
|
||
setPendingOrderId(null);
|
||
|
||
// 刷新积分余额
|
||
loadOverview();
|
||
} catch (error: any) {
|
||
if (error?.errorFields) return;
|
||
message.error(error?.message || '提交失败,请稍后重试');
|
||
} finally {
|
||
setExchangeLoading(false);
|
||
}
|
||
};
|
||
|
||
const userPoints = overview?.me?.points || 0;
|
||
const canAfford = selectedProduct ? userPoints >= selectedProduct.pointsPrice : false;
|
||
|
||
return (
|
||
<div className="page-container" style={{ maxWidth: 1400 }}>
|
||
<div className="points-mall-hero">
|
||
<div className="points-mall-header">
|
||
<div className="points-mall-title-section">
|
||
<h1 className="page-title stats-page-title">积分商城</h1>
|
||
<p className="page-subtitle stats-page-subtitle">
|
||
使用积分兑换权益、工具和活动礼包。积分通过 API 调用消费自动累积,1 美元 = 1000 积分。
|
||
</p>
|
||
</div>
|
||
<div className="points-mall-balance-card">
|
||
{overviewLoading ? (
|
||
<Spin />
|
||
) : (
|
||
<div className="points-balance-row">
|
||
<div>
|
||
<div className="points-balance-label">我的积分</div>
|
||
<div className="points-balance-value">{userPoints.toLocaleString()}</div>
|
||
</div>
|
||
<Tag bordered={false} style={{ margin: 0, borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}>
|
||
{String(overview?.me?.level || 'Lv.0')}
|
||
</Tag>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Card className="points-mall-category-card" bodyStyle={{ padding: 0 }}>
|
||
<div className="points-mall-category-body">
|
||
<div className="points-mall-category-row">
|
||
<span className="points-mall-category-label">商品分类</span>
|
||
{categories.map((c) => (
|
||
<Button
|
||
key={c.id}
|
||
size="small"
|
||
type={c.id === categoryId ? 'primary' : 'default'}
|
||
style={{ borderRadius: 999 }}
|
||
onClick={() => {
|
||
setPage(1);
|
||
setCategoryId(c.id);
|
||
}}
|
||
>
|
||
{c.name}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<div className="points-mall-banner-section">
|
||
<div className="points-mall-banner-card">
|
||
<div className="points-mall-banner-header">
|
||
<div>
|
||
<div className="points-mall-banner-title">{banner?.title || '本期活动'}</div>
|
||
<div className="points-mall-banner-subtitle">{banner?.subtitle || 'Up to 25% Off'}</div>
|
||
</div>
|
||
<Button type="primary" style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
|
||
查看活动
|
||
</Button>
|
||
</div>
|
||
<div className="points-mall-banner-footer">banner 图片与跳转链接由后端配置</div>
|
||
</div>
|
||
|
||
<div className="points-mall-promo-grid">
|
||
{promoEntries.slice(0, 2).map((p) => (
|
||
<div key={p.id} className="points-mall-promo-card">
|
||
<div style={{ minWidth: 0 }}>
|
||
<div className="points-mall-promo-title">{p.title}</div>
|
||
<div className="points-mall-promo-subtitle">{p.subtitle}</div>
|
||
</div>
|
||
<Button size="small" className="points-mall-exchange-btn">进入</Button>
|
||
</div>
|
||
))}
|
||
{promoEntries.length < 2 && (
|
||
<div className="points-mall-promo-empty">促销入口由后端配置</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<Card className="stats-page-chart-card">
|
||
<div className="points-mall-filters-row">
|
||
<Space size={10} wrap className="points-mall-filters-left">
|
||
<Input
|
||
value={q}
|
||
onChange={(e) => {
|
||
setPage(1);
|
||
setQ(e.target.value);
|
||
}}
|
||
prefix={<SearchOutlined />}
|
||
placeholder="搜索商品"
|
||
allowClear
|
||
className="points-mall-search-input"
|
||
/>
|
||
<Select
|
||
value={sort}
|
||
className="points-mall-filter-select"
|
||
onChange={(v: SortKey) => {
|
||
setPage(1);
|
||
setSort(v);
|
||
}}
|
||
options={[
|
||
{ value: 'popular', label: '热度优先' },
|
||
{ value: 'newest', label: '最新上架' },
|
||
{ value: 'price_asc', label: '积分从低到高' },
|
||
{ value: 'price_desc', label: '积分从高到低' }
|
||
]}
|
||
/>
|
||
<Select
|
||
value={pageSize}
|
||
className="points-mall-filter-select-small"
|
||
onChange={(v) => {
|
||
setPage(1);
|
||
setPageSize(v);
|
||
}}
|
||
options={[
|
||
{ value: 12, label: '每页 12' },
|
||
{ value: 24, label: '每页 24' },
|
||
{ value: 48, label: '每页 48' }
|
||
]}
|
||
/>
|
||
</Space>
|
||
<div className="points-mall-total-text">
|
||
共 {total.toLocaleString()} 件商品
|
||
</div>
|
||
</div>
|
||
|
||
{productsLoading ? (
|
||
<Spin style={{ marginTop: 40, display: 'block' }} />
|
||
) : products.length === 0 ? (
|
||
<Empty description="暂无商品" style={{ marginTop: 60 }} />
|
||
) : (
|
||
<div className="points-mall-products-grid">
|
||
{products.map((p) => (
|
||
<div key={p.id} className="points-mall-product-card">
|
||
<div className="points-mall-product-cover" />
|
||
<div className="points-mall-product-body">
|
||
<div className="points-mall-product-header">
|
||
<div className="points-mall-product-info">
|
||
<div className="points-mall-product-name">{p.name}</div>
|
||
<div className="points-mall-product-desc">{p.subtitle}</div>
|
||
</div>
|
||
{p.tags?.length ? (
|
||
<Tag bordered={false} className="points-mall-product-tag">
|
||
{p.tags[0]}
|
||
</Tag>
|
||
) : null}
|
||
</div>
|
||
<div className="points-mall-product-price-row">
|
||
<div>
|
||
<span className="points-mall-product-price">{Number(p.pointsPrice).toLocaleString()}</span>
|
||
<span className="points-mall-product-price-label">积分</span>
|
||
</div>
|
||
<Button
|
||
type="primary"
|
||
className="points-mall-exchange-btn points-mall-product-exchange-btn"
|
||
disabled={userPoints < p.pointsPrice}
|
||
onClick={() => handleExchangeClick(p)}
|
||
>
|
||
{userPoints < p.pointsPrice ? '积分不足' : '兑换'}
|
||
</Button>
|
||
</div>
|
||
<div className="points-mall-product-footer">
|
||
<span>库存 {p.stock}</span>
|
||
<span>已兑 {p.sold}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{!!productsRes && (
|
||
<div className="points-mall-pagination">
|
||
<Space size={10}>
|
||
<Button disabled={page <= 1} onClick={() => setPage((v) => Math.max(1, v - 1))} className="points-mall-exchange-btn">
|
||
上一页
|
||
</Button>
|
||
<Tag bordered={false} className="points-mall-pagination-tag">
|
||
第 {page} 页
|
||
</Tag>
|
||
<Button
|
||
disabled={page * pageSize >= total}
|
||
onClick={() => setPage((v) => v + 1)}
|
||
className="points-mall-exchange-btn"
|
||
>
|
||
下一页
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* 兑换弹窗 */}
|
||
<Modal
|
||
title="兑换商品"
|
||
open={exchangeModalVisible}
|
||
onCancel={() => {
|
||
setExchangeModalVisible(false);
|
||
setPendingOrderId(null);
|
||
}}
|
||
footer={null}
|
||
width={500}
|
||
className="points-exchange-modal"
|
||
destroyOnClose
|
||
>
|
||
{selectedProduct && (
|
||
<>
|
||
<div className="points-exchange-modal-header">
|
||
<div className="points-mall-exchange-modal-title">{selectedProduct.name}</div>
|
||
<div className="points-mall-exchange-modal-points">{selectedProduct.pointsPrice.toLocaleString()} 积分</div>
|
||
</div>
|
||
|
||
{!canAfford && (
|
||
<div className="points-exchange-balance-warning">
|
||
积分不足!当前余额:{userPoints.toLocaleString()} 积分,还需:{(selectedProduct.pointsPrice - userPoints).toLocaleString()} 积分
|
||
</div>
|
||
)}
|
||
|
||
<Form form={form} layout="vertical" className="points-exchange-form">
|
||
<Form.Item
|
||
name="recipientName"
|
||
label={<span className="points-exchange-form-label">收货人姓名</span>}
|
||
rules={[{ required: true, message: '请输入收货人姓名' }]}
|
||
className="points-exchange-form-item"
|
||
>
|
||
<Input placeholder="请输入收货人姓名" className="points-exchange-form-input" />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="phone"
|
||
label={<span className="points-exchange-form-label">手机号码</span>}
|
||
rules={[
|
||
{ required: true, message: '请输入手机号码' },
|
||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' }
|
||
]}
|
||
className="points-exchange-form-item"
|
||
>
|
||
<Input placeholder="请输入手机号码" className="points-exchange-form-input" />
|
||
</Form.Item>
|
||
|
||
<div className="points-mall-address-grid">
|
||
<Form.Item
|
||
name="province"
|
||
label={<span className="points-exchange-form-label">省份</span>}
|
||
rules={[{ required: true, message: '请选择省份' }]}
|
||
className="points-exchange-form-item points-exchange-form-item-small"
|
||
>
|
||
<Select placeholder="省份" className="points-exchange-form-input">
|
||
<Select.Option value="北京市">北京市</Select.Option>
|
||
<Select.Option value="上海市">上海市</Select.Option>
|
||
<Select.Option value="广东省">广东省</Select.Option>
|
||
<Select.Option value="浙江省">浙江省</Select.Option>
|
||
<Select.Option value="江苏省">江苏省</Select.Option>
|
||
</Select>
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="city"
|
||
label={<span className="points-exchange-form-label">城市</span>}
|
||
rules={[{ required: true, message: '请选择城市' }]}
|
||
className="points-exchange-form-item points-exchange-form-item-small"
|
||
>
|
||
<Select placeholder="城市" className="points-exchange-form-input">
|
||
<Select.Option value="深圳市">深圳市</Select.Option>
|
||
<Select.Option value="广州市">广州市</Select.Option>
|
||
<Select.Option value="杭州市">杭州市</Select.Option>
|
||
<Select.Option value="南京市">南京市</Select.Option>
|
||
</Select>
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="district"
|
||
label={<span className="points-exchange-form-label">区/县</span>}
|
||
rules={[{ required: true, message: '请选择区/县' }]}
|
||
className="points-exchange-form-item points-exchange-form-item-small"
|
||
>
|
||
<Select placeholder="区/县" className="points-exchange-form-input">
|
||
<Select.Option value="南山区">南山区</Select.Option>
|
||
<Select.Option value="福田区">福田区</Select.Option>
|
||
<Select.Option value="宝安区">宝安区</Select.Option>
|
||
</Select>
|
||
</Form.Item>
|
||
</div>
|
||
|
||
<Form.Item
|
||
name="address"
|
||
label={<span className="points-exchange-form-label">详细地址</span>}
|
||
rules={[{ required: true, message: '请输入详细地址' }]}
|
||
className="points-exchange-form-item"
|
||
>
|
||
<Input.TextArea placeholder="请输入详细地址(街道、门牌号等)" rows={3} className="points-exchange-form-input" />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="zipCode"
|
||
label={<span className="points-exchange-form-label">邮政编码(选填)</span>}
|
||
className="points-exchange-form-item"
|
||
>
|
||
<Input placeholder="请输入邮政编码" className="points-exchange-form-input" />
|
||
</Form.Item>
|
||
|
||
<Button
|
||
type="primary"
|
||
className="points-exchange-submit-btn"
|
||
onClick={handleExchangeSubmit}
|
||
loading={exchangeLoading}
|
||
disabled={!canAfford}
|
||
>
|
||
{canAfford ? '确认兑换' : '积分不足,无法兑换'}
|
||
</Button>
|
||
</Form>
|
||
</>
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|