160 lines
6.0 KiB
TypeScript
160 lines
6.0 KiB
TypeScript
import { ArrowRightOutlined, LeftOutlined, RightOutlined, SearchOutlined } from '@ant-design/icons';
|
||
import { Button, Card, Empty, Input, Select, Space, Spin, Tag } from 'antd';
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import type { PointsMallProduct } from '../../../api';
|
||
import type { PointsMallPageLogicOutput } from '../PointsMallPageLogic';
|
||
import '../styles/points-mall-h5-products.css';
|
||
|
||
interface Props {
|
||
logic: PointsMallPageLogicOutput;
|
||
}
|
||
|
||
export default function PointsMallH5Products({ logic }: Props) {
|
||
const {
|
||
q,
|
||
sort,
|
||
productsLoading,
|
||
products,
|
||
total,
|
||
userPoints,
|
||
exchangeLoading,
|
||
setQ,
|
||
setSort,
|
||
page,
|
||
setPage,
|
||
handleExchangeClick
|
||
} = logic;
|
||
|
||
const [displayProducts, setDisplayProducts] = useState<PointsMallProduct[]>([]);
|
||
const loadingMoreRef = useRef(false);
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 监听搜索/排序变化,重置列表
|
||
useEffect(() => {
|
||
setDisplayProducts(products);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [q, sort]);
|
||
|
||
// 监听数据变化,追加到显示列表
|
||
useEffect(() => {
|
||
if (page === 1) {
|
||
setDisplayProducts(products);
|
||
} else {
|
||
setDisplayProducts(prev => {
|
||
const existingIds = new Set(prev.map(p => p.id));
|
||
const newItems = products.filter(p => !existingIds.has(p.id));
|
||
return [...prev, ...newItems];
|
||
});
|
||
}
|
||
loadingMoreRef.current = false;
|
||
}, [products, page]);
|
||
|
||
const hasMore = displayProducts.length < total;
|
||
|
||
// 瀑布流滚动加载
|
||
useEffect(() => {
|
||
const handleScroll = () => {
|
||
// 获取滚动容器,H5 端通常是 App.tsx 里的 .main-content
|
||
const container = document.querySelector('.main-content');
|
||
if (!container || !hasMore || productsLoading || loadingMoreRef.current) return;
|
||
|
||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||
// 距离底部 100px 时触发加载
|
||
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
||
loadingMoreRef.current = true;
|
||
setPage(prev => prev + 1);
|
||
}
|
||
};
|
||
|
||
const container = document.querySelector('.main-content');
|
||
container?.addEventListener('scroll', handleScroll);
|
||
return () => container?.removeEventListener('scroll', handleScroll);
|
||
}, [hasMore, productsLoading]);
|
||
|
||
return (
|
||
<div className="h5-points-mall-products-card" ref={scrollRef}>
|
||
<div className="points-mall-filters-row h5-points-mall-filters-row">
|
||
<div className="points-mall-filter-search">
|
||
<Input
|
||
value={q}
|
||
onChange={(e) => {
|
||
setQ(e.target.value);
|
||
}}
|
||
prefix={<SearchOutlined className="points-mall-input-icon" />}
|
||
placeholder="搜索商品"
|
||
allowClear
|
||
variant="borderless"
|
||
className="points-mall-search-input-h5"
|
||
/>
|
||
</div>
|
||
<div className="points-mall-filter-selects-h5">
|
||
<Select
|
||
value={sort}
|
||
variant="borderless"
|
||
className="points-mall-filter-select-h5"
|
||
onChange={(v) => {
|
||
setSort(v);
|
||
}}
|
||
options={[
|
||
{ value: 'popular', label: '热度优先' },
|
||
{ value: 'newest', label: '最新上架' },
|
||
{ value: 'price_asc', label: 'Token升序' },
|
||
{ value: 'price_desc', label: 'Token降序' }
|
||
]}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{displayProducts.length === 0 && productsLoading ? (
|
||
<Spin className="points-mall-state-spin" />
|
||
) : displayProducts.length === 0 ? (
|
||
<Empty description="暂无商品" className="points-mall-empty-state" />
|
||
) : (
|
||
<div className="points-mall-products-grid h5-points-mall-products-grid">
|
||
{displayProducts.map((p: PointsMallProduct) => (
|
||
<div key={p.id} className="points-mall-product-card h5-points-mall-product-card h5-product-tile">
|
||
<div className="points-mall-product-cover h5-product-tile-cover">
|
||
{p.coverUrl ? <img src={p.coverUrl} alt={p.name} className="h5-product-tile-image" /> : null}
|
||
</div>
|
||
<div className="points-mall-product-body">
|
||
<div className="points-mall-product-name-row">
|
||
<span className="points-mall-product-name">{p.name}</span>
|
||
{p.tags?.length ? (
|
||
<Tag bordered={false} className="points-mall-product-tag h5-product-tile-tag-inline">
|
||
{p.tags[0]}
|
||
</Tag>
|
||
) : null}
|
||
</div>
|
||
{p.subtitle && <div className="points-mall-product-subtitle">{p.subtitle}</div>}
|
||
<div className="points-mall-product-price-row h5-points-mall-product-price-row">
|
||
<div className="points-mall-product-price-container">
|
||
<span className="points-mall-product-price">{p.pointsPrice >= 1000 ? `${(p.pointsPrice / 1000).toFixed(1)} K` : Number(p.pointsPrice).toLocaleString()}</span>
|
||
<span className="points-mall-product-price-label">Token</span>
|
||
</div>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
className="points-mall-exchange-btn points-mall-product-exchange-btn"
|
||
disabled={userPoints < p.pointsPrice || exchangeLoading}
|
||
onClick={() => handleExchangeClick(p)}
|
||
>
|
||
{userPoints < p.pointsPrice ? '不足' : '兑'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="h5-points-mall-infinite-footer">
|
||
{productsLoading ? (
|
||
<div className="h5-infinite-loading"><Spin size="small" /> 正在加载中...</div>
|
||
) : !hasMore && displayProducts.length > 0 ? (
|
||
<div className="h5-infinite-no-more">没有更多了</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|