This commit is contained in:
魔方熊成祥
2026-04-29 21:38:14 +08:00
commit 1c8de9b5f4
48 changed files with 10797 additions and 0 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=https://test-gateway.yuntuo.hk

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

27
src/App.tsx Normal file
View 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
View File

@@ -0,0 +1 @@
export * from './mall-app'

67
src/apis/apiUser.ts Normal file
View 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

View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
export * from './apiMallApp'
export * from './apiUser'
export * from './base/types'
export * from './base/useAxios'

View 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
View 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
)

View 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
View 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)

View 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'

View 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),
},
}
}

View 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
)

View 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'

View 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
)

View 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

View 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

View 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
View 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

File diff suppressed because it is too large Load Diff

10
src/main.tsx Normal file
View 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
View 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

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
export interface IUserInfo {
uid: number
name: string
account: string
}

3
src/types/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './ITokenData'
export * from './IUserInfo'
export * from './mall'

659
src/types/mall.ts Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
export * from './transform'
export * from './home'

133
src/utils/mall/transform.ts Normal file
View 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
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

26
tsconfig.app.json Normal file
View 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
View 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
View 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
View 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,
},
})