diff --git a/docs/points-mall-api.md b/docs/points-mall-api.md new file mode 100644 index 0000000..dde175e --- /dev/null +++ b/docs/points-mall-api.md @@ -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 `(以现有登录实现为准),接口需校验登录态 +- 图片字段:返回绝对 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。 + diff --git a/src/App.tsx b/src/App.tsx index 184eaf1..b7fb14e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/api.ts b/src/api.ts index d88558b..2bd7449 100644 --- a/src/api.ts +++ b/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('/points-mall/overview').then((r) => r.data), + products: (opts: { + categoryId?: string; + q?: string; + sort?: string; + page?: number; + pageSize?: number; + } = {}) => + api + .get('/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 { diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 64027fb..a680737 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -45,6 +45,12 @@ const NAV_GROUPS: Array<{ { to: '/stats', icon: , label: '调用统计' }, { to: '/teams', icon: , label: '团队' } ] + }, + { + label: '商城', + items: [ + { to: '/points-mall', icon: , label: '积分商城' } + ] } ]; diff --git a/src/pages/PointsMallPage.tsx b/src/pages/PointsMallPage.tsx new file mode 100644 index 0000000..3d0cf66 --- /dev/null +++ b/src/pages/PointsMallPage.tsx @@ -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(null); + const [categories, setCategories] = useState([]); + + const [categoryId, setCategoryId] = useState('all'); + const [q, setQ] = useState(''); + const [sort, setSort] = useState('popular'); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(24); + const [productsRes, setProductsRes] = useState(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 ( +
+
+
+
+

积分商城

+
+ 使用积分兑换权益、工具和活动礼包。后续会接入库存、订单与积分流水。 +
+
+
+ {overviewLoading ? ( + + ) : ( +
+
+
+ 我的积分 +
+
+ {Number(overview?.me?.points || 0).toLocaleString()} +
+
+ + {String(overview?.me?.level || 'Lv.0')} + +
+ )} +
+
+
+ + +
+ + 商品分类 + + {categories.map((c) => ( + + ))} +
+
+ + {!!announcement && ( + + {announcement.title} + + } + description={announcement.content} + style={{ borderRadius: 18, marginBottom: 14 }} + /> + )} + +
+
+
+
+
+ {banner?.title || '本期活动'} +
+
+ {banner?.subtitle || 'Up to 25% Off'} +
+
+ +
+
+ banner 图片与跳转链接由后端配置 +
+
+ +
+ {promoEntries.slice(0, 2).map((p) => ( +
+
+
+ {p.title} +
+
+ {p.subtitle} +
+
+ +
+ ))} + {promoEntries.length < 2 && ( +
+ 促销入口由后端配置 +
+ )} +
+
+ + +
+ + { + setPage(1); + setQ(e.target.value); + }} + prefix={} + placeholder="搜索商品" + allowClear + style={{ width: 260, borderRadius: 12 }} + /> + { + setPage(1); + setPageSize(v); + }} + options={[ + { value: 12, label: '每页 12' }, + { value: 24, label: '每页 24' }, + { value: 48, label: '每页 48' } + ]} + /> + +
+ 共 {total.toLocaleString()} 件商品 +
+
+ + {productsLoading ? ( + + ) : products.length === 0 ? ( + + ) : ( +
+ {products.map((p) => ( +
+
+
+
+
+
+ {p.name} +
+
+ {p.subtitle} +
+
+ {p.tags?.length ? ( + + {p.tags[0]} + + ) : null} +
+
+
+ + {Number(p.pointsPrice).toLocaleString()} + + 积分 +
+ +
+
+ 库存 {p.stock} + 已兑 {p.sold} +
+
+
+ ))} +
+ )} + + {!!productsRes && ( +
+ + + + 第 {page} 页 + + + +
+ )} + +
+ ); +} +