aura-web/src/pages/PointsMallPage/components/PointsMallH5Products.tsx

160 lines
6.0 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 { 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>
);
}