1
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
381
docs/home-interface-missing-fields.md
Normal file
381
docs/home-interface-missing-fields.md
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
# 商城首页接口缺字段补充建议
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前首页已经按原来的页面结构保留:
|
||||||
|
|
||||||
|
- 顶部搜索区
|
||||||
|
- 左侧分类导航
|
||||||
|
- 中间 Banner 主视觉
|
||||||
|
- 右侧档口排行榜
|
||||||
|
- 中下方 Banner 卡片区
|
||||||
|
- 商品列表区
|
||||||
|
- 店铺推荐区
|
||||||
|
|
||||||
|
同时页面展示已经改成“优先按接口字段渲染”,不再使用本地 mock 数据补业务内容。
|
||||||
|
|
||||||
|
当前直接使用的接口是:
|
||||||
|
|
||||||
|
- `AppHome`
|
||||||
|
- `AppGoodsPage`
|
||||||
|
|
||||||
|
现状是页面能跑通,但为了完全摆脱前端硬编码、数字占位和临时推导,首页还有几组字段缺口需要后端补齐。
|
||||||
|
|
||||||
|
## 当前接口已能直接支撑的内容
|
||||||
|
|
||||||
|
### `AppHome`
|
||||||
|
|
||||||
|
当前已足够支撑:
|
||||||
|
|
||||||
|
- Banner 图片:`banners[].imageUrl`
|
||||||
|
- Banner 主标题:`banners[].title`
|
||||||
|
- Banner 跳转信息:`banners[].linkType`、`banners[].linkValue`
|
||||||
|
- 推荐商品:`recommendGoods[]`
|
||||||
|
- 推荐店铺:`recommendStores[]`
|
||||||
|
|
||||||
|
### `AppGoodsPage`
|
||||||
|
|
||||||
|
当前已足够支撑:
|
||||||
|
|
||||||
|
- 商品分页列表:`list[]`
|
||||||
|
- 商品基础卡片:`spuName`、`subTitle`、`coverUrl`
|
||||||
|
- 价格:`minPrice`、`maxPrice`
|
||||||
|
- 销量:`salesVolume`、`virtualSales`
|
||||||
|
- 库存:`totalStock`
|
||||||
|
- 排序:`sortType`
|
||||||
|
- 类目筛选请求值:`categoryId`
|
||||||
|
|
||||||
|
## 缺字段清单
|
||||||
|
|
||||||
|
### 1. 首页分类导航缺少类目展示字段
|
||||||
|
|
||||||
|
#### 当前问题
|
||||||
|
|
||||||
|
当前前端只能拿到 `GoodsSummary.categoryId`,但左侧分类导航如果要保持现有样式,需要:
|
||||||
|
|
||||||
|
- 类目名称
|
||||||
|
- 类目排序
|
||||||
|
- 商品数量
|
||||||
|
|
||||||
|
现在如果只靠 `categoryId`,前端只能显示成 `类目 12` 这种数字占位,不够可用,也不适合正式页面。
|
||||||
|
|
||||||
|
#### 为什么需要
|
||||||
|
|
||||||
|
左侧分类导航不仅是筛选入口,也是首页首屏的重要信息模块。如果没有类目名称:
|
||||||
|
|
||||||
|
- 用户看不懂类目内容
|
||||||
|
- 前端无法稳定排序
|
||||||
|
- 无法避免写死类目文案或临时用数字占位
|
||||||
|
|
||||||
|
#### 建议补充方式
|
||||||
|
|
||||||
|
优先建议直接补到 `AppHomeResponse`,因为首页首屏进入时就需要这组数据。
|
||||||
|
|
||||||
|
#### 建议定义
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface HomeCategoryNav {
|
||||||
|
categoryId: number
|
||||||
|
categoryName: string
|
||||||
|
goodsCount: number
|
||||||
|
sort: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
建议追加到:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface AppHomeResponse {
|
||||||
|
banners: BannerInfo[]
|
||||||
|
recommendGoods: GoodsSummary[]
|
||||||
|
recommendStores: RecommendStoreInfo[]
|
||||||
|
categoryNavs: HomeCategoryNav[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"categoryId": 101,
|
||||||
|
"categoryName": "手机配件",
|
||||||
|
"goodsCount": 286,
|
||||||
|
"sort": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Banner 缺少副文案和按钮文案字段
|
||||||
|
|
||||||
|
#### 当前问题
|
||||||
|
|
||||||
|
`BannerInfo` 目前只有:
|
||||||
|
|
||||||
|
- `title`
|
||||||
|
- `imageUrl`
|
||||||
|
- `linkType`
|
||||||
|
- `linkValue`
|
||||||
|
|
||||||
|
这足够渲染图片和标题,但首屏 Banner 按当前页面样式还需要:
|
||||||
|
|
||||||
|
- 副标题
|
||||||
|
- 描述文案
|
||||||
|
- 主按钮文案
|
||||||
|
|
||||||
|
不补的话,这些内容只能前端写死。
|
||||||
|
|
||||||
|
#### 为什么需要
|
||||||
|
|
||||||
|
Banner 是首页视觉中心。如果副文案和按钮文案不能由接口控制:
|
||||||
|
|
||||||
|
- 运营无法灵活调整首屏内容
|
||||||
|
- 前端只能用固定文案
|
||||||
|
- 不同 Banner 的表达层级无法统一
|
||||||
|
|
||||||
|
#### 建议定义
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface BannerInfo {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
subTitle: string
|
||||||
|
description: string
|
||||||
|
buttonText: string
|
||||||
|
positionCode: string
|
||||||
|
imageUrl: string
|
||||||
|
linkType: string
|
||||||
|
linkValue: string
|
||||||
|
sort: number
|
||||||
|
status: "enabled" | "disabled"
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "春季上新",
|
||||||
|
"subTitle": "精选档口同步上新",
|
||||||
|
"description": "聚合热卖新品与高频补货商品",
|
||||||
|
"buttonText": "立即搜款",
|
||||||
|
"positionCode": "home_top",
|
||||||
|
"imageUrl": "https://cdn.example.com/banner/1.png",
|
||||||
|
"linkType": "goods",
|
||||||
|
"linkValue": "10001",
|
||||||
|
"sort": 1,
|
||||||
|
"status": "enabled",
|
||||||
|
"startTime": 1710000000,
|
||||||
|
"endTime": 1730000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 推荐店铺缺少推荐理由和排行依据
|
||||||
|
|
||||||
|
#### 当前问题
|
||||||
|
|
||||||
|
`RecommendStoreInfo` 现在只有:
|
||||||
|
|
||||||
|
- `storeName`
|
||||||
|
- `storeLogo`
|
||||||
|
- `storeNotice`
|
||||||
|
- `storeStatus`
|
||||||
|
|
||||||
|
右侧“档口排行榜”区域如果要保持现有样式和业务含义,还需要:
|
||||||
|
|
||||||
|
- 推荐理由
|
||||||
|
- 主营类目
|
||||||
|
- 排行分值或展示指标
|
||||||
|
|
||||||
|
不补的话,只能展示店铺公告,排行榜会显得信息不完整。
|
||||||
|
|
||||||
|
#### 为什么需要
|
||||||
|
|
||||||
|
当前右侧区域不是普通店铺列表,而是“推荐 / 排行”型模块。用户需要知道:
|
||||||
|
|
||||||
|
- 为什么推荐这家店
|
||||||
|
- 它主要卖什么
|
||||||
|
- 排行依据是什么
|
||||||
|
|
||||||
|
#### 建议定义
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface RecommendStoreInfo {
|
||||||
|
id: number
|
||||||
|
merchantId: number
|
||||||
|
storeName: string
|
||||||
|
storeLogo: string
|
||||||
|
storeNotice: string
|
||||||
|
storeStatus: "enabled" | "disabled"
|
||||||
|
mainCategoryName: string
|
||||||
|
recommendReason: string
|
||||||
|
rankScore: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 9001,
|
||||||
|
"merchantId": 5001,
|
||||||
|
"storeName": "星驰数码档口",
|
||||||
|
"storeLogo": "https://cdn.example.com/store/logo-1.png",
|
||||||
|
"storeNotice": "手机配件热销档口",
|
||||||
|
"storeStatus": "enabled",
|
||||||
|
"mainCategoryName": "手机配件",
|
||||||
|
"recommendReason": "近 7 天出单稳定,适合做日常补货",
|
||||||
|
"rankScore": 98.6
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 商品摘要缺少类目名称
|
||||||
|
|
||||||
|
#### 当前问题
|
||||||
|
|
||||||
|
`GoodsSummary` 里有 `categoryId`,但没有 `categoryName`。
|
||||||
|
|
||||||
|
这会导致:
|
||||||
|
|
||||||
|
- 商品卡片无法展示人类可读的类目名
|
||||||
|
- 分类筛选区无法直接使用列表结果做回显
|
||||||
|
- 前端必须再维护一套 `categoryId -> 名称` 映射
|
||||||
|
|
||||||
|
#### 为什么需要
|
||||||
|
|
||||||
|
商品列表卡片、推荐商品卡片、筛选回显,都需要类目中文名。只靠 `categoryId` 不够。
|
||||||
|
|
||||||
|
#### 建议定义
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface GoodsSummary {
|
||||||
|
id: number
|
||||||
|
merchantId: number
|
||||||
|
storeId: number
|
||||||
|
categoryId: number
|
||||||
|
categoryName: string
|
||||||
|
spuNo: string
|
||||||
|
spuName: string
|
||||||
|
subTitle: string
|
||||||
|
coverUrl: string
|
||||||
|
status: "enabled" | "disabled"
|
||||||
|
auditStatus: "pending" | "approved" | "rejected"
|
||||||
|
saleStatus: "off" | "on"
|
||||||
|
sort: number
|
||||||
|
salesVolume: number
|
||||||
|
virtualSales: number
|
||||||
|
viewCount: number
|
||||||
|
minPrice: string
|
||||||
|
maxPrice: string
|
||||||
|
totalStock: number
|
||||||
|
lockedStock: number
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 搜索热词缺少接口字段
|
||||||
|
|
||||||
|
#### 当前问题
|
||||||
|
|
||||||
|
搜索框下方原样式里有热词区。当前文档没有专门的热词字段,所以前端只能临时从 `recommendGoods[].spuName` 里抽。
|
||||||
|
|
||||||
|
#### 为什么需要
|
||||||
|
|
||||||
|
热词一般是运营位,不应该完全依赖推荐商品标题:
|
||||||
|
|
||||||
|
- 热词和推荐商品不是一个概念
|
||||||
|
- 热词排序和内容应该可单独控制
|
||||||
|
- 需要支持临时活动词、营销词
|
||||||
|
|
||||||
|
#### 建议定义
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface AppHomeResponse {
|
||||||
|
banners: BannerInfo[]
|
||||||
|
recommendGoods: GoodsSummary[]
|
||||||
|
recommendStores: RecommendStoreInfo[]
|
||||||
|
categoryNavs: HomeCategoryNav[]
|
||||||
|
searchKeywords: string[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"磁吸手机壳",
|
||||||
|
"直播补光灯",
|
||||||
|
"蓝牙耳机",
|
||||||
|
"快充套装",
|
||||||
|
"智能手表表带"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 平台专区 / 频道卡片缺少独立数据源
|
||||||
|
|
||||||
|
#### 当前问题
|
||||||
|
|
||||||
|
原首页样式里有一块“平台专区 / 场景卡片”区域。当前接口没有对应字段,所以如果要保留这块业务含义,前端只能拿别的数据硬凑。
|
||||||
|
|
||||||
|
#### 为什么需要
|
||||||
|
|
||||||
|
这类区域通常代表固定业务入口,例如:
|
||||||
|
|
||||||
|
- 平台铺货
|
||||||
|
- 一件代发
|
||||||
|
- 活动专区
|
||||||
|
- 类目专题
|
||||||
|
|
||||||
|
它不适合和 Banner 或推荐商品混用。
|
||||||
|
|
||||||
|
#### 建议定义
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface HomeChannelCard {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
subTitle: string
|
||||||
|
iconUrl: string
|
||||||
|
linkType: string
|
||||||
|
linkValue: string
|
||||||
|
sort: number
|
||||||
|
status: "enabled" | "disabled"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
建议追加到:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface AppHomeResponse {
|
||||||
|
banners: BannerInfo[]
|
||||||
|
recommendGoods: GoodsSummary[]
|
||||||
|
recommendStores: RecommendStoreInfo[]
|
||||||
|
categoryNavs: HomeCategoryNav[]
|
||||||
|
searchKeywords: string[]
|
||||||
|
channels: HomeChannelCard[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 建议优先级
|
||||||
|
|
||||||
|
如果按“先把当前首页做完整”来排,建议优先级如下:
|
||||||
|
|
||||||
|
1. `categoryNavs`
|
||||||
|
2. `BannerInfo.subTitle / description / buttonText`
|
||||||
|
3. `GoodsSummary.categoryName`
|
||||||
|
4. `RecommendStoreInfo.mainCategoryName / recommendReason / rankScore`
|
||||||
|
5. `searchKeywords`
|
||||||
|
6. `channels`
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
当前首页已经可以只用现有接口字段跑起来,但如果要真正做到:
|
||||||
|
|
||||||
|
- 样式不变
|
||||||
|
- 内容不写死
|
||||||
|
- 页面表达完整
|
||||||
|
- 运营可配置
|
||||||
|
|
||||||
|
上面这些字段建议尽快补到文档和接口定义里。
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Mall App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "mall-app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"typecheck": "tsc -b",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ahooks": "^3.8.5",
|
||||||
|
"axios": "^1.9.0",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"react-router-dom": "^7.6.3",
|
||||||
|
"sonner": "^2.0.6",
|
||||||
|
"zustand": "^5.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.13",
|
||||||
|
"@types/react": "^18.2.48",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^7.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1268
pnpm-lock.yaml
generated
Normal file
1268
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
src/App.tsx
Normal file
27
src/App.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import { Toaster } from 'sonner'
|
||||||
|
import GoodsDetailPage from '@/pages/goods/GoodsDetailPage'
|
||||||
|
import HomePage from '@/pages/home/HomePage'
|
||||||
|
import LoginPage from '@/pages/login/LoginPage'
|
||||||
|
import MemberCenterPage from '@/pages/member/MemberCenterPage'
|
||||||
|
import ShopPage from '@/pages/shop/ShopPage'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toaster position="top-center" richColors />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/list/new" element={<HomePage />} />
|
||||||
|
<Route path="/member" element={<MemberCenterPage />} />
|
||||||
|
<Route path="/goods/:spuId" element={<GoodsDetailPage />} />
|
||||||
|
<Route path="/shop/:merchantId" element={<ShopPage />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<LoginPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
1
src/apis/apiMallApp.ts
Normal file
1
src/apis/apiMallApp.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './mall-app'
|
||||||
67
src/apis/apiUser.ts
Normal file
67
src/apis/apiUser.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import axiosInstance from './base/axiosInstance'
|
||||||
|
import type { IBaseResponse } from './base/types'
|
||||||
|
import type { ITokenData } from '@/types/ITokenData'
|
||||||
|
import type { IUserInfo } from '@/types/IUserInfo'
|
||||||
|
|
||||||
|
export type UserAccountLoginType = '0' | '1'
|
||||||
|
|
||||||
|
export interface UserLoginPayload {
|
||||||
|
account: string
|
||||||
|
captcha?: string
|
||||||
|
password?: string
|
||||||
|
loginType: UserAccountLoginType
|
||||||
|
type: '0' | '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRegisterPayload {
|
||||||
|
account: string
|
||||||
|
password: string
|
||||||
|
captcha: string
|
||||||
|
loginType: UserAccountLoginType
|
||||||
|
invite?: string
|
||||||
|
type: '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmsCodePayload {
|
||||||
|
account: string
|
||||||
|
loginType: UserAccountLoginType
|
||||||
|
type: '0' | '1' | '2'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiUserLogin = async (payload: UserLoginPayload, signal?: AbortSignal) => {
|
||||||
|
const path = '/api/v1/user/login'
|
||||||
|
const response = await axiosInstance.post<IBaseResponse<ITokenData>>(path, payload, {
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiUserRegister = async (payload: UserRegisterPayload, signal?: AbortSignal) => {
|
||||||
|
const path = '/api/v1/user/register'
|
||||||
|
const response = await axiosInstance.post<IBaseResponse<ITokenData>>(path, payload, {
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiGetSmsCode = async (payload: SmsCodePayload, signal?: AbortSignal) => {
|
||||||
|
const path = '/api/v1/user/captcha'
|
||||||
|
const response = await axiosInstance.post<IBaseResponse<unknown>>(path, payload, {
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiGetUserInfo = async (signal?: AbortSignal) => {
|
||||||
|
const path = '/api/v1/user/detail'
|
||||||
|
const response = await axiosInstance.post<IBaseResponse<IUserInfo>>(
|
||||||
|
path,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiUserReg = apiUserRegister
|
||||||
147
src/apis/base/axiosInstance.ts
Normal file
147
src/apis/base/axiosInstance.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import axios, { AxiosHeaders } from 'axios'
|
||||||
|
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||||
|
import { getBaseUrl, getVersion } from '@/config/source/config'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { API_ERROR_CODES, API_ERROR_TYPES, ApiError, type IBaseResponse } from './types'
|
||||||
|
|
||||||
|
const axiosInstance = axios.create({
|
||||||
|
baseURL: getBaseUrl(),
|
||||||
|
timeout: 30 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getMessage = (data: unknown, fallback: string) => {
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeMessage = (data as { message?: unknown; msg?: unknown }).message
|
||||||
|
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
|
||||||
|
return maybeMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeMsg = (data as { message?: unknown; msg?: unknown }).msg
|
||||||
|
if (typeof maybeMsg === 'string' && maybeMsg.trim()) {
|
||||||
|
return maybeMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHttpError = (status: number, data: unknown, message: string, error: unknown): never => {
|
||||||
|
if (status === 401) {
|
||||||
|
useUserStore.getState().logout()
|
||||||
|
throw new ApiError(API_ERROR_CODES.UNAUTHORIZED, 'Unauthorized', API_ERROR_TYPES.HTTP, data, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 403) {
|
||||||
|
throw new ApiError(API_ERROR_CODES.FORBIDDEN, 'Forbidden', API_ERROR_TYPES.HTTP, data, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 500 || status === 503) {
|
||||||
|
throw new ApiError(API_ERROR_CODES.SERVER_ERROR, 'Server error', API_ERROR_TYPES.HTTP, data, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError(status, message, API_ERROR_TYPES.HTTP, data, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAxiosError = (error: AxiosError): never => {
|
||||||
|
if (error.code === 'ECONNABORTED') {
|
||||||
|
throw new ApiError(
|
||||||
|
API_ERROR_CODES.CLIENT_TIMEOUT_ERROR,
|
||||||
|
'Request timeout',
|
||||||
|
API_ERROR_TYPES.NETWORK,
|
||||||
|
undefined,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
const { status, data } = error.response
|
||||||
|
return handleHttpError(status, data, getMessage(data, error.message), error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.request) {
|
||||||
|
throw new ApiError(
|
||||||
|
API_ERROR_CODES.CLIENT_NETWORK_ERROR,
|
||||||
|
'Network error',
|
||||||
|
API_ERROR_TYPES.NETWORK,
|
||||||
|
undefined,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError(
|
||||||
|
API_ERROR_CODES.SERVER_ERROR,
|
||||||
|
error.message,
|
||||||
|
API_ERROR_TYPES.UNKNOWN,
|
||||||
|
undefined,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
axiosInstance.interceptors.request.use(
|
||||||
|
(config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = useUserStore.getState().token?.accessToken ?? ''
|
||||||
|
const version = getVersion()
|
||||||
|
|
||||||
|
config.baseURL = getBaseUrl()
|
||||||
|
const headers = AxiosHeaders.from(config.headers)
|
||||||
|
headers.set('Content-Type', 'application/json;charset=utf-8')
|
||||||
|
headers.set('version', version)
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers.set('authorization', token)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.headers = headers
|
||||||
|
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
throw new ApiError(
|
||||||
|
API_ERROR_CODES.SERVER_ERROR,
|
||||||
|
'Request setup failed',
|
||||||
|
API_ERROR_TYPES.UNKNOWN,
|
||||||
|
undefined,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
axiosInstance.interceptors.response.use(
|
||||||
|
response => {
|
||||||
|
const payload = response.data as IBaseResponse<unknown>
|
||||||
|
const code = payload?.code
|
||||||
|
|
||||||
|
if (code === API_ERROR_CODES.SUCCESS) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = getMessage(payload, 'Request failed')
|
||||||
|
if (code === API_ERROR_CODES.UNAUTHORIZED) {
|
||||||
|
useUserStore.getState().logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError(code, message, API_ERROR_TYPES.BUSINESS, payload?.data)
|
||||||
|
},
|
||||||
|
(error: unknown): never => {
|
||||||
|
if (axios.isCancel(error)) {
|
||||||
|
throw new ApiError(
|
||||||
|
API_ERROR_CODES.CLIENT_CANCEL_ERROR,
|
||||||
|
'Request canceled',
|
||||||
|
API_ERROR_TYPES.UNKNOWN,
|
||||||
|
undefined,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && 'isAxiosError' in error && (error as AxiosError).isAxiosError) {
|
||||||
|
return handleAxiosError(error as AxiosError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
throw new ApiError(API_ERROR_CODES.SERVER_ERROR, message, API_ERROR_TYPES.UNKNOWN, undefined, error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default axiosInstance
|
||||||
58
src/apis/base/types.ts
Normal file
58
src/apis/base/types.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
export const API_ERROR_TYPES = {
|
||||||
|
HTTP: 'HTTP',
|
||||||
|
BUSINESS: 'BUSINESS',
|
||||||
|
NETWORK: 'NETWORK',
|
||||||
|
UNKNOWN: 'UNKNOWN',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ApiErrorType = (typeof API_ERROR_TYPES)[keyof typeof API_ERROR_TYPES]
|
||||||
|
|
||||||
|
export const API_ERROR_CODES = {
|
||||||
|
SUCCESS: 200,
|
||||||
|
UNAUTHORIZED: 600,
|
||||||
|
TOKEN_GENERATE_FAILED: 802,
|
||||||
|
PARAM_ERROR: 965,
|
||||||
|
SERVER_BUSY: 999,
|
||||||
|
DB_ERROR: 1001,
|
||||||
|
UPDATE_NO_ROWS: 1002,
|
||||||
|
FORBIDDEN: 403,
|
||||||
|
SERVER_ERROR: 500,
|
||||||
|
CLIENT_NETWORK_ERROR: 10000,
|
||||||
|
CLIENT_TIMEOUT_ERROR: 10001,
|
||||||
|
CLIENT_CANCEL_ERROR: 10002,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
code: number
|
||||||
|
data?: unknown
|
||||||
|
originalError?: unknown
|
||||||
|
type: ApiErrorType
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
code: number,
|
||||||
|
message: string,
|
||||||
|
type: ApiErrorType,
|
||||||
|
data?: unknown,
|
||||||
|
originalError?: unknown
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.code = code
|
||||||
|
this.type = type
|
||||||
|
this.data = data
|
||||||
|
this.originalError = originalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBaseResponse<T> {
|
||||||
|
code: number
|
||||||
|
message?: string
|
||||||
|
msg?: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPageParams {
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
78
src/apis/base/useAxios.ts
Normal file
78
src/apis/base/useAxios.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useRequest } from 'ahooks'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { API_ERROR_CODES, type ApiError } from './types'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
|
||||||
|
type RequestService<TData, TParams extends unknown[]> = (...args: TParams) => Promise<TData>
|
||||||
|
|
||||||
|
export interface OptionsExt<TData, TParams extends unknown[]> {
|
||||||
|
manual?: boolean
|
||||||
|
defaultParams?: TParams
|
||||||
|
showLoading?: boolean
|
||||||
|
showError?: boolean
|
||||||
|
showSuccess?: boolean
|
||||||
|
successText?: string
|
||||||
|
errorText?: string
|
||||||
|
onSuccess?: (data: TData, params: TParams) => void
|
||||||
|
onError?: (error: Error, params: TParams) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAxios = <TData, TParams extends unknown[]>(
|
||||||
|
service: RequestService<TData, TParams>,
|
||||||
|
options: OptionsExt<TData, TParams> = {}
|
||||||
|
) => {
|
||||||
|
const loadingToastRef = useRef<string | number | null>(null)
|
||||||
|
const { logout } = useUserStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (loadingToastRef.current) {
|
||||||
|
toast.dismiss(loadingToastRef.current)
|
||||||
|
loadingToastRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return useRequest(service, {
|
||||||
|
manual: options.manual ?? true,
|
||||||
|
defaultParams: options.defaultParams,
|
||||||
|
onBefore: () => {
|
||||||
|
if (options.showLoading) {
|
||||||
|
loadingToastRef.current = toast.loading('Processing...')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (data, params) => {
|
||||||
|
if (loadingToastRef.current) {
|
||||||
|
toast.dismiss(loadingToastRef.current)
|
||||||
|
loadingToastRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.showSuccess) {
|
||||||
|
toast.success(options.successText || 'Success')
|
||||||
|
}
|
||||||
|
|
||||||
|
options.onSuccess?.(data, params)
|
||||||
|
},
|
||||||
|
onError: (error, params) => {
|
||||||
|
if (loadingToastRef.current) {
|
||||||
|
toast.dismiss(loadingToastRef.current)
|
||||||
|
loadingToastRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiError = error as ApiError
|
||||||
|
if (options.showError !== false) {
|
||||||
|
toast.error(options.errorText || apiError.message || 'Request failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError.code === API_ERROR_CODES.UNAUTHORIZED) {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
options.onError?.(error, params)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAxios
|
||||||
|
|
||||||
4
src/apis/index.ts
Normal file
4
src/apis/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './apiMallApp'
|
||||||
|
export * from './apiUser'
|
||||||
|
export * from './base/types'
|
||||||
|
export * from './base/useAxios'
|
||||||
15
src/apis/mall-app/afterSale.ts
Normal file
15
src/apis/mall-app/afterSale.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { APP_API_PATHS, normalizeMallAppPayload, postMallApp } from './shared'
|
||||||
|
import type {
|
||||||
|
AppAfterSaleApplyRequest,
|
||||||
|
AppAfterSaleApplyResponse,
|
||||||
|
} from '@/types/mall'
|
||||||
|
|
||||||
|
export const apiAppAfterSaleApply = (
|
||||||
|
payload: AppAfterSaleApplyRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) =>
|
||||||
|
postMallApp<AppAfterSaleApplyResponse, AppAfterSaleApplyRequest>(
|
||||||
|
APP_API_PATHS.afterSaleApply,
|
||||||
|
normalizeMallAppPayload(payload),
|
||||||
|
signal
|
||||||
|
)
|
||||||
36
src/apis/mall-app/cart.ts
Normal file
36
src/apis/mall-app/cart.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { APP_API_PATHS, normalizeAppCartListResponse, normalizeMallAppPayload, postMallApp } from './shared'
|
||||||
|
import type {
|
||||||
|
AppCartListRequest,
|
||||||
|
AppCartListResponse,
|
||||||
|
AppCartRemoveRequest,
|
||||||
|
AppCartRemoveResponse,
|
||||||
|
AppCartSaveRequest,
|
||||||
|
AppCartSaveResponse,
|
||||||
|
} from '@/types/mall'
|
||||||
|
|
||||||
|
export const apiAppCartList = async (payload: AppCartListRequest, signal?: AbortSignal) => {
|
||||||
|
const response = await postMallApp<AppCartListResponse, AppCartListRequest>(
|
||||||
|
APP_API_PATHS.cartList,
|
||||||
|
normalizeMallAppPayload(payload),
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: normalizeAppCartListResponse(response.data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiAppCartSave = (payload: AppCartSaveRequest, signal?: AbortSignal) =>
|
||||||
|
postMallApp<AppCartSaveResponse, AppCartSaveRequest>(
|
||||||
|
APP_API_PATHS.cartSave,
|
||||||
|
normalizeMallAppPayload(payload),
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
|
||||||
|
export const apiAppCartRemove = (payload: AppCartRemoveRequest, signal?: AbortSignal) =>
|
||||||
|
postMallApp<AppCartRemoveResponse, AppCartRemoveRequest>(
|
||||||
|
APP_API_PATHS.cartRemove,
|
||||||
|
normalizeMallAppPayload(payload),
|
||||||
|
signal
|
||||||
|
)
|
||||||
39
src/apis/mall-app/goods.ts
Normal file
39
src/apis/mall-app/goods.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
APP_API_PATHS,
|
||||||
|
normalizeAppGoodsDetailResponse,
|
||||||
|
normalizeAppGoodsPageResponse,
|
||||||
|
normalizeMallAppPayload,
|
||||||
|
postMallApp,
|
||||||
|
} from './shared'
|
||||||
|
import type {
|
||||||
|
AppGoodsDetailRequest,
|
||||||
|
AppGoodsDetailResponse,
|
||||||
|
AppGoodsPageRequest,
|
||||||
|
AppGoodsPageResponse,
|
||||||
|
} from '@/types/mall'
|
||||||
|
|
||||||
|
export const apiAppGoodsPage = async (payload: AppGoodsPageRequest, signal?: AbortSignal) => {
|
||||||
|
const response = await postMallApp<AppGoodsPageResponse, AppGoodsPageRequest>(
|
||||||
|
APP_API_PATHS.goodsPage,
|
||||||
|
normalizeMallAppPayload(payload),
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: normalizeAppGoodsPageResponse(response.data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiAppGoodsDetail = async (payload: AppGoodsDetailRequest, signal?: AbortSignal) => {
|
||||||
|
const response = await postMallApp<AppGoodsDetailResponse, AppGoodsDetailRequest>(
|
||||||
|
APP_API_PATHS.goodsDetail,
|
||||||
|
normalizeMallAppPayload(payload),
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: normalizeAppGoodsDetailResponse(response.data),
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/apis/mall-app/home.ts
Normal file
67
src/apis/mall-app/home.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
APP_API_PATHS,
|
||||||
|
normalizeAppHomeMerchantGoodsResponse,
|
||||||
|
normalizeAppHomeResponse,
|
||||||
|
normalizeAppRecentNewGoodsResponse,
|
||||||
|
normalizeMallAppPayload,
|
||||||
|
postMallApp,
|
||||||
|
} from './shared'
|
||||||
|
import type {
|
||||||
|
AppHomeMerchantGoodsRequest,
|
||||||
|
AppHomeMerchantGoodsResponse,
|
||||||
|
AppHomeRequest,
|
||||||
|
AppHomeResponse,
|
||||||
|
AppHomeStoreGoodsRequest,
|
||||||
|
AppRecentNewGoodsRequest,
|
||||||
|
AppRecentNewGoodsResponse,
|
||||||
|
} from '@/types/mall'
|
||||||
|
|
||||||
|
export const apiAppHome = async (payload: AppHomeRequest = {}, signal?: AbortSignal) => {
|
||||||
|
const response = await postMallApp<AppHomeResponse, AppHomeRequest>(
|
||||||
|
APP_API_PATHS.home,
|
||||||
|
normalizeMallAppPayload(payload),
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: normalizeAppHomeResponse(response.data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiAppRecentNewGoods = async (
|
||||||
|
payload: AppRecentNewGoodsRequest = {},
|
||||||
|
signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
const response = await postMallApp<AppRecentNewGoodsResponse, AppRecentNewGoodsRequest>(
|
||||||
|
APP_API_PATHS.recentNewGoods,
|
||||||
|
normalizeMallAppPayload(payload),
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: normalizeAppRecentNewGoodsResponse(response.data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiAppHomeMerchantGoods = async (
|
||||||
|
payload: AppHomeMerchantGoodsRequest = {},
|
||||||
|
signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
const response = await postMallApp<AppHomeMerchantGoodsResponse, AppHomeMerchantGoodsRequest>(
|
||||||
|
APP_API_PATHS.homeMerchantGoods,
|
||||||
|
normalizeMallAppPayload(payload),
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: normalizeAppHomeMerchantGoodsResponse(response.data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiAppHomeStoreGoods = (
|
||||||
|
payload: AppHomeStoreGoodsRequest = {},
|
||||||
|
signal?: AbortSignal
|
||||||
|
) => apiAppHomeMerchantGoods(payload, signal)
|
||||||
7
src/apis/mall-app/index.ts
Normal file
7
src/apis/mall-app/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './shared'
|
||||||
|
export * from './home'
|
||||||
|
export * from './goods'
|
||||||
|
export * from './cart'
|
||||||
|
export * from './trade'
|
||||||
|
export * from './order'
|
||||||
|
export * from './afterSale'
|
||||||
373
src/apis/mall-app/normalize.ts
Normal file
373
src/apis/mall-app/normalize.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import type {
|
||||||
|
AppCartListResponse,
|
||||||
|
AppGoodsDetailResponse,
|
||||||
|
AppGoodsPageResponse,
|
||||||
|
AppHomeMerchantGoodsResponse,
|
||||||
|
AppHomeResponse,
|
||||||
|
AppRecentNewGoodsResponse,
|
||||||
|
AppOrderDetailResponse,
|
||||||
|
AppOrderPageResponse,
|
||||||
|
CartItemInfo,
|
||||||
|
GoodsDetailInfo,
|
||||||
|
GoodsSkuInfo,
|
||||||
|
GoodsSummary,
|
||||||
|
AppTradePreviewResponse,
|
||||||
|
HomeMerchantGoodsBlock,
|
||||||
|
MerchantId,
|
||||||
|
MerchantInfo,
|
||||||
|
OrderDetailInfo,
|
||||||
|
OrderSummary,
|
||||||
|
RecommendMerchantInfo,
|
||||||
|
RecentNewGoodsItem,
|
||||||
|
TradePreviewInfo,
|
||||||
|
TradePreviewStoreInfo,
|
||||||
|
} from '@/types/mall'
|
||||||
|
|
||||||
|
type MaybeMallUidPayload = {
|
||||||
|
uid?: number
|
||||||
|
memberId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type MaybeMallMerchantPayload = {
|
||||||
|
merchantId?: MerchantId | number
|
||||||
|
storeId?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawRecommendMerchantPayload = Partial<RecommendMerchantInfo> & {
|
||||||
|
id?: RecommendMerchantInfo['id']
|
||||||
|
merchantId?: MerchantId | number | string
|
||||||
|
merchantName?: string
|
||||||
|
logo?: string
|
||||||
|
notice?: string
|
||||||
|
status?: string
|
||||||
|
rankScore?: number | string
|
||||||
|
goodsCount?: number | string
|
||||||
|
recentNewGoodsCount?: number | string
|
||||||
|
orderCount?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawMerchantEntityPayload = Partial<MerchantInfo> & {
|
||||||
|
id?: MerchantInfo['id'] | string
|
||||||
|
merchantId?: MerchantId | number | string
|
||||||
|
merchantName?: string
|
||||||
|
logo?: string
|
||||||
|
notice?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawGoodsSummaryPayload = Partial<GoodsSummary> & {
|
||||||
|
merchantId?: MerchantId | number | string
|
||||||
|
createdAt?: GoodsSummary['createdAt'] | string | null
|
||||||
|
updatedAt?: GoodsSummary['updatedAt'] | string | null
|
||||||
|
createAt?: number | string | null
|
||||||
|
updateAt?: number | string | null
|
||||||
|
created_at?: number | string | null
|
||||||
|
updated_at?: number | string | null
|
||||||
|
createTime?: number | string | null
|
||||||
|
updateTime?: number | string | null
|
||||||
|
create_time?: number | string | null
|
||||||
|
update_time?: number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMerchantId = (value: MerchantId | number | string | null | undefined): MerchantId =>
|
||||||
|
value == null ? '' : String(value)
|
||||||
|
|
||||||
|
const toNumber = (value: number | string | null | undefined): number => {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickText = (...values: Array<string | null | undefined>): string => {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const toUnixTime = (value: number | string | null | undefined): number => {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value > 1_000_000_000_000 ? Math.floor(value / 1000) : value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = value.trim()
|
||||||
|
if (!text) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(text)
|
||||||
|
if (Number.isFinite(numeric)) {
|
||||||
|
return numeric > 1_000_000_000_000 ? Math.floor(numeric / 1000) : numeric
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Date.parse(text.replace(/-/g, '/'))
|
||||||
|
return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeMallAppPayload = <TPayload extends object>(payload: TPayload): TPayload => {
|
||||||
|
const normalized = { ...payload } as TPayload & MaybeMallUidPayload & MaybeMallMerchantPayload
|
||||||
|
|
||||||
|
if (normalized.uid == null && normalized.memberId != null) {
|
||||||
|
normalized.uid = normalized.memberId
|
||||||
|
}
|
||||||
|
delete normalized.memberId
|
||||||
|
|
||||||
|
if (normalized.merchantId != null) {
|
||||||
|
normalized.merchantId = toMerchantId(normalized.merchantId)
|
||||||
|
}
|
||||||
|
if (normalized.merchantId == null && normalized.storeId != null) {
|
||||||
|
normalized.merchantId = toMerchantId(normalized.storeId)
|
||||||
|
}
|
||||||
|
delete normalized.storeId
|
||||||
|
|
||||||
|
return normalized as TPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeGoodsSku = (sku: GoodsSkuInfo): GoodsSkuInfo => ({
|
||||||
|
...sku,
|
||||||
|
merchantId: toMerchantId(sku.merchantId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeGoodsSummary = (goods: RawGoodsSummaryPayload): GoodsSummary => {
|
||||||
|
const normalized = goods as GoodsSummary
|
||||||
|
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
merchantId: toMerchantId(goods.merchantId),
|
||||||
|
merchantName: pickText(goods.merchantName),
|
||||||
|
merchantLogo: pickText(goods.merchantLogo),
|
||||||
|
createdAt: toUnixTime(goods.createdAt ?? goods.createAt ?? goods.created_at ?? goods.createTime ?? goods.create_time),
|
||||||
|
updatedAt: toUnixTime(goods.updatedAt ?? goods.updateAt ?? goods.updated_at ?? goods.updateTime ?? goods.update_time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeRecentNewGoodsItem = (goods: RecentNewGoodsItem): RecentNewGoodsItem => ({
|
||||||
|
...goods,
|
||||||
|
merchantId: goods.merchantId == null ? undefined : toMerchantId(goods.merchantId),
|
||||||
|
merchantName: pickText(goods.merchantName, goods.storeName),
|
||||||
|
merchantLogo: goods.merchantLogo?.trim(),
|
||||||
|
storeName: pickText(goods.storeName, goods.merchantName),
|
||||||
|
spuId: String(goods.spuId),
|
||||||
|
createdAt: toUnixTime(goods.createdAt),
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeGoodsDetailInfo = (detail: GoodsDetailInfo): GoodsDetailInfo => ({
|
||||||
|
...detail,
|
||||||
|
merchantId: toMerchantId(detail.merchantId),
|
||||||
|
createdAt: toUnixTime(detail.createdAt),
|
||||||
|
updatedAt: toUnixTime(detail.updatedAt),
|
||||||
|
skus: (detail.skus ?? []).map(normalizeGoodsSku),
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeCartItem = (item: CartItemInfo): CartItemInfo => ({
|
||||||
|
...item,
|
||||||
|
merchantId: toMerchantId(item.merchantId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeTradePreviewGroup = (group: TradePreviewStoreInfo): TradePreviewStoreInfo => ({
|
||||||
|
...group,
|
||||||
|
merchantId: toMerchantId(group.merchantId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeOrderSummary = (order: OrderSummary): OrderSummary => ({
|
||||||
|
...order,
|
||||||
|
merchantId: toMerchantId(order.merchantId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeOrderDetailInfo = (order: OrderDetailInfo): OrderDetailInfo => ({
|
||||||
|
...order,
|
||||||
|
merchantId: toMerchantId(order.merchantId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeRecommendMerchant = (
|
||||||
|
merchant: RawRecommendMerchantPayload | null | undefined
|
||||||
|
): RecommendMerchantInfo | undefined => {
|
||||||
|
if (!merchant) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(merchant as RecommendMerchantInfo),
|
||||||
|
merchantId: toMerchantId(merchant.merchantId ?? merchant.id),
|
||||||
|
storeName: pickText(merchant.storeName, merchant.merchantName),
|
||||||
|
storeLogo: pickText(merchant.storeLogo, merchant.logo),
|
||||||
|
storeNotice: pickText(merchant.storeNotice, merchant.notice),
|
||||||
|
storeStatus: (merchant.storeStatus ?? merchant.status ?? 'enabled') as RecommendMerchantInfo['storeStatus'],
|
||||||
|
rankScore: toNumber(merchant.rankScore),
|
||||||
|
goodsCount: toNumber(merchant.goodsCount),
|
||||||
|
recentNewGoodsCount: toNumber(merchant.recentNewGoodsCount),
|
||||||
|
orderCount: toNumber(merchant.orderCount),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeMerchantEntity = (
|
||||||
|
merchant: RawMerchantEntityPayload | null | undefined
|
||||||
|
): MerchantInfo | undefined => {
|
||||||
|
if (!merchant) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(merchant as MerchantInfo),
|
||||||
|
merchantId: toMerchantId(merchant.merchantId ?? merchant.id),
|
||||||
|
storeName: pickText(merchant.storeName, merchant.merchantName),
|
||||||
|
storeLogo: pickText(merchant.storeLogo, merchant.logo),
|
||||||
|
storeNotice: pickText(merchant.storeNotice, merchant.notice),
|
||||||
|
storeStatus: (merchant.storeStatus ?? merchant.status ?? 'enabled') as MerchantInfo['storeStatus'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeHomeMerchantGoodsBlock = (
|
||||||
|
block: HomeMerchantGoodsBlock | undefined
|
||||||
|
): HomeMerchantGoodsBlock | undefined => {
|
||||||
|
if (!block) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const merchant = normalizeRecommendMerchant(block.merchant ?? block.store)
|
||||||
|
if (!merchant) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
merchant,
|
||||||
|
goods: block.goods.map(normalizeGoodsSummary),
|
||||||
|
store: merchant,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeAppGoodsPageResponse = (
|
||||||
|
payload: AppGoodsPageResponse
|
||||||
|
): AppGoodsPageResponse => ({
|
||||||
|
...payload,
|
||||||
|
list: payload.list.map(normalizeGoodsSummary),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const normalizeAppRecentNewGoodsResponse = (
|
||||||
|
payload: AppRecentNewGoodsResponse
|
||||||
|
): AppRecentNewGoodsResponse => ({
|
||||||
|
...payload,
|
||||||
|
total: payload.total ?? 0,
|
||||||
|
list: (payload.list ?? []).map(normalizeRecentNewGoodsItem),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const normalizeAppCartListResponse = (
|
||||||
|
payload: AppCartListResponse
|
||||||
|
): AppCartListResponse => ({
|
||||||
|
...payload,
|
||||||
|
list: payload.list.map(normalizeCartItem),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const normalizeAppOrderPageResponse = (
|
||||||
|
payload: AppOrderPageResponse
|
||||||
|
): AppOrderPageResponse => ({
|
||||||
|
...payload,
|
||||||
|
list: payload.list.map(normalizeOrderSummary),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const normalizeAppOrderDetailResponse = (
|
||||||
|
payload: AppOrderDetailResponse
|
||||||
|
): AppOrderDetailResponse => ({
|
||||||
|
...payload,
|
||||||
|
data: normalizeOrderDetailInfo(payload.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const normalizeAppHomeResponse = (payload: AppHomeResponse): AppHomeResponse => {
|
||||||
|
const recommendMerchants = (payload.recommendMerchants ?? payload.recommendStores ?? [])
|
||||||
|
.map(normalizeRecommendMerchant)
|
||||||
|
.filter(Boolean) as RecommendMerchantInfo[]
|
||||||
|
const featuredMerchants = (payload.featuredMerchants ?? payload.featuredStores ?? [])
|
||||||
|
.map(normalizeRecommendMerchant)
|
||||||
|
.filter(Boolean) as RecommendMerchantInfo[]
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
recommendGoods: payload.recommendGoods.map(normalizeGoodsSummary),
|
||||||
|
recommendMerchants,
|
||||||
|
featuredMerchants,
|
||||||
|
recommendStores: recommendMerchants,
|
||||||
|
featuredStores: featuredMerchants,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeAppHomeMerchantGoodsResponse = (
|
||||||
|
payload: AppHomeMerchantGoodsResponse
|
||||||
|
): AppHomeMerchantGoodsResponse => ({
|
||||||
|
...payload,
|
||||||
|
data:
|
||||||
|
normalizeHomeMerchantGoodsBlock(payload.data) ??
|
||||||
|
({
|
||||||
|
merchant: {
|
||||||
|
id: 0,
|
||||||
|
merchantId: '0',
|
||||||
|
storeName: '',
|
||||||
|
storeLogo: '',
|
||||||
|
storeNotice: '',
|
||||||
|
storeStatus: 'disabled',
|
||||||
|
mainCategoryName: '',
|
||||||
|
recommendReason: '',
|
||||||
|
rankScore: 0,
|
||||||
|
goodsCount: 0,
|
||||||
|
recentNewGoodsCount: 0,
|
||||||
|
orderCount: 0,
|
||||||
|
},
|
||||||
|
store: {
|
||||||
|
id: 0,
|
||||||
|
merchantId: '0',
|
||||||
|
storeName: '',
|
||||||
|
storeLogo: '',
|
||||||
|
storeNotice: '',
|
||||||
|
storeStatus: 'disabled',
|
||||||
|
mainCategoryName: '',
|
||||||
|
recommendReason: '',
|
||||||
|
rankScore: 0,
|
||||||
|
goodsCount: 0,
|
||||||
|
recentNewGoodsCount: 0,
|
||||||
|
orderCount: 0,
|
||||||
|
},
|
||||||
|
goods: [],
|
||||||
|
} satisfies HomeMerchantGoodsBlock),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const normalizeAppGoodsDetailResponse = (
|
||||||
|
payload: AppGoodsDetailResponse
|
||||||
|
): AppGoodsDetailResponse => {
|
||||||
|
const merchant = normalizeMerchantEntity(payload.merchant ?? payload.store)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
data: normalizeGoodsDetailInfo(payload.data),
|
||||||
|
merchant: merchant ?? payload.store!,
|
||||||
|
store: merchant ?? payload.store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeAppTradePreviewResponse = (
|
||||||
|
payload: AppTradePreviewResponse
|
||||||
|
): AppTradePreviewResponse => {
|
||||||
|
const data = payload.data as TradePreviewInfo & {
|
||||||
|
uid?: number
|
||||||
|
memberId?: number
|
||||||
|
merchantGroups?: TradePreviewInfo['merchantGroups']
|
||||||
|
storeGroups?: TradePreviewInfo['merchantGroups']
|
||||||
|
}
|
||||||
|
const merchantGroups = data.merchantGroups ?? data.storeGroups ?? []
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
uid: data.uid ?? data.memberId ?? 0,
|
||||||
|
memberId: data.memberId ?? data.uid,
|
||||||
|
merchantGroups: merchantGroups.map(normalizeTradePreviewGroup),
|
||||||
|
storeGroups: merchantGroups.map(normalizeTradePreviewGroup),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/apis/mall-app/order.ts
Normal file
48
src/apis/mall-app/order.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
APP_API_PATHS,
|
||||||
|
normalizeAppOrderDetailResponse,
|
||||||
|
normalizeAppOrderPageResponse,
|
||||||
|
normalizeMallAppPayload,
|
||||||
|
postMallApp,
|
||||||
|
} from './shared'
|
||||||
|
import type {
|
||||||
|
AppOrderConfirmRequest,
|
||||||
|
AppOrderConfirmResponse,
|
||||||
|
AppOrderDetailRequest,
|
||||||
|
AppOrderDetailResponse,
|
||||||
|
AppOrderPageRequest,
|
||||||
|
AppOrderPageResponse,
|
||||||
|
} from '@/types/mall'
|
||||||
|
|
||||||
|
export const apiAppOrderPage = async (payload: AppOrderPageRequest, signal?: AbortSignal) => {
|
||||||
|
const response = await postMallApp<AppOrderPageResponse, AppOrderPageRequest>(
|
||||||
|
APP_API_PATHS.orderPage,
|
||||||
|
normalizeMallAppPayload(payload),
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: normalizeAppOrderPageResponse(response.data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiAppOrderDetail = async (payload: AppOrderDetailRequest, signal?: AbortSignal) => {
|
||||||
|
const response = await postMallApp<AppOrderDetailResponse, AppOrderDetailRequest>(
|
||||||
|
APP_API_PATHS.orderDetail,
|
||||||
|
normalizeMallAppPayload(payload),
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: normalizeAppOrderDetailResponse(response.data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiAppOrderConfirm = (payload: AppOrderConfirmRequest, signal?: AbortSignal) =>
|
||||||
|
postMallApp<AppOrderConfirmResponse, AppOrderConfirmRequest>(
|
||||||
|
APP_API_PATHS.orderConfirm,
|
||||||
|
normalizeMallAppPayload(payload),
|
||||||
|
signal
|
||||||
|
)
|
||||||
34
src/apis/mall-app/shared.ts
Normal file
34
src/apis/mall-app/shared.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import axiosInstance from '@/apis/base/axiosInstance'
|
||||||
|
import type { IBaseResponse } from '@/apis/base/types'
|
||||||
|
|
||||||
|
export const APP_API_PATHS = {
|
||||||
|
home: '/api/v1/mall/app/home',
|
||||||
|
recentNewGoods: '/api/v1/mall/app/recent_new_goods',
|
||||||
|
homeMerchantGoods: '/api/v1/mall/app/home_merchant_goods',
|
||||||
|
homeStoreGoods: '/api/v1/mall/app/home_merchant_goods',
|
||||||
|
goodsPage: '/api/v1/mall/app/goods_page',
|
||||||
|
goodsDetail: '/api/v1/mall/app/goods_detail',
|
||||||
|
cartList: '/api/v1/mall/app/cart_list',
|
||||||
|
cartSave: '/api/v1/mall/app/cart_save',
|
||||||
|
cartRemove: '/api/v1/mall/app/cart_remove',
|
||||||
|
tradePreview: '/api/v1/mall/app/trade_preview',
|
||||||
|
tradeCreate: '/api/v1/mall/app/trade_create',
|
||||||
|
orderPage: '/api/v1/mall/app/order_page',
|
||||||
|
orderDetail: '/api/v1/mall/app/order_detail',
|
||||||
|
orderConfirm: '/api/v1/mall/app/order_confirm',
|
||||||
|
afterSaleApply: '/api/v1/mall/app/after_sale_apply',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const postMallApp = async <TData, TPayload>(
|
||||||
|
path: string,
|
||||||
|
payload: TPayload,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<IBaseResponse<TData>> => {
|
||||||
|
const response = await axiosInstance.post<IBaseResponse<TData>>(path, payload, {
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from './normalize'
|
||||||
32
src/apis/mall-app/trade.ts
Normal file
32
src/apis/mall-app/trade.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
APP_API_PATHS,
|
||||||
|
normalizeAppTradePreviewResponse,
|
||||||
|
normalizeMallAppPayload,
|
||||||
|
postMallApp,
|
||||||
|
} from './shared'
|
||||||
|
import type {
|
||||||
|
AppTradeCreateRequest,
|
||||||
|
AppTradeCreateResponse,
|
||||||
|
AppTradePreviewRequest,
|
||||||
|
AppTradePreviewResponse,
|
||||||
|
} from '@/types/mall'
|
||||||
|
|
||||||
|
export const apiAppTradePreview = async (payload: AppTradePreviewRequest, signal?: AbortSignal) => {
|
||||||
|
const response = await postMallApp<AppTradePreviewResponse, AppTradePreviewRequest>(
|
||||||
|
APP_API_PATHS.tradePreview,
|
||||||
|
normalizeMallAppPayload(payload),
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: normalizeAppTradePreviewResponse(response.data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiAppTradeCreate = (payload: AppTradeCreateRequest, signal?: AbortSignal) =>
|
||||||
|
postMallApp<AppTradeCreateResponse, AppTradeCreateRequest>(
|
||||||
|
APP_API_PATHS.tradeCreate,
|
||||||
|
normalizeMallAppPayload(payload),
|
||||||
|
signal
|
||||||
|
)
|
||||||
34
src/components/MarketNav.tsx
Normal file
34
src/components/MarketNav.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export type MarketNavKey = 'home' | 'rank'
|
||||||
|
|
||||||
|
export const MARKET_NAV_ITEMS: Array<{ key: MarketNavKey; label: string }> = [
|
||||||
|
{ key: 'home', label: '首页' },
|
||||||
|
{ key: 'rank', label: '商家排行' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface MarketNavProps {
|
||||||
|
activeKey?: MarketNavKey
|
||||||
|
onSelect: (key: MarketNavKey) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarketNav({ activeKey, onSelect }: MarketNavProps) {
|
||||||
|
return (
|
||||||
|
<div className="market-nav">
|
||||||
|
<div className="shell market-nav__inner">
|
||||||
|
<div className="market-nav__links">
|
||||||
|
{MARKET_NAV_ITEMS.map(item => (
|
||||||
|
<button
|
||||||
|
className={activeKey === item.key ? 'is-active' : ''}
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(item.key)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MarketNav
|
||||||
173
src/components/MarketPageHeader.tsx
Normal file
173
src/components/MarketPageHeader.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import MarketNav, { type MarketNavKey } from '@/components/MarketNav'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
|
||||||
|
interface MarketPageHeaderProps {
|
||||||
|
activeNav?: MarketNavKey
|
||||||
|
hotKeywords?: string[]
|
||||||
|
keyword: string
|
||||||
|
onBrandClick?: () => void
|
||||||
|
onKeywordChange: (value: string) => void
|
||||||
|
onNavSelect?: (key: MarketNavKey) => void
|
||||||
|
onSearch?: (keyword: string) => void
|
||||||
|
searchInputId?: string
|
||||||
|
searchLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSearchPath = (keyword: string) => {
|
||||||
|
const trimmedKeyword = keyword.trim()
|
||||||
|
return trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new'
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarketPageHeader({
|
||||||
|
activeNav,
|
||||||
|
hotKeywords = [],
|
||||||
|
keyword,
|
||||||
|
onBrandClick,
|
||||||
|
onKeywordChange,
|
||||||
|
onNavSelect,
|
||||||
|
onSearch,
|
||||||
|
searchInputId = 'market-search-input',
|
||||||
|
searchLoading = false,
|
||||||
|
}: MarketPageHeaderProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const token = useUserStore(state => state.token)
|
||||||
|
const userInfo = useUserStore(state => state.userInfo)
|
||||||
|
const storedMemberId = useUserStore(state => state.memberId)
|
||||||
|
const logout = useUserStore(state => state.logout)
|
||||||
|
|
||||||
|
const userLabel = userInfo?.name?.trim() || userInfo?.account?.trim() || token?.account?.trim() || String(storedMemberId || '')
|
||||||
|
|
||||||
|
const handleSearch = (nextKeyword: string) => {
|
||||||
|
if (onSearch) {
|
||||||
|
onSearch(nextKeyword)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(getSearchPath(nextKeyword))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNavSelect = (key: MarketNavKey) => {
|
||||||
|
if (onNavSelect) {
|
||||||
|
onNavSelect(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(key === 'rank' ? '/?nav=rank' : '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="market-topbar">
|
||||||
|
<div className="shell market-topbar__inner">
|
||||||
|
<div className="market-topbar__left">
|
||||||
|
<span>{token ? `您好,${userLabel || '采购伙伴'}` : '您好!'}</span>
|
||||||
|
{token ? (
|
||||||
|
<button className="market-link-button" type="button" onClick={logout}>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link to="/login">请登录</Link>
|
||||||
|
<Link to="/register">注册</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header className="market-header">
|
||||||
|
<div className="shell market-header__inner">
|
||||||
|
<button className="market-brand" type="button" onClick={onBrandClick ?? (() => navigate('/'))}>
|
||||||
|
<span className="market-brand__mark">YT</span>
|
||||||
|
<span>
|
||||||
|
<strong>云拓选货</strong>
|
||||||
|
<small>Search-led sourcing market</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="market-search"
|
||||||
|
onSubmit={event => {
|
||||||
|
event.preventDefault()
|
||||||
|
handleSearch(keyword)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="market-search__bar">
|
||||||
|
<div className="market-search__core">
|
||||||
|
<div className="market-search__picker-trigger">
|
||||||
|
<span>商品</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="market-search__divider" />
|
||||||
|
|
||||||
|
<div className="market-search__field">
|
||||||
|
<input
|
||||||
|
id={searchInputId}
|
||||||
|
value={keyword}
|
||||||
|
onChange={event => onKeywordChange(event.target.value)}
|
||||||
|
placeholder="商品标题 / 关键词 / 货号"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label="以图搜款"
|
||||||
|
className="market-search__camera"
|
||||||
|
type="button"
|
||||||
|
onClick={() => toast.info('以图搜款接口文档暂未提供,当前先保留样式入口')}
|
||||||
|
>
|
||||||
|
<span className="market-search__icon market-search__icon--camera" />
|
||||||
|
</button>
|
||||||
|
<button className="market-search__submit" type="submit" disabled={searchLoading}>
|
||||||
|
<span className="market-search__icon market-search__icon--search" />
|
||||||
|
<span>{searchLoading ? '加载中' : '搜索'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hotKeywords.length > 0 ? (
|
||||||
|
<div className="market-search__hotwords">
|
||||||
|
{hotKeywords.map(word => (
|
||||||
|
<button
|
||||||
|
key={word}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onKeywordChange(word)
|
||||||
|
handleSearch(word)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="market-header__actions">
|
||||||
|
<button className="market-ghost-action" type="button">
|
||||||
|
语言: 简体中文
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="market-primary-action"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!token) {
|
||||||
|
navigate('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/member?tab=cart')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
购物车
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<MarketNav activeKey={activeNav} onSelect={handleNavSelect} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MarketPageHeader
|
||||||
79
src/config/source/config.ts
Normal file
79
src/config/source/config.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import packageJson from '../../../package.json'
|
||||||
|
|
||||||
|
export interface GlobalConfig {
|
||||||
|
VERSION: string
|
||||||
|
BASE_URL: string
|
||||||
|
RESOURCE_ASSETS_URL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_VERSION = packageJson.version ?? '0.1.0'
|
||||||
|
const DEFAULT_BASE_URL = 'https://test-gateway.yuntuo.hk'
|
||||||
|
|
||||||
|
export const getGlobalConfig = (): GlobalConfig => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {
|
||||||
|
VERSION: DEFAULT_VERSION,
|
||||||
|
BASE_URL: import.meta.env.VITE_API_BASE_URL || DEFAULT_BASE_URL,
|
||||||
|
RESOURCE_ASSETS_URL: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowConfig = (window as Window & { config?: GlobalConfig }).config
|
||||||
|
return (
|
||||||
|
windowConfig || {
|
||||||
|
VERSION: DEFAULT_VERSION,
|
||||||
|
BASE_URL: import.meta.env.VITE_API_BASE_URL || DEFAULT_BASE_URL,
|
||||||
|
RESOURCE_ASSETS_URL: '',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBaseUrl = (): string => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const config = (window as Window & { config?: GlobalConfig }).config
|
||||||
|
const fromConfig = config?.BASE_URL?.trim()
|
||||||
|
if (fromConfig) {
|
||||||
|
return fromConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryMeta = document.querySelector('meta[name="api-base-url"]') as
|
||||||
|
| HTMLMetaElement
|
||||||
|
| null
|
||||||
|
const altMeta = document.querySelector('meta[name="yt-api-base-url"]') as
|
||||||
|
| HTMLMetaElement
|
||||||
|
| null
|
||||||
|
|
||||||
|
if (primaryMeta?.content?.trim()) {
|
||||||
|
return primaryMeta.content.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (altMeta?.content?.trim()) {
|
||||||
|
return altMeta.content.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return import.meta.env.VITE_API_BASE_URL || DEFAULT_BASE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getVersion = (): string => DEFAULT_VERSION
|
||||||
|
|
||||||
|
export const getResourceAssetsUrl = (): string => {
|
||||||
|
const value = getGlobalConfig().RESOURCE_ASSETS_URL
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getResourceUrl = (path: string): string => {
|
||||||
|
const baseUrl = getResourceAssetsUrl()
|
||||||
|
if (!baseUrl) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
|
||||||
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path
|
||||||
|
|
||||||
|
if (cleanBaseUrl.includes('/dist/assets')) {
|
||||||
|
return `${cleanBaseUrl}/${cleanPath.replace(/^assets\//, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${cleanBaseUrl}/${cleanPath}`
|
||||||
|
}
|
||||||
47
src/constants/mall.ts
Normal file
47
src/constants/mall.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { AfterSaleStatus, AfterSaleType, GoodsSortType, OrderStatus, SaleStatus } from '@/types/mall'
|
||||||
|
|
||||||
|
export const GOODS_SORT_OPTIONS: Array<{ label: string; value: GoodsSortType }> = [
|
||||||
|
{ label: 'Comprehensive', value: 'comprehensive' },
|
||||||
|
{ label: 'Price low to high', value: 'price_asc' },
|
||||||
|
{ label: 'Price high to low', value: 'price_desc' },
|
||||||
|
{ label: 'Sales high to low', value: 'sales_desc' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SALE_STATUS_LABELS: Record<SaleStatus, string> = {
|
||||||
|
off: 'Off shelf',
|
||||||
|
on: 'On shelf',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
|
||||||
|
pending_pay: 'Pending payment',
|
||||||
|
paid_wait_ship: 'Pending shipment',
|
||||||
|
shipped: 'Shipped',
|
||||||
|
finished: 'Finished',
|
||||||
|
canceled: 'Canceled',
|
||||||
|
after_sale: 'After sale',
|
||||||
|
refund_finished: 'Refund finished',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AFTER_SALE_STATUS_LABELS: Record<AfterSaleStatus, string> = {
|
||||||
|
none: 'No after sale',
|
||||||
|
pending: 'Pending review',
|
||||||
|
approved: 'Approved',
|
||||||
|
rejected: 'Rejected',
|
||||||
|
refunding: 'Refunding',
|
||||||
|
finished: 'Finished',
|
||||||
|
closed: 'Closed',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AFTER_SALE_TYPE_LABELS: Record<AfterSaleType, string> = {
|
||||||
|
1: 'Refund only',
|
||||||
|
2: 'Return and refund',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_ORDER_PRIMARY_ACTIONS: Partial<Record<OrderStatus, string>> = {
|
||||||
|
pending_pay: 'Pending payment',
|
||||||
|
paid_wait_ship: 'Pending shipment',
|
||||||
|
shipped: 'Confirm receipt',
|
||||||
|
finished: 'Apply after sale',
|
||||||
|
after_sale: 'After sale in progress',
|
||||||
|
}
|
||||||
|
|
||||||
3614
src/index.css
Normal file
3614
src/index.css
Normal file
File diff suppressed because it is too large
Load Diff
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
262
src/mocks/mall.ts
Normal file
262
src/mocks/mall.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import type { AppGoodsPageResponse, AppHomeResponse, GoodsSummary, RecommendMerchantInfo } from '@/types/mall'
|
||||||
|
|
||||||
|
export interface MarketCategory {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
subtitle: string
|
||||||
|
accent: string
|
||||||
|
accentSoft: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformChannel {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
subtitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const createArtwork = (
|
||||||
|
title: string,
|
||||||
|
subtitle: string,
|
||||||
|
accent: string,
|
||||||
|
accentSoft: string,
|
||||||
|
shadow = 'rgba(15, 23, 42, 0.18)'
|
||||||
|
) =>
|
||||||
|
`data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 720">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${accent}" />
|
||||||
|
<stop offset="100%" stop-color="${accentSoft}" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="960" height="720" rx="42" fill="url(#g)" />
|
||||||
|
<circle cx="150" cy="140" r="86" fill="rgba(255,255,255,0.12)" />
|
||||||
|
<circle cx="830" cy="110" r="64" fill="rgba(255,255,255,0.16)" />
|
||||||
|
<circle cx="760" cy="560" r="130" fill="rgba(255,255,255,0.08)" />
|
||||||
|
<rect x="70" y="470" width="420" height="110" rx="28" fill="rgba(255,255,255,0.12)" />
|
||||||
|
<rect x="560" y="170" width="210" height="210" rx="34" fill="rgba(255,255,255,0.12)" />
|
||||||
|
<rect x="610" y="430" width="240" height="42" rx="20" fill="rgba(255,255,255,0.18)" />
|
||||||
|
<text x="72" y="174" font-size="62" font-weight="700" fill="#ffffff">${title}</text>
|
||||||
|
<text x="74" y="238" font-size="28" fill="rgba(255,255,255,0.86)">${subtitle}</text>
|
||||||
|
<text x="80" y="536" font-size="26" fill="#ffffff">YT Mall Showcase</text>
|
||||||
|
<text x="80" y="572" font-size="18" fill="rgba(255,255,255,0.74)">search-led sourcing · dropshipping · best-seller picks</text>
|
||||||
|
<rect x="560" y="168" width="210" height="210" rx="34" fill="none" stroke="rgba(255,255,255,0.28)" stroke-width="6" />
|
||||||
|
<rect x="0" y="0" width="960" height="720" rx="42" fill="none" stroke="${shadow}" stroke-opacity="0.22" stroke-width="4" />
|
||||||
|
</svg>
|
||||||
|
`)}`
|
||||||
|
|
||||||
|
export const MARKET_CATEGORIES: MarketCategory[] = [
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
label: '全部货源',
|
||||||
|
subtitle: '一站搜款与热门供给',
|
||||||
|
accent: '#e4393c',
|
||||||
|
accentSoft: '#ff8a65',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'digital',
|
||||||
|
label: '数码配件',
|
||||||
|
subtitle: '耳机、充电、支架',
|
||||||
|
accent: '#ef4444',
|
||||||
|
accentSoft: '#f59e0b',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wearables',
|
||||||
|
label: '智能穿戴',
|
||||||
|
subtitle: '手表、表带、贴膜',
|
||||||
|
accent: '#2563eb',
|
||||||
|
accentSoft: '#38bdf8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'live',
|
||||||
|
label: '直播器材',
|
||||||
|
subtitle: '补光灯、麦克风、支架',
|
||||||
|
accent: '#7c3aed',
|
||||||
|
accentSoft: '#c084fc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'home',
|
||||||
|
label: '小家电',
|
||||||
|
subtitle: '高转化日用电器',
|
||||||
|
accent: '#0f766e',
|
||||||
|
accentSoft: '#34d399',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fashion',
|
||||||
|
label: '潮流饰品',
|
||||||
|
subtitle: '包袋、首饰、文创',
|
||||||
|
accent: '#db2777',
|
||||||
|
accentSoft: '#f472b6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'office',
|
||||||
|
label: '电脑周边',
|
||||||
|
subtitle: '键鼠、扩展坞、线材',
|
||||||
|
accent: '#4f46e5',
|
||||||
|
accentSoft: '#8b5cf6',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PLATFORM_CHANNELS: PlatformChannel[] = [
|
||||||
|
{ key: 'taobao', label: '淘宝', subtitle: '传统电商铺货' },
|
||||||
|
{ key: 'douyin', label: '抖音', subtitle: '内容带货承接' },
|
||||||
|
{ key: 'pdd', label: '拼多多', subtitle: '性价比爆单' },
|
||||||
|
{ key: 'xiaohongshu', label: '小红书', subtitle: '种草与测品' },
|
||||||
|
{ key: 'kuaishou', label: '快手', subtitle: '直播出单' },
|
||||||
|
{ key: '1688', label: '1688', subtitle: '供应链补货' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SERVICE_POINTS = [
|
||||||
|
'精准搜款',
|
||||||
|
'一件代发',
|
||||||
|
'爆款推荐',
|
||||||
|
'商家排行',
|
||||||
|
'多平台上货',
|
||||||
|
'移动端协同',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const HOT_KEYWORDS = [
|
||||||
|
'磁吸手机壳',
|
||||||
|
'降噪蓝牙耳机',
|
||||||
|
'直播补光灯',
|
||||||
|
'智能手表表带',
|
||||||
|
'氮化镓快充',
|
||||||
|
'车载支架',
|
||||||
|
]
|
||||||
|
|
||||||
|
const productSeeds = [
|
||||||
|
['MagSafe 磁吸手机壳', '防摔透明边框,支持磁吸快充,适合直播起量'],
|
||||||
|
['55W 氮化镓快充套装', '双口快充,适合拼团和福利品渠道'],
|
||||||
|
['蓝牙主动降噪耳机', '通勤人群高复购款,支持多色混批'],
|
||||||
|
['直播桌面补光灯', '可折叠支架,适合短视频拍摄'],
|
||||||
|
['智能手表尼龙表带', '轻量快干,多尺寸兼容'],
|
||||||
|
['平板磁吸键盘壳', '办公学习双场景,高客单价'],
|
||||||
|
['无线领夹麦克风', '直播收音稳定,单反手机通用'],
|
||||||
|
['桌面多口扩展坞', '接口齐全,适合办公设备渠道'],
|
||||||
|
['车载磁吸导航支架', '车品长期热卖,支持门店陈列'],
|
||||||
|
['桌面香薰小风扇', '礼品属性强,适合夏季上新'],
|
||||||
|
['电竞机械键盘', 'RGB 灯效,适合年轻用户'],
|
||||||
|
['创意数显充电线', '视觉卖点强,适合短视频素材'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const storeSeeds = [
|
||||||
|
['星驰数码商家', '手机配件热销商家'],
|
||||||
|
['新锐直播器材', '补光灯与麦克风主推'],
|
||||||
|
['轻潮智造馆', '智能穿戴与家居小电'],
|
||||||
|
['云仓选品中心', '多平台一件代发'],
|
||||||
|
['闪电快充工坊', '充电品类深度供给'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const buildGoods = (seed: string[], index: number): GoodsSummary => {
|
||||||
|
const category = MARKET_CATEGORIES[(index % (MARKET_CATEGORIES.length - 1)) + 1]
|
||||||
|
const price = 29 + index * 7
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 10001 + index,
|
||||||
|
merchantId: String(5001 + (index % 4)),
|
||||||
|
storeId: 6001 + (index % 5),
|
||||||
|
categoryId: index + 1,
|
||||||
|
categoryName: category.label,
|
||||||
|
spuNo: `YTSPU${String(index + 1).padStart(4, '0')}`,
|
||||||
|
spuName: seed[0],
|
||||||
|
subTitle: seed[1],
|
||||||
|
coverUrl: createArtwork(seed[0], category.label, category.accent, category.accentSoft),
|
||||||
|
status: 'enabled',
|
||||||
|
auditStatus: 'approved',
|
||||||
|
saleStatus: 'on',
|
||||||
|
sort: 100 - index,
|
||||||
|
salesVolume: 220 + index * 37,
|
||||||
|
virtualSales: 860 + index * 64,
|
||||||
|
viewCount: 1200 + index * 80,
|
||||||
|
minPrice: `${price}.00`,
|
||||||
|
maxPrice: `${price + 12}.00`,
|
||||||
|
totalStock: 320 + index * 18,
|
||||||
|
lockedStock: 16 + index,
|
||||||
|
createdAt: 1710000000 + index * 1000,
|
||||||
|
updatedAt: 1710003600 + index * 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildStore = (seed: string[], index: number): RecommendMerchantInfo => {
|
||||||
|
const category = MARKET_CATEGORIES[(index % (MARKET_CATEGORIES.length - 1)) + 1]
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 9001 + index,
|
||||||
|
merchantId: String(5001 + index),
|
||||||
|
storeName: seed[0],
|
||||||
|
storeLogo: createArtwork(seed[0], seed[1], category.accent, category.accentSoft),
|
||||||
|
storeNotice: seed[1],
|
||||||
|
storeStatus: 'enabled',
|
||||||
|
mainCategoryName: category.label,
|
||||||
|
recommendReason: '首页推荐商家',
|
||||||
|
rankScore: 95 - index * 1.2,
|
||||||
|
goodsCount: 88 - index * 9,
|
||||||
|
recentNewGoodsCount: 12 - index,
|
||||||
|
orderCount: 1260 - index * 138,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goodsList = productSeeds.map(buildGoods)
|
||||||
|
const storeList = storeSeeds.map(buildStore)
|
||||||
|
|
||||||
|
export const FALLBACK_GOODS_PAGE: AppGoodsPageResponse = {
|
||||||
|
total: goodsList.length,
|
||||||
|
list: goodsList,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FALLBACK_HOME: AppHomeResponse = {
|
||||||
|
categoryNavs: MARKET_CATEGORIES.map((item, index) => ({
|
||||||
|
categoryId: index + 1,
|
||||||
|
categoryName: item.label,
|
||||||
|
goodsCount: Math.max(0, goodsList.length - index),
|
||||||
|
sort: index,
|
||||||
|
})),
|
||||||
|
banners: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '精准搜款 · 一件代发',
|
||||||
|
subTitle: '多平台供给与商家资源聚合',
|
||||||
|
description: '首页主视觉广告栏',
|
||||||
|
buttonText: '立即搜款',
|
||||||
|
positionCode: 'home_top',
|
||||||
|
imageUrl: createArtwork('精准搜款', '多平台供给与商家资源聚合', '#e4393c', '#fb7185'),
|
||||||
|
linkType: 'goods',
|
||||||
|
linkValue: '10001',
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
startTime: 1710000000,
|
||||||
|
endTime: 1730000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '热卖数码专场',
|
||||||
|
subTitle: '快充、支架、耳机同步起量',
|
||||||
|
description: '首页活动广告栏',
|
||||||
|
buttonText: '查看专场',
|
||||||
|
positionCode: 'home_top',
|
||||||
|
imageUrl: createArtwork('热卖数码专场', '快充、支架、耳机同步起量', '#2563eb', '#22d3ee'),
|
||||||
|
linkType: 'goods',
|
||||||
|
linkValue: '10002',
|
||||||
|
sort: 2,
|
||||||
|
status: 'enabled',
|
||||||
|
startTime: 1710000000,
|
||||||
|
endTime: 1730000000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
searchKeywords: HOT_KEYWORDS,
|
||||||
|
channels: PLATFORM_CHANNELS.map((item, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
title: item.label,
|
||||||
|
subTitle: item.subtitle,
|
||||||
|
iconUrl: '',
|
||||||
|
linkType: 'topic',
|
||||||
|
linkValue: item.key,
|
||||||
|
sort: index,
|
||||||
|
status: 'enabled',
|
||||||
|
})),
|
||||||
|
recommendGoods: goodsList.slice(0, 6),
|
||||||
|
recommendMerchants: storeList,
|
||||||
|
featuredMerchants: storeList.slice(0, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createFallbackArtwork = createArtwork
|
||||||
585
src/pages/goods/GoodsDetailPage.tsx
Normal file
585
src/pages/goods/GoodsDetailPage.tsx
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { apiAppGoodsDetail } from '@/apis'
|
||||||
|
import MarketPageHeader from '@/components/MarketPageHeader'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
import type {
|
||||||
|
AppGoodsDetailResponse,
|
||||||
|
GoodsSkuInfo,
|
||||||
|
MerchantInfo,
|
||||||
|
ParamItem,
|
||||||
|
ResolvedGoodsDetailInfo,
|
||||||
|
ResolvedGoodsSkuInfo,
|
||||||
|
} from '@/types/mall'
|
||||||
|
import { resolveGoodsDetailInfo } from '@/utils/mall/transform'
|
||||||
|
|
||||||
|
const detailDateFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (value: number) => {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return '日期待定'
|
||||||
|
}
|
||||||
|
|
||||||
|
return detailDateFormatter.format(new Date(value * 1000)).replace(/\//g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMoney = (value?: string | number | null) => {
|
||||||
|
const text = value == null ? '' : String(value)
|
||||||
|
return text ? `¥${text.replace(/\.00$/, '')}` : '¥--'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSkuImage = (sku: GoodsSkuInfo) => sku.imageUrl || ''
|
||||||
|
|
||||||
|
type SkuDisplayOption = {
|
||||||
|
color: string
|
||||||
|
size: string
|
||||||
|
image: string
|
||||||
|
sku: ResolvedGoodsSkuInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetailTabKey = 'detail' | 'dropship' | 'whiteImages'
|
||||||
|
|
||||||
|
const DETAIL_TABS: Array<{ key: DetailTabKey; label: string }> = [
|
||||||
|
{ key: 'detail', label: '商品详情' },
|
||||||
|
{ key: 'dropship', label: '代发说明' },
|
||||||
|
{ key: 'whiteImages', label: '水洗图/白底图' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const COLOR_NAME_PATTERNS = ['颜色', '色号', '色系', 'color']
|
||||||
|
const SIZE_NAME_PATTERNS = ['尺码', '尺寸', '型号', '机型', '规格', '容量', '版本', 'size', 'model']
|
||||||
|
const SKU_NAME_SEPARATORS = /[_\-//|,,;;]+/
|
||||||
|
const COLOR_TEXT_PATTERN = /色|白|黑|红|粉|蓝|绿|黄|橙|紫|灰|金|银|棕|咖|奶|米|香|透明|原色/i
|
||||||
|
const IMAGE_URL_PATTERN = /^(https?:)?\/\/|^data:image\//i
|
||||||
|
const WHITE_IMAGE_KEYWORDS = ['white', 'wash', 'clean', 'plain', 'background', '白底', '水洗']
|
||||||
|
|
||||||
|
const getSpecValue = (sku: ResolvedGoodsSkuInfo, patterns: string[]) => {
|
||||||
|
const matched = sku.specs.find(spec => {
|
||||||
|
const name = typeof spec.name === 'string' ? spec.name : ''
|
||||||
|
|
||||||
|
return patterns.some(pattern => name.toLowerCase().includes(pattern.toLowerCase()))
|
||||||
|
})
|
||||||
|
|
||||||
|
return typeof matched?.value === 'string' ? matched.value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitSkuName = (skuName: string) =>
|
||||||
|
skuName
|
||||||
|
.split(SKU_NAME_SEPARATORS)
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const resolveSkuColorSize = (sku: ResolvedGoodsSkuInfo) => {
|
||||||
|
const specColor = getSpecValue(sku, COLOR_NAME_PATTERNS)
|
||||||
|
const specSize = getSpecValue(sku, SIZE_NAME_PATTERNS)
|
||||||
|
const parts = splitSkuName(sku.skuName)
|
||||||
|
const colorIndex = parts.findIndex(part => COLOR_TEXT_PATTERN.test(part))
|
||||||
|
const fallbackColor = colorIndex >= 0 ? parts[colorIndex] : parts[1] || parts[0] || '默认'
|
||||||
|
const fallbackSize =
|
||||||
|
parts.find((_, index) => index !== colorIndex) || sku.skuName || sku.skuNo || '默认尺寸'
|
||||||
|
|
||||||
|
return {
|
||||||
|
color: specColor || fallbackColor,
|
||||||
|
size: specSize || fallbackSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSkuOptions = (skus: ResolvedGoodsSkuInfo[]): SkuDisplayOption[] =>
|
||||||
|
skus.map(sku => {
|
||||||
|
const { color, size } = resolveSkuColorSize(sku)
|
||||||
|
|
||||||
|
return {
|
||||||
|
color,
|
||||||
|
size,
|
||||||
|
image: getSkuImage(sku),
|
||||||
|
sku,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isImageUrl = (value: string) => IMAGE_URL_PATTERN.test(value.trim())
|
||||||
|
|
||||||
|
const getUniqueTexts = (values: string[]) =>
|
||||||
|
Array.from(new Set(values.map(value => value.trim()).filter(Boolean)))
|
||||||
|
|
||||||
|
const collectImages = (value: unknown): string[] => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const text = value.trim()
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((text.startsWith('[') && text.endsWith(']')) || (text.startsWith('{') && text.endsWith('}'))) {
|
||||||
|
try {
|
||||||
|
return collectImages(JSON.parse(text) as unknown)
|
||||||
|
} catch {
|
||||||
|
return isImageUrl(text) ? [text] : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
.split(/[,,\n\r]+/)
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(isImageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.flatMap(collectImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.values(value as Record<string, unknown>).flatMap(collectImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDetailImageBlocks = (detail: ResolvedGoodsDetailInfo) =>
|
||||||
|
getUniqueTexts(
|
||||||
|
detail.detail.blocks
|
||||||
|
.filter(block => block.type.toLowerCase().includes('image') || isImageUrl(block.value))
|
||||||
|
.flatMap(block => collectImages(block.value))
|
||||||
|
)
|
||||||
|
|
||||||
|
const getDetailTextBlocks = (detail: ResolvedGoodsDetailInfo) =>
|
||||||
|
detail.detail.blocks
|
||||||
|
.filter(block => !block.type.toLowerCase().includes('image') && !isImageUrl(block.value))
|
||||||
|
.map(block => block.value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const getWhiteImageList = (detail: ResolvedGoodsDetailInfo) => {
|
||||||
|
const matchedImages = Object.entries(detail.extraInfo)
|
||||||
|
.filter(([key]) => {
|
||||||
|
const normalizedKey = key.toLowerCase()
|
||||||
|
return WHITE_IMAGE_KEYWORDS.some(keyword => normalizedKey.includes(keyword))
|
||||||
|
})
|
||||||
|
.flatMap(([, value]) => collectImages(value))
|
||||||
|
|
||||||
|
return getUniqueTexts(matchedImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProductParams = (detail: ResolvedGoodsDetailInfo): ParamItem[] => {
|
||||||
|
const params = detail.params.filter(item => item.name?.trim() && item.value?.trim())
|
||||||
|
|
||||||
|
if (params.length > 0) {
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: '商品分类', value: detail.categoryName || `类目 ${detail.categoryId}` },
|
||||||
|
{ name: '商品货号', value: detail.spuNo },
|
||||||
|
{ name: '库存', value: String(detail.totalStock) },
|
||||||
|
].filter(item => item.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GoodsDetailPage() {
|
||||||
|
const { spuId = '' } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const token = useUserStore(state => state.token)
|
||||||
|
const userInfo = useUserStore(state => state.userInfo)
|
||||||
|
const storedMemberId = useUserStore(state => state.memberId)
|
||||||
|
|
||||||
|
const currentUid = userInfo?.uid ?? storedMemberId ?? token?.memberId ?? token?.uid ?? 0
|
||||||
|
|
||||||
|
const [payload, setPayload] = useState<AppGoodsDetailResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [activeImage, setActiveImage] = useState('')
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [selectedColor, setSelectedColor] = useState('')
|
||||||
|
const [activeDetailTab, setActiveDetailTab] = useState<DetailTabKey>('detail')
|
||||||
|
const [quantities, setQuantities] = useState<Record<number, number>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!spuId) {
|
||||||
|
setError('商品参数缺失')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
apiAppGoodsDetail({ uid: currentUid, spuId }, controller.signal)
|
||||||
|
.then(response => {
|
||||||
|
setPayload(response.data)
|
||||||
|
})
|
||||||
|
.catch(nextError => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : '商品详情接口请求失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [currentUid, spuId])
|
||||||
|
|
||||||
|
const detail = useMemo(() => (payload?.data ? resolveGoodsDetailInfo(payload.data) : null), [payload])
|
||||||
|
const merchant = payload?.merchant ?? payload?.store ?? null
|
||||||
|
const merchantName = getMerchantName(merchant)
|
||||||
|
|
||||||
|
const galleryImages = useMemo(() => {
|
||||||
|
if (!detail) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Set([
|
||||||
|
detail.coverUrl,
|
||||||
|
...detail.media.images,
|
||||||
|
...detail.skus.map(getSkuImage),
|
||||||
|
].filter(Boolean))
|
||||||
|
)
|
||||||
|
}, [detail])
|
||||||
|
|
||||||
|
const skuOptions = useMemo(() => resolveSkuOptions(detail?.skus ?? []), [detail])
|
||||||
|
const colorOptions = useMemo(() => {
|
||||||
|
const options = new Map<string, SkuDisplayOption>()
|
||||||
|
|
||||||
|
skuOptions.forEach(option => {
|
||||||
|
if (!options.has(option.color)) {
|
||||||
|
options.set(option.color, option)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(options.values())
|
||||||
|
}, [skuOptions])
|
||||||
|
const selectedColorOptions = useMemo(
|
||||||
|
() => skuOptions.filter(option => option.color === selectedColor),
|
||||||
|
[selectedColor, skuOptions]
|
||||||
|
)
|
||||||
|
const productParams = useMemo(() => (detail ? getProductParams(detail) : []), [detail])
|
||||||
|
const detailImages = useMemo(() => {
|
||||||
|
if (!detail) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDetailImageBlocks(detail)
|
||||||
|
}, [detail])
|
||||||
|
const detailTextBlocks = useMemo(() => (detail ? getDetailTextBlocks(detail) : []), [detail])
|
||||||
|
const fallbackDetailImages = useMemo(() => {
|
||||||
|
if (detailImages.length > 0) {
|
||||||
|
return detailImages
|
||||||
|
}
|
||||||
|
|
||||||
|
return galleryImages
|
||||||
|
}, [detailImages, galleryImages])
|
||||||
|
const whiteImages = useMemo(() => (detail ? getWhiteImageList(detail) : []), [detail])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveImage(galleryImages[0] ?? '')
|
||||||
|
}, [galleryImages])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedColor(colorOptions[0]?.color ?? '')
|
||||||
|
}, [colorOptions])
|
||||||
|
|
||||||
|
const updateQuantity = (skuId: number, delta: number) => {
|
||||||
|
setQuantities(current => {
|
||||||
|
const nextValue = Math.max(0, (current[skuId] ?? 0) + delta)
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[skuId]: nextValue,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHeaderSearch = (nextKeyword: string) => {
|
||||||
|
const trimmedKeyword = nextKeyword.trim()
|
||||||
|
navigate(trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="market-app">
|
||||||
|
<MarketPageHeader
|
||||||
|
keyword={keyword}
|
||||||
|
searchInputId="market-detail-search-input"
|
||||||
|
onKeywordChange={setKeyword}
|
||||||
|
onSearch={handleHeaderSearch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="shell market-main market-detail-main">
|
||||||
|
{loading ? <div className="market-empty-state">商品详情加载中...</div> : null}
|
||||||
|
{!loading && error ? (
|
||||||
|
<div className="market-empty-state">
|
||||||
|
<strong>商品详情加载失败</strong>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!loading && detail ? (
|
||||||
|
<>
|
||||||
|
<section className="market-detail-layout">
|
||||||
|
<aside className="market-detail-gallery">
|
||||||
|
<div className="market-detail-gallery__stage">
|
||||||
|
{activeImage ? (
|
||||||
|
<img alt={detail.spuName} src={activeImage} />
|
||||||
|
) : (
|
||||||
|
<div className="market-media-placeholder market-media-placeholder--product">暂无主图</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{galleryImages.length > 1 ? (
|
||||||
|
<div className="market-detail-thumbs">
|
||||||
|
{galleryImages.slice(0, 8).map(image => (
|
||||||
|
<button
|
||||||
|
className={image === activeImage ? 'is-active' : ''}
|
||||||
|
key={image}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveImage(image)}
|
||||||
|
>
|
||||||
|
<img alt="" src={image} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="market-detail-gallery__stats">
|
||||||
|
<span>浏览量:{detail.viewCount + detail.virtualSales}</span>
|
||||||
|
<span>库存量:{detail.totalStock}</span>
|
||||||
|
<span>SKU:{detail.skus.length}</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="market-detail-info">
|
||||||
|
<div className="market-detail-title">
|
||||||
|
<h1>{detail.spuName}</h1>
|
||||||
|
{detail.subTitle && detail.subTitle !== '暂无副标题' ? <p>{detail.subTitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="market-detail-meta">
|
||||||
|
<article>
|
||||||
|
<span>商品ID</span>
|
||||||
|
<strong>{detail.id}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>货号</span>
|
||||||
|
<strong>{detail.spuNo}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>上架时间</span>
|
||||||
|
<strong>{formatDate(detail.createdAt)}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>更新时间</span>
|
||||||
|
<strong>{formatDate(detail.updatedAt)}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="market-detail-line">
|
||||||
|
<span>分类:</span>
|
||||||
|
<strong>{detail.categoryName || `类目 ${detail.categoryId}`}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detail.services.length > 0 ? (
|
||||||
|
<div className="market-detail-services">
|
||||||
|
<span>服务:</span>
|
||||||
|
{detail.services.map(service => (
|
||||||
|
<strong key={`${service.title}-${service.desc}`}>{service.title}</strong>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="market-detail-price">
|
||||||
|
<span>价格:</span>
|
||||||
|
<strong>{formatMoney(detail.minPrice)}</strong>
|
||||||
|
{detail.minPrice !== detail.maxPrice ? <em>{formatMoney(detail.maxPrice)}</em> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{colorOptions.length > 0 ? (
|
||||||
|
<div className="market-detail-spec-group">
|
||||||
|
<span>颜色:</span>
|
||||||
|
<div className="market-detail-color-grid">
|
||||||
|
{colorOptions.map(option => (
|
||||||
|
<button
|
||||||
|
className={option.color === selectedColor ? 'is-active' : ''}
|
||||||
|
key={option.color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedColor(option.color)
|
||||||
|
if (option.image) {
|
||||||
|
setActiveImage(option.image)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.image ? <img alt={option.color} src={option.image} /> : null}
|
||||||
|
<strong>{option.color}</strong>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedColorOptions.length > 0 ? (
|
||||||
|
<div className="market-detail-spec-group">
|
||||||
|
<span>尺码:</span>
|
||||||
|
<div className="market-detail-sku-list">
|
||||||
|
{selectedColorOptions.map(option => (
|
||||||
|
<article className="market-detail-sku" key={option.sku.id}>
|
||||||
|
<span>{option.size}</span>
|
||||||
|
<strong>{formatMoney(option.sku.salePrice || detail.minPrice)}</strong>
|
||||||
|
<small>库存 {option.sku.stock}</small>
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={() => updateQuantity(option.sku.id, -1)}>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<b>{quantities[option.sku.id] ?? 0}</b>
|
||||||
|
<button type="button" onClick={() => updateQuantity(option.sku.id, 1)}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="market-empty-state">暂无 SKU 信息</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
className="market-detail-merchant"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
if (merchant?.merchantId) {
|
||||||
|
navigate(`/shop/${merchant.merchantId}`)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if ((event.key === 'Enter' || event.key === ' ') && merchant?.merchantId) {
|
||||||
|
navigate(`/shop/${merchant.merchantId}`)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="market-detail-merchant__logo">
|
||||||
|
{merchant?.storeLogo || merchant?.logo ? (
|
||||||
|
<img alt={merchantName} src={merchant.storeLogo || merchant.logo} />
|
||||||
|
) : (
|
||||||
|
<span>{merchantName.slice(0, 1)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2>{merchantName}</h2>
|
||||||
|
<div className="market-detail-merchant__metrics">
|
||||||
|
<article>
|
||||||
|
<strong>{detail.skus.length}</strong>
|
||||||
|
<span>商品规格</span>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<strong>{detail.salesVolume + detail.virtualSales}</strong>
|
||||||
|
<span>销量</span>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<strong>{detail.viewCount}</strong>
|
||||||
|
<span>浏览量</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p>主营:{detail.categoryName || '选品供给'}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={event => {
|
||||||
|
event.stopPropagation()
|
||||||
|
navigate('/')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
返回商品列表
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="market-detail-content-panel">
|
||||||
|
<div className="market-detail-content-tabs">
|
||||||
|
{DETAIL_TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
className={activeDetailTab === tab.key ? 'is-active' : ''}
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveDetailTab(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeDetailTab === 'detail' ? (
|
||||||
|
<div className="market-detail-content-body">
|
||||||
|
{productParams.length > 0 ? (
|
||||||
|
<div className="market-detail-param-row">
|
||||||
|
{productParams.map(item => (
|
||||||
|
<article key={`${item.name}-${item.value}`}>
|
||||||
|
<strong>{item.name}:</strong>
|
||||||
|
<span>{item.value}</span>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{detailTextBlocks.length > 0 ? (
|
||||||
|
<div className="market-detail-text-blocks">
|
||||||
|
{detailTextBlocks.map(text => (
|
||||||
|
<p key={text}>{text}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fallbackDetailImages.length > 0 ? (
|
||||||
|
<div className="market-detail-long-images">
|
||||||
|
{fallbackDetailImages.map(image => (
|
||||||
|
<img alt={`${detail.spuName} 商品详情`} key={image} src={image} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="market-empty-state">暂无商品详情图</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeDetailTab === 'dropship' ? (
|
||||||
|
<div className="market-detail-content-body">
|
||||||
|
{detail.services.length > 0 ? (
|
||||||
|
<div className="market-detail-dropship-list">
|
||||||
|
{detail.services.map(service => (
|
||||||
|
<article key={`${service.title}-${service.desc}`}>
|
||||||
|
<strong>{service.title}</strong>
|
||||||
|
{service.desc ? <span>{service.desc}</span> : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="market-empty-state">暂无代发说明</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeDetailTab === 'whiteImages' ? (
|
||||||
|
<div className="market-detail-content-body">
|
||||||
|
{whiteImages.length > 0 ? (
|
||||||
|
<div className="market-detail-long-images">
|
||||||
|
{whiteImages.map(image => (
|
||||||
|
<img alt={`${detail.spuName} 水洗图/白底图`} key={image} src={image} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="market-empty-state">暂无水洗图/白底图</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMerchantName = (merchant: MerchantInfo | null) => {
|
||||||
|
const name = merchant?.storeName?.trim() || merchant?.merchantName?.trim()
|
||||||
|
return name || '未命名商家'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GoodsDetailPage
|
||||||
1041
src/pages/home/HomePage.tsx
Normal file
1041
src/pages/home/HomePage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
412
src/pages/login/LoginPage.tsx
Normal file
412
src/pages/login/LoginPage.tsx
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { apiGetSmsCode, apiUserLogin, apiUserRegister } from '@/apis'
|
||||||
|
import MarketPageHeader from '@/components/MarketPageHeader'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
|
||||||
|
type LoginMode = 'password' | 'sms'
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const token = useUserStore(state => state.token)
|
||||||
|
const setSession = useUserStore(state => state.setSession)
|
||||||
|
const refreshUser = useUserStore(state => state.refreshUser)
|
||||||
|
const isRegisterView = location.pathname === '/register'
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<LoginMode>('password')
|
||||||
|
const [account, setAccount] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [captcha, setCaptcha] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [inviteCode, setInviteCode] = useState('')
|
||||||
|
const [headerKeyword, setHeaderKeyword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [smsSending, setSmsSending] = useState(false)
|
||||||
|
const [smsLeft, setSmsLeft] = useState(0)
|
||||||
|
const timerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token?.accessToken) {
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
}
|
||||||
|
}, [navigate, token])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
window.clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRegisterView) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(location.search)
|
||||||
|
const invite = searchParams.get('inviteCode') || searchParams.get('invite') || ''
|
||||||
|
if (invite) {
|
||||||
|
setInviteCode(invite)
|
||||||
|
}
|
||||||
|
}, [isRegisterView, location.search])
|
||||||
|
|
||||||
|
const clearCountdown = () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
window.clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
setSmsLeft(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearCountdown()
|
||||||
|
setCaptcha('')
|
||||||
|
setPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
|
||||||
|
if (!isRegisterView) {
|
||||||
|
setInviteCode('')
|
||||||
|
}
|
||||||
|
}, [isRegisterView])
|
||||||
|
|
||||||
|
const startCountdown = (seconds: number) => {
|
||||||
|
clearCountdown()
|
||||||
|
setSmsLeft(seconds)
|
||||||
|
|
||||||
|
timerRef.current = window.setInterval(() => {
|
||||||
|
setSmsLeft(value => {
|
||||||
|
if (value <= 1) {
|
||||||
|
if (timerRef.current) {
|
||||||
|
window.clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value - 1
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendSms = async () => {
|
||||||
|
if (!/^1\d{10}$/.test(account.trim())) {
|
||||||
|
toast.error('请输入有效的手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSmsSending(true)
|
||||||
|
await apiGetSmsCode({
|
||||||
|
account: account.trim(),
|
||||||
|
loginType: '0',
|
||||||
|
type: isRegisterView ? '0' : '1',
|
||||||
|
})
|
||||||
|
toast.success('验证码已发送')
|
||||||
|
startCountdown(60)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
toast.error(error instanceof Error ? error.message : '发送验证码失败')
|
||||||
|
} finally {
|
||||||
|
setSmsSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (!account.trim()) {
|
||||||
|
toast.error('请输入手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^1\d{10}$/.test(account.trim())) {
|
||||||
|
toast.error('请输入有效的手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRegisterView && mode === 'password' && password.trim().length < 6) {
|
||||||
|
toast.error('请输入至少 6 位密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRegisterView && mode === 'sms' && !/^\d{4}$/.test(captcha.trim())) {
|
||||||
|
toast.error('请输入 4 位短信验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRegisterView && !/^\d{4}$/.test(captcha.trim())) {
|
||||||
|
toast.error('请输入 4 位短信验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRegisterView && password.trim().length < 6) {
|
||||||
|
toast.error('请输入至少 6 位密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRegisterView && confirmPassword.trim().length < 6) {
|
||||||
|
toast.error('请再次输入至少 6 位密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRegisterView && password.trim() !== confirmPassword.trim()) {
|
||||||
|
toast.error('两次输入的密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = isRegisterView
|
||||||
|
? await apiUserRegister({
|
||||||
|
account: account.trim(),
|
||||||
|
password: password.trim(),
|
||||||
|
captcha: captcha.trim(),
|
||||||
|
loginType: '0',
|
||||||
|
type: '0',
|
||||||
|
invite: inviteCode.trim() || undefined,
|
||||||
|
})
|
||||||
|
: await apiUserLogin(
|
||||||
|
mode === 'password'
|
||||||
|
? {
|
||||||
|
account: account.trim(),
|
||||||
|
password: password.trim(),
|
||||||
|
loginType: '0',
|
||||||
|
type: '0',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
account: account.trim(),
|
||||||
|
captcha: captcha.trim(),
|
||||||
|
loginType: '0',
|
||||||
|
type: '1',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
setSession({
|
||||||
|
token: response.data,
|
||||||
|
memberId: response.data.memberId ?? response.data.uid ?? null,
|
||||||
|
})
|
||||||
|
await refreshUser()
|
||||||
|
toast.success(isRegisterView ? '注册成功' : '登录成功')
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
toast.error(error instanceof Error ? error.message : isRegisterView ? '注册失败' : '登录失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHeaderSearch = (nextKeyword: string) => {
|
||||||
|
const trimmedKeyword = nextKeyword.trim()
|
||||||
|
navigate(trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="market-app">
|
||||||
|
<MarketPageHeader
|
||||||
|
keyword={headerKeyword}
|
||||||
|
onKeywordChange={setHeaderKeyword}
|
||||||
|
onSearch={handleHeaderSearch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="login-page login-page--with-market-header">
|
||||||
|
<div className="shell login-layout">
|
||||||
|
<section className="login-showcase">
|
||||||
|
<Link className="login-back" to="/">
|
||||||
|
返回商城首页
|
||||||
|
</Link>
|
||||||
|
<p className="market-kicker">{isRegisterView ? '用户注册' : '用户登录'}</p>
|
||||||
|
<h1>{isRegisterView ? '把 ERP 注册接口接进商城前台。' : '把 ERP 登录接口接进商城前台。'}</h1>
|
||||||
|
<p>
|
||||||
|
登录、注册、验证码和用户详情统一复用 `YT-ERP` 的 ERP 用户接口定义,商城前台只保留自己的页面视觉和路由。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="login-feature-list">
|
||||||
|
<article>
|
||||||
|
<strong>登录接口</strong>
|
||||||
|
<span>`/api/v1/user/login`</span>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<strong>注册接口</strong>
|
||||||
|
<span>`/api/v1/user/register`</span>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<strong>短信与用户详情</strong>
|
||||||
|
<span>`/api/v1/user/captcha` · `/api/v1/user/detail`</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="login-card">
|
||||||
|
<div className="login-auth-tabs">
|
||||||
|
<Link className={!isRegisterView ? 'is-active' : ''} to="/login">
|
||||||
|
登录
|
||||||
|
</Link>
|
||||||
|
<Link className={isRegisterView ? 'is-active' : ''} to="/register">
|
||||||
|
注册
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="login-card__head">
|
||||||
|
<p className="market-kicker">{isRegisterView ? '创建账户' : '欢迎回来'}</p>
|
||||||
|
<h2>{isRegisterView ? '注册 ERP 采购账户' : '登录 ERP 采购账户'}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isRegisterView ? (
|
||||||
|
<div className="login-mode-tabs">
|
||||||
|
<button
|
||||||
|
className={mode === 'password' ? 'is-active' : ''}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
clearCountdown()
|
||||||
|
setCaptcha('')
|
||||||
|
setMode('password')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
密码登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={mode === 'sms' ? 'is-active' : ''}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
clearCountdown()
|
||||||
|
setCaptcha('')
|
||||||
|
setMode('sms')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
短信登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="login-form__hint">注册使用 ERP 短信验证码接口,邀请码为选填项。</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="login-form" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
<span>手机号</span>
|
||||||
|
<input
|
||||||
|
autoComplete={isRegisterView ? 'tel' : 'username'}
|
||||||
|
maxLength={11}
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
value={account}
|
||||||
|
onChange={event => setAccount(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!isRegisterView && mode === 'password' ? (
|
||||||
|
<label>
|
||||||
|
<span>密码</span>
|
||||||
|
<input
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={event => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isRegisterView && mode === 'sms' ? (
|
||||||
|
<label>
|
||||||
|
<span>短信验证码</span>
|
||||||
|
<div className="login-inline-field">
|
||||||
|
<input
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={4}
|
||||||
|
placeholder="请输入 4 位验证码"
|
||||||
|
value={captcha}
|
||||||
|
onChange={event => setCaptcha(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="market-ghost-action"
|
||||||
|
disabled={smsLeft > 0 || smsSending}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSendSms()}
|
||||||
|
>
|
||||||
|
{smsLeft > 0 ? `${smsLeft}s` : smsSending ? '发送中' : '获取验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isRegisterView ? (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span>短信验证码</span>
|
||||||
|
<div className="login-inline-field">
|
||||||
|
<input
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={4}
|
||||||
|
placeholder="请输入 4 位验证码"
|
||||||
|
value={captcha}
|
||||||
|
onChange={event => setCaptcha(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="market-ghost-action"
|
||||||
|
disabled={smsLeft > 0 || smsSending}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSendSms()}
|
||||||
|
>
|
||||||
|
{smsLeft > 0 ? `${smsLeft}s` : smsSending ? '发送中' : '获取验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>登录密码</span>
|
||||||
|
<input
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="请输入至少 6 位密码"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={event => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>确认密码</span>
|
||||||
|
<input
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={event => setConfirmPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>邀请码</span>
|
||||||
|
<input
|
||||||
|
placeholder="邀请码(选填)"
|
||||||
|
value={inviteCode}
|
||||||
|
onChange={event => setInviteCode(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button className="market-primary-action login-submit" disabled={loading} type="submit">
|
||||||
|
{loading ? (isRegisterView ? '注册中...' : '登录中...') : isRegisterView ? '注册' : '登录'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="login-card__footer">
|
||||||
|
{isRegisterView ? (
|
||||||
|
<>
|
||||||
|
已有账号?<Link to="/login">去登录</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
还没有账号?<Link to="/register">立即注册</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage
|
||||||
455
src/pages/member/MemberCenterPage.tsx
Normal file
455
src/pages/member/MemberCenterPage.tsx
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { apiAppCartList, apiAppCartRemove, apiAppCartSave, apiAppOrderPage } from '@/apis'
|
||||||
|
import MarketPageHeader from '@/components/MarketPageHeader'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
import type { AppCartListResponse, AppOrderPageResponse, CartItemInfo, OrderSummary } from '@/types/mall'
|
||||||
|
|
||||||
|
type MemberTabKey = 'cart' | 'orders' | 'after-sales'
|
||||||
|
|
||||||
|
const MEMBER_TABS: Array<{ key: MemberTabKey; label: string }> = [
|
||||||
|
{ key: 'cart', label: '购物车' },
|
||||||
|
{ key: 'orders', label: '订单页面' },
|
||||||
|
{ key: 'after-sales', label: '售后管理' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ORDER_STATUS_TEXT: Record<string, string> = {
|
||||||
|
pending_pay: '待付款',
|
||||||
|
paid_wait_ship: '待发货',
|
||||||
|
shipped: '待收货',
|
||||||
|
finished: '已完成',
|
||||||
|
canceled: '已取消',
|
||||||
|
after_sale: '售后中',
|
||||||
|
refund_finished: '退款完成',
|
||||||
|
}
|
||||||
|
|
||||||
|
const AFTER_SALE_STATUS_TEXT: Record<string, string> = {
|
||||||
|
none: '无售后',
|
||||||
|
pending: '待处理',
|
||||||
|
approved: '已同意',
|
||||||
|
rejected: '已拒绝',
|
||||||
|
refunding: '退款中',
|
||||||
|
finished: '已完成',
|
||||||
|
closed: '已关闭',
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberDateFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
const getCurrentTab = (value: string | null): MemberTabKey =>
|
||||||
|
MEMBER_TABS.some(item => item.key === value) ? (value as MemberTabKey) : 'cart'
|
||||||
|
|
||||||
|
const formatDate = (value: number) => {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberDateFormatter.format(new Date(value * 1000)).replace(/\//g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMoney = (value?: string | number | null) => {
|
||||||
|
const text = value == null ? '' : String(value)
|
||||||
|
return text ? `¥${text.replace(/\.00$/, '')}` : '¥0'
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseSkuSpecs = (value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
.map(item => {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = item as { name?: unknown; value?: unknown }
|
||||||
|
const name = typeof spec.name === 'string' ? spec.name : ''
|
||||||
|
const specValue = typeof spec.value === 'string' ? spec.value : ''
|
||||||
|
|
||||||
|
return [name, specValue].filter(Boolean).join(': ')
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' / ')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
return Object.entries(parsed as Record<string, unknown>)
|
||||||
|
.map(([key, specValue]) => `${key}: ${String(specValue)}`)
|
||||||
|
.join(' / ')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCartItemAmount = (item: CartItemInfo) => {
|
||||||
|
const price = Number(item.salePrice)
|
||||||
|
const count = Number(item.buyNum)
|
||||||
|
|
||||||
|
if (!Number.isFinite(price) || !Number.isFinite(count)) {
|
||||||
|
return '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (price * count).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOrderStatusText = (order: OrderSummary) => ORDER_STATUS_TEXT[order.orderStatus] ?? order.orderStatus
|
||||||
|
|
||||||
|
const getAfterSaleStatusText = (order: OrderSummary) =>
|
||||||
|
AFTER_SALE_STATUS_TEXT[order.afterSaleStatus] ?? order.afterSaleStatus
|
||||||
|
|
||||||
|
function MemberCenterPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const token = useUserStore(state => state.token)
|
||||||
|
const userInfo = useUserStore(state => state.userInfo)
|
||||||
|
const storedMemberId = useUserStore(state => state.memberId)
|
||||||
|
const currentUid = userInfo?.uid ?? storedMemberId ?? token?.memberId ?? token?.uid ?? 0
|
||||||
|
const activeTab = getCurrentTab(searchParams.get('tab'))
|
||||||
|
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [cartPayload, setCartPayload] = useState<AppCartListResponse | null>(null)
|
||||||
|
const [orderPayload, setOrderPayload] = useState<AppOrderPageResponse | null>(null)
|
||||||
|
const [cartLoading, setCartLoading] = useState(false)
|
||||||
|
const [ordersLoading, setOrdersLoading] = useState(false)
|
||||||
|
const [cartError, setCartError] = useState<string | null>(null)
|
||||||
|
const [ordersError, setOrdersError] = useState<string | null>(null)
|
||||||
|
const [cartRefreshKey, setCartRefreshKey] = useState(0)
|
||||||
|
|
||||||
|
const isLoggedIn = Boolean(token)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
setCartPayload(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
setCartLoading(true)
|
||||||
|
setCartError(null)
|
||||||
|
|
||||||
|
apiAppCartList({ uid: currentUid }, controller.signal)
|
||||||
|
.then(response => {
|
||||||
|
setCartPayload(response.data)
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setCartPayload(null)
|
||||||
|
setCartError(error instanceof Error ? error.message : '购物车加载失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setCartLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [cartRefreshKey, currentUid, isLoggedIn])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
setOrderPayload(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
setOrdersLoading(true)
|
||||||
|
setOrdersError(null)
|
||||||
|
|
||||||
|
apiAppOrderPage({ uid: currentUid, page: 1, size: 50 }, controller.signal)
|
||||||
|
.then(response => {
|
||||||
|
setOrderPayload(response.data)
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setOrderPayload(null)
|
||||||
|
setOrdersError(error instanceof Error ? error.message : '订单加载失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setOrdersLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [currentUid, isLoggedIn])
|
||||||
|
|
||||||
|
const cartItems = cartPayload?.list ?? []
|
||||||
|
const orders = orderPayload?.list ?? []
|
||||||
|
const afterSaleOrders = useMemo(
|
||||||
|
() => orders.filter(order => order.afterSaleStatus !== 'none' || order.orderStatus === 'after_sale'),
|
||||||
|
[orders]
|
||||||
|
)
|
||||||
|
const checkedCartAmount =
|
||||||
|
cartPayload?.checkedAmount ??
|
||||||
|
cartItems
|
||||||
|
.reduce((total, item) => {
|
||||||
|
if (!item.checked) {
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
return total + Number(getCartItemAmount(item))
|
||||||
|
}, 0)
|
||||||
|
.toFixed(2)
|
||||||
|
|
||||||
|
const handleTabChange = (key: MemberTabKey) => {
|
||||||
|
setSearchParams({ tab: key })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCartQuantityChange = async (item: CartItemInfo, buyNum: number) => {
|
||||||
|
if (buyNum < 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiAppCartSave({
|
||||||
|
uid: currentUid,
|
||||||
|
skuId: item.skuId,
|
||||||
|
buyNum,
|
||||||
|
checked: item.checked,
|
||||||
|
})
|
||||||
|
setCartRefreshKey(value => value + 1)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : '购物车更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCartCheckedChange = async (item: CartItemInfo, checked: boolean) => {
|
||||||
|
try {
|
||||||
|
await apiAppCartSave({
|
||||||
|
uid: currentUid,
|
||||||
|
skuId: item.skuId,
|
||||||
|
buyNum: item.buyNum,
|
||||||
|
checked,
|
||||||
|
})
|
||||||
|
setCartRefreshKey(value => value + 1)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : '购物车更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCartRemove = async (item: CartItemInfo) => {
|
||||||
|
try {
|
||||||
|
await apiAppCartRemove({ uid: currentUid, skuIds: [item.skuId] })
|
||||||
|
setCartRefreshKey(value => value + 1)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : '商品移除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = (nextKeyword: string) => {
|
||||||
|
const trimmedKeyword = nextKeyword.trim()
|
||||||
|
navigate(trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="market-app">
|
||||||
|
<MarketPageHeader keyword={keyword} onKeywordChange={setKeyword} onSearch={handleSearch} />
|
||||||
|
|
||||||
|
<main className="shell market-main">
|
||||||
|
<section className="market-member-layout">
|
||||||
|
<aside className="market-member-sidebar">
|
||||||
|
<strong>个人中心</strong>
|
||||||
|
<div className="market-member-tabs">
|
||||||
|
{MEMBER_TABS.map(item => (
|
||||||
|
<button
|
||||||
|
className={activeTab === item.key ? 'is-active' : ''}
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTabChange(item.key)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="market-member-panel">
|
||||||
|
{!isLoggedIn ? (
|
||||||
|
<div className="market-empty-state">
|
||||||
|
<strong>请先登录</strong>
|
||||||
|
<Link className="market-member-login-link" to="/login">
|
||||||
|
去登录
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoggedIn && activeTab === 'cart' ? (
|
||||||
|
<>
|
||||||
|
<div className="market-member-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="market-kicker">购物车</p>
|
||||||
|
<h1>我的购物车</h1>
|
||||||
|
</div>
|
||||||
|
<div className="market-member-summary">
|
||||||
|
<span>已选 {cartPayload?.checkedCount ?? 0} 件</span>
|
||||||
|
<strong>{formatMoney(checkedCartAmount)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cartLoading ? <div className="market-empty-state">购物车加载中...</div> : null}
|
||||||
|
{!cartLoading && cartError ? <div className="market-empty-state">{cartError}</div> : null}
|
||||||
|
{!cartLoading && !cartError && cartItems.length === 0 ? (
|
||||||
|
<div className="market-empty-state">购物车暂无商品</div>
|
||||||
|
) : null}
|
||||||
|
{!cartLoading && !cartError && cartItems.length > 0 ? (
|
||||||
|
<div className="market-cart-list">
|
||||||
|
{cartItems.map(item => {
|
||||||
|
const specText = parseSkuSpecs(item.skuSpecJson)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="market-cart-item" key={item.id}>
|
||||||
|
<input
|
||||||
|
aria-label={`选择 ${item.spuName}`}
|
||||||
|
checked={item.checked}
|
||||||
|
type="checkbox"
|
||||||
|
onChange={event => handleCartCheckedChange(item, event.target.checked)}
|
||||||
|
/>
|
||||||
|
<Link className="market-cart-item__media" to={`/goods/${item.spuId}`}>
|
||||||
|
{item.coverUrl ? (
|
||||||
|
<img alt={item.spuName} src={item.coverUrl} />
|
||||||
|
) : (
|
||||||
|
<span className="market-media-placeholder market-media-placeholder--product">暂无主图</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<div className="market-cart-item__body">
|
||||||
|
<Link to={`/goods/${item.spuId}`}>{item.spuName}</Link>
|
||||||
|
{item.skuName ? <span>{item.skuName}</span> : null}
|
||||||
|
{specText ? <small>{specText}</small> : null}
|
||||||
|
</div>
|
||||||
|
<strong>{formatMoney(item.salePrice)}</strong>
|
||||||
|
<div className="market-cart-item__quantity">
|
||||||
|
<button type="button" onClick={() => handleCartQuantityChange(item, item.buyNum - 1)}>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<span>{item.buyNum}</span>
|
||||||
|
<button type="button" onClick={() => handleCartQuantityChange(item, item.buyNum + 1)}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<strong>{formatMoney(getCartItemAmount(item))}</strong>
|
||||||
|
<button type="button" onClick={() => handleCartRemove(item)}>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoggedIn && activeTab === 'orders' ? (
|
||||||
|
<>
|
||||||
|
<div className="market-member-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="market-kicker">订单</p>
|
||||||
|
<h1>订单页面</h1>
|
||||||
|
</div>
|
||||||
|
<span className="market-section__meta">共 {orderPayload?.total ?? orders.length} 个订单</span>
|
||||||
|
</div>
|
||||||
|
<OrderList emptyText="暂无订单" error={ordersError} loading={ordersLoading} orders={orders} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoggedIn && activeTab === 'after-sales' ? (
|
||||||
|
<>
|
||||||
|
<div className="market-member-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="market-kicker">售后</p>
|
||||||
|
<h1>售后管理</h1>
|
||||||
|
</div>
|
||||||
|
<span className="market-section__meta">共 {afterSaleOrders.length} 个售后订单</span>
|
||||||
|
</div>
|
||||||
|
<OrderList
|
||||||
|
emptyText="暂无售后订单"
|
||||||
|
error={ordersError}
|
||||||
|
loading={ordersLoading}
|
||||||
|
orders={afterSaleOrders}
|
||||||
|
showAfterSale
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrderList({
|
||||||
|
emptyText,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
orders,
|
||||||
|
showAfterSale = false,
|
||||||
|
}: {
|
||||||
|
emptyText: string
|
||||||
|
error: string | null
|
||||||
|
loading: boolean
|
||||||
|
orders: OrderSummary[]
|
||||||
|
showAfterSale?: boolean
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="market-empty-state">订单加载中...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="market-empty-state">{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orders.length === 0) {
|
||||||
|
return <div className="market-empty-state">{emptyText}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="market-order-list">
|
||||||
|
{orders.map(order => (
|
||||||
|
<article className="market-order-card" key={order.id}>
|
||||||
|
<div className="market-order-card__head">
|
||||||
|
<div>
|
||||||
|
<strong>{order.orderNo}</strong>
|
||||||
|
<span>{order.storeName}</span>
|
||||||
|
</div>
|
||||||
|
<span className="market-order-status">
|
||||||
|
{showAfterSale ? getAfterSaleStatusText(order) : getOrderStatusText(order)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="market-order-card__body">
|
||||||
|
<article>
|
||||||
|
<span>商品金额</span>
|
||||||
|
<strong>{formatMoney(order.goodsAmount)}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>运费</span>
|
||||||
|
<strong>{formatMoney(order.freightAmount)}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>实付</span>
|
||||||
|
<strong>{formatMoney(order.payAmount)}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>下单时间</span>
|
||||||
|
<strong>{formatDate(order.createdAt)}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MemberCenterPage
|
||||||
246
src/pages/shop/ShopPage.tsx
Normal file
246
src/pages/shop/ShopPage.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { apiAppGoodsPage, apiAppHomeMerchantGoods } from '@/apis'
|
||||||
|
import MarketPageHeader from '@/components/MarketPageHeader'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
import type { AppGoodsPageResponse, HomeMerchantGoodsBlock, GoodsSortType, GoodsSummary } from '@/types/mall'
|
||||||
|
|
||||||
|
const GOODS_SORT_OPTIONS: Array<{ key: GoodsSortType; label: string }> = [
|
||||||
|
{ key: 'comprehensive', label: '综合' },
|
||||||
|
{ key: 'sales_desc', label: '上新' },
|
||||||
|
{ key: 'price_asc', label: '价格升序' },
|
||||||
|
{ key: 'price_desc', label: '价格降序' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const shopDateFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (value: number) => {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return '日期待定'
|
||||||
|
}
|
||||||
|
|
||||||
|
return shopDateFormatter.format(new Date(value * 1000)).replace(/\//g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPrice = (item: GoodsSummary) => {
|
||||||
|
const min = item.minPrice?.replace(/\.00$/, '') || '--'
|
||||||
|
const max = item.maxPrice?.replace(/\.00$/, '') || min
|
||||||
|
|
||||||
|
return min === max ? `¥${min}` : `¥${min}-${max}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShopPage() {
|
||||||
|
const { merchantId = '' } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const token = useUserStore(state => state.token)
|
||||||
|
const userInfo = useUserStore(state => state.userInfo)
|
||||||
|
const storedMemberId = useUserStore(state => state.memberId)
|
||||||
|
|
||||||
|
const currentUid = userInfo?.uid ?? storedMemberId ?? token?.memberId ?? token?.uid ?? 0
|
||||||
|
|
||||||
|
const [headerKeyword, setHeaderKeyword] = useState('')
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
|
const [sortType, setSortType] = useState<GoodsSortType>('comprehensive')
|
||||||
|
const [merchantBlock, setMerchantBlock] = useState<HomeMerchantGoodsBlock | null>(null)
|
||||||
|
const [goodsPayload, setGoodsPayload] = useState<AppGoodsPageResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!merchantId) {
|
||||||
|
setError('商家参数缺失')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
apiAppHomeMerchantGoods({ uid: currentUid, merchantId, limit: 12 }, controller.signal)
|
||||||
|
.then(response => {
|
||||||
|
setMerchantBlock(response.data.data)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [currentUid, merchantId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!merchantId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
apiAppGoodsPage(
|
||||||
|
{
|
||||||
|
uid: currentUid,
|
||||||
|
merchantId,
|
||||||
|
keyword: searchKeyword || undefined,
|
||||||
|
sortType,
|
||||||
|
page: 1,
|
||||||
|
size: 24,
|
||||||
|
},
|
||||||
|
controller.signal
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
setGoodsPayload(response.data)
|
||||||
|
})
|
||||||
|
.catch(nextError => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : '商家商品接口请求失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [currentUid, merchantId, searchKeyword, sortType])
|
||||||
|
|
||||||
|
const goodsList = useMemo(() => goodsPayload?.list ?? merchantBlock?.goods ?? [], [goodsPayload, merchantBlock])
|
||||||
|
const merchant = merchantBlock?.merchant ?? merchantBlock?.store ?? null
|
||||||
|
const merchantName = merchant?.storeName?.trim() || goodsList[0]?.merchantName?.trim() || '未命名商家'
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setSearchKeyword(keyword.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHeaderSearch = (nextKeyword: string) => {
|
||||||
|
const trimmedKeyword = nextKeyword.trim()
|
||||||
|
navigate(trimmedKeyword ? `/list/new?key=${encodeURIComponent(trimmedKeyword)}` : '/list/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="market-app">
|
||||||
|
<MarketPageHeader
|
||||||
|
keyword={headerKeyword}
|
||||||
|
onKeywordChange={setHeaderKeyword}
|
||||||
|
onSearch={handleHeaderSearch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="shell market-main">
|
||||||
|
<section className="market-shop-layout">
|
||||||
|
<aside className="market-shop-sidebar">
|
||||||
|
<div className="market-shop-logo">
|
||||||
|
{merchant?.storeLogo ? <img alt={merchantName} src={merchant.storeLogo} /> : <span>{merchantName.slice(0, 1)}</span>}
|
||||||
|
</div>
|
||||||
|
<h1>{merchantName}</h1>
|
||||||
|
<div className="market-shop-metrics">
|
||||||
|
<article>
|
||||||
|
<strong>{merchant?.rankScore || '-'}</strong>
|
||||||
|
<span>档口排名</span>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<strong>{goodsPayload?.total ?? merchant?.goodsCount ?? goodsList.length}</strong>
|
||||||
|
<span>商品数</span>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<strong>{merchant?.recentNewGoodsCount ?? '-'}</strong>
|
||||||
|
<span>近期上新</span>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<strong>{merchant?.orderCount ?? '-'}</strong>
|
||||||
|
<span>出单数</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p>主营:{merchant?.mainCategoryName || '选品供给'}</p>
|
||||||
|
{merchant?.storeNotice ? <p>简介:{merchant.storeNotice}</p> : null}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="market-shop-content">
|
||||||
|
<div className="market-shop-filter">
|
||||||
|
<strong>商品属性:</strong>
|
||||||
|
<span>图片:全部</span>
|
||||||
|
<span>状态:上架</span>
|
||||||
|
<span>视频:全部</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="market-shop-search"
|
||||||
|
onSubmit={event => {
|
||||||
|
event.preventDefault()
|
||||||
|
handleSearch()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
关键词:
|
||||||
|
<input value={keyword} onChange={event => setKeyword(event.target.value)} placeholder="商品关键词" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">搜索</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setKeyword('')
|
||||||
|
setSearchKeyword('')
|
||||||
|
setSortType('comprehensive')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="market-shop-sort">
|
||||||
|
<strong>排序:</strong>
|
||||||
|
{GOODS_SORT_OPTIONS.map(item => (
|
||||||
|
<button
|
||||||
|
className={sortType === item.key ? 'is-active' : ''}
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSortType(item.key)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span>共 {goodsPayload?.total ?? goodsList.length} 件商品</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="market-empty-state">{error}</div> : null}
|
||||||
|
{!error && goodsList.length > 0 ? (
|
||||||
|
<div className="market-shop-product-grid">
|
||||||
|
{goodsList.map(item => (
|
||||||
|
<Link
|
||||||
|
aria-label={`查看商品详情:${item.spuName}`}
|
||||||
|
className="market-product-card"
|
||||||
|
key={item.id}
|
||||||
|
title={item.spuName}
|
||||||
|
to={`/goods/${item.id}`}
|
||||||
|
>
|
||||||
|
<div className="market-product-card__media">
|
||||||
|
{item.coverUrl ? (
|
||||||
|
<img alt={item.spuName} src={item.coverUrl} />
|
||||||
|
) : (
|
||||||
|
<div className="market-media-placeholder market-media-placeholder--product">暂无主图</div>
|
||||||
|
)}
|
||||||
|
{item.categoryName ? <span>{item.categoryName}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="market-product-card__center">
|
||||||
|
<strong>{item.spuName}</strong>
|
||||||
|
<span>{formatPrice(item)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="market-product-card__bottom">
|
||||||
|
<span>{item.merchantName || merchantName}</span>
|
||||||
|
<time>{formatDate(item.updatedAt)}</time>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!error && !loading && goodsList.length === 0 ? <div className="market-empty-state">暂无商品</div> : null}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShopPage
|
||||||
105
src/store/user.ts
Normal file
105
src/store/user.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||||
|
import { apiGetUserInfo } from '@/apis/apiUser'
|
||||||
|
import type { ITokenData } from '@/types/ITokenData'
|
||||||
|
import type { IUserInfo } from '@/types/IUserInfo'
|
||||||
|
import logger from '@/utils/logger'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'mall-user-store'
|
||||||
|
|
||||||
|
interface UserState {
|
||||||
|
token: ITokenData | null
|
||||||
|
userInfo: IUserInfo | null
|
||||||
|
memberId: number | null
|
||||||
|
refreshUsering: boolean
|
||||||
|
setToken: (token: ITokenData | null) => void
|
||||||
|
setUserInfo: (userInfo: IUserInfo | null) => void
|
||||||
|
setMemberId: (memberId: number | null) => void
|
||||||
|
setSession: (payload: {
|
||||||
|
token: ITokenData | null
|
||||||
|
memberId?: number | null
|
||||||
|
userInfo?: IUserInfo | null
|
||||||
|
}) => void
|
||||||
|
refreshUser: () => Promise<void>
|
||||||
|
refreshAll: () => Promise<void>
|
||||||
|
logout: () => void
|
||||||
|
clearStorage: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserStore = create<UserState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
token: null,
|
||||||
|
userInfo: null,
|
||||||
|
memberId: null,
|
||||||
|
refreshUsering: false,
|
||||||
|
setToken: token => {
|
||||||
|
set({
|
||||||
|
token,
|
||||||
|
memberId: token?.memberId ?? token?.uid ?? get().memberId,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
setUserInfo: userInfo => {
|
||||||
|
set({
|
||||||
|
userInfo,
|
||||||
|
memberId: userInfo?.uid ?? get().memberId,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
setMemberId: memberId => {
|
||||||
|
set({ memberId })
|
||||||
|
},
|
||||||
|
setSession: ({ token, memberId, userInfo }) => {
|
||||||
|
set({
|
||||||
|
token,
|
||||||
|
userInfo: userInfo ?? get().userInfo,
|
||||||
|
memberId: memberId ?? userInfo?.uid ?? token?.memberId ?? token?.uid ?? null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
refreshUser: async () => {
|
||||||
|
try {
|
||||||
|
if (!get().token?.accessToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set({ refreshUsering: true })
|
||||||
|
const response = await apiGetUserInfo()
|
||||||
|
set({
|
||||||
|
userInfo: response.data,
|
||||||
|
memberId: response.data.uid,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Refresh user failed', error)
|
||||||
|
} finally {
|
||||||
|
set({ refreshUsering: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refreshAll: async () => {
|
||||||
|
await get().refreshUser()
|
||||||
|
},
|
||||||
|
logout: () => {
|
||||||
|
get().clearStorage()
|
||||||
|
},
|
||||||
|
clearStorage: () => {
|
||||||
|
set({
|
||||||
|
token: null,
|
||||||
|
userInfo: null,
|
||||||
|
memberId: null,
|
||||||
|
})
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: STORAGE_KEY,
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
partialize: state => ({
|
||||||
|
token: state.token,
|
||||||
|
userInfo: state.userInfo,
|
||||||
|
memberId: state.memberId,
|
||||||
|
}),
|
||||||
|
onRehydrateStorage: () => state => {
|
||||||
|
if (state?.token?.accessToken) {
|
||||||
|
void state.refreshAll()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
11
src/types/ITokenData.ts
Normal file
11
src/types/ITokenData.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export interface ITokenData {
|
||||||
|
accessToken: string
|
||||||
|
accessExpire: number
|
||||||
|
refreshAfter: number
|
||||||
|
role?: string[]
|
||||||
|
utype?: string
|
||||||
|
uid?: number
|
||||||
|
account?: string
|
||||||
|
refreshToken?: string
|
||||||
|
memberId?: number
|
||||||
|
}
|
||||||
6
src/types/IUserInfo.ts
Normal file
6
src/types/IUserInfo.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface IUserInfo {
|
||||||
|
uid: number
|
||||||
|
name: string
|
||||||
|
account: string
|
||||||
|
}
|
||||||
|
|
||||||
3
src/types/index.ts
Normal file
3
src/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './ITokenData'
|
||||||
|
export * from './IUserInfo'
|
||||||
|
export * from './mall'
|
||||||
659
src/types/mall.ts
Normal file
659
src/types/mall.ts
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
import type { IPageParams } from '@/apis/base/types'
|
||||||
|
|
||||||
|
export type EnabledStatus = 'enabled' | 'disabled'
|
||||||
|
export type AuditStatus = 'pending' | 'approved' | 'rejected'
|
||||||
|
export type SettleStatus = 'pending' | 'active' | 'frozen'
|
||||||
|
export type SaleStatus = 'off' | 'on'
|
||||||
|
export type PayStatus = 'pending' | 'paid' | 'refunded'
|
||||||
|
export type TradeStatus = 'pending' | 'paid' | 'closed' | 'finished' | 'refunding' | 'refunded'
|
||||||
|
export type OrderStatus =
|
||||||
|
| 'pending_pay'
|
||||||
|
| 'paid_wait_ship'
|
||||||
|
| 'shipped'
|
||||||
|
| 'finished'
|
||||||
|
| 'canceled'
|
||||||
|
| 'after_sale'
|
||||||
|
| 'refund_finished'
|
||||||
|
export type AfterSaleStatus =
|
||||||
|
| 'none'
|
||||||
|
| 'pending'
|
||||||
|
| 'approved'
|
||||||
|
| 'rejected'
|
||||||
|
| 'refunding'
|
||||||
|
| 'finished'
|
||||||
|
| 'closed'
|
||||||
|
export type CartItemStatus = 'enabled' | 'disabled'
|
||||||
|
export type GoodsSortType = 'comprehensive' | 'price_asc' | 'price_desc' | 'sales_desc'
|
||||||
|
export type AfterSaleType = 1 | 2
|
||||||
|
export type MerchantId = string
|
||||||
|
export type HomePositionCode =
|
||||||
|
| 'home_top'
|
||||||
|
| 'home_rank_store'
|
||||||
|
| 'home_featured_store'
|
||||||
|
| 'home_featured_goods'
|
||||||
|
export type BannerLinkType = 'goods' | 'store' | 'topic' | 'h5' | (string & {})
|
||||||
|
|
||||||
|
export interface MediaJson {
|
||||||
|
images: string[]
|
||||||
|
videos: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailBlock {
|
||||||
|
type: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailJson {
|
||||||
|
blocks: DetailBlock[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecItem {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParamItem {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServicePromiseItem {
|
||||||
|
title: string
|
||||||
|
desc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceJson {
|
||||||
|
images: string[]
|
||||||
|
videos: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtraInfoJson {
|
||||||
|
source?: string
|
||||||
|
tags?: string[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreInfo {
|
||||||
|
id: number
|
||||||
|
merchantId: MerchantId
|
||||||
|
merchantName?: string
|
||||||
|
logo?: string
|
||||||
|
storeNo: string
|
||||||
|
storeName: string
|
||||||
|
storeLogo: string
|
||||||
|
storeBanner: string
|
||||||
|
storeNotice: string
|
||||||
|
storeStatus: EnabledStatus
|
||||||
|
auditStatus: AuditStatus
|
||||||
|
servicePhone: string
|
||||||
|
serviceEmail: string
|
||||||
|
contactName: string
|
||||||
|
contactMobile: string
|
||||||
|
province: string
|
||||||
|
city: string
|
||||||
|
district: string
|
||||||
|
addressDetail: string
|
||||||
|
zipCode: string
|
||||||
|
sort: number
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MerchantInfo = StoreInfo
|
||||||
|
|
||||||
|
export interface GoodsSkuInfo {
|
||||||
|
id: number
|
||||||
|
spuId: number
|
||||||
|
merchantId: MerchantId
|
||||||
|
storeId: number
|
||||||
|
skuNo: string
|
||||||
|
skuName: string
|
||||||
|
barcode: string
|
||||||
|
imageUrl: string
|
||||||
|
specJson: string
|
||||||
|
salePrice: string
|
||||||
|
originPrice: string
|
||||||
|
costPrice: string
|
||||||
|
weight: string
|
||||||
|
status: EnabledStatus
|
||||||
|
stock: number
|
||||||
|
lockedStock: number
|
||||||
|
warnStock: number
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoodsSummary {
|
||||||
|
id: number
|
||||||
|
merchantId: MerchantId
|
||||||
|
merchantName?: string
|
||||||
|
merchantLogo?: string
|
||||||
|
storeId: number
|
||||||
|
categoryId: number
|
||||||
|
categoryName: string
|
||||||
|
spuNo: string
|
||||||
|
spuName: string
|
||||||
|
subTitle: string
|
||||||
|
coverUrl: string
|
||||||
|
status: EnabledStatus
|
||||||
|
auditStatus: AuditStatus
|
||||||
|
saleStatus: SaleStatus
|
||||||
|
sort: number
|
||||||
|
salesVolume: number
|
||||||
|
virtualSales: number
|
||||||
|
viewCount: number
|
||||||
|
minPrice: string
|
||||||
|
maxPrice: string
|
||||||
|
totalStock: number
|
||||||
|
lockedStock: number
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoodsDetailInfo {
|
||||||
|
id: number
|
||||||
|
merchantId: MerchantId
|
||||||
|
storeId: number
|
||||||
|
categoryId: number
|
||||||
|
categoryName?: string
|
||||||
|
spuNo: string
|
||||||
|
spuName: string
|
||||||
|
subTitle: string
|
||||||
|
coverUrl: string
|
||||||
|
mediaJson: string
|
||||||
|
detailJson: string
|
||||||
|
extraInfoJson: string
|
||||||
|
status: EnabledStatus
|
||||||
|
auditStatus: AuditStatus
|
||||||
|
saleStatus: SaleStatus
|
||||||
|
sort: number
|
||||||
|
salesVolume: number
|
||||||
|
virtualSales: number
|
||||||
|
viewCount: number
|
||||||
|
minPrice: string
|
||||||
|
maxPrice: string
|
||||||
|
totalStock: number
|
||||||
|
lockedStock: number
|
||||||
|
paramJson: string
|
||||||
|
serviceJson: string
|
||||||
|
skus: GoodsSkuInfo[]
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartItemInfo {
|
||||||
|
id: number
|
||||||
|
uid: number
|
||||||
|
memberId?: number
|
||||||
|
merchantId: MerchantId
|
||||||
|
storeId: number
|
||||||
|
spuId: number
|
||||||
|
skuId: number
|
||||||
|
spuName: string
|
||||||
|
skuName: string
|
||||||
|
coverUrl: string
|
||||||
|
skuSpecJson: string
|
||||||
|
buyNum: number
|
||||||
|
checked: boolean
|
||||||
|
salePrice: string
|
||||||
|
status: CartItemStatus
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradeBuyItem {
|
||||||
|
skuId: number
|
||||||
|
buyNum: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradePreviewItemInfo {
|
||||||
|
spuId: number
|
||||||
|
skuId: number
|
||||||
|
spuName: string
|
||||||
|
skuName: string
|
||||||
|
coverUrl: string
|
||||||
|
skuSpecJson: string
|
||||||
|
buyNum: number
|
||||||
|
salePrice: string
|
||||||
|
itemAmount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradePreviewStoreInfo {
|
||||||
|
merchantId: MerchantId
|
||||||
|
storeId: number
|
||||||
|
storeName: string
|
||||||
|
goodsAmount: string
|
||||||
|
freightAmount: string
|
||||||
|
discountAmount: string
|
||||||
|
payAmount: string
|
||||||
|
items: TradePreviewItemInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradePreviewInfo {
|
||||||
|
uid: number
|
||||||
|
memberId?: number
|
||||||
|
addressId: number
|
||||||
|
receiverName: string
|
||||||
|
receiverMobile: string
|
||||||
|
receiverCountryCode: string
|
||||||
|
receiverProvince: string
|
||||||
|
receiverCity: string
|
||||||
|
receiverDistrict: string
|
||||||
|
receiverAddress: string
|
||||||
|
receiverZipCode: string
|
||||||
|
buyerRemark: string
|
||||||
|
currency: string
|
||||||
|
goodsAmount: string
|
||||||
|
freightAmount: string
|
||||||
|
discountAmount: string
|
||||||
|
payAmount: string
|
||||||
|
orderCount: number
|
||||||
|
merchantGroups: TradePreviewStoreInfo[]
|
||||||
|
storeGroups?: TradePreviewStoreInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderItemInfo {
|
||||||
|
id: number
|
||||||
|
orderId: number
|
||||||
|
tradeId: number
|
||||||
|
orderNo: string
|
||||||
|
spuId: number
|
||||||
|
skuId: number
|
||||||
|
spuName: string
|
||||||
|
skuName: string
|
||||||
|
coverUrl: string
|
||||||
|
skuSpecJson: string
|
||||||
|
buyNum: number
|
||||||
|
salePrice: string
|
||||||
|
originPrice: string
|
||||||
|
discountAmount: string
|
||||||
|
itemAmount: string
|
||||||
|
refundAmount: string
|
||||||
|
afterSaleStatus: AfterSaleStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderSummary {
|
||||||
|
id: number
|
||||||
|
tradeId: number
|
||||||
|
tradeNo: string
|
||||||
|
orderNo: string
|
||||||
|
uid: number
|
||||||
|
memberId?: number
|
||||||
|
merchantId: MerchantId
|
||||||
|
storeId: number
|
||||||
|
storeName: string
|
||||||
|
orderStatus: OrderStatus
|
||||||
|
afterSaleStatus: AfterSaleStatus
|
||||||
|
currency: string
|
||||||
|
goodsAmount: string
|
||||||
|
freightAmount: string
|
||||||
|
discountAmount: string
|
||||||
|
payAmount: string
|
||||||
|
payTime: number
|
||||||
|
shipTime: number
|
||||||
|
finishTime: number
|
||||||
|
cancelTime: number
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderDetailInfo {
|
||||||
|
id: number
|
||||||
|
tradeId: number
|
||||||
|
tradeNo: string
|
||||||
|
orderNo: string
|
||||||
|
uid: number
|
||||||
|
memberId?: number
|
||||||
|
merchantId: MerchantId
|
||||||
|
storeId: number
|
||||||
|
storeName: string
|
||||||
|
orderStatus: OrderStatus
|
||||||
|
afterSaleStatus: AfterSaleStatus
|
||||||
|
currency: string
|
||||||
|
goodsAmount: string
|
||||||
|
freightAmount: string
|
||||||
|
discountAmount: string
|
||||||
|
payAmount: string
|
||||||
|
buyerRemark: string
|
||||||
|
sellerRemark: string
|
||||||
|
receiverName: string
|
||||||
|
receiverMobile: string
|
||||||
|
receiverCountryCode: string
|
||||||
|
receiverProvince: string
|
||||||
|
receiverCity: string
|
||||||
|
receiverDistrict: string
|
||||||
|
receiverAddress: string
|
||||||
|
receiverZipCode: string
|
||||||
|
deliveryCompany: string
|
||||||
|
deliveryNo: string
|
||||||
|
payChannel: string
|
||||||
|
extraInfoJson: string
|
||||||
|
payTime: number
|
||||||
|
shipTime: number
|
||||||
|
finishTime: number
|
||||||
|
cancelTime: number
|
||||||
|
autoConfirmAt: number
|
||||||
|
items: OrderItemInfo[]
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BannerInfo {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
subTitle: string
|
||||||
|
description: string
|
||||||
|
buttonText: string
|
||||||
|
positionCode: HomePositionCode | string
|
||||||
|
imageUrl: string
|
||||||
|
linkType: BannerLinkType
|
||||||
|
linkValue: string
|
||||||
|
sort: number
|
||||||
|
status: EnabledStatus
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeCategoryNav {
|
||||||
|
categoryId: number
|
||||||
|
categoryName: string
|
||||||
|
goodsCount: number
|
||||||
|
sort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeChannelCard {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
subTitle: string
|
||||||
|
iconUrl: string
|
||||||
|
linkType: BannerLinkType
|
||||||
|
linkValue: string
|
||||||
|
sort: number
|
||||||
|
status: EnabledStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecommendStoreInfo {
|
||||||
|
id: string | number
|
||||||
|
merchantId: MerchantId
|
||||||
|
storeName: string
|
||||||
|
storeLogo: string
|
||||||
|
storeNotice: string
|
||||||
|
storeStatus: EnabledStatus
|
||||||
|
mainCategoryName: string
|
||||||
|
recommendReason: string
|
||||||
|
rankScore: number
|
||||||
|
goodsCount: number
|
||||||
|
recentNewGoodsCount: number
|
||||||
|
orderCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeStoreGoodsBlock {
|
||||||
|
merchant: RecommendStoreInfo
|
||||||
|
goods: GoodsSummary[]
|
||||||
|
store?: RecommendStoreInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecommendMerchantInfo = RecommendStoreInfo
|
||||||
|
export type HomeMerchantGoodsBlock = HomeStoreGoodsBlock
|
||||||
|
export type TradePreviewMerchantInfo = TradePreviewStoreInfo
|
||||||
|
|
||||||
|
export interface ResolvedGoodsSkuInfo extends GoodsSkuInfo {
|
||||||
|
specs: SpecItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedGoodsDetailInfo extends GoodsDetailInfo {
|
||||||
|
media: MediaJson
|
||||||
|
detail: DetailJson
|
||||||
|
extraInfo: ExtraInfoJson
|
||||||
|
params: ParamItem[]
|
||||||
|
services: ServicePromiseItem[]
|
||||||
|
skus: ResolvedGoodsSkuInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedCartItemInfo extends CartItemInfo {
|
||||||
|
skuSpecs: SpecItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedTradePreviewItemInfo extends TradePreviewItemInfo {
|
||||||
|
skuSpecs: SpecItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedTradePreviewStoreInfo extends TradePreviewStoreInfo {
|
||||||
|
items: ResolvedTradePreviewItemInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedTradePreviewInfo extends TradePreviewInfo {
|
||||||
|
merchantGroups: ResolvedTradePreviewStoreInfo[]
|
||||||
|
storeGroups?: ResolvedTradePreviewStoreInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvedTradePreviewMerchantInfo = ResolvedTradePreviewStoreInfo
|
||||||
|
|
||||||
|
export interface ResolvedOrderItemInfo extends OrderItemInfo {
|
||||||
|
skuSpecs: SpecItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedOrderDetailInfo extends OrderDetailInfo {
|
||||||
|
extraInfo: ExtraInfoJson
|
||||||
|
items: ResolvedOrderItemInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppHomeRequest {
|
||||||
|
uid?: number
|
||||||
|
/** @deprecated use uid */
|
||||||
|
memberId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppHomeResponse {
|
||||||
|
banners: BannerInfo[]
|
||||||
|
categoryNavs: HomeCategoryNav[]
|
||||||
|
searchKeywords: string[]
|
||||||
|
channels: HomeChannelCard[]
|
||||||
|
recommendGoods: GoodsSummary[]
|
||||||
|
recommendMerchants: RecommendMerchantInfo[]
|
||||||
|
featuredMerchants: RecommendMerchantInfo[]
|
||||||
|
/** @deprecated use recommendMerchants */
|
||||||
|
recommendStores?: RecommendStoreInfo[]
|
||||||
|
/** @deprecated use featuredMerchants */
|
||||||
|
featuredStores?: RecommendStoreInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentNewGoodsItem {
|
||||||
|
merchantId?: MerchantId
|
||||||
|
merchantName?: string
|
||||||
|
merchantLogo?: string
|
||||||
|
storeName?: string
|
||||||
|
spuId: string
|
||||||
|
spuName: string
|
||||||
|
coverUrl: string
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppRecentNewGoodsRequest {
|
||||||
|
uid?: number
|
||||||
|
/** @deprecated use uid */
|
||||||
|
memberId?: number
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppRecentNewGoodsResponse {
|
||||||
|
total: number
|
||||||
|
list: RecentNewGoodsItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppHomeMerchantGoodsRequest {
|
||||||
|
uid?: number
|
||||||
|
/** @deprecated use uid */
|
||||||
|
memberId?: number
|
||||||
|
merchantId?: MerchantId
|
||||||
|
/** @deprecated use merchantId */
|
||||||
|
storeId?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppHomeMerchantGoodsResponse {
|
||||||
|
data: HomeMerchantGoodsBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppGoodsPageRequest extends IPageParams {
|
||||||
|
uid?: number
|
||||||
|
/** @deprecated use uid */
|
||||||
|
memberId?: number
|
||||||
|
merchantId?: MerchantId
|
||||||
|
/** @deprecated use merchantId */
|
||||||
|
storeId?: number
|
||||||
|
categoryId?: number
|
||||||
|
keyword?: string
|
||||||
|
sortType?: GoodsSortType
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppGoodsPageResponse {
|
||||||
|
total: number
|
||||||
|
list: GoodsSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppGoodsDetailRequest {
|
||||||
|
uid?: number
|
||||||
|
/** @deprecated use uid */
|
||||||
|
memberId?: number
|
||||||
|
spuId: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppGoodsDetailResponse {
|
||||||
|
data: GoodsDetailInfo
|
||||||
|
merchant: MerchantInfo
|
||||||
|
/** @deprecated use merchant */
|
||||||
|
store?: StoreInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppCartListRequest {
|
||||||
|
uid?: number
|
||||||
|
/** @deprecated use uid */
|
||||||
|
memberId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppCartListResponse {
|
||||||
|
list: CartItemInfo[]
|
||||||
|
checkedCount: number
|
||||||
|
checkedAmount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppCartSaveRequest {
|
||||||
|
uid?: number
|
||||||
|
/** @deprecated use uid */
|
||||||
|
memberId?: number
|
||||||
|
skuId: number
|
||||||
|
buyNum: number
|
||||||
|
checked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppCartSaveResponse {
|
||||||
|
cartId: number
|
||||||
|
buyNum: number
|
||||||
|
checked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppCartRemoveRequest {
|
||||||
|
uid?: number
|
||||||
|
/** @deprecated use uid */
|
||||||
|
memberId?: number
|
||||||
|
skuIds: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppCartRemoveResponse {
|
||||||
|
removedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppTradePreviewRequest {
|
||||||
|
uid?: number
|
||||||
|
/** @deprecated use uid */
|
||||||
|
memberId?: number
|
||||||
|
buyItems?: TradeBuyItem[]
|
||||||
|
addressId: number
|
||||||
|
buyerRemark?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppTradePreviewResponse {
|
||||||
|
data: TradePreviewInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppTradeCreateRequest {
|
||||||
|
uid?: number
|
||||||
|
/** @deprecated use uid */
|
||||||
|
memberId?: number
|
||||||
|
buyItems?: TradeBuyItem[]
|
||||||
|
addressId: number
|
||||||
|
buyerRemark?: string
|
||||||
|
payChannel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppTradeCreateResponse {
|
||||||
|
tradeId: number
|
||||||
|
tradeNo: string
|
||||||
|
payStatus: PayStatus
|
||||||
|
tradeStatus: TradeStatus
|
||||||
|
payAmount: string
|
||||||
|
expiredAt: number
|
||||||
|
orderIds: number[]
|
||||||
|
orderNos: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppOrderPageRequest extends IPageParams {
|
||||||
|
uid?: number
|
||||||
|
/** @deprecated use uid */
|
||||||
|
memberId?: number
|
||||||
|
orderStatus?: OrderStatus
|
||||||
|
afterSaleStatus?: AfterSaleStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppOrderPageResponse {
|
||||||
|
total: number
|
||||||
|
list: OrderSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppOrderDetailRequest {
|
||||||
|
uid?: number
|
||||||
|
/** @deprecated use uid */
|
||||||
|
memberId?: number
|
||||||
|
orderId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppOrderDetailResponse {
|
||||||
|
data: OrderDetailInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppOrderConfirmRequest {
|
||||||
|
uid?: number
|
||||||
|
/** @deprecated use uid */
|
||||||
|
memberId?: number
|
||||||
|
orderId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppOrderConfirmResponse {
|
||||||
|
orderId: number
|
||||||
|
orderStatus: OrderStatus
|
||||||
|
finishTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppAfterSaleApplyRequest {
|
||||||
|
uid?: number
|
||||||
|
/** @deprecated use uid */
|
||||||
|
memberId?: number
|
||||||
|
orderId: number
|
||||||
|
orderItemId: number
|
||||||
|
afterSaleType: AfterSaleType
|
||||||
|
reason: string
|
||||||
|
description?: string
|
||||||
|
evidenceJson?: string
|
||||||
|
applyAmount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppAfterSaleApplyResponse {
|
||||||
|
afterSaleId: number
|
||||||
|
afterSaleNo: string
|
||||||
|
afterSaleStatus: Exclude<AfterSaleStatus, 'none'>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppHomeStoreGoodsRequest = AppHomeMerchantGoodsRequest
|
||||||
|
export type AppHomeStoreGoodsResponse = AppHomeMerchantGoodsResponse
|
||||||
74
src/utils/logger.ts
Normal file
74
src/utils/logger.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
type LogLevel = 'error' | 'warn' | 'info' | 'log' | 'debug'
|
||||||
|
|
||||||
|
const levelWeight: Record<LogLevel, number> = {
|
||||||
|
error: 4,
|
||||||
|
warn: 3,
|
||||||
|
info: 2,
|
||||||
|
log: 2,
|
||||||
|
debug: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEnabled = () => {
|
||||||
|
const value = (import.meta.env.VITE_ENABLE_LOGS ?? '').toString().trim().toLowerCase()
|
||||||
|
if (value === 'false' || value === '0' || value === 'off' || value === 'no') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (value === 'true' || value === '1' || value === 'on' || value === 'yes') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return import.meta.env.MODE !== 'production'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMinLevel = (): LogLevel => {
|
||||||
|
const value = (import.meta.env.VITE_LOG_LEVEL ?? '').toString().trim().toLowerCase()
|
||||||
|
if (value === 'error' || value === 'warn' || value === 'info' || value === 'log' || value === 'debug') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return 'warn'
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldLog = (level: LogLevel) => {
|
||||||
|
if (!getEnabled()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return levelWeight[level] >= levelWeight[getMinLevel()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebLogger {
|
||||||
|
private static prefix = '[mall-app]'
|
||||||
|
|
||||||
|
static log(...args: unknown[]) {
|
||||||
|
if (shouldLog('log')) {
|
||||||
|
console.log(this.prefix, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static info(...args: unknown[]) {
|
||||||
|
if (shouldLog('info')) {
|
||||||
|
console.info(this.prefix, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static warn(...args: unknown[]) {
|
||||||
|
if (shouldLog('warn')) {
|
||||||
|
console.warn(this.prefix, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static error(...args: unknown[]) {
|
||||||
|
if (shouldLog('error')) {
|
||||||
|
console.error(this.prefix, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static debug(...args: unknown[]) {
|
||||||
|
if (shouldLog('debug')) {
|
||||||
|
console.debug(this.prefix, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = WebLogger
|
||||||
|
|
||||||
|
export default logger
|
||||||
|
|
||||||
39
src/utils/mall/home.ts
Normal file
39
src/utils/mall/home.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type {
|
||||||
|
AppHomeResponse,
|
||||||
|
BannerInfo,
|
||||||
|
GoodsSummary,
|
||||||
|
HomeCategoryNav,
|
||||||
|
HomeChannelCard,
|
||||||
|
RecommendStoreInfo,
|
||||||
|
} from '@/types/mall'
|
||||||
|
|
||||||
|
const bySort = <T extends { sort: number }>(a: T, b: T) => a.sort - b.sort
|
||||||
|
|
||||||
|
const isEnabledStatus = <T extends { status: string }>(item: T) => item.status === 'enabled'
|
||||||
|
|
||||||
|
const isEnabledStore = (store: RecommendStoreInfo) => store.storeStatus === 'enabled'
|
||||||
|
|
||||||
|
const isOnSaleGoods = (item: GoodsSummary) => item.saleStatus === 'on'
|
||||||
|
|
||||||
|
export const resolveHomeTopBanners = (payload: AppHomeResponse | null): BannerInfo[] =>
|
||||||
|
(payload?.banners ?? [])
|
||||||
|
.filter(item => item.positionCode === 'home_top' && isEnabledStatus(item))
|
||||||
|
.sort(bySort)
|
||||||
|
|
||||||
|
export const resolveHomeCategoryNavs = (payload: AppHomeResponse | null): HomeCategoryNav[] =>
|
||||||
|
[...(payload?.categoryNavs ?? [])].sort(bySort)
|
||||||
|
|
||||||
|
export const resolveHomeSearchKeywords = (payload: AppHomeResponse | null): string[] =>
|
||||||
|
(payload?.searchKeywords ?? []).filter(Boolean)
|
||||||
|
|
||||||
|
export const resolveHomeChannels = (payload: AppHomeResponse | null): HomeChannelCard[] =>
|
||||||
|
(payload?.channels ?? []).filter(isEnabledStatus).sort(bySort)
|
||||||
|
|
||||||
|
export const resolveHomeRecommendGoods = (payload: AppHomeResponse | null): GoodsSummary[] =>
|
||||||
|
(payload?.recommendGoods ?? []).filter(isOnSaleGoods)
|
||||||
|
|
||||||
|
export const resolveHomeRankStores = (payload: AppHomeResponse | null): RecommendStoreInfo[] =>
|
||||||
|
(payload?.recommendMerchants ?? payload?.recommendStores ?? []).filter(isEnabledStore)
|
||||||
|
|
||||||
|
export const resolveHomeFeaturedStores = (payload: AppHomeResponse | null): RecommendStoreInfo[] =>
|
||||||
|
(payload?.featuredMerchants ?? payload?.featuredStores ?? []).filter(isEnabledStore)
|
||||||
2
src/utils/mall/index.ts
Normal file
2
src/utils/mall/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './transform'
|
||||||
|
export * from './home'
|
||||||
133
src/utils/mall/transform.ts
Normal file
133
src/utils/mall/transform.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import type {
|
||||||
|
CartItemInfo,
|
||||||
|
DetailJson,
|
||||||
|
EvidenceJson,
|
||||||
|
ExtraInfoJson,
|
||||||
|
GoodsDetailInfo,
|
||||||
|
GoodsSkuInfo,
|
||||||
|
MediaJson,
|
||||||
|
OrderDetailInfo,
|
||||||
|
OrderItemInfo,
|
||||||
|
ParamItem,
|
||||||
|
ResolvedCartItemInfo,
|
||||||
|
ResolvedGoodsDetailInfo,
|
||||||
|
ResolvedGoodsSkuInfo,
|
||||||
|
ResolvedOrderDetailInfo,
|
||||||
|
ResolvedOrderItemInfo,
|
||||||
|
ResolvedTradePreviewInfo,
|
||||||
|
ResolvedTradePreviewItemInfo,
|
||||||
|
ResolvedTradePreviewStoreInfo,
|
||||||
|
ServicePromiseItem,
|
||||||
|
SpecItem,
|
||||||
|
TradePreviewInfo,
|
||||||
|
TradePreviewItemInfo,
|
||||||
|
TradePreviewStoreInfo,
|
||||||
|
} from '@/types/mall'
|
||||||
|
import logger from '@/utils/logger'
|
||||||
|
|
||||||
|
const EMPTY_MEDIA_JSON: MediaJson = {
|
||||||
|
images: [],
|
||||||
|
videos: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_DETAIL_JSON: DetailJson = {
|
||||||
|
blocks: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_EVIDENCE_JSON: EvidenceJson = {
|
||||||
|
images: [],
|
||||||
|
videos: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_EXTRA_INFO_JSON: ExtraInfoJson = {}
|
||||||
|
const EMPTY_NAME_VALUE_LIST: SpecItem[] = []
|
||||||
|
const EMPTY_PARAM_LIST: ParamItem[] = []
|
||||||
|
const EMPTY_SERVICE_LIST: ServicePromiseItem[] = []
|
||||||
|
|
||||||
|
const parseJson = <T>(fieldName: string, value: string | null | undefined, fallback: T): T => {
|
||||||
|
if (!value) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as T
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to parse ${fieldName}`, error, value)
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseMediaJson = (value?: string | null): MediaJson =>
|
||||||
|
parseJson('mediaJson', value, EMPTY_MEDIA_JSON)
|
||||||
|
|
||||||
|
export const parseDetailJson = (value?: string | null): DetailJson =>
|
||||||
|
parseJson('detailJson', value, EMPTY_DETAIL_JSON)
|
||||||
|
|
||||||
|
export const parseSpecJson = (value?: string | null): SpecItem[] =>
|
||||||
|
parseJson('specJson', value, EMPTY_NAME_VALUE_LIST)
|
||||||
|
|
||||||
|
export const parseParamJson = (value?: string | null): ParamItem[] =>
|
||||||
|
parseJson('paramJson', value, EMPTY_PARAM_LIST)
|
||||||
|
|
||||||
|
export const parseServiceJson = (value?: string | null): ServicePromiseItem[] =>
|
||||||
|
parseJson('serviceJson', value, EMPTY_SERVICE_LIST)
|
||||||
|
|
||||||
|
export const parseEvidenceJson = (value?: string | null): EvidenceJson =>
|
||||||
|
parseJson('evidenceJson', value, EMPTY_EVIDENCE_JSON)
|
||||||
|
|
||||||
|
export const parseExtraInfoJson = (value?: string | null): ExtraInfoJson =>
|
||||||
|
parseJson('extraInfoJson', value, EMPTY_EXTRA_INFO_JSON)
|
||||||
|
|
||||||
|
export const serializeEvidenceJson = (value?: EvidenceJson | null): string =>
|
||||||
|
JSON.stringify(value ?? EMPTY_EVIDENCE_JSON)
|
||||||
|
|
||||||
|
export const resolveGoodsSkuInfo = (sku: GoodsSkuInfo): ResolvedGoodsSkuInfo => ({
|
||||||
|
...sku,
|
||||||
|
specs: parseSpecJson(sku.specJson),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resolveGoodsDetailInfo = (detail: GoodsDetailInfo): ResolvedGoodsDetailInfo => ({
|
||||||
|
...detail,
|
||||||
|
media: parseMediaJson(detail.mediaJson),
|
||||||
|
detail: parseDetailJson(detail.detailJson),
|
||||||
|
extraInfo: parseExtraInfoJson(detail.extraInfoJson),
|
||||||
|
params: parseParamJson(detail.paramJson),
|
||||||
|
services: parseServiceJson(detail.serviceJson),
|
||||||
|
skus: detail.skus.map(resolveGoodsSkuInfo),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resolveCartItemInfo = (item: CartItemInfo): ResolvedCartItemInfo => ({
|
||||||
|
...item,
|
||||||
|
skuSpecs: parseSpecJson(item.skuSpecJson),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resolveTradePreviewItemInfo = (
|
||||||
|
item: TradePreviewItemInfo
|
||||||
|
): ResolvedTradePreviewItemInfo => ({
|
||||||
|
...item,
|
||||||
|
skuSpecs: parseSpecJson(item.skuSpecJson),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resolveTradePreviewStoreInfo = (
|
||||||
|
group: TradePreviewStoreInfo
|
||||||
|
): ResolvedTradePreviewStoreInfo => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.map(resolveTradePreviewItemInfo),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resolveTradePreviewInfo = (preview: TradePreviewInfo): ResolvedTradePreviewInfo => ({
|
||||||
|
...preview,
|
||||||
|
merchantGroups: (preview.merchantGroups ?? preview.storeGroups ?? []).map(resolveTradePreviewStoreInfo),
|
||||||
|
storeGroups: (preview.merchantGroups ?? preview.storeGroups ?? []).map(resolveTradePreviewStoreInfo),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resolveOrderItemInfo = (item: OrderItemInfo): ResolvedOrderItemInfo => ({
|
||||||
|
...item,
|
||||||
|
skuSpecs: parseSpecJson(item.skuSpecJson),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resolveOrderDetailInfo = (detail: OrderDetailInfo): ResolvedOrderDetailInfo => ({
|
||||||
|
...detail,
|
||||||
|
extraInfo: parseExtraInfoJson(detail.extraInfoJson),
|
||||||
|
items: detail.items.map(resolveOrderItemInfo),
|
||||||
|
})
|
||||||
2
src/vite-env.d.ts
vendored
Normal file
2
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
17
tsconfig.node.json
Normal file
17
tsconfig.node.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
19
vite.config.ts
Normal file
19
vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react-swc'
|
||||||
|
import { dirname, resolve } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
Reference in New Issue
Block a user