422 lines
16 KiB
TypeScript
422 lines
16 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Alert, Button, Card, Empty, Input, Select, Space, Spin, Tag } 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: [
|
|
{ id: 'a1', title: '公告:积分规则升级中', content: '本期暂不开放兑换,页面仅用于 UI 预览。', linkUrl: '' }
|
|
],
|
|
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 ? ['热卖'] : []
|
|
}));
|
|
|
|
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 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 {
|
|
setOverview(MOCK_OVERVIEW);
|
|
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 announcement = overview?.announcements?.[0];
|
|
const banner = overview?.banners?.[0];
|
|
const promoEntries = overview?.promoEntries || [];
|
|
|
|
const products = productsRes?.items || [];
|
|
const total = productsRes?.total || 0;
|
|
|
|
const sortOptions = useMemo(
|
|
() => [
|
|
{ value: 'popular', label: '热度优先' },
|
|
{ value: 'newest', label: '最新上架' },
|
|
{ value: 'price_asc', label: '积分从低到高' },
|
|
{ value: 'price_desc', label: '积分从高到低' }
|
|
],
|
|
[]
|
|
);
|
|
|
|
return (
|
|
<div className="page-container" style={{ maxWidth: 1400 }}>
|
|
<div
|
|
style={{
|
|
borderRadius: 24,
|
|
padding: '30px 30px 22px',
|
|
background:
|
|
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 44%, rgba(239,246,255,0.96) 100%)',
|
|
border: '1px solid rgba(8, 145, 178, 0.12)',
|
|
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
|
|
marginBottom: 18
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 20, flexWrap: 'wrap' }}>
|
|
<div style={{ maxWidth: 720 }}>
|
|
<h1 className="page-title" style={{ marginBottom: 10 }}>积分商城</h1>
|
|
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
|
|
使用积分兑换权益、工具和活动礼包。后续会接入库存、订单与积分流水。
|
|
</div>
|
|
</div>
|
|
<div
|
|
style={{
|
|
minWidth: 280,
|
|
borderRadius: 18,
|
|
padding: '14px 16px',
|
|
background: 'rgba(255,255,255,0.72)',
|
|
border: '1px solid rgba(255,255,255,0.7)'
|
|
}}
|
|
>
|
|
{overviewLoading ? (
|
|
<Spin />
|
|
) : (
|
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
|
<div>
|
|
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 6 }}>
|
|
我的积分
|
|
</div>
|
|
<div style={{ fontSize: 28, fontWeight: 800, color: 'var(--color-text)' }}>
|
|
{Number(overview?.me?.points || 0).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
|
|
bodyStyle={{ padding: 16 }}
|
|
style={{
|
|
borderRadius: 18,
|
|
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
|
|
marginBottom: 14
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)', fontWeight: 600 }}>
|
|
商品分类
|
|
</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>
|
|
</Card>
|
|
|
|
{!!announcement && (
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
message={
|
|
<span style={{ fontWeight: 600 }}>
|
|
{announcement.title}
|
|
</span>
|
|
}
|
|
description={announcement.content}
|
|
style={{ borderRadius: 18, marginBottom: 14 }}
|
|
/>
|
|
)}
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.55fr) minmax(0, 1fr)', gap: 14, marginBottom: 16 }}>
|
|
<div
|
|
style={{
|
|
borderRadius: 22,
|
|
padding: 22,
|
|
border: '1px solid rgba(236, 72, 153, 0.22)',
|
|
background: 'linear-gradient(135deg, rgba(236, 72, 153, 0.16) 0%, rgba(59, 130, 246, 0.16) 60%, rgba(34, 197, 94, 0.12) 100%)',
|
|
boxShadow: '0 12px 30px rgba(15, 23, 42, 0.05)',
|
|
minHeight: 176,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'space-between'
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
|
<div>
|
|
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--color-text)', marginBottom: 4 }}>
|
|
{banner?.title || '本期活动'}
|
|
</div>
|
|
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
|
{banner?.subtitle || 'Up to 25% Off'}
|
|
</div>
|
|
</div>
|
|
<Button type="primary" style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
|
|
查看活动
|
|
</Button>
|
|
</div>
|
|
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>
|
|
banner 图片与跳转链接由后端配置
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateRows: 'repeat(2, minmax(0, 1fr))', gap: 14 }}>
|
|
{promoEntries.slice(0, 2).map((p) => (
|
|
<div
|
|
key={p.id}
|
|
style={{
|
|
borderRadius: 22,
|
|
padding: 18,
|
|
border: '1px solid var(--color-border)',
|
|
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
|
boxShadow: '0 10px 28px rgba(15, 23, 42, 0.04)',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
gap: 12,
|
|
alignItems: 'center'
|
|
}}
|
|
>
|
|
<div style={{ minWidth: 0 }}>
|
|
<div style={{ fontSize: 16, fontWeight: 800, color: 'var(--color-text)', marginBottom: 4 }}>
|
|
{p.title}
|
|
</div>
|
|
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)' }}>
|
|
{p.subtitle}
|
|
</div>
|
|
</div>
|
|
<Button size="small" style={{ borderRadius: 999 }}>进入</Button>
|
|
</div>
|
|
))}
|
|
{promoEntries.length < 2 && (
|
|
<div
|
|
style={{
|
|
borderRadius: 22,
|
|
padding: 18,
|
|
border: '1px dashed var(--color-border)',
|
|
background: 'var(--color-surface)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: 'var(--color-text-tertiary)'
|
|
}}
|
|
>
|
|
促销入口由后端配置
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Card style={{ borderRadius: 22, boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap', marginBottom: 14 }}>
|
|
<Space size={10} wrap>
|
|
<Input
|
|
value={q}
|
|
onChange={(e) => {
|
|
setPage(1);
|
|
setQ(e.target.value);
|
|
}}
|
|
prefix={<SearchOutlined />}
|
|
placeholder="搜索商品"
|
|
allowClear
|
|
style={{ width: 260, borderRadius: 12 }}
|
|
/>
|
|
<Select
|
|
value={sort}
|
|
style={{ width: 160 }}
|
|
onChange={(v) => {
|
|
setPage(1);
|
|
setSort(v);
|
|
}}
|
|
options={sortOptions}
|
|
/>
|
|
<Select
|
|
value={pageSize}
|
|
style={{ width: 140 }}
|
|
onChange={(v) => {
|
|
setPage(1);
|
|
setPageSize(v);
|
|
}}
|
|
options={[
|
|
{ value: 12, label: '每页 12' },
|
|
{ value: 24, label: '每页 24' },
|
|
{ value: 48, label: '每页 48' }
|
|
]}
|
|
/>
|
|
</Space>
|
|
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)' }}>
|
|
共 {total.toLocaleString()} 件商品
|
|
</div>
|
|
</div>
|
|
|
|
{productsLoading ? (
|
|
<Spin style={{ marginTop: 40, display: 'block' }} />
|
|
) : products.length === 0 ? (
|
|
<Empty description="暂无商品" style={{ marginTop: 60 }} />
|
|
) : (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 14 }}>
|
|
{products.map((p) => (
|
|
<div
|
|
key={p.id}
|
|
style={{
|
|
borderRadius: 20,
|
|
border: '1px solid rgba(148, 163, 184, 0.14)',
|
|
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
|
boxShadow: '0 10px 28px rgba(15, 23, 42, 0.045)',
|
|
overflow: 'hidden',
|
|
display: 'flex',
|
|
flexDirection: 'column'
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
height: 156,
|
|
background: 'linear-gradient(135deg, rgba(8,145,178,0.12) 0%, rgba(59,130,246,0.10) 55%, rgba(34,197,94,0.10) 100%)'
|
|
}}
|
|
/>
|
|
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'flex-start' }}>
|
|
<div style={{ minWidth: 0 }}>
|
|
<div style={{ fontSize: 15, fontWeight: 800, color: 'var(--color-text)', marginBottom: 4, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{p.name}
|
|
</div>
|
|
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.5, minHeight: 36 }}>
|
|
{p.subtitle}
|
|
</div>
|
|
</div>
|
|
{p.tags?.length ? (
|
|
<Tag bordered={false} style={{ margin: 0, borderRadius: 999, background: 'var(--color-warning-soft)', color: 'var(--color-warning)' }}>
|
|
{p.tags[0]}
|
|
</Tag>
|
|
) : null}
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10 }}>
|
|
<div>
|
|
<span style={{ fontSize: 18, fontWeight: 900, color: 'var(--color-text)' }}>
|
|
{Number(p.pointsPrice).toLocaleString()}
|
|
</span>
|
|
<span style={{ marginLeft: 6, fontSize: 12.5, color: 'var(--color-text-secondary)' }}>积分</span>
|
|
</div>
|
|
<Button type="primary" style={{ borderRadius: 12, height: 34, fontWeight: 700 }}>
|
|
兑换
|
|
</Button>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: 12, color: 'var(--color-text-tertiary)' }}>
|
|
<span>库存 {p.stock}</span>
|
|
<span>已兑 {p.sold}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!!productsRes && (
|
|
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 18 }}>
|
|
<Space size={10}>
|
|
<Button disabled={page <= 1} onClick={() => setPage((v) => Math.max(1, v - 1))} style={{ borderRadius: 10 }}>
|
|
上一页
|
|
</Button>
|
|
<Tag bordered={false} style={{ margin: 0, borderRadius: 999, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)' }}>
|
|
第 {page} 页
|
|
</Tag>
|
|
<Button
|
|
disabled={page * pageSize >= total}
|
|
onClick={() => setPage((v) => v + 1)}
|
|
style={{ borderRadius: 10 }}
|
|
>
|
|
下一页
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|