aura-web/src/pages/PointsMallPage.tsx

496 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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>
);
}