feat: points mall page and api spec
parent
e93073fd74
commit
6b8ccf0af2
|
|
@ -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。
|
||||
|
||||
|
|
@ -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 />} />
|
||||
|
|
|
|||
85
src/api.ts
85
src/api.ts
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: '积分商城' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue