feat: points mall page and api spec

main
sp mac bookpro 2605 2026-05-30 20:31:24 +08:00
parent e93073fd74
commit 6b8ccf0af2
5 changed files with 859 additions and 0 deletions

345
docs/points-mall-api.md Normal file
View File

@ -0,0 +1,345 @@
# 积分商城Points Mall接口需求说明
本文档用于对接 aura-web 的「积分商城」页面数据与兑换流程,供后端实现接口与数据表结构时参考。
## 页面结构对应的数据
页面 UI 由 4 个区域组成:
1. 头部商品分类导航栏(横向)
2. 公告栏
3. banner 区 + 促销活动入口2 个入口卡片)
4. 商品内容区(商品列表 + 搜索/排序/分页)
## 统一约定
- BaseURL`https://api.hoyidata.com/aura/v1`
- 鉴权沿用当前登录态Cookie / Session`Authorization: Bearer <token>`(以现有登录实现为准),接口需校验登录态
- 图片字段:返回绝对 URL推荐 CDN 域名),前端不拼接
- 时间字段建议统一使用毫秒时间戳number
- 失败格式:沿用现有 aura 接口的错误格式HTTP Status + JSON message/error
## 接口列表(前端必需)
### 1) 商城概览
用于首屏一次性拉齐「分类、公告、banner、促销入口、我的积分」。
`GET /points-mall/overview`
**Query**
- `include_me`:可选,默认 `true`(是否返回我的积分)
**Response**
```json
{
"me": {
"points": 1280,
"level": "Lv.2"
},
"categories": [
{ "id": "all", "name": "全部", "sort": 0 },
{ "id": "digital", "name": "虚拟权益", "sort": 1 }
],
"announcements": [
{
"id": "a1",
"title": "公告:积分规则升级中",
"content": "本期暂不开放兑换,页面仅用于 UI 预览。",
"linkUrl": "https://..."
}
],
"banners": [
{
"id": "b1",
"title": "限时上新",
"subtitle": "Up to 25% Off",
"imageUrl": "https://cdn.../banner.png",
"linkUrl": "https://..."
}
],
"promoEntries": [
{
"id": "p1",
"title": "促销活动",
"subtitle": "本周精选",
"iconUrl": "https://cdn.../promo.png",
"linkUrl": "https://..."
},
{
"id": "p2",
"title": "积分任务",
"subtitle": "快速涨积分",
"iconUrl": "https://cdn.../tasks.png",
"linkUrl": "https://..."
}
]
}
```
### 2) 商品列表(搜索/筛选/排序/分页)
`GET /points-mall/products`
**Query**
- `categoryId`:可选(分类 ID不传代表全部
- `q`:可选(搜索关键字;匹配 name/subtitle
- `sort`:可选,默认 `popular`
- `popular`:热度优先(建议按 sold 或近期兑换量)
- `newest`:最新上架
- `price_asc`:积分从低到高
- `price_desc`:积分从高到低
- `page`:可选,默认 `1`
- `pageSize`:可选,默认 `24`
**Response**
```json
{
"page": 1,
"pageSize": 24,
"total": 120,
"items": [
{
"id": "prd_001",
"categoryId": "digital",
"name": "Claude Pro 月卡",
"subtitle": "兑换后获得激活码",
"coverUrl": "https://cdn.../cover.png",
"pointsPrice": 1999,
"stock": 99,
"sold": 12,
"tags": ["热卖", "限时"]
}
]
}
```
### 3) 商品详情
`GET /points-mall/products/{productId}`
**Response建议**
```json
{
"id": "prd_001",
"categoryId": "digital",
"name": "Claude Pro 月卡",
"subtitle": "兑换后获得激活码",
"coverUrl": "https://cdn.../cover.png",
"imageUrls": ["https://cdn.../1.png", "https://cdn.../2.png"],
"pointsPrice": 1999,
"stock": 99,
"sold": 12,
"tags": ["热卖", "限时"],
"description": "富文本/Markdown 均可,建议 Markdown",
"type": "virtual",
"delivery": {
"mode": "code",
"tips": "兑换成功后在【订单详情】查看兑换码"
}
}
```
### 4) 创建兑换订单(扣积分)
`POST /points-mall/orders`
**Request**
```json
{
"productId": "prd_001",
"quantity": 1
}
```
**Response**
```json
{
"orderId": "ord_001",
"status": "paid",
"pointsCost": 1999,
"remainingPoints": 281
}
```
**关键规则**
- 需要幂等:建议支持 `Idempotency-Key` header避免重复扣积分
- 校验库存与用户积分
- 虚拟商品/实物商品可共用该接口,但实物商品需要补充收货信息(可扩展字段)
### 5) 订单列表 / 订单详情
`GET /points-mall/orders`
**Query建议**
- `page` / `pageSize`
- `status`:可选
`GET /points-mall/orders/{orderId}`
**Response建议**
```json
{
"id": "ord_001",
"status": "paid",
"createdAt": 1730000000000,
"product": {
"id": "prd_001",
"name": "Claude Pro 月卡",
"coverUrl": "https://cdn.../cover.png"
},
"quantity": 1,
"pointsCost": 1999,
"delivery": {
"mode": "code",
"code": "XXXX-YYYY-ZZZZ"
}
}
```
### 6) 积分流水(可选,但强烈建议)
用于解释积分变动与对账。
`GET /points-mall/me/points-ledger`
**Query建议**
- `page` / `pageSize`
**Response建议**
```json
{
"page": 1,
"pageSize": 20,
"total": 300,
"items": [
{
"id": "pl_001",
"createdAt": 1730000000000,
"change": -1999,
"balance": 281,
"reason": "兑换 Claude Pro 月卡",
"bizType": "points_mall_order",
"bizId": "ord_001"
}
]
}
```
## 数据表结构建议(后端实现参考)
说明:下述为建议表结构,字段类型可按现有数据库规范调整;建议均包含 `created_at / updated_at` 与必要索引。
### 1) `points_mall_categories`(分类)
- `id`PK, string/uuid
- `name`varchar
- `sort`int
- `enabled`bool
索引:
- `enabled, sort`
### 2) `points_mall_announcements`(公告)
- `id`PK
- `title`varchar
- `content`text
- `link_url`varchar, nullable
- `start_at`bigint, nullable
- `end_at`bigint, nullable
- `enabled`bool
- `sort`int
索引:
- `enabled, sort`
- `start_at, end_at`
### 3) `points_mall_banners`banner
- `id`PK
- `title`varchar
- `subtitle`varchar, nullable
- `image_url`varchar
- `link_url`varchar, nullable
- `start_at`bigint, nullable
- `end_at`bigint, nullable
- `enabled`bool
- `sort`int
索引:
- `enabled, sort`
- `start_at, end_at`
### 4) `points_mall_promo_entries`(促销入口)
- `id`PK
- `title`varchar
- `subtitle`varchar, nullable
- `icon_url`varchar, nullable
- `link_url`varchar, nullable
- `enabled`bool
- `sort`int
索引:
- `enabled, sort`
### 5) `points_mall_products`(商品)
- `id`PK
- `category_id`FK -> categories.id
- `name`varchar
- `subtitle`varchar, nullable
- `description`text / markdown
- `cover_url`varchar
- `image_urls`json/text数组
- `type`enum: `virtual` | `physical`
- `points_price`int
- `stock`int
- `sold`int累计兑换量或可由订单聚合
- `tags`json/text数组
- `enabled`bool
- `start_at`bigint, nullable
- `end_at`bigint, nullable
- `sort`int
索引:
- `enabled, start_at, end_at`
- `category_id, enabled, sort`
- 搜索:`name`/`subtitle` 建议全文索引或 LIKE视数据库而定
### 6) `points_mall_orders`(订单)
- `id`PK
- `user_id`FK
- `product_id`FK
- `quantity`int
- `points_cost`int
- `status`enum: `created` | `paid` | `delivered` | `canceled` | `refunded`
- `delivery_mode`enum: `code` | `shipping`
- `delivery_payload`json虚拟码/物流信息等)
- `idempotency_key`varchar, nullable建议唯一索引 + user_id 组合)
索引:
- `user_id, created_at desc`
- `status, created_at desc`
- `user_id, idempotency_key`(唯一)
### 7) `points_ledger`(积分流水)
- `id`PK
- `user_id`FK
- `change`int正负
- `balance`int
- `reason`varchar
- `biz_type`varchar
- `biz_id`varchar
索引:
- `user_id, created_at desc`
- `biz_type, biz_id`
## 前端实现已对齐的字段
前端已按以下字段读取:
- 分类:`id/name/sort`
- 公告:`title/content/linkUrl`
- banner`title/subtitle/imageUrl/linkUrl`
- 促销入口:`title/subtitle/iconUrl/linkUrl`
- 商品列表:`id/categoryId/name/subtitle/coverUrl/pointsPrice/stock/sold/tags`
如后端字段命名需要用 snake_case可在接口层做映射但建议直接使用上述 camelCase减少前端 mapping。

View File

@ -8,6 +8,7 @@ import AgentEditor from './pages/AgentEditor';
import ChatPage from './pages/ChatPage';
import LoginPage from './pages/LoginPage';
import MarketplacePage from './pages/MarketplacePage';
import PointsMallPage from './pages/PointsMallPage';
import TeamsPage from './pages/TeamsPage';
import PromptLibraryPage from './pages/PromptLibraryPage';
import StatsPage from './pages/StatsPage';
@ -48,6 +49,7 @@ export default function App() {
<Route path="/agents/:id" element={<AgentEditor />} />
<Route path="/agents/:id/chat" element={<Navigate to="/chat" replace />} />
<Route path="/marketplace" element={<MarketplacePage />} />
<Route path="/points-mall" element={<PointsMallPage />} />
<Route path="/teams" element={<TeamsPage />} />
<Route path="/prompts" element={<PromptLibraryPage />} />
<Route path="/stats" element={<StatsPage />} />

View File

@ -466,6 +466,91 @@ export const PromptTemplateAPI = {
use: (id: string) => api.post(`/prompt-templates/${id}/use`).then((r) => r.data)
};
// ============== 积分商城 (v1) ==============
export interface PointsMallMe {
points: number;
level?: string;
}
export interface PointsMallCategory {
id: string;
name: string;
sort: number;
}
export interface PointsMallAnnouncement {
id: string;
title: string;
content: string;
linkUrl?: string;
}
export interface PointsMallBanner {
id: string;
title: string;
subtitle?: string;
imageUrl: string;
linkUrl?: string;
}
export interface PointsMallPromoEntry {
id: string;
title: string;
subtitle?: string;
iconUrl?: string;
linkUrl?: string;
}
export interface PointsMallProduct {
id: string;
categoryId: string;
name: string;
subtitle?: string;
coverUrl: string;
pointsPrice: number;
stock: number;
sold: number;
tags?: string[];
}
export interface PointsMallOverview {
me: PointsMallMe;
categories: PointsMallCategory[];
announcements: PointsMallAnnouncement[];
banners: PointsMallBanner[];
promoEntries: PointsMallPromoEntry[];
}
export interface PointsMallProductsResponse {
page: number;
pageSize: number;
total: number;
items: PointsMallProduct[];
}
export const PointsMallAPI = {
overview: () => api.get<PointsMallOverview>('/points-mall/overview').then((r) => r.data),
products: (opts: {
categoryId?: string;
q?: string;
sort?: string;
page?: number;
pageSize?: number;
} = {}) =>
api
.get<PointsMallProductsResponse>('/points-mall/products', {
params: {
categoryId: opts.categoryId,
q: opts.q ?? '',
sort: opts.sort ?? 'popular',
page: opts.page ?? 1,
pageSize: opts.pageSize ?? 24
}
})
.then((r) => r.data)
};
// ============== 调用统计 (v0.8 P1) ==============
export interface StatsOverview {

View File

@ -45,6 +45,12 @@ const NAV_GROUPS: Array<{
{ to: '/stats', icon: <BarChartOutlined />, label: '调用统计' },
{ to: '/teams', icon: <TeamOutlined />, label: '团队' }
]
},
{
label: '商城',
items: [
{ to: '/points-mall', icon: <CompassOutlined />, label: '积分商城' }
]
}
];

View File

@ -0,0 +1,421 @@
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>
);
}