aura-web/src/pages/PointsMallPage.tsx

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