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