1
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
*.local
|
||||
*.tsbuildinfo
|
||||
vite.config.js
|
||||
vite.config.d.ts
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="api-base-url" content="https://test-gateway.yuntuo.hk" />
|
||||
<title>云拓商城商家端</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "mall-merchant",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"antd": "^5.19.3",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "7.9.6",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
2151
pnpm-lock.yaml
generated
Normal file
2151
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
src/App.tsx
Normal file
12
src/App.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Toaster } from 'sonner'
|
||||
import AppRoutes from '@/routes'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<AppRoutes />
|
||||
<Toaster position="top-center" richColors />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
113
src/apis/apisCommon.ts
Normal file
113
src/apis/apisCommon.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import axiosInstance from './base/axiosInstance'
|
||||
import type { BaseResponse } from './base/types'
|
||||
|
||||
const unwrapResponse = <T>(payload: BaseResponse<T>) => {
|
||||
if (payload.code !== 200) {
|
||||
throw new Error(payload.message || payload.msg || '请求失败')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const unwrapResponseData = <T>(payload: BaseResponse<T>) => {
|
||||
return unwrapResponse(payload).data
|
||||
}
|
||||
|
||||
export interface OssSignToken {
|
||||
fileName: string
|
||||
accessId: string
|
||||
callback: string
|
||||
directory: string
|
||||
expire: number
|
||||
host: string
|
||||
policy: string
|
||||
signature: string
|
||||
'x:callbackkey': string
|
||||
}
|
||||
|
||||
export interface OssSignData {
|
||||
storeType: string
|
||||
token: OssSignToken
|
||||
}
|
||||
|
||||
export interface OssUploadData {
|
||||
size: number
|
||||
mime: string
|
||||
width?: number
|
||||
height?: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export const apiGetSmsCode = async (
|
||||
props: {
|
||||
account: string
|
||||
type: '0' | '1' | '2' | '8'
|
||||
},
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const path = '/api/v1/user/captcha'
|
||||
const response = await axiosInstance.post<BaseResponse<unknown>>(
|
||||
path,
|
||||
{
|
||||
...props,
|
||||
loginType: '0',
|
||||
},
|
||||
{
|
||||
signal,
|
||||
}
|
||||
)
|
||||
return unwrapResponse(response.data)
|
||||
}
|
||||
|
||||
export const apiGetOssSign = async (
|
||||
props: {
|
||||
suffix: string
|
||||
},
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const path = '/api/v1/file/tst'
|
||||
const response = await axiosInstance.post<BaseResponse<OssSignData>>(path, props, {
|
||||
signal,
|
||||
})
|
||||
|
||||
return unwrapResponseData(response.data)
|
||||
}
|
||||
|
||||
export const uploadFileToOss = async (
|
||||
props: {
|
||||
file: File
|
||||
sign: OssSignData
|
||||
onProgress?: (percent: number) => void
|
||||
},
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const { file, sign, onProgress } = props
|
||||
const token = sign.token
|
||||
const key = token.directory + token.fileName
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('key', key)
|
||||
formData.append('policy', token.policy)
|
||||
formData.append('OSSAccessKeyId', token.accessId)
|
||||
formData.append('success_action_status', '200')
|
||||
formData.append('signature', token.signature)
|
||||
formData.append('callback', token.callback)
|
||||
formData.append('x:callbackkey', token['x:callbackkey'])
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await axiosInstance.post<BaseResponse<OssUploadData>>(token.host, formData, {
|
||||
signal,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: event => {
|
||||
if (!event.total) {
|
||||
return
|
||||
}
|
||||
|
||||
onProgress?.(Math.round((event.loaded / event.total) * 100))
|
||||
},
|
||||
})
|
||||
|
||||
return unwrapResponseData(response.data)
|
||||
}
|
||||
27
src/apis/apisUser.ts
Normal file
27
src/apis/apisUser.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import axiosInstance from './base/axiosInstance'
|
||||
import type { BaseResponse } from './base/types'
|
||||
import type { ITokenData } from '@/types/ITokenData'
|
||||
|
||||
const unwrapResponse = <T>(payload: BaseResponse<T>) => {
|
||||
if (payload.code !== 200) {
|
||||
throw new Error(payload.message || payload.msg || '请求失败')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export const apiMallMerchantLogin = async (
|
||||
props: {
|
||||
account: string
|
||||
loginIp?: string
|
||||
captcha?: string
|
||||
password?: string
|
||||
loginType: '0' | '1'
|
||||
type: '0' | '1'
|
||||
},
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const path = '/api/v1/user/mall_merchant/login'
|
||||
const response = await axiosInstance.post<BaseResponse<ITokenData>>(path, props, { signal })
|
||||
return unwrapResponse(response.data)
|
||||
}
|
||||
46
src/apis/base/axiosInstance.ts
Normal file
46
src/apis/base/axiosInstance.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { getBaseUrl as getConfigBaseUrl, getVersion } from '@/config/source/config'
|
||||
import { ApiError, API_ERROR_CODES, type BaseResponse } from './types'
|
||||
import { useSessionStore } from '@/store/session'
|
||||
|
||||
const resolveBaseUrl = () => getConfigBaseUrl()
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
timeout: 30 * 1000,
|
||||
})
|
||||
|
||||
axiosInstance.interceptors.request.use(config => {
|
||||
const { token } = useSessionStore.getState()
|
||||
|
||||
config.baseURL = resolveBaseUrl()
|
||||
config.headers = {
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
version: getVersion(),
|
||||
...(token?.accessToken ? { authorization: token.accessToken } : {}),
|
||||
...config.headers,
|
||||
} as any
|
||||
|
||||
return config
|
||||
})
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
response => response,
|
||||
(error: AxiosError<BaseResponse<unknown>>) => {
|
||||
if (error.response?.data) {
|
||||
const data = error.response.data
|
||||
throw new ApiError(
|
||||
data.code ?? error.response.status,
|
||||
data.message || data.msg || error.message || '请求失败',
|
||||
data.data
|
||||
)
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
throw new ApiError(API_ERROR_CODES.SERVER_ERROR, '请求超时,请稍后重试')
|
||||
}
|
||||
|
||||
throw new ApiError(API_ERROR_CODES.SERVER_ERROR, error.message || '网络异常')
|
||||
}
|
||||
)
|
||||
|
||||
export default axiosInstance
|
||||
33
src/apis/base/types.ts
Normal file
33
src/apis/base/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const API_ERROR_CODES = {
|
||||
SUCCESS: 200,
|
||||
UNAUTHORIZED: 600,
|
||||
PARAMS_ERROR: 965,
|
||||
SERVER_ERROR: 999,
|
||||
DB_ERROR: 1001,
|
||||
UPDATE_EMPTY: 1002,
|
||||
} as const
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: number
|
||||
data?: unknown
|
||||
|
||||
constructor(code: number, message: string, data?: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.code = code
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
export interface BaseResponse<T> {
|
||||
code: number
|
||||
message?: string
|
||||
msg?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface PageRequest {
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
96
src/apis/hqb.ts
Normal file
96
src/apis/hqb.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export interface HqbImageItem {
|
||||
url?: string
|
||||
thumbUrl?: string
|
||||
imgSrc?: string
|
||||
}
|
||||
|
||||
export interface HqbSpecItem {
|
||||
valueId?: string | number
|
||||
value?: string
|
||||
imgSrc?: string | null
|
||||
thumbUrl?: string | null
|
||||
}
|
||||
|
||||
export interface HqbSpecGroup {
|
||||
specId?: string | number
|
||||
specName?: string
|
||||
isUploadImage?: number
|
||||
items?: HqbSpecItem[]
|
||||
}
|
||||
|
||||
export interface HqbSkuInfo {
|
||||
id?: string | number
|
||||
goodsId?: string | number
|
||||
storeCount?: number
|
||||
keyName?: string
|
||||
price?: string | number | null
|
||||
skuCode?: string
|
||||
key?: string
|
||||
imgUrl?: string
|
||||
skuImg?: string
|
||||
thumbUrl?: string
|
||||
originalPrice?: string | number | null
|
||||
weight?: string | number | null
|
||||
}
|
||||
|
||||
export interface HqbShopInfo {
|
||||
id?: string | number
|
||||
shopName?: string
|
||||
}
|
||||
|
||||
export interface HqbGoodsInfo {
|
||||
id: string | number
|
||||
title?: string
|
||||
remark?: string
|
||||
goodsSn?: string
|
||||
imageUrl?: string
|
||||
videoUrl?: string
|
||||
price?: string | number | null
|
||||
maxPrice?: string | number | null
|
||||
yprice?: string | number | null
|
||||
catePath?: string
|
||||
cateIdPath?: string
|
||||
siteCode?: string
|
||||
textDesc?: string | null
|
||||
descImageList?: unknown
|
||||
specsList?: Record<string, HqbSpecGroup>
|
||||
goodsImageList?: HqbImageItem[]
|
||||
skuInfoList?: HqbSkuInfo[]
|
||||
shopInfo?: HqbShopInfo
|
||||
}
|
||||
|
||||
interface HqbBaseResponse<T> {
|
||||
status: number
|
||||
code: number
|
||||
msg?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
const HQB_API_BASE = 'https://api.52dsy.com'
|
||||
|
||||
export const fetchHqbGoodsInfo = async (goodsId: string | number, signal?: AbortSignal) => {
|
||||
const response = await fetch(`${HQB_API_BASE}/newapi/goods/goods/getGoodsInfo`, {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json;charset=UTF-8',
|
||||
site: 'hqb',
|
||||
'app-type': 'dsy-home',
|
||||
'app-version': '0.1',
|
||||
t: String(Date.now()),
|
||||
},
|
||||
body: JSON.stringify({ goodsId: Number(goodsId) }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`52hqb 接口请求失败:${response.status}`)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as HqbBaseResponse<HqbGoodsInfo | null>
|
||||
if (payload.status !== 1 || payload.code !== 0 || !payload.data) {
|
||||
throw new Error(payload.msg || '52hqb 未返回商品数据')
|
||||
}
|
||||
|
||||
return payload.data
|
||||
}
|
||||
26
src/apis/mallCategory.ts
Normal file
26
src/apis/mallCategory.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { BaseResponse } from '@/apis/base/types'
|
||||
import axiosInstance from '@/apis/base/axiosInstance'
|
||||
import type { MallCategoryTreeData, MallCategoryTreeParams } from '@/types/mall'
|
||||
import { compactObject } from '@/utils/mall'
|
||||
|
||||
export const mallCategoryTree = async (
|
||||
payload: MallCategoryTreeParams = {},
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<BaseResponse<MallCategoryTreeData>>(
|
||||
'/api/v1/mall/admin/category_tree',
|
||||
compactObject(payload),
|
||||
{ signal }
|
||||
)
|
||||
|
||||
const businessPayload = response.data
|
||||
if (!businessPayload) {
|
||||
throw new Error('服务端未返回业务数据')
|
||||
}
|
||||
|
||||
if (businessPayload.code !== 200) {
|
||||
throw new Error(businessPayload.message || businessPayload.msg || '请求失败')
|
||||
}
|
||||
|
||||
return businessPayload.data
|
||||
}
|
||||
24
src/apis/mallMerchant/afterSale.ts
Normal file
24
src/apis/mallMerchant/afterSale.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
MerchantAfterSaleAuditData,
|
||||
MerchantAfterSaleAuditParams,
|
||||
MerchantAfterSalePageData,
|
||||
MerchantAfterSalePageParams,
|
||||
} from '@/types/mall'
|
||||
import { merchantPost } from './shared'
|
||||
|
||||
export const merchantAfterSalePage = (payload: MerchantAfterSalePageParams, signal?: AbortSignal) =>
|
||||
merchantPost<MerchantAfterSalePageData, MerchantAfterSalePageParams>(
|
||||
'/api/v1/mall/merchant/after_sale_page',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
|
||||
export const merchantAfterSaleAudit = (
|
||||
payload: MerchantAfterSaleAuditParams,
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
merchantPost<MerchantAfterSaleAuditData, MerchantAfterSaleAuditParams>(
|
||||
'/api/v1/mall/merchant/after_sale_audit',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
27
src/apis/mallMerchant/category.ts
Normal file
27
src/apis/mallMerchant/category.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
MallCategoryTreeData,
|
||||
MallCategoryTreeParams,
|
||||
MerchantCategoryAttributeListData,
|
||||
MerchantCategoryAttributeListParams,
|
||||
} from '@/types/mall'
|
||||
import { merchantPost } from './shared'
|
||||
|
||||
export const merchantCategoryTree = (
|
||||
payload: MallCategoryTreeParams = {},
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
merchantPost<MallCategoryTreeData, MallCategoryTreeParams>(
|
||||
'/api/v1/mall/merchant/category_tree',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
|
||||
export const merchantCategoryAttributeList = (
|
||||
payload: MerchantCategoryAttributeListParams,
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
merchantPost<MerchantCategoryAttributeListData, MerchantCategoryAttributeListParams>(
|
||||
'/api/v1/mall/merchant/category_attribute_list',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
39
src/apis/mallMerchant/featuredGoods.ts
Normal file
39
src/apis/mallMerchant/featuredGoods.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
MerchantFeaturedGoodsDeleteData,
|
||||
MerchantFeaturedGoodsDeleteParams,
|
||||
MerchantFeaturedGoodsPageData,
|
||||
MerchantFeaturedGoodsPageParams,
|
||||
MerchantFeaturedGoodsSaveData,
|
||||
MerchantFeaturedGoodsSaveParams,
|
||||
} from '@/types/mall'
|
||||
import { merchantPost } from './shared'
|
||||
|
||||
export const merchantFeaturedGoodsPage = (
|
||||
payload: MerchantFeaturedGoodsPageParams,
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
merchantPost<MerchantFeaturedGoodsPageData, MerchantFeaturedGoodsPageParams>(
|
||||
'/api/v1/mall/merchant/featured_goods_page',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
|
||||
export const merchantFeaturedGoodsSave = (
|
||||
payload: MerchantFeaturedGoodsSaveParams,
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
merchantPost<MerchantFeaturedGoodsSaveData, MerchantFeaturedGoodsSaveParams>(
|
||||
'/api/v1/mall/merchant/featured_goods_save',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
|
||||
export const merchantFeaturedGoodsDelete = (
|
||||
payload: MerchantFeaturedGoodsDeleteParams,
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
merchantPost<MerchantFeaturedGoodsDeleteData, MerchantFeaturedGoodsDeleteParams>(
|
||||
'/api/v1/mall/merchant/featured_goods_delete',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
39
src/apis/mallMerchant/goods.ts
Normal file
39
src/apis/mallMerchant/goods.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
MerchantGoodsDetailData,
|
||||
MerchantGoodsDetailParams,
|
||||
MerchantGoodsPageData,
|
||||
MerchantGoodsPageParams,
|
||||
MerchantGoodsPublishData,
|
||||
MerchantGoodsPublishParams,
|
||||
MerchantGoodsSaveData,
|
||||
MerchantGoodsSaveParams,
|
||||
} from '@/types/mall'
|
||||
import { merchantPost } from './shared'
|
||||
|
||||
export const merchantGoodsPage = (payload: MerchantGoodsPageParams, signal?: AbortSignal) =>
|
||||
merchantPost<MerchantGoodsPageData, MerchantGoodsPageParams>(
|
||||
'/api/v1/mall/merchant/goods_page',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
|
||||
export const merchantGoodsDetail = (payload: MerchantGoodsDetailParams, signal?: AbortSignal) =>
|
||||
merchantPost<MerchantGoodsDetailData, MerchantGoodsDetailParams>(
|
||||
'/api/v1/mall/merchant/goods_detail',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
|
||||
export const merchantGoodsSave = (payload: MerchantGoodsSaveParams, signal?: AbortSignal) =>
|
||||
merchantPost<MerchantGoodsSaveData, MerchantGoodsSaveParams>(
|
||||
'/api/v1/mall/merchant/goods_save',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
|
||||
export const merchantGoodsPublish = (payload: MerchantGoodsPublishParams, signal?: AbortSignal) =>
|
||||
merchantPost<MerchantGoodsPublishData, MerchantGoodsPublishParams>(
|
||||
'/api/v1/mall/merchant/goods_publish',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
6
src/apis/mallMerchant/index.ts
Normal file
6
src/apis/mallMerchant/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './profile'
|
||||
export * from './category'
|
||||
export * from './goods'
|
||||
export * from './featuredGoods'
|
||||
export * from './orders'
|
||||
export * from './afterSale'
|
||||
30
src/apis/mallMerchant/orders.ts
Normal file
30
src/apis/mallMerchant/orders.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
MerchantOrderDetailData,
|
||||
MerchantOrderDetailParams,
|
||||
MerchantOrderPageData,
|
||||
MerchantOrderPageParams,
|
||||
MerchantOrderShipData,
|
||||
MerchantOrderShipParams,
|
||||
} from '@/types/mall'
|
||||
import { merchantPost } from './shared'
|
||||
|
||||
export const merchantOrderPage = (payload: MerchantOrderPageParams, signal?: AbortSignal) =>
|
||||
merchantPost<MerchantOrderPageData, MerchantOrderPageParams>(
|
||||
'/api/v1/mall/merchant/order_page',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
|
||||
export const merchantOrderDetail = (payload: MerchantOrderDetailParams, signal?: AbortSignal) =>
|
||||
merchantPost<MerchantOrderDetailData, MerchantOrderDetailParams>(
|
||||
'/api/v1/mall/merchant/order_detail',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
|
||||
export const merchantOrderShip = (payload: MerchantOrderShipParams, signal?: AbortSignal) =>
|
||||
merchantPost<MerchantOrderShipData, MerchantOrderShipParams>(
|
||||
'/api/v1/mall/merchant/order_ship',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
24
src/apis/mallMerchant/profile.ts
Normal file
24
src/apis/mallMerchant/profile.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
MerchantProfileGetData,
|
||||
MerchantProfileGetParams,
|
||||
MerchantProfileUpdateData,
|
||||
MerchantProfileUpdateParams,
|
||||
} from '@/types/mall'
|
||||
import { merchantPost } from './shared'
|
||||
|
||||
export const merchantProfileGet = (payload: MerchantProfileGetParams = {}, signal?: AbortSignal) =>
|
||||
merchantPost<MerchantProfileGetData, MerchantProfileGetParams>(
|
||||
'/api/v1/mall/merchant/profile_get',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
|
||||
export const merchantProfileUpdate = (
|
||||
payload: MerchantProfileUpdateParams,
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
merchantPost<MerchantProfileUpdateData, MerchantProfileUpdateParams>(
|
||||
'/api/v1/mall/merchant/profile_update',
|
||||
payload,
|
||||
signal
|
||||
)
|
||||
24
src/apis/mallMerchant/shared.ts
Normal file
24
src/apis/mallMerchant/shared.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { BaseResponse } from '@/apis/base/types'
|
||||
import axiosInstance from '@/apis/base/axiosInstance'
|
||||
import { compactObject } from '@/utils/mall'
|
||||
|
||||
export const merchantPost = async <TData, TPayload extends object>(
|
||||
path: string,
|
||||
payload: TPayload,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<BaseResponse<TData>>(path, compactObject(payload), {
|
||||
signal,
|
||||
})
|
||||
|
||||
const businessPayload = response.data
|
||||
if (!businessPayload) {
|
||||
throw new Error('服务端未返回业务数据')
|
||||
}
|
||||
|
||||
if (businessPayload.code !== 200) {
|
||||
throw new Error(businessPayload.message || businessPayload.msg || '请求失败')
|
||||
}
|
||||
|
||||
return businessPayload.data
|
||||
}
|
||||
219
src/components/ImageUploadField.tsx
Normal file
219
src/components/ImageUploadField.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { CloudUploadOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { Button, Flex, Image, Progress, Typography, message } from 'antd'
|
||||
import { useRef, useState } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { apiGetOssSign, uploadFileToOss } from '@/apis/apisCommon'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface ImageUploadFieldProps {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
title?: string
|
||||
hint?: string
|
||||
buttonText?: string
|
||||
disabled?: boolean
|
||||
previewHeight?: number
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const normalizeUrl = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(trimmed, window.location.origin)
|
||||
if (window.location.protocol === 'https:' && parsedUrl.protocol === 'http:') {
|
||||
parsedUrl.protocol = 'https:'
|
||||
}
|
||||
|
||||
return parsedUrl.toString()
|
||||
} catch {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
export default function ImageUploadField({
|
||||
value,
|
||||
onChange,
|
||||
title = '上传图片',
|
||||
hint = '支持 JPG / PNG / WEBP,上传成功后自动回填。',
|
||||
buttonText = '上传图片',
|
||||
disabled = false,
|
||||
previewHeight = 160,
|
||||
compact = false,
|
||||
}: ImageUploadFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error('请选择图片文件')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true)
|
||||
setProgress(0)
|
||||
|
||||
const suffix = file.name.split('.').pop() || file.type.split('/').pop() || 'png'
|
||||
const sign = await apiGetOssSign({ suffix })
|
||||
const result = await uploadFileToOss(
|
||||
{
|
||||
file,
|
||||
sign,
|
||||
onProgress: percent => setProgress(percent),
|
||||
}
|
||||
)
|
||||
|
||||
onChange?.(normalizeUrl(result.url))
|
||||
message.success('图片上传成功')
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : '图片上传失败'
|
||||
message.error(messageText)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
await handleUpload(file)
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="image-upload-compact">
|
||||
<button
|
||||
type="button"
|
||||
className="image-upload-compact__trigger"
|
||||
disabled={disabled || uploading}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{value ? (
|
||||
<img src={value} alt="uploaded" />
|
||||
) : (
|
||||
<>
|
||||
<CloudUploadOutlined className="image-upload-compact__icon" />
|
||||
<span>上传</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{value ? (
|
||||
<Button
|
||||
className="image-upload-compact__remove"
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={disabled || uploading}
|
||||
onClick={() => onChange?.('')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={event => void handleFileChange(event)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--ant-color-border-secondary)',
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
background: '#fcfcfd',
|
||||
}}
|
||||
>
|
||||
<Flex justify="space-between" align="center" gap={12} wrap="wrap">
|
||||
<div>
|
||||
<Text strong>{title}</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text type="secondary">{hint}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Flex gap={8} wrap="wrap">
|
||||
{value ? (
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={disabled || uploading}
|
||||
onClick={() => onChange?.('')}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
icon={value ? <ReloadOutlined /> : <CloudUploadOutlined />}
|
||||
disabled={disabled || uploading}
|
||||
loading={uploading}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{value ? '重新上传' : buttonText}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
minHeight: previewHeight,
|
||||
borderRadius: 12,
|
||||
border: '1px dashed var(--ant-color-border)',
|
||||
background: '#fff',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{value ? (
|
||||
<Image
|
||||
src={value}
|
||||
alt="uploaded"
|
||||
width="100%"
|
||||
height={previewHeight}
|
||||
style={{ objectFit: 'cover' }}
|
||||
preview={{ src: value }}
|
||||
/>
|
||||
) : (
|
||||
<Text type="secondary">暂未上传图片</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploading ? (
|
||||
<Progress percent={progress} size="small" style={{ marginTop: 10, marginBottom: 0 }} />
|
||||
) : null}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={event => void handleFileChange(event)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
src/components/ImageWallUploadField.tsx
Normal file
190
src/components/ImageWallUploadField.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
CloudUploadOutlined,
|
||||
DeleteOutlined,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Button, Image, Typography, message } from 'antd'
|
||||
import { useRef, useState } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { apiGetOssSign, uploadFileToOss } from '@/apis/apisCommon'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface ImageWallUploadFieldProps {
|
||||
value?: string[]
|
||||
onChange?: (value: string[]) => void
|
||||
disabled?: boolean
|
||||
maxCount?: number
|
||||
}
|
||||
|
||||
const normalizeUrl = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(trimmed, window.location.origin)
|
||||
if (window.location.protocol === 'https:' && parsedUrl.protocol === 'http:') {
|
||||
parsedUrl.protocol = 'https:'
|
||||
}
|
||||
|
||||
return parsedUrl.toString()
|
||||
} catch {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeImageList = (value?: string[]) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value.map(item => item.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
export default function ImageWallUploadField({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
maxCount = 10,
|
||||
}: ImageWallUploadFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadTargetIndex, setUploadTargetIndex] = useState<number | null>(null)
|
||||
const images = normalizeImageList(value)
|
||||
|
||||
const triggerUpload = (targetIndex: number | null) => {
|
||||
if (disabled || uploading) {
|
||||
return
|
||||
}
|
||||
|
||||
if (targetIndex === null && images.length >= maxCount) {
|
||||
message.warning(`轮播图最多上传 ${maxCount} 张`)
|
||||
return
|
||||
}
|
||||
|
||||
setUploadTargetIndex(targetIndex)
|
||||
inputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
onChange?.(images.filter((_, currentIndex) => currentIndex !== index))
|
||||
}
|
||||
|
||||
const handleUpload = async (file: File, targetIndex: number | null) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error('请选择图片文件')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true)
|
||||
const suffix = file.name.split('.').pop() || file.type.split('/').pop() || 'png'
|
||||
const sign = await apiGetOssSign({ suffix })
|
||||
const result = await uploadFileToOss({ file, sign })
|
||||
const uploadedUrl = normalizeUrl(result.url)
|
||||
const nextImages = [...images]
|
||||
|
||||
if (targetIndex === null) {
|
||||
nextImages.push(uploadedUrl)
|
||||
} else {
|
||||
nextImages[targetIndex] = uploadedUrl
|
||||
}
|
||||
|
||||
onChange?.(nextImages)
|
||||
message.success('图片上传成功')
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : '图片上传失败'
|
||||
message.error(messageText)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadTargetIndex(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
const currentTargetIndex = uploadTargetIndex
|
||||
event.target.value = ''
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
await handleUpload(file, currentTargetIndex)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="image-wall-upload">
|
||||
<div className="image-wall-upload__list">
|
||||
{images.length < maxCount ? (
|
||||
<button
|
||||
type="button"
|
||||
className="image-wall-upload__add"
|
||||
disabled={disabled || uploading}
|
||||
onClick={() => triggerUpload(null)}
|
||||
>
|
||||
<div className="image-wall-upload__add-icon">
|
||||
{uploading && uploadTargetIndex === null ? <LoadingOutlined /> : <CloudUploadOutlined />}
|
||||
</div>
|
||||
<div className="image-wall-upload__add-text">
|
||||
{uploading && uploadTargetIndex === null ? '上传中' : '上传'}
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{images.map((item, index) => (
|
||||
<div key={`${item}-${index}`} className="image-wall-upload__item">
|
||||
{index === 0 ? <span className="image-wall-upload__badge">主图</span> : null}
|
||||
<Image
|
||||
src={item}
|
||||
alt={`carousel-${index + 1}`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ objectFit: 'cover' }}
|
||||
preview={{ src: item }}
|
||||
/>
|
||||
<div className="image-wall-upload__toolbar">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={uploading && uploadTargetIndex === index}
|
||||
disabled={disabled || uploading}
|
||||
onClick={() => triggerUpload(index)}
|
||||
>
|
||||
替换
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={disabled || uploading}
|
||||
onClick={() => handleDelete(index)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={event => void handleFileChange(event)}
|
||||
/>
|
||||
|
||||
<Text className="image-wall-upload__counter">共{images.length}张</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
src/config/source/config.ts
Normal file
10
src/config/source/config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import pkg from '../../../package.json'
|
||||
|
||||
const DEFAULT_VERSION = pkg.version ?? '0.1.0'
|
||||
export const FIXED_API_BASE_URL = 'https://test-gateway.yuntuo.hk'
|
||||
|
||||
export const getBaseUrl = () => {
|
||||
return FIXED_API_BASE_URL
|
||||
}
|
||||
|
||||
export const getVersion = () => DEFAULT_VERSION
|
||||
2
src/config/themes/index.ts
Normal file
2
src/config/themes/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { lightTheme } from './light'
|
||||
|
||||
98
src/config/themes/light.ts
Normal file
98
src/config/themes/light.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { ThemeConfig } from 'antd'
|
||||
|
||||
export const lightTheme: ThemeConfig = {
|
||||
token: {
|
||||
colorPrimaryBg: '#e5f2ee',
|
||||
colorPrimaryBgHover: '#cde7de',
|
||||
colorPrimaryBorder: '#86c2ae',
|
||||
colorPrimaryBorderHover: '#61ae94',
|
||||
colorPrimaryActive: '#054e38',
|
||||
colorPrimaryTextHover: '#349172',
|
||||
colorPrimaryTextActive: '#054e38',
|
||||
colorPrimaryHover: '#349172',
|
||||
colorPrimary: '#156f4e',
|
||||
colorInfo: '#156f4e',
|
||||
colorTextBase: '#0e1513',
|
||||
colorError: '#ef485e',
|
||||
colorWarning: '#eda617',
|
||||
colorSuccess: '#05aa81',
|
||||
colorSuccessBg: '#dff4eb',
|
||||
colorSuccessBgHover: '#a9e9d0',
|
||||
colorTextTertiary: 'rgba(14,21,19,0.45)',
|
||||
colorTextQuaternary: 'rgba(14,21,19,0.25)',
|
||||
colorBgLayout: '#f5f6f8',
|
||||
colorBgContainer: '#ffffff',
|
||||
fontSize: 14,
|
||||
fontSizeLG: 14,
|
||||
fontSizeHeading3: 18,
|
||||
fontSizeHeading4: 16,
|
||||
fontSizeHeading5: 15,
|
||||
lineHeight: 1.25,
|
||||
controlHeight: 32,
|
||||
controlHeightLG: 36,
|
||||
controlHeightSM: 28,
|
||||
sizeStep: 4,
|
||||
sizeUnit: 4,
|
||||
borderRadiusXS: 4,
|
||||
borderRadiusSM: 6,
|
||||
borderRadius: 8,
|
||||
borderRadiusLG: 12,
|
||||
wireframe: false,
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
defaultColor: 'rgb(21,111,78)',
|
||||
defaultActiveColor: 'rgb(21,111,78)',
|
||||
defaultHoverColor: 'rgb(52,145,114)',
|
||||
defaultHoverBorderColor: 'rgb(52,145,114)',
|
||||
defaultActiveBorderColor: 'rgb(21,111,78)',
|
||||
groupBorderColor: 'rgb(52,145,114)',
|
||||
primaryShadow: '0 0px 0 rgba(5,145,255,0.1)',
|
||||
defaultShadow: '0 0px 0 rgba(0,0,0,0.02)',
|
||||
dangerShadow: '0 0px 0 rgba(255,38,5,0.06)',
|
||||
},
|
||||
Pagination: {
|
||||
colorPrimary: 'rgb(21,111,78)',
|
||||
},
|
||||
DatePicker: {
|
||||
activeBorderColor: 'rgb(21,111,78)',
|
||||
},
|
||||
Input: {
|
||||
activeBorderColor: 'rgb(21,111,78)',
|
||||
hoverBorderColor: 'rgb(52,145,114)',
|
||||
},
|
||||
InputNumber: {
|
||||
activeBorderColor: 'rgb(21,111,78)',
|
||||
hoverBorderColor: 'rgb(52,145,114)',
|
||||
},
|
||||
Select: {
|
||||
activeBorderColor: 'rgb(21,111,78)',
|
||||
hoverBorderColor: 'rgb(52,145,114)',
|
||||
},
|
||||
Tabs: {
|
||||
itemSelectedColor: 'rgb(21,111,78)',
|
||||
itemHoverColor: 'rgb(52,145,114)',
|
||||
inkBarColor: 'rgb(21,111,78)',
|
||||
},
|
||||
Menu: {
|
||||
itemBorderRadius: 6,
|
||||
itemSelectedBg: '#e5f2ee',
|
||||
itemSelectedColor: '#156f4e',
|
||||
itemHoverBg: '#f3f8f6',
|
||||
itemHoverColor: '#156f4e',
|
||||
},
|
||||
Alert: {
|
||||
colorInfoBg: 'rgb(240,244,242)',
|
||||
colorInfoBorder: 'rgb(200,215,208)',
|
||||
},
|
||||
Progress: {
|
||||
circleTextColor: 'rgb(14,21,19)',
|
||||
},
|
||||
Card: {
|
||||
headerHeight: 46,
|
||||
bodyPadding: 16,
|
||||
bodyPaddingSM: 14,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
115
src/hooks/useMallCategoryTree.ts
Normal file
115
src/hooks/useMallCategoryTree.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { mallCategoryTree } from '@/apis/mallCategory'
|
||||
import type { MallCategoryItem } from '@/types/mall'
|
||||
|
||||
interface MallCategoryTreeNode {
|
||||
key: number
|
||||
value: number
|
||||
title: string
|
||||
selectable?: boolean
|
||||
children?: MallCategoryTreeNode[]
|
||||
}
|
||||
|
||||
let cachedCategoryTree: MallCategoryItem[] | null = null
|
||||
let pendingCategoryTreeRequest: Promise<MallCategoryItem[]> | null = null
|
||||
|
||||
const loadMallCategoryTree = async () => {
|
||||
if (cachedCategoryTree) {
|
||||
return cachedCategoryTree
|
||||
}
|
||||
|
||||
if (!pendingCategoryTreeRequest) {
|
||||
pendingCategoryTreeRequest = mallCategoryTree({ status: 'enabled' })
|
||||
.then(data => {
|
||||
cachedCategoryTree = data.list ?? []
|
||||
return cachedCategoryTree
|
||||
})
|
||||
.finally(() => {
|
||||
pendingCategoryTreeRequest = null
|
||||
})
|
||||
}
|
||||
|
||||
return pendingCategoryTreeRequest
|
||||
}
|
||||
|
||||
const buildTreeData = (
|
||||
list: MallCategoryItem[],
|
||||
options?: {
|
||||
leafOnly?: boolean
|
||||
}
|
||||
): MallCategoryTreeNode[] => {
|
||||
return list.map(item => ({
|
||||
key: item.id,
|
||||
value: item.id,
|
||||
title: item.categoryName,
|
||||
selectable: options?.leafOnly ? item.isLeaf : true,
|
||||
children: buildTreeData(item.children ?? [], options),
|
||||
}))
|
||||
}
|
||||
|
||||
const buildCategoryPathMap = (
|
||||
list: MallCategoryItem[],
|
||||
parentLabels: string[] = [],
|
||||
pathMap = new Map<number, string>()
|
||||
) => {
|
||||
list.forEach(item => {
|
||||
const currentLabels = [...parentLabels, item.categoryName]
|
||||
pathMap.set(item.id, currentLabels.join(' / '))
|
||||
|
||||
if (item.children?.length) {
|
||||
buildCategoryPathMap(item.children, currentLabels, pathMap)
|
||||
}
|
||||
})
|
||||
|
||||
return pathMap
|
||||
}
|
||||
|
||||
export const useMallCategoryTree = () => {
|
||||
const [categories, setCategories] = useState<MallCategoryItem[]>(cachedCategoryTree ?? [])
|
||||
const [loading, setLoading] = useState(!cachedCategoryTree)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const list = await loadMallCategoryTree()
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setCategories(list)
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : '加载商品分类失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void run()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const filterTreeData = useMemo(() => buildTreeData(categories), [categories])
|
||||
const leafTreeData = useMemo(() => buildTreeData(categories, { leafOnly: true }), [categories])
|
||||
const categoryPathMap = useMemo(() => buildCategoryPathMap(categories), [categories])
|
||||
|
||||
return {
|
||||
categories,
|
||||
loading,
|
||||
filterTreeData,
|
||||
leafTreeData,
|
||||
categoryPathMap,
|
||||
}
|
||||
}
|
||||
953
src/index.css
Normal file
953
src/index.css
Normal file
@@ -0,0 +1,953 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: #141414;
|
||||
background: #f5f6f8;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
"Hiragino Sans GB",
|
||||
"Microsoft YaHei",
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.app-page-title {
|
||||
margin-bottom: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.page-card .ant-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-card .ant-card-head {
|
||||
min-height: 46px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.page-card .ant-card-head-title {
|
||||
padding: 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.merchant-shell__topbar {
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.merchant-shell__brand,
|
||||
.merchant-shell__topnav-btn,
|
||||
.merchant-shell__user-trigger {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.merchant-shell__brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.merchant-shell__brand-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #156f4e 0%, #2c9a73 100%);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 18px rgba(21, 111, 78, 0.16);
|
||||
}
|
||||
|
||||
.merchant-shell__brand-copy {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.merchant-shell__collapse-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.merchant-shell__topnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.merchant-shell__topnav-btn {
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: 12px;
|
||||
color: #2e3b37;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.merchant-shell__topnav-btn:hover {
|
||||
color: var(--ant-color-primary);
|
||||
background: #f3f8f6;
|
||||
}
|
||||
|
||||
.merchant-shell__topnav-btn.is-active {
|
||||
color: var(--ant-color-primary);
|
||||
background: var(--ant-color-primary-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.merchant-shell__user-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.merchant-shell__sider {
|
||||
height: 100%;
|
||||
padding: 14px 12px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.merchant-shell__sider-title {
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
font-size: 13px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-default:not(.ant-btn-dangerous),
|
||||
.ant-btn.ant-btn-dashed:not(.ant-btn-dangerous) {
|
||||
color: var(--ant-color-primary);
|
||||
border-color: var(--ant-color-primary-border);
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-default:not(.ant-btn-dangerous):hover,
|
||||
.ant-btn.ant-btn-dashed:not(.ant-btn-dangerous):hover {
|
||||
color: var(--ant-color-primary-hover);
|
||||
border-color: var(--ant-color-primary-hover);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link:not(.ant-btn-dangerous),
|
||||
.ant-btn.ant-btn-text:not(.ant-btn-dangerous) {
|
||||
color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link:not(.ant-btn-dangerous):hover,
|
||||
.ant-btn.ant-btn-text:not(.ant-btn-dangerous):hover {
|
||||
color: var(--ant-color-primary-hover);
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-input-number,
|
||||
.ant-select-selector,
|
||||
.ant-btn,
|
||||
.ant-tabs-tab,
|
||||
.ant-form-item-label > label,
|
||||
.ant-descriptions,
|
||||
.ant-menu,
|
||||
.ant-table,
|
||||
.ant-tag {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.json-textarea textarea {
|
||||
font-family:
|
||||
"SFMono-Regular",
|
||||
Consolas,
|
||||
"Liberation Mono",
|
||||
Menlo,
|
||||
monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.goods-category-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.goods-category-card__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.goods-category-card__label {
|
||||
width: 112px;
|
||||
padding-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.goods-category-card__required {
|
||||
margin-right: 4px;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.goods-category-card__control {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.goods-category-card__picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.goods-category-card__picker .ant-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.goods-category-card__picker .ant-cascader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.goods-category-card__picker .ant-select-selector,
|
||||
.goods-category-card__picker .ant-cascader .ant-select-selector,
|
||||
.goods-category-card__picker .ant-input,
|
||||
.goods-category-card__picker .ant-btn {
|
||||
height: 38px !important;
|
||||
}
|
||||
|
||||
.goods-category-card__picker .ant-select-selection-placeholder,
|
||||
.goods-category-card__picker .ant-select-selection-item,
|
||||
.goods-category-card__picker .ant-cascader .ant-select-selection-placeholder,
|
||||
.goods-category-card__picker .ant-cascader .ant-select-selection-item,
|
||||
.goods-category-card__picker .ant-input {
|
||||
line-height: 36px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.goods-category-card__picker .ant-btn {
|
||||
min-width: 104px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.goods-category-card__path {
|
||||
margin-top: 8px;
|
||||
padding-left: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.goods-product-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.goods-product-card__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.goods-product-card__row--image {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.goods-product-card__label {
|
||||
width: 104px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.goods-product-card__row--image .goods-product-card__label {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.goods-product-card__required {
|
||||
margin-right: 4px;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.goods-product-card__control {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.goods-product-card__control .ant-input,
|
||||
.goods-product-card__control .ant-input-number,
|
||||
.goods-product-card__control .ant-select-selector {
|
||||
height: 30px !important;
|
||||
border-radius: 7px !important;
|
||||
}
|
||||
|
||||
.goods-product-card__control .ant-input {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
line-height: 28px;
|
||||
font-size: 12px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.goods-product-card__control .ant-input-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.goods-product-card__control .ant-select-selection-placeholder,
|
||||
.goods-product-card__control .ant-select-selection-item {
|
||||
line-height: 28px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.goods-product-card__control .ant-input-show-count-suffix {
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.goods-product-card__control .ant-radio-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.goods-product-card__control .ant-radio-wrapper {
|
||||
margin-inline-end: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.goods-product-card__control--medium {
|
||||
max-width: 660px;
|
||||
}
|
||||
|
||||
.goods-attribute-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.goods-attribute-card__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.goods-attribute-card__label {
|
||||
width: 104px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.goods-attribute-card__control {
|
||||
width: min(520px, 100%);
|
||||
}
|
||||
|
||||
.goods-attribute-card__control .ant-select-selector {
|
||||
min-height: 30px !important;
|
||||
border-radius: 7px !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.goods-attribute-card__control .ant-select-selection-placeholder,
|
||||
.goods-attribute-card__control .ant-select-selection-item {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.goods-attribute-card__empty {
|
||||
min-height: 42px;
|
||||
border: 1px dashed #d8dce3;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.image-wall-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.image-wall-upload__list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.image-wall-upload__add {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
flex: 0 0 112px;
|
||||
border: 1px dashed #d2d7de;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.image-wall-upload__add:hover:not(:disabled) {
|
||||
border-color: var(--ant-color-primary);
|
||||
color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.image-wall-upload__add:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.image-wall-upload__add-icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.image-wall-upload__add-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.image-wall-upload__item {
|
||||
position: relative;
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
flex: 0 0 112px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d9dee6;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.image-wall-upload__item .ant-image,
|
||||
.image-wall-upload__item .ant-image-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-wall-upload__badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 2;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: #2f76ff;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.image-wall-upload__toolbar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 14px 4px 4px;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0) 0%, rgba(15, 23, 42, 0.68) 100%);
|
||||
}
|
||||
|
||||
.image-wall-upload__toolbar .ant-btn {
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.image-wall-upload__toolbar .ant-btn.ant-btn-text:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.image-wall-upload__toolbar .ant-btn.ant-btn-text.ant-btn-dangerous {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.image-wall-upload__counter {
|
||||
font-size: 11px;
|
||||
color: #b7bcc4 !important;
|
||||
}
|
||||
|
||||
.hqb-import-modal__input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hqb-import-modal__section {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.hqb-import-modal__section-title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #202624;
|
||||
}
|
||||
|
||||
.hqb-import-modal__images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hqb-import-modal__image,
|
||||
.hqb-import-modal__sku-image {
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.goods-list-product {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.goods-list-product__image {
|
||||
flex: 0 0 56px;
|
||||
border-radius: 9px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.goods-list-product__placeholder {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex: 0 0 56px;
|
||||
border-radius: 9px;
|
||||
border: 1px dashed var(--ant-color-border);
|
||||
background: #fafafa;
|
||||
color: #a0a6ad;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.goods-list-product__content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.goods-list-product__content .ant-typography {
|
||||
max-width: 100%;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.goods-list-product__title {
|
||||
display: -webkit-box;
|
||||
max-height: 34px;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
font-size: 13px !important;
|
||||
line-height: 17px !important;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.goods-detail-editor {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.goods-detail-editor__canvas {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 320px;
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
background: #f7f7f8;
|
||||
border: 1px solid #efefef;
|
||||
}
|
||||
|
||||
.goods-detail-editor__block {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.goods-detail-editor__block + .goods-detail-editor__block {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.goods-detail-editor__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.goods-detail-editor__content .ant-input,
|
||||
.goods-detail-editor__content .ant-input-affix-wrapper,
|
||||
.goods-detail-editor__content .ant-input-textarea textarea {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.goods-detail-editor__content .ant-input-textarea textarea {
|
||||
min-height: 72px !important;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.goods-detail-editor__tools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.goods-detail-editor__tools .ant-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.goods-detail-editor__sidebar {
|
||||
width: 150px;
|
||||
flex: 0 0 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.goods-detail-editor__action.ant-btn {
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.goods-detail-editor__empty {
|
||||
min-height: 280px;
|
||||
border: 1px dashed #d8dce3;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sku-variant-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.sku-variant-editor__specs {
|
||||
padding: 16px;
|
||||
border: 1px solid #eef0f3;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.sku-variant-editor__spec-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-right: 34px;
|
||||
}
|
||||
|
||||
.sku-variant-editor__spec-row + .sku-variant-editor__spec-row {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #eceff3;
|
||||
}
|
||||
|
||||
.sku-variant-editor__inputs {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(180px, 1fr) 76px;
|
||||
gap: 10px;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.sku-variant-editor__name {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.sku-variant-editor__inputs .ant-input,
|
||||
.sku-variant-editor__inputs .ant-btn {
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sku-variant-editor__values {
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding-left: 190px;
|
||||
}
|
||||
|
||||
.sku-variant-editor__values .ant-tag {
|
||||
margin-inline-end: 0;
|
||||
padding: 3px 8px;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sku-variant-editor__remove {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.sku-variant-editor__title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sku-variant-editor__empty {
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sku-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #eceff3;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sku-table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sku-table th,
|
||||
.sku-table td {
|
||||
min-width: 128px;
|
||||
padding: 10px 12px;
|
||||
border-right: 1px solid #eef0f3;
|
||||
border-bottom: 1px solid #eef0f3;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sku-table th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sku-table th span {
|
||||
margin-left: 2px;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.sku-table__image-col,
|
||||
.sku-table td:first-child {
|
||||
min-width: 112px;
|
||||
width: 112px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sku-table .ant-input,
|
||||
.sku-table .ant-input-number,
|
||||
.sku-table .ant-select-selector {
|
||||
height: 34px !important;
|
||||
border-radius: 8px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sku-table .ant-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sku-table .ant-select-selection-item,
|
||||
.sku-table .ant-select-selection-placeholder {
|
||||
line-height: 32px !important;
|
||||
}
|
||||
|
||||
.image-upload-compact {
|
||||
position: relative;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.image-upload-compact__trigger {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
padding: 0;
|
||||
border: 1px dashed #d8dce3;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-upload-compact__trigger:hover:not(:disabled) {
|
||||
border-color: var(--ant-color-primary);
|
||||
color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.image-upload-compact__trigger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.image-upload-compact__icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.image-upload-compact__trigger span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.image-upload-compact__trigger img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-upload-compact__remove {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.goods-category-card__row,
|
||||
.goods-product-card__row {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.goods-category-card__label,
|
||||
.goods-product-card__label {
|
||||
width: auto;
|
||||
padding-top: 0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.goods-category-card__picker {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.image-wall-upload {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.goods-detail-editor {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.goods-detail-editor__sidebar {
|
||||
width: 100%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sku-variant-editor__inputs {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.sku-variant-editor__values {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
17
src/layouts/RootLayout.tsx
Normal file
17
src/layouts/RootLayout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useSessionStore } from '@/store/session'
|
||||
|
||||
export default function RootLayout() {
|
||||
const token = useSessionStore(state => state.token)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/') {
|
||||
navigate(token ? '/profile' : '/login', { replace: true })
|
||||
}
|
||||
}, [location.pathname, navigate, token])
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
33
src/layouts/auth/AuthLayout.tsx
Normal file
33
src/layouts/auth/AuthLayout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Layout, Space, Typography, theme } from 'antd'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
export default function AuthLayout() {
|
||||
const { token } = theme.useToken()
|
||||
|
||||
return (
|
||||
<Layout
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: token.colorBgLayout,
|
||||
}}
|
||||
>
|
||||
<Layout.Content
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 480, maxWidth: '100%' }}>
|
||||
<Space direction="vertical" size={8} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ marginBottom: 0 }}>
|
||||
云拓商城商家端
|
||||
</Typography.Title>
|
||||
</Space>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
293
src/layouts/main/MainLayout.tsx
Normal file
293
src/layouts/main/MainLayout.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
AuditOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
ShopOutlined,
|
||||
ShoppingCartOutlined,
|
||||
StarOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Avatar, Button, Dropdown, Layout, Menu, Space, Typography, theme } from 'antd'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { toast } from 'sonner'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { merchantProfileGet } from '@/apis/mallMerchant'
|
||||
import { useSessionStore } from '@/store/session'
|
||||
import { resolveMerchantSessionIdentity } from '@/utils/mall'
|
||||
|
||||
const { Header, Sider, Content } = Layout
|
||||
|
||||
interface SectionItem {
|
||||
key: string
|
||||
label: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
key: string
|
||||
label: string
|
||||
items: SectionItem[]
|
||||
}
|
||||
|
||||
const NAV_SECTIONS: NavSection[] = [
|
||||
{
|
||||
key: 'shop',
|
||||
label: '店铺',
|
||||
items: [
|
||||
{
|
||||
key: '/profile',
|
||||
label: '商家资料',
|
||||
icon: <ShopOutlined />,
|
||||
},
|
||||
{
|
||||
key: '/featured-goods',
|
||||
label: '首页精选商品',
|
||||
icon: <StarOutlined />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'goods',
|
||||
label: '商品',
|
||||
items: [
|
||||
{
|
||||
key: '/goods',
|
||||
label: '商品管理',
|
||||
icon: <AppstoreOutlined />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'trade',
|
||||
label: '交易',
|
||||
items: [
|
||||
{
|
||||
key: '/orders',
|
||||
label: '订单管理',
|
||||
icon: <ShoppingCartOutlined />,
|
||||
},
|
||||
{
|
||||
key: '/after-sales',
|
||||
label: '售后管理',
|
||||
icon: <AuditOutlined />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const resolveCurrentSection = (pathname: string) => {
|
||||
return (
|
||||
NAV_SECTIONS.find(section => section.items.some(item => pathname.startsWith(item.key))) ?? NAV_SECTIONS[0]
|
||||
)
|
||||
}
|
||||
|
||||
const getAccountTypeLabel = (value?: string) => {
|
||||
if (value === '0') {
|
||||
return '主账号'
|
||||
}
|
||||
|
||||
if (value === '1') {
|
||||
return '子账号'
|
||||
}
|
||||
|
||||
return '商家账号'
|
||||
}
|
||||
|
||||
export default function MainLayout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { token } = theme.useToken()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const authToken = useSessionStore(state => state.token)
|
||||
const userInfo = useSessionStore(state => state.userInfo)
|
||||
const loginAccount = useSessionStore(state => state.loginAccount)
|
||||
const merchantName = useSessionStore(state => state.merchantName)
|
||||
const merchantId = useSessionStore(state => state.merchantId)
|
||||
const updateIdentity = useSessionStore(state => state.updateIdentity)
|
||||
const clearSession = useSessionStore(state => state.clearSession)
|
||||
|
||||
const currentSection = useMemo(() => resolveCurrentSection(location.pathname), [location.pathname])
|
||||
|
||||
const sideMenuItems: MenuProps['items'] = useMemo(
|
||||
() =>
|
||||
currentSection.items.map(item => ({
|
||||
key: item.key,
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
})),
|
||||
[currentSection]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!authToken?.accessToken ||
|
||||
location.pathname.startsWith('/profile') ||
|
||||
(merchantId && merchantName)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const syncIdentity = async () => {
|
||||
try {
|
||||
const data = await merchantProfileGet(merchantId ? { merchantId } : {})
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
updateIdentity(resolveMerchantSessionIdentity(data.merchant))
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : '同步商家资料失败'
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
void syncIdentity()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [authToken?.accessToken, location.pathname, merchantId, merchantName, updateIdentity])
|
||||
|
||||
const dropdownItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: () => {
|
||||
clearSession()
|
||||
navigate('/login', { replace: true })
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh', background: token.colorBgLayout }}>
|
||||
<Header
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
padding: 0,
|
||||
height: 64,
|
||||
lineHeight: '64px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
boxShadow: `0 2px 8px ${token.colorBorderSecondary}`,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<div className="merchant-shell__topbar">
|
||||
<Space size={12} align="center">
|
||||
<button
|
||||
type="button"
|
||||
className="merchant-shell__brand"
|
||||
onClick={() => navigate('/profile')}
|
||||
>
|
||||
<span className="merchant-shell__brand-mark">M</span>
|
||||
<span className="merchant-shell__brand-copy">
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||
云拓商城
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
商家端
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
type="text"
|
||||
className="merchant-shell__collapse-btn"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(prev => !prev)}
|
||||
/>
|
||||
<div className="merchant-shell__topnav">
|
||||
{NAV_SECTIONS.map(section => {
|
||||
const active = section.key === currentSection.key
|
||||
|
||||
return (
|
||||
<button
|
||||
key={section.key}
|
||||
type="button"
|
||||
className={`merchant-shell__topnav-btn${active ? ' is-active' : ''}`}
|
||||
onClick={() => navigate(section.items[0].key)}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Space size={14} align="center">
|
||||
<Typography.Text type="secondary">
|
||||
{userInfo?.account ||
|
||||
loginAccount ||
|
||||
[merchantName, getAccountTypeLabel(authToken?.uType)].filter(Boolean).join(' | ') ||
|
||||
'已登录用户'}
|
||||
</Typography.Text>
|
||||
<Dropdown menu={{ items: dropdownItems }} trigger={['click']}>
|
||||
<button type="button" className="merchant-shell__user-trigger">
|
||||
<Avatar
|
||||
size={36}
|
||||
icon={<UserOutlined />}
|
||||
style={{ backgroundColor: token.colorPrimary }}
|
||||
/>
|
||||
</button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Layout>
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={220}
|
||||
collapsedWidth={76}
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
boxShadow: `2px 0 8px ${token.colorBorderSecondary}`,
|
||||
zIndex: 9,
|
||||
}}
|
||||
>
|
||||
<div className="merchant-shell__sider">
|
||||
{!collapsed ? (
|
||||
<Typography.Text type="secondary" className="merchant-shell__sider-title">
|
||||
{currentSection.label}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={sideMenuItems}
|
||||
inlineCollapsed={collapsed}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
style={{
|
||||
borderInlineEnd: 'none',
|
||||
background: token.colorBgContainer,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
<Content
|
||||
style={{
|
||||
background: token.colorBgLayout,
|
||||
height: 'calc(100vh - 64px)',
|
||||
padding: 16,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
21
src/main.tsx
Normal file
21
src/main.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { App as AntdApp, ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import App from './App'
|
||||
import { lightTheme } from '@/config/themes'
|
||||
import './index.css'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<ConfigProvider locale={zhCN} theme={{ ...lightTheme, cssVar: true }}>
|
||||
<AntdApp>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
)
|
||||
108
src/pages/afterSale/AuditModal.tsx
Normal file
108
src/pages/afterSale/AuditModal.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Form, Input, Modal, Radio } from 'antd'
|
||||
import { toast } from 'sonner'
|
||||
import { merchantAfterSaleAudit } from '@/apis/mallMerchant'
|
||||
import type { AfterSaleSummary } from '@/types/mall'
|
||||
|
||||
interface AuditModalProps {
|
||||
open: boolean
|
||||
merchantId: string
|
||||
record: AfterSaleSummary | null
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
interface AuditFormValues {
|
||||
auditStatus: 'approved' | 'rejected'
|
||||
refundAmount?: string
|
||||
rejectReason?: string
|
||||
handleRemark?: string
|
||||
}
|
||||
|
||||
export default function AuditModal({
|
||||
open,
|
||||
merchantId,
|
||||
record,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: AuditModalProps) {
|
||||
const [form] = Form.useForm<AuditFormValues>()
|
||||
const auditStatus = Form.useWatch('auditStatus', form)
|
||||
|
||||
const handleOk = async () => {
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
await merchantAfterSaleAudit({
|
||||
merchantId,
|
||||
afterSaleId: record.id,
|
||||
auditStatus: values.auditStatus,
|
||||
refundAmount: values.refundAmount?.trim(),
|
||||
rejectReason: values.rejectReason?.trim(),
|
||||
handleRemark: values.handleRemark?.trim(),
|
||||
})
|
||||
toast.success('售后审核已提交')
|
||||
form.resetFields()
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '提交审核失败'
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={record ? `审核售后 #${record.afterSaleNo}` : '审核售后'}
|
||||
open={open}
|
||||
destroyOnClose
|
||||
onCancel={() => {
|
||||
form.resetFields()
|
||||
onClose()
|
||||
}}
|
||||
onOk={() => void handleOk()}
|
||||
okText="提交审核"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
initialValues={{
|
||||
auditStatus: 'approved',
|
||||
refundAmount: record?.applyAmount ?? '',
|
||||
}}
|
||||
>
|
||||
<Form.Item name="auditStatus" label="审核结果" rules={[{ required: true }]}>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ label: '同意', value: 'approved' },
|
||||
{ label: '驳回', value: 'rejected' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{auditStatus === 'approved' ? (
|
||||
<Form.Item
|
||||
name="refundAmount"
|
||||
label="退款金额"
|
||||
rules={[{ required: true, message: '同意售后时请输入退款金额' }]}
|
||||
>
|
||||
<Input placeholder="例如 64.00" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="rejectReason"
|
||||
label="驳回原因"
|
||||
rules={[{ required: true, message: '驳回售后时请输入原因' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="handleRemark" label="处理备注">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
230
src/pages/afterSale/index.tsx
Normal file
230
src/pages/afterSale/index.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Form, Input, Select, Space, Table, Tag, Typography } from 'antd'
|
||||
import { toast } from 'sonner'
|
||||
import { merchantAfterSalePage } from '@/apis/mallMerchant'
|
||||
import AuditModal from './AuditModal'
|
||||
import { useSessionStore } from '@/store/session'
|
||||
import type { AfterSaleStatus, AfterSaleSummary } from '@/types/mall'
|
||||
import {
|
||||
AFTER_SALE_STATUS_OPTIONS,
|
||||
AFTER_SALE_TYPE_OPTIONS,
|
||||
formatUnixTime,
|
||||
getStatusColor,
|
||||
getStatusLabel,
|
||||
prettyJsonText,
|
||||
} from '@/utils/mall'
|
||||
|
||||
interface AfterSaleSearchValues {
|
||||
afterSaleType?: 0 | 1 | 2
|
||||
afterSaleStatus?: AfterSaleStatus | ''
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export default function AfterSalePage() {
|
||||
const merchantId = useSessionStore(state => state.merchantId)
|
||||
const [form] = Form.useForm<AfterSaleSearchValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [size, setSize] = useState(20)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [list, setList] = useState<AfterSaleSummary[]>([])
|
||||
const [auditRecord, setAuditRecord] = useState<AfterSaleSummary | null>(null)
|
||||
|
||||
const loadList = async (nextPage = page, nextSize = size) => {
|
||||
if (!merchantId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const values = form.getFieldsValue()
|
||||
const result = await merchantAfterSalePage({
|
||||
merchantId,
|
||||
afterSaleType: values.afterSaleType ?? 0,
|
||||
afterSaleStatus: values.afterSaleStatus || undefined,
|
||||
keyword: values.keyword?.trim(),
|
||||
page: nextPage,
|
||||
size: nextSize,
|
||||
})
|
||||
setList(result.list ?? [])
|
||||
setTotal(result.total ?? 0)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载售后列表失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
afterSaleType: 0,
|
||||
afterSaleStatus: '',
|
||||
keyword: '',
|
||||
})
|
||||
setPage(1)
|
||||
void loadList(1, size)
|
||||
}, [merchantId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={18} style={{ width: '100%' }}>
|
||||
<Card className="page-card" title="售后筛选">
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Form form={form} layout="inline">
|
||||
<Form.Item name="afterSaleType" label="售后类型">
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
options={AFTER_SALE_TYPE_OPTIONS.map(item => ({ ...item }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="afterSaleStatus" label="售后状态">
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
options={AFTER_SALE_STATUS_OPTIONS.map(item => ({ ...item }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input placeholder="售后单号 / 订单号" style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setPage(1)
|
||||
void loadList(1, size)
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ afterSaleType: 0 })
|
||||
setPage(1)
|
||||
void loadList(1, size)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="page-card" title="售后列表">
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={list}
|
||||
expandable={{
|
||||
expandedRowRender: record => (
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Typography.Text>售后单 ID:{record.id}</Typography.Text>
|
||||
<Typography.Text>会员 ID:{record.uid}</Typography.Text>
|
||||
<Typography.Text>原因:{record.reason || '-'}</Typography.Text>
|
||||
<Typography.Text>说明:{record.description || '-'}</Typography.Text>
|
||||
<Typography.Text>驳回原因:{record.rejectReason || '-'}</Typography.Text>
|
||||
<Typography.Text>处理备注:{record.handleRemark || '-'}</Typography.Text>
|
||||
<Typography.Text>审核时间:{formatUnixTime(record.auditTime)}</Typography.Text>
|
||||
<Typography.Text>完成时间:{formatUnixTime(record.successTime)}</Typography.Text>
|
||||
<Typography.Text>关闭时间:{formatUnixTime(record.closeTime)}</Typography.Text>
|
||||
<div>
|
||||
凭证 JSON:
|
||||
<Typography.Text style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{prettyJsonText(record.evidenceJson) || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setSize(nextSize)
|
||||
void loadList(nextPage, nextSize)
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '售后单号',
|
||||
dataIndex: 'afterSaleNo',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>{record.afterSaleNo}</span>
|
||||
<span style={{ color: 'var(--ant-color-text-secondary)' }}>{record.orderNo}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'spuName',
|
||||
width: 240,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>{record.spuName}</span>
|
||||
<span style={{ color: 'var(--ant-color-text-secondary)' }}>{record.skuName}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'afterSaleType',
|
||||
width: 120,
|
||||
render: value => (value === 1 ? '仅退款' : '退货退款'),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'applyAmount',
|
||||
width: 160,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>申请:{record.applyAmount}</span>
|
||||
<span style={{ color: 'var(--ant-color-text-secondary)' }}>
|
||||
已退:{record.refundAmount || '-'}
|
||||
</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'afterSaleStatus',
|
||||
width: 160,
|
||||
render: value => <Tag color={getStatusColor(value)}>{getStatusLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '申请时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 180,
|
||||
render: value => formatUnixTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 140,
|
||||
render: (_, record) => (
|
||||
<Button type="link" disabled={record.afterSaleStatus !== 'pending'} onClick={() => setAuditRecord(record)}>
|
||||
审核
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<AuditModal
|
||||
open={Boolean(auditRecord)}
|
||||
merchantId={merchantId ?? ''}
|
||||
record={auditRecord}
|
||||
onClose={() => setAuditRecord(null)}
|
||||
onSuccess={() => void loadList()}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
226
src/pages/featuredGoods/GoodsPickerModal.tsx
Normal file
226
src/pages/featuredGoods/GoodsPickerModal.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Form, Input, Modal, Select, Space, Table, Tag, Typography } from 'antd'
|
||||
import { toast } from 'sonner'
|
||||
import { merchantGoodsPage } from '@/apis/mallMerchant'
|
||||
import type { AuditStatus, GoodsSummary, SaleStatus } from '@/types/mall'
|
||||
import {
|
||||
AUDIT_STATUS_OPTIONS,
|
||||
SALE_STATUS_OPTIONS,
|
||||
getStatusColor,
|
||||
getStatusLabel,
|
||||
} from '@/utils/mall'
|
||||
|
||||
interface GoodsPickerSearchValues {
|
||||
keyword?: string
|
||||
auditStatus?: AuditStatus | ''
|
||||
saleStatus?: SaleStatus | ''
|
||||
}
|
||||
|
||||
interface GoodsPickerModalProps {
|
||||
open: boolean
|
||||
merchantId: string
|
||||
configuredSpuIds: number[]
|
||||
currentSpuId?: number
|
||||
onCancel: () => void
|
||||
onSelect: (goods: GoodsSummary) => void
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export default function GoodsPickerModal({
|
||||
open,
|
||||
merchantId,
|
||||
configuredSpuIds,
|
||||
currentSpuId,
|
||||
onCancel,
|
||||
onSelect,
|
||||
}: GoodsPickerModalProps) {
|
||||
const [form] = Form.useForm<GoodsPickerSearchValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [list, setList] = useState<GoodsSummary[]>([])
|
||||
|
||||
const disabledSpuIds = useMemo(
|
||||
() => new Set(configuredSpuIds.filter(spuId => spuId !== currentSpuId)),
|
||||
[configuredSpuIds, currentSpuId]
|
||||
)
|
||||
|
||||
const loadList = async (nextPage = page) => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const values = form.getFieldsValue()
|
||||
const result = await merchantGoodsPage({
|
||||
merchantId,
|
||||
keyword: values.keyword?.trim(),
|
||||
auditStatus: values.auditStatus || undefined,
|
||||
saleStatus: values.saleStatus || undefined,
|
||||
page: nextPage,
|
||||
size: PAGE_SIZE,
|
||||
})
|
||||
setList(result.list ?? [])
|
||||
setTotal(result.total ?? 0)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载商品列表失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
form.setFieldsValue({
|
||||
keyword: '',
|
||||
auditStatus: '',
|
||||
saleStatus: '',
|
||||
})
|
||||
setPage(1)
|
||||
void loadList(1)
|
||||
}, [form, merchantId, open]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="选择商家商品"
|
||||
open={open}
|
||||
width={1120}
|
||||
onCancel={onCancel}
|
||||
footer={[
|
||||
<Button key="close" onClick={onCancel}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary">
|
||||
当前只展示该商家主体下的商品,merchantId 为 {merchantId};已在精选列表中的商品不能重复选择。
|
||||
</Typography.Text>
|
||||
|
||||
<Form form={form} layout="inline">
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input placeholder="商品编码 / 商品名称" style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="auditStatus" label="审核状态">
|
||||
<Select style={{ width: 150 }} options={AUDIT_STATUS_OPTIONS.map(item => ({ ...item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="saleStatus" label="上下架">
|
||||
<Select style={{ width: 150 }} options={SALE_STATUS_OPTIONS.map(item => ({ ...item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setPage(1)
|
||||
void loadList(1)
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
setPage(1)
|
||||
void loadList(1)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={list}
|
||||
scroll={{ x: 1080 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: PAGE_SIZE,
|
||||
total,
|
||||
onChange: nextPage => {
|
||||
setPage(nextPage)
|
||||
void loadList(nextPage)
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '商品编码',
|
||||
dataIndex: 'spuNo',
|
||||
width: 160,
|
||||
render: value => value || '-',
|
||||
},
|
||||
{
|
||||
title: '商品名称',
|
||||
dataIndex: 'spuName',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text strong>{record.spuName}</Typography.Text>
|
||||
<Typography.Text type="secondary">{record.subTitle || '-'}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类目 ID',
|
||||
dataIndex: 'categoryId',
|
||||
width: 110,
|
||||
},
|
||||
{
|
||||
title: '商品状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: value => <Tag color={getStatusColor(value)}>{getStatusLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '审核状态',
|
||||
dataIndex: 'auditStatus',
|
||||
width: 110,
|
||||
render: value => <Tag color={getStatusColor(value)}>{getStatusLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '上下架',
|
||||
dataIndex: 'saleStatus',
|
||||
width: 110,
|
||||
render: value => <Tag color={getStatusColor(value)}>{getStatusLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '价格区间',
|
||||
dataIndex: 'minPrice',
|
||||
width: 160,
|
||||
render: (_, record) => `${record.minPrice} - ${record.maxPrice}`,
|
||||
},
|
||||
{
|
||||
title: '库存',
|
||||
dataIndex: 'totalStock',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type="link"
|
||||
disabled={disabledSpuIds.has(record.id)}
|
||||
onClick={() => onSelect(record)}
|
||||
>
|
||||
{disabledSpuIds.has(record.id) ? '已配置' : '选择'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
465
src/pages/featuredGoods/index.tsx
Normal file
465
src/pages/featuredGoods/index.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
merchantFeaturedGoodsDelete,
|
||||
merchantFeaturedGoodsPage,
|
||||
merchantFeaturedGoodsSave,
|
||||
} from '@/apis/mallMerchant'
|
||||
import { useSessionStore } from '@/store/session'
|
||||
import type { EnableStatus, GoodsSummary, MerchantFeaturedGoodsItem } from '@/types/mall'
|
||||
import { ENABLE_STATUS_OPTIONS, formatUnixTime, getStatusColor, getStatusLabel } from '@/utils/mall'
|
||||
import GoodsPickerModal from './GoodsPickerModal'
|
||||
|
||||
interface SearchValues {
|
||||
keyword?: string
|
||||
status?: EnableStatus | ''
|
||||
}
|
||||
|
||||
interface EditorValues {
|
||||
sort?: number
|
||||
status: EnableStatus
|
||||
}
|
||||
|
||||
interface SelectedGoodsInfo {
|
||||
id: number
|
||||
spuNo: string
|
||||
spuName: string
|
||||
subTitle: string
|
||||
}
|
||||
|
||||
const ENABLED_DISABLED_OPTIONS = [
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
] as const
|
||||
|
||||
const getGoodsStatusColor = (value?: EnableStatus) => {
|
||||
if (value === 'enabled') {
|
||||
return 'green'
|
||||
}
|
||||
|
||||
if (value === 'disabled') {
|
||||
return 'red'
|
||||
}
|
||||
|
||||
return getStatusColor(value)
|
||||
}
|
||||
|
||||
const getAuditRiskColor = (value?: string) => {
|
||||
if (value === 'approved') {
|
||||
return 'green'
|
||||
}
|
||||
|
||||
if (value === 'pending' || value === 'rejected') {
|
||||
return 'red'
|
||||
}
|
||||
|
||||
return getStatusColor(value)
|
||||
}
|
||||
|
||||
const getSaleRiskColor = (value?: string) => {
|
||||
if (value === 'on') {
|
||||
return 'processing'
|
||||
}
|
||||
|
||||
if (value === 'off') {
|
||||
return 'red'
|
||||
}
|
||||
|
||||
return getStatusColor(value)
|
||||
}
|
||||
|
||||
export default function FeaturedGoodsPage() {
|
||||
const merchantId = useSessionStore(state => state.merchantId)
|
||||
const [searchForm] = Form.useForm<SearchValues>()
|
||||
const [editorForm] = Form.useForm<EditorValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [size, setSize] = useState(20)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [list, setList] = useState<MerchantFeaturedGoodsItem[]>([])
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<MerchantFeaturedGoodsItem | null>(null)
|
||||
const [selectedGoods, setSelectedGoods] = useState<SelectedGoodsInfo | null>(null)
|
||||
|
||||
const configuredSpuIds = useMemo(() => list.map(item => item.spuId), [list])
|
||||
|
||||
const loadList = async (nextPage = page, nextSize = size) => {
|
||||
if (!merchantId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const values = searchForm.getFieldsValue()
|
||||
const result = await merchantFeaturedGoodsPage({
|
||||
merchantId,
|
||||
keyword: values.keyword?.trim(),
|
||||
status: values.status || undefined,
|
||||
page: nextPage,
|
||||
size: nextSize,
|
||||
})
|
||||
setList(result.list ?? [])
|
||||
setTotal(result.total ?? 0)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载首页精选商品失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
searchForm.setFieldsValue({
|
||||
keyword: '',
|
||||
status: '',
|
||||
})
|
||||
setPage(1)
|
||||
void loadList(1, size)
|
||||
}, [merchantId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingRecord(null)
|
||||
setSelectedGoods(null)
|
||||
editorForm.setFieldsValue({
|
||||
sort: 0,
|
||||
status: 'enabled',
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const handleOpenEdit = (record: MerchantFeaturedGoodsItem) => {
|
||||
setEditingRecord(record)
|
||||
setSelectedGoods({
|
||||
id: record.spuId,
|
||||
spuNo: record.spuNo,
|
||||
spuName: record.spuName,
|
||||
subTitle: record.subTitle,
|
||||
})
|
||||
editorForm.setFieldsValue({
|
||||
sort: record.sort,
|
||||
status: record.configStatus,
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (record: MerchantFeaturedGoodsItem) => {
|
||||
if (!merchantId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await merchantFeaturedGoodsDelete({
|
||||
merchantId,
|
||||
id: record.id,
|
||||
})
|
||||
toast.success('精选商品已删除')
|
||||
await loadList()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '删除精选商品失败'
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!merchantId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedGoods) {
|
||||
toast.error('请选择商家商品')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const values = await editorForm.validateFields()
|
||||
setSaving(true)
|
||||
await merchantFeaturedGoodsSave({
|
||||
merchantId,
|
||||
id: editingRecord?.id,
|
||||
spuId: selectedGoods.id,
|
||||
sort: values.sort ?? 0,
|
||||
status: values.status,
|
||||
})
|
||||
toast.success(editingRecord ? '精选商品已更新' : '精选商品已新增')
|
||||
setEditorOpen(false)
|
||||
await loadList()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '保存精选商品失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={18} style={{ width: '100%' }}>
|
||||
<Card
|
||||
className="page-card"
|
||||
title="查询条件"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} disabled={!merchantId} onClick={handleOpenCreate}>
|
||||
新增精选商品
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Form form={searchForm} layout="inline">
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input placeholder="商品编码 / 商品名称" style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="配置状态">
|
||||
<Radio.Group
|
||||
options={ENABLE_STATUS_OPTIONS.map(item => ({ ...item }))}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!merchantId}
|
||||
onClick={() => {
|
||||
setPage(1)
|
||||
void loadList(1, size)
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!merchantId}
|
||||
onClick={() => {
|
||||
searchForm.resetFields()
|
||||
setPage(1)
|
||||
void loadList(1, size)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="page-card" title="已配置精选商品">
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={list}
|
||||
scroll={{ x: 1800 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setSize(nextSize)
|
||||
void loadList(nextPage, nextSize)
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '配置 ID',
|
||||
dataIndex: 'id',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '商品 ID',
|
||||
dataIndex: 'spuId',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '商品编码',
|
||||
dataIndex: 'spuNo',
|
||||
width: 160,
|
||||
render: value => value || '-',
|
||||
},
|
||||
{
|
||||
title: '商品名称',
|
||||
dataIndex: 'spuName',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '副标题',
|
||||
dataIndex: 'subTitle',
|
||||
width: 180,
|
||||
render: value => value || '-',
|
||||
},
|
||||
{
|
||||
title: '封面图',
|
||||
dataIndex: 'coverUrl',
|
||||
width: 120,
|
||||
render: value =>
|
||||
value ? (
|
||||
<Image width={56} height={56} src={value} alt="cover" style={{ objectFit: 'cover' }} />
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品状态',
|
||||
dataIndex: 'goodsStatus',
|
||||
width: 110,
|
||||
render: value => <Tag color={getGoodsStatusColor(value)}>{getStatusLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '审核状态',
|
||||
dataIndex: 'auditStatus',
|
||||
width: 110,
|
||||
render: value => <Tag color={getAuditRiskColor(value)}>{getStatusLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '上下架状态',
|
||||
dataIndex: 'saleStatus',
|
||||
width: 120,
|
||||
render: value => <Tag color={getSaleRiskColor(value)}>{getStatusLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '配置状态',
|
||||
dataIndex: 'configStatus',
|
||||
width: 110,
|
||||
render: value => <Tag color={getStatusColor(value)}>{getStatusLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '最低价',
|
||||
dataIndex: 'minPrice',
|
||||
width: 100,
|
||||
render: value => value || '-',
|
||||
},
|
||||
{
|
||||
title: '最高价',
|
||||
dataIndex: 'maxPrice',
|
||||
width: 100,
|
||||
render: value => value || '-',
|
||||
},
|
||||
{
|
||||
title: '总库存',
|
||||
dataIndex: 'totalStock',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 180,
|
||||
render: value => formatUnixTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => handleOpenEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除这条精选商品配置吗?"
|
||||
onConfirm={() => void handleDelete(record)}
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingRecord ? '编辑精选商品' : '新增精选商品'}
|
||||
open={editorOpen}
|
||||
confirmLoading={saving}
|
||||
width={640}
|
||||
onOk={() => void handleSave()}
|
||||
onCancel={() => setEditorOpen(false)}
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary">
|
||||
当前页按商家主体维护首页精选商品。新增时提交 merchantId、spuId、sort、status;编辑时会额外带上当前配置 ID。
|
||||
</Typography.Text>
|
||||
|
||||
<Form form={editorForm} layout="vertical" requiredMark={false}>
|
||||
<Form.Item label="商品" required>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Input
|
||||
readOnly
|
||||
placeholder="请选择商家商品"
|
||||
value={
|
||||
selectedGoods
|
||||
? `${selectedGoods.spuName}${selectedGoods.spuNo ? `(${selectedGoods.spuNo})` : ''}`
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
{selectedGoods ? (
|
||||
<Typography.Text type="secondary">
|
||||
spuId: {selectedGoods.id} {selectedGoods.subTitle ? `| ${selectedGoods.subTitle}` : ''}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
<Button onClick={() => setPickerOpen(true)}>选择商家商品</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="sort" label="排序">
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="默认 0,越小越靠前" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="配置状态"
|
||||
rules={[{ required: true, message: '请选择配置状态' }]}
|
||||
>
|
||||
<Radio.Group options={ENABLED_DISABLED_OPTIONS.map(item => ({ ...item }))} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
{merchantId ? (
|
||||
<GoodsPickerModal
|
||||
open={pickerOpen}
|
||||
merchantId={merchantId}
|
||||
configuredSpuIds={configuredSpuIds}
|
||||
currentSpuId={editingRecord?.spuId}
|
||||
onCancel={() => setPickerOpen(false)}
|
||||
onSelect={(goods: GoodsSummary) => {
|
||||
setSelectedGoods({
|
||||
id: goods.id,
|
||||
spuNo: goods.spuNo,
|
||||
spuName: goods.spuName,
|
||||
subTitle: goods.subTitle,
|
||||
})
|
||||
setPickerOpen(false)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
1602
src/pages/goods/GoodsEditorDrawer.tsx
Normal file
1602
src/pages/goods/GoodsEditorDrawer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
285
src/pages/goods/HqbImportModal.tsx
Normal file
285
src/pages/goods/HqbImportModal.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { CloudDownloadOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import { toast } from 'sonner'
|
||||
import { fetchHqbGoodsInfo } from '@/apis/hqb'
|
||||
import { merchantGoodsSave } from '@/apis/mallMerchant'
|
||||
import {
|
||||
buildMallGoodsFromHqb,
|
||||
extractHqbGoodsId,
|
||||
type HqbImportPreview,
|
||||
} from '@/utils/hqbImport'
|
||||
import type { MerchantGoodsSkuInput } from '@/types/mall'
|
||||
|
||||
interface HqbImportModalProps {
|
||||
open: boolean
|
||||
merchantId: string
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const parseSpecNames = (specJson: string) => {
|
||||
try {
|
||||
const specs = JSON.parse(specJson) as Array<{ attributeName?: string; valueName?: string }>
|
||||
return specs.map(item => item.valueName).filter(Boolean).join(' / ') || '-'
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
export default function HqbImportModal({ open, merchantId, onClose, onSuccess }: HqbImportModalProps) {
|
||||
const [sourceText, setSourceText] = useState('')
|
||||
const [preview, setPreview] = useState<HqbImportPreview | null>(null)
|
||||
const [parsing, setParsing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPreview(null)
|
||||
setParsing(false)
|
||||
setSaving(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleParse = async () => {
|
||||
const goodsId = extractHqbGoodsId(sourceText)
|
||||
if (!goodsId) {
|
||||
toast.error('请输入 52hqb 商品详情链接,例如 https://www.52hqb.com/good/12432863')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setParsing(true)
|
||||
const goods = await fetchHqbGoodsInfo(goodsId)
|
||||
setPreview(buildMallGoodsFromHqb(goods, merchantId, `https://www.52hqb.com/good/${goodsId}`))
|
||||
toast.success('商品解析完成')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '商品解析失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setParsing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!merchantId) {
|
||||
toast.error('请先登录商家账号')
|
||||
return
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
await handleParse()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
await merchantGoodsSave({
|
||||
...preview.payload,
|
||||
merchantId,
|
||||
})
|
||||
toast.success('商品已创建,默认进入待审核/下架状态')
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '创建商品失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space size={8}>
|
||||
<LinkOutlined />
|
||||
<span>52hqb 链接上品</span>
|
||||
</Space>
|
||||
}
|
||||
open={open}
|
||||
width={920}
|
||||
onCancel={onClose}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button
|
||||
icon={<CloudDownloadOutlined />}
|
||||
loading={parsing}
|
||||
disabled={saving}
|
||||
onClick={() => void handleParse()}
|
||||
>
|
||||
解析商品
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={saving}
|
||||
disabled={!merchantId || parsing || !preview}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
创建商品
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
destroyOnClose
|
||||
>
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="输入 52hqb 商品链接后,系统会自动读取标题、轮播图、SKU 图片与规格,并按商城商家端 goods_save 字段创建商品。"
|
||||
/>
|
||||
|
||||
<div className="hqb-import-modal__input">
|
||||
<Typography.Text strong>商品详情链接</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={sourceText}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
placeholder="https://www.52hqb.com/good/12432863"
|
||||
onChange={event => {
|
||||
setSourceText(event.target.value)
|
||||
setPreview(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Descriptions
|
||||
size="small"
|
||||
bordered
|
||||
column={2}
|
||||
labelStyle={{ width: 110 }}
|
||||
items={[
|
||||
{
|
||||
key: 'title',
|
||||
label: '商品标题',
|
||||
span: 2,
|
||||
children: preview.title,
|
||||
},
|
||||
{
|
||||
key: 'sourceGoodsId',
|
||||
label: '源商品 ID',
|
||||
children: preview.sourceGoodsId,
|
||||
},
|
||||
{
|
||||
key: 'targetCategory',
|
||||
label: '目标分类',
|
||||
children: (
|
||||
<Space size={6}>
|
||||
<Tag color="green">{preview.categoryName}</Tag>
|
||||
<Typography.Text type="secondary">1126672</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sourceCategory',
|
||||
label: '源站分类',
|
||||
children: preview.sourceCategoryPath || '-',
|
||||
},
|
||||
{
|
||||
key: 'shopName',
|
||||
label: '源店铺',
|
||||
children: preview.shopName || '-',
|
||||
},
|
||||
{
|
||||
key: 'imageCount',
|
||||
label: '轮播图',
|
||||
children: `${preview.imageUrls.length} 张`,
|
||||
},
|
||||
{
|
||||
key: 'skuCount',
|
||||
label: 'SKU',
|
||||
children: `${preview.skuCount} 个`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="hqb-import-modal__section">
|
||||
<div className="hqb-import-modal__section-title">图片预览</div>
|
||||
<div className="hqb-import-modal__images">
|
||||
{preview.imageUrls.slice(0, 8).map(url => (
|
||||
<Image
|
||||
key={url}
|
||||
src={url}
|
||||
width={74}
|
||||
height={74}
|
||||
className="hqb-import-modal__image"
|
||||
preview={{ src: url }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hqb-import-modal__section">
|
||||
<div className="hqb-import-modal__section-title">SKU 预览</div>
|
||||
<Table<MerchantGoodsSkuInput>
|
||||
rowKey={(record, index) => `${record.skuNo}-${index}`}
|
||||
size="small"
|
||||
dataSource={preview.payload.skus}
|
||||
pagination={false}
|
||||
scroll={{ y: 240 }}
|
||||
columns={[
|
||||
{
|
||||
title: '图片',
|
||||
dataIndex: 'imageUrl',
|
||||
width: 74,
|
||||
render: value =>
|
||||
value ? (
|
||||
<Image
|
||||
src={value}
|
||||
width={46}
|
||||
height={46}
|
||||
className="hqb-import-modal__sku-image"
|
||||
preview={{ src: value }}
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '规格',
|
||||
dataIndex: 'specJson',
|
||||
render: value => parseSpecNames(value),
|
||||
},
|
||||
{
|
||||
title: 'SKU 货号',
|
||||
dataIndex: 'skuNo',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '售价',
|
||||
dataIndex: 'salePrice',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: '库存',
|
||||
dataIndex: 'stock',
|
||||
width: 80,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="解析后会在这里预览即将创建的商品数据"
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
329
src/pages/goods/index.tsx
Normal file
329
src/pages/goods/index.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
TreeSelect,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { LinkOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { toast } from 'sonner'
|
||||
import { merchantGoodsPage, merchantGoodsPublish } from '@/apis/mallMerchant'
|
||||
import GoodsEditorDrawer from './GoodsEditorDrawer'
|
||||
import HqbImportModal from './HqbImportModal'
|
||||
import { useMallCategoryTree } from '@/hooks/useMallCategoryTree'
|
||||
import { useSessionStore } from '@/store/session'
|
||||
import type { AuditStatus, GoodsSummary, SaleStatus } from '@/types/mall'
|
||||
import {
|
||||
AUDIT_STATUS_OPTIONS,
|
||||
SALE_STATUS_OPTIONS,
|
||||
formatUnixTime,
|
||||
getStatusColor,
|
||||
getStatusLabel,
|
||||
} from '@/utils/mall'
|
||||
|
||||
interface GoodsSearchValues {
|
||||
categoryId?: number
|
||||
keyword?: string
|
||||
auditStatus?: AuditStatus | ''
|
||||
saleStatus?: SaleStatus | ''
|
||||
}
|
||||
|
||||
export default function GoodsPage() {
|
||||
const merchantId = useSessionStore(state => state.merchantId)
|
||||
const { filterTreeData, loading: categoryLoading } = useMallCategoryTree()
|
||||
const [form] = Form.useForm<GoodsSearchValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [size, setSize] = useState(20)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [list, setList] = useState<GoodsSummary[]>([])
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<GoodsSummary | null>(null)
|
||||
|
||||
const loadList = async (nextPage = page, nextSize = size) => {
|
||||
if (!merchantId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const values = form.getFieldsValue()
|
||||
const result = await merchantGoodsPage({
|
||||
merchantId,
|
||||
categoryId: values.categoryId ? Number(values.categoryId) : undefined,
|
||||
keyword: values.keyword?.trim(),
|
||||
auditStatus: values.auditStatus || undefined,
|
||||
saleStatus: values.saleStatus || undefined,
|
||||
page: nextPage,
|
||||
size: nextSize,
|
||||
})
|
||||
setList(result.list ?? [])
|
||||
setTotal(result.total ?? 0)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载商品列表失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
categoryId: undefined,
|
||||
keyword: '',
|
||||
auditStatus: '',
|
||||
saleStatus: '',
|
||||
})
|
||||
void loadList(1, size)
|
||||
setPage(1)
|
||||
}, [merchantId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handlePublish = async (record: GoodsSummary) => {
|
||||
try {
|
||||
await merchantGoodsPublish({
|
||||
merchantId: merchantId ?? '',
|
||||
spuId: record.id,
|
||||
saleStatus: record.saleStatus === 'on' ? 'off' : 'on',
|
||||
})
|
||||
toast.success(`商品已${record.saleStatus === 'on' ? '下架' : '上架'}`)
|
||||
await loadList()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '更新上下架状态失败'
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={18} style={{ width: '100%' }}>
|
||||
<Card
|
||||
className="page-card"
|
||||
title="当前商家商品"
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<LinkOutlined />}
|
||||
disabled={!merchantId}
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
链接上品
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
disabled={!merchantId}
|
||||
onClick={() => {
|
||||
setEditingRecord(null)
|
||||
setDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
新增商品
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Form form={form} layout="inline">
|
||||
<Form.Item name="categoryId" label="商品分类">
|
||||
<TreeSelect
|
||||
style={{ width: 220 }}
|
||||
treeData={filterTreeData}
|
||||
placeholder="请选择分类"
|
||||
allowClear
|
||||
showSearch
|
||||
treeDefaultExpandAll
|
||||
treeNodeFilterProp="title"
|
||||
notFoundContent={categoryLoading ? '分类加载中...' : '暂无分类'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="auditStatus" label="审核状态">
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
options={AUDIT_STATUS_OPTIONS.map(item => ({ ...item }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="saleStatus" label="上下架">
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
options={SALE_STATUS_OPTIONS.map(item => ({ ...item }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input placeholder="商品名 / 商品编号" style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setPage(1)
|
||||
void loadList(1, size)
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
setPage(1)
|
||||
void loadList(1, size)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="page-card" title="商品列表">
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={list}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setSize(nextSize)
|
||||
void loadList(nextPage, nextSize)
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '商品信息',
|
||||
dataIndex: 'spuName',
|
||||
width: 300,
|
||||
render: (_, record) => (
|
||||
<div className="goods-list-product">
|
||||
{record.coverUrl ? (
|
||||
<Image
|
||||
src={record.coverUrl}
|
||||
alt={record.spuName}
|
||||
width={56}
|
||||
height={56}
|
||||
className="goods-list-product__image"
|
||||
preview={{ src: record.coverUrl }}
|
||||
/>
|
||||
) : (
|
||||
<div className="goods-list-product__placeholder">暂无主图</div>
|
||||
)}
|
||||
<Space direction="vertical" size={2} className="goods-list-product__content">
|
||||
<Typography.Text
|
||||
strong
|
||||
className="goods-list-product__title"
|
||||
title={record.spuName}
|
||||
>
|
||||
{record.spuName}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis>
|
||||
{record.spuNo || `spuId: ${record.id}`}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis={{ tooltip: record.subTitle }}>
|
||||
{record.subTitle || '-'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品 ID / 类目',
|
||||
dataIndex: 'id',
|
||||
width: 170,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>spuId: {record.id}</span>
|
||||
<Typography.Text type="secondary">
|
||||
{record.categoryName || `categoryId: ${record.categoryId}`}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '价格区间',
|
||||
dataIndex: 'minPrice',
|
||||
width: 96,
|
||||
render: (_, record) => `${record.minPrice} - ${record.maxPrice}`,
|
||||
},
|
||||
{
|
||||
title: '库存',
|
||||
dataIndex: 'totalStock',
|
||||
width: 96,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>总库存:{record.totalStock}</span>
|
||||
<Typography.Text type="secondary">锁定:{record.lockedStock}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 118,
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
<Tag color={getStatusColor(record.status)}>{getStatusLabel(record.status)}</Tag>
|
||||
<Tag color={getStatusColor(record.auditStatus)}>{getStatusLabel(record.auditStatus)}</Tag>
|
||||
<Tag color={getStatusColor(record.saleStatus)}>{getStatusLabel(record.saleStatus)}</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 138,
|
||||
render: value => formatUnixTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 118,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => {
|
||||
setEditingRecord(record)
|
||||
setDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" onClick={() => void handlePublish(record)}>
|
||||
{record.saleStatus === 'on' ? '下架' : '上架'}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<GoodsEditorDrawer
|
||||
open={drawerOpen}
|
||||
merchantId={merchantId ?? ''}
|
||||
spuId={editingRecord?.id}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onSuccess={() => void loadList()}
|
||||
/>
|
||||
|
||||
<HqbImportModal
|
||||
open={importOpen}
|
||||
merchantId={merchantId ?? ''}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onSuccess={() => void loadList()}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
276
src/pages/login/index.tsx
Normal file
276
src/pages/login/index.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Space,
|
||||
Tabs,
|
||||
} from 'antd'
|
||||
import { LockOutlined, MessageOutlined, MobileOutlined } from '@ant-design/icons'
|
||||
import { theme } from 'antd'
|
||||
import { toast } from 'sonner'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { apiGetSmsCode } from '@/apis/apisCommon'
|
||||
import { merchantProfileGet } from '@/apis/mallMerchant'
|
||||
import { apiMallMerchantLogin } from '@/apis/apisUser'
|
||||
import { useSessionStore } from '@/store/session'
|
||||
import { resolveMerchantSessionIdentity } from '@/utils/mall'
|
||||
|
||||
interface LoginFormValues {
|
||||
account: string
|
||||
password?: string
|
||||
captcha?: string
|
||||
}
|
||||
|
||||
type LoginMode = 'password' | 'sms'
|
||||
|
||||
const PHONE_REGEX = /^1\d{10}$/
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
const resolveLoginType = (account: string): '0' | '1' => {
|
||||
return EMAIL_REGEX.test(account) ? '1' : '0'
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const { token } = theme.useToken()
|
||||
const navigate = useNavigate()
|
||||
const setSession = useSessionStore(state => state.setSession)
|
||||
const setToken = useSessionStore(state => state.setToken)
|
||||
const setUserInfo = useSessionStore(state => state.setUserInfo)
|
||||
const existingToken = useSessionStore(state => state.token)
|
||||
const loginAccount = useSessionStore(state => state.loginAccount)
|
||||
const userInfo = useSessionStore(state => state.userInfo)
|
||||
const [mode, setMode] = useState<LoginMode>('password')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [smsSending, setSmsSending] = useState(false)
|
||||
const [smsLeft, setSmsLeft] = useState(0)
|
||||
const smsTimerRef = useRef<number | null>(null)
|
||||
const [form] = Form.useForm<LoginFormValues>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && existingToken?.accessToken) {
|
||||
navigate('/profile', { replace: true })
|
||||
}
|
||||
}, [existingToken, loading, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (smsTimerRef.current) {
|
||||
window.clearInterval(smsTimerRef.current)
|
||||
smsTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ key: 'password', label: '密码登录' },
|
||||
{ key: 'sms', label: '短信登录' },
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const startSmsCountdown = (seconds: number) => {
|
||||
setSmsLeft(seconds)
|
||||
if (smsTimerRef.current) {
|
||||
window.clearInterval(smsTimerRef.current)
|
||||
smsTimerRef.current = null
|
||||
}
|
||||
smsTimerRef.current = window.setInterval(() => {
|
||||
setSmsLeft(prev => {
|
||||
if (prev <= 1) {
|
||||
if (smsTimerRef.current) {
|
||||
window.clearInterval(smsTimerRef.current)
|
||||
smsTimerRef.current = null
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const onSendSms = async () => {
|
||||
try {
|
||||
const account = (form.getFieldValue('account') ?? '').toString().trim()
|
||||
if (!PHONE_REGEX.test(account)) {
|
||||
toast.error('请输入有效的手机号')
|
||||
return
|
||||
}
|
||||
setSmsSending(true)
|
||||
await apiGetSmsCode({ account, type: '1' })
|
||||
toast.success('验证码已发送')
|
||||
startSmsCountdown(60)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '验证码发送失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSmsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: token.borderRadiusLG,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
initialValues={{
|
||||
account: loginAccount || userInfo?.account || '',
|
||||
}}
|
||||
onFinish={async values => {
|
||||
try {
|
||||
const account = values.account.trim()
|
||||
const isPhone = PHONE_REGEX.test(account)
|
||||
const isEmail = EMAIL_REGEX.test(account)
|
||||
|
||||
if (!isPhone && !isEmail) {
|
||||
toast.error('请输入有效的手机号或邮箱')
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'sms' && !isPhone) {
|
||||
toast.error('验证码登录仅支持手机号')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const payload =
|
||||
mode === 'password'
|
||||
? {
|
||||
account,
|
||||
password: values.password?.trim(),
|
||||
loginType: resolveLoginType(account),
|
||||
type: '0' as const,
|
||||
loginIp: '',
|
||||
}
|
||||
: {
|
||||
account,
|
||||
captcha: values.captcha?.trim(),
|
||||
loginType: '0' as const,
|
||||
type: '1' as const,
|
||||
loginIp: '',
|
||||
}
|
||||
|
||||
const loginResult = await apiMallMerchantLogin(payload)
|
||||
setToken(loginResult.data)
|
||||
setUserInfo(null)
|
||||
setSession({
|
||||
loginAccount: account,
|
||||
merchantId: null,
|
||||
merchantName: '',
|
||||
})
|
||||
|
||||
try {
|
||||
const profileData = await merchantProfileGet()
|
||||
setSession({
|
||||
loginAccount: account,
|
||||
...resolveMerchantSessionIdentity(profileData.merchant),
|
||||
})
|
||||
} catch (profileError) {
|
||||
const message =
|
||||
profileError instanceof Error ? profileError.message : '同步商家资料失败'
|
||||
toast.error(`登录成功,但同步商家资料失败:${message}`)
|
||||
}
|
||||
|
||||
toast.success('登录成功')
|
||||
navigate('/profile', { replace: true })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '登录失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={mode}
|
||||
items={tabs}
|
||||
onChange={key => {
|
||||
setMode(key as LoginMode)
|
||||
form.resetFields(['password', 'captcha'])
|
||||
}}
|
||||
/>
|
||||
<Form.Item
|
||||
name="account"
|
||||
label={mode === 'sms' ? '手机号' : '登录账号'}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const account = (value ?? '').toString().trim()
|
||||
|
||||
if (!account) {
|
||||
return Promise.reject(
|
||||
new Error(mode === 'sms' ? '请输入手机号' : '请输入手机号或邮箱')
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'sms') {
|
||||
return PHONE_REGEX.test(account)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请输入有效的手机号'))
|
||||
}
|
||||
|
||||
return PHONE_REGEX.test(account) || EMAIL_REGEX.test(account)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请输入有效的手机号或邮箱'))
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={mode === 'sms' ? '请输入手机号' : '请输入手机号或邮箱'}
|
||||
autoComplete="username"
|
||||
prefix={<MobileOutlined />}
|
||||
/>
|
||||
</Form.Item>
|
||||
{mode === 'password' ? (
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码不能少于6位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请输入密码"
|
||||
autoComplete="current-password"
|
||||
prefix={<LockOutlined />}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="captcha"
|
||||
label="短信验证码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入短信验证码' },
|
||||
{ pattern: /^\d{4,6}$/, message: '请输入有效验证码' },
|
||||
]}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input placeholder="请输入短信验证码" prefix={<MessageOutlined />} />
|
||||
<Button disabled={smsLeft > 0} loading={smsSending} onClick={() => void onSendSms()}>
|
||||
{smsLeft > 0 ? `${smsLeft}s` : '获取验证码'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
登录并进入工作台
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
196
src/pages/orders/OrderDetailDrawer.tsx
Normal file
196
src/pages/orders/OrderDetailDrawer.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Descriptions, Drawer, Space, Spin, Table, Tag, Typography } from 'antd'
|
||||
import { toast } from 'sonner'
|
||||
import { merchantOrderDetail } from '@/apis/mallMerchant'
|
||||
import type { OrderDetailInfo } from '@/types/mall'
|
||||
import { formatUnixTime, getStatusColor, getStatusLabel, prettyJsonText } from '@/utils/mall'
|
||||
|
||||
interface OrderDetailDrawerProps {
|
||||
open: boolean
|
||||
merchantId: string
|
||||
orderId?: number
|
||||
refreshToken?: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function OrderDetailDrawer({
|
||||
open,
|
||||
merchantId,
|
||||
orderId,
|
||||
refreshToken,
|
||||
onClose,
|
||||
}: OrderDetailDrawerProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState<OrderDetailInfo | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !orderId) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await merchantOrderDetail({
|
||||
merchantId,
|
||||
orderId,
|
||||
})
|
||||
|
||||
if (!cancelled) {
|
||||
setData(result.data)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载订单详情失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [merchantId, open, orderId, refreshToken])
|
||||
|
||||
return (
|
||||
<Drawer title={orderId ? `订单详情 #${orderId}` : '订单详情'} width={920} open={open} onClose={onClose}>
|
||||
<Spin spinning={loading}>
|
||||
{!data ? null : (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card className="page-card" title="基础信息">
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="订单号">{data.orderNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="交易号">{data.tradeNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单 ID">{data.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="会员 ID">{data.uid}</Descriptions.Item>
|
||||
<Descriptions.Item label="商家 ID">{data.merchantId}</Descriptions.Item>
|
||||
<Descriptions.Item label="商家名称">{data.merchantName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单状态">
|
||||
<Tag color={getStatusColor(data.orderStatus)}>{getStatusLabel(data.orderStatus)}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="售后状态">
|
||||
<Tag color={getStatusColor(data.afterSaleStatus)}>
|
||||
{getStatusLabel(data.afterSaleStatus)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="商品金额">{data.goodsAmount}</Descriptions.Item>
|
||||
<Descriptions.Item label="支付金额">{data.payAmount}</Descriptions.Item>
|
||||
<Descriptions.Item label="运费">{data.freightAmount}</Descriptions.Item>
|
||||
<Descriptions.Item label="优惠金额">{data.discountAmount}</Descriptions.Item>
|
||||
<Descriptions.Item label="币种">{data.currency || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="支付时间">{formatUnixTime(data.payTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="发货时间">{formatUnixTime(data.shipTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="完成时间">{formatUnixTime(data.finishTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="取消时间">{formatUnixTime(data.cancelTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="自动确认">{formatUnixTime(data.autoConfirmAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="支付渠道">{data.payChannel || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{formatUnixTime(data.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">{formatUnixTime(data.updatedAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="扩展 JSON" span={2}>
|
||||
<Typography.Text style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{prettyJsonText(data.extraInfoJson) || '-'}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card className="page-card" title="收货与物流">
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="收货人">{data.receiverName}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">{data.receiverMobile}</Descriptions.Item>
|
||||
<Descriptions.Item label="国家区号">{data.receiverCountryCode || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="省份">{data.receiverProvince || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="城市">{data.receiverCity || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="区县">{data.receiverDistrict || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="完整地址" span={2}>
|
||||
{[data.receiverProvince, data.receiverCity, data.receiverDistrict, data.receiverAddress]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="邮编">{data.receiverZipCode || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="物流公司">{data.deliveryCompany || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">{data.deliveryNo || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="商家备注" span={2}>
|
||||
{data.sellerRemark || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="买家备注" span={2}>
|
||||
{data.buyerRemark || '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card className="page-card" title="商品明细">
|
||||
<Table
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={data.items}
|
||||
columns={[
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'spuName',
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text strong>{record.spuName}</Typography.Text>
|
||||
<Typography.Text type="secondary">{record.skuName}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '规格 JSON',
|
||||
dataIndex: 'skuSpecJson',
|
||||
width: 260,
|
||||
render: value => (
|
||||
<Typography.Text style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{prettyJsonText(value) || '-'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'buyNum',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: '单价',
|
||||
dataIndex: 'salePrice',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '原价',
|
||||
dataIndex: 'originPrice',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'itemAmount',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '优惠',
|
||||
dataIndex: 'discountAmount',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '退款',
|
||||
dataIndex: 'refundAmount',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '售后状态',
|
||||
dataIndex: 'afterSaleStatus',
|
||||
width: 120,
|
||||
render: value => <Tag color={getStatusColor(value)}>{getStatusLabel(value)}</Tag>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
)}
|
||||
</Spin>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
86
src/pages/orders/ShipModal.tsx
Normal file
86
src/pages/orders/ShipModal.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Form, Input, Modal } from 'antd'
|
||||
import { toast } from 'sonner'
|
||||
import { merchantOrderShip } from '@/apis/mallMerchant'
|
||||
import type { OrderSummary } from '@/types/mall'
|
||||
|
||||
interface ShipModalProps {
|
||||
open: boolean
|
||||
merchantId: string
|
||||
record: OrderSummary | null
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
interface ShipFormValues {
|
||||
deliveryCompany: string
|
||||
deliveryNo: string
|
||||
sellerRemark?: string
|
||||
}
|
||||
|
||||
export default function ShipModal({
|
||||
open,
|
||||
merchantId,
|
||||
record,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ShipModalProps) {
|
||||
const [form] = Form.useForm<ShipFormValues>()
|
||||
|
||||
const handleOk = async () => {
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
await merchantOrderShip({
|
||||
merchantId,
|
||||
orderId: record.id,
|
||||
deliveryCompany: values.deliveryCompany.trim(),
|
||||
deliveryNo: values.deliveryNo.trim(),
|
||||
sellerRemark: values.sellerRemark?.trim(),
|
||||
})
|
||||
toast.success('发货信息已提交')
|
||||
form.resetFields()
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '发货失败'
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={record ? `订单发货 #${record.orderNo}` : '订单发货'}
|
||||
open={open}
|
||||
onCancel={() => {
|
||||
form.resetFields()
|
||||
onClose()
|
||||
}}
|
||||
onOk={() => void handleOk()}
|
||||
okText="确认发货"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item
|
||||
name="deliveryCompany"
|
||||
label="物流公司"
|
||||
rules={[{ required: true, message: '请输入物流公司' }]}
|
||||
>
|
||||
<Input placeholder="例如 顺丰速运" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="deliveryNo"
|
||||
label="物流单号"
|
||||
rules={[{ required: true, message: '请输入物流单号' }]}
|
||||
>
|
||||
<Input placeholder="请输入运单号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sellerRemark" label="商家备注">
|
||||
<Input.TextArea rows={4} placeholder="可选" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
234
src/pages/orders/index.tsx
Normal file
234
src/pages/orders/index.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Form, Input, Select, Space, Table, Tag, Typography } from 'antd'
|
||||
import { toast } from 'sonner'
|
||||
import { merchantOrderPage } from '@/apis/mallMerchant'
|
||||
import OrderDetailDrawer from './OrderDetailDrawer'
|
||||
import ShipModal from './ShipModal'
|
||||
import { useSessionStore } from '@/store/session'
|
||||
import type { AfterSaleStatus, OrderStatus, OrderSummary } from '@/types/mall'
|
||||
import {
|
||||
AFTER_SALE_STATUS_OPTIONS,
|
||||
ORDER_STATUS_OPTIONS,
|
||||
formatUnixTime,
|
||||
getStatusColor,
|
||||
getStatusLabel,
|
||||
} from '@/utils/mall'
|
||||
|
||||
interface OrderSearchValues {
|
||||
orderStatus?: OrderStatus | ''
|
||||
afterSaleStatus?: AfterSaleStatus | ''
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
const merchantId = useSessionStore(state => state.merchantId)
|
||||
const [form] = Form.useForm<OrderSearchValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [size, setSize] = useState(20)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [list, setList] = useState<OrderSummary[]>([])
|
||||
const [detailRecord, setDetailRecord] = useState<OrderSummary | null>(null)
|
||||
const [shipRecord, setShipRecord] = useState<OrderSummary | null>(null)
|
||||
const [detailRefreshToken, setDetailRefreshToken] = useState(0)
|
||||
|
||||
const renderOrderAction = (record: OrderSummary) => {
|
||||
switch (record.orderStatus) {
|
||||
case 'pending_pay':
|
||||
return <Typography.Text type="secondary">待支付</Typography.Text>
|
||||
case 'paid_wait_ship':
|
||||
return (
|
||||
<Button type="link" onClick={() => setShipRecord(record)}>
|
||||
去发货
|
||||
</Button>
|
||||
)
|
||||
case 'shipped':
|
||||
return <Typography.Text type="secondary">已发货</Typography.Text>
|
||||
case 'finished':
|
||||
return <Typography.Text type="secondary">已完成</Typography.Text>
|
||||
default:
|
||||
return <Typography.Text type="secondary">-</Typography.Text>
|
||||
}
|
||||
}
|
||||
|
||||
const loadList = async (nextPage = page, nextSize = size) => {
|
||||
if (!merchantId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const values = form.getFieldsValue()
|
||||
const result = await merchantOrderPage({
|
||||
merchantId,
|
||||
orderStatus: values.orderStatus || undefined,
|
||||
afterSaleStatus: values.afterSaleStatus || undefined,
|
||||
keyword: values.keyword?.trim(),
|
||||
page: nextPage,
|
||||
size: nextSize,
|
||||
})
|
||||
setList(result.list ?? [])
|
||||
setTotal(result.total ?? 0)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载订单列表失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
orderStatus: '',
|
||||
afterSaleStatus: '',
|
||||
keyword: '',
|
||||
})
|
||||
setPage(1)
|
||||
void loadList(1, size)
|
||||
}, [merchantId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={18} style={{ width: '100%' }}>
|
||||
<Card className="page-card" title="订单筛选">
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Form form={form} layout="inline">
|
||||
<Form.Item name="orderStatus" label="订单状态">
|
||||
<Select style={{ width: 180 }} options={ORDER_STATUS_OPTIONS.map(item => ({ ...item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="afterSaleStatus" label="售后状态">
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
options={AFTER_SALE_STATUS_OPTIONS.map(item => ({ ...item }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input placeholder="订单号 / 交易号 / 物流单号" style={{ width: 260 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setPage(1)
|
||||
void loadList(1, size)
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
setPage(1)
|
||||
void loadList(1, size)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="page-card" title="订单列表">
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={list}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setSize(nextSize)
|
||||
void loadList(nextPage, nextSize)
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'orderNo',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>{record.orderNo}</span>
|
||||
<span style={{ color: 'var(--ant-color-text-secondary)' }}>{record.tradeNo}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'orderStatus',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space wrap>
|
||||
<Tag color={getStatusColor(record.orderStatus)}>{getStatusLabel(record.orderStatus)}</Tag>
|
||||
<Tag color={getStatusColor(record.afterSaleStatus)}>
|
||||
{getStatusLabel(record.afterSaleStatus)}
|
||||
</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'payAmount',
|
||||
width: 160,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<span>支付:{record.payAmount}</span>
|
||||
<span style={{ color: 'var(--ant-color-text-secondary)' }}>商品:{record.goodsAmount}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '支付时间',
|
||||
dataIndex: 'payTime',
|
||||
width: 180,
|
||||
render: value => formatUnixTime(value),
|
||||
},
|
||||
{
|
||||
title: '发货时间',
|
||||
dataIndex: 'shipTime',
|
||||
width: 180,
|
||||
render: value => formatUnixTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => setDetailRecord(record)}>
|
||||
详情
|
||||
</Button>
|
||||
{renderOrderAction(record)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<OrderDetailDrawer
|
||||
open={Boolean(detailRecord)}
|
||||
merchantId={merchantId ?? ''}
|
||||
orderId={detailRecord?.id}
|
||||
refreshToken={detailRefreshToken}
|
||||
onClose={() => setDetailRecord(null)}
|
||||
/>
|
||||
|
||||
<ShipModal
|
||||
open={Boolean(shipRecord)}
|
||||
merchantId={merchantId ?? ''}
|
||||
record={shipRecord}
|
||||
onClose={() => setShipRecord(null)}
|
||||
onSuccess={() => {
|
||||
void loadList()
|
||||
setDetailRefreshToken(value => value + 1)
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
230
src/pages/profile/index.tsx
Normal file
230
src/pages/profile/index.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
} from 'antd'
|
||||
import { toast } from 'sonner'
|
||||
import { merchantProfileGet, merchantProfileUpdate } from '@/apis/mallMerchant'
|
||||
import { useSessionStore } from '@/store/session'
|
||||
import type { MerchantInfo } from '@/types/mall'
|
||||
import { resolveMerchantSessionIdentity } from '@/utils/mall'
|
||||
|
||||
interface MerchantFormValues {
|
||||
merchantName: string
|
||||
logo?: string
|
||||
banner?: string
|
||||
notice?: string
|
||||
servicePhone?: string
|
||||
serviceEmail?: string
|
||||
contactName?: string
|
||||
contactMobile?: string
|
||||
province?: string
|
||||
city?: string
|
||||
district?: string
|
||||
addressDetail?: string
|
||||
zipCode?: string
|
||||
}
|
||||
|
||||
const merchantToFormValues = (merchant?: MerchantInfo): MerchantFormValues => ({
|
||||
merchantName: merchant?.merchantName ?? '',
|
||||
logo: merchant?.logo ?? '',
|
||||
banner: merchant?.banner ?? '',
|
||||
notice: merchant?.notice ?? '',
|
||||
servicePhone: merchant?.servicePhone ?? '',
|
||||
serviceEmail: merchant?.serviceEmail ?? '',
|
||||
contactName: merchant?.contactName ?? '',
|
||||
contactMobile: merchant?.contactMobile ?? '',
|
||||
province: merchant?.province ?? '',
|
||||
city: merchant?.city ?? '',
|
||||
district: merchant?.district ?? '',
|
||||
addressDetail: merchant?.addressDetail ?? '',
|
||||
zipCode: merchant?.zipCode ?? '',
|
||||
})
|
||||
|
||||
export default function ProfilePage() {
|
||||
const authToken = useSessionStore(state => state.token)
|
||||
const updateIdentity = useSessionStore(state => state.updateIdentity)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form] = Form.useForm<MerchantFormValues>()
|
||||
|
||||
const applyProfileData = (data: Awaited<ReturnType<typeof merchantProfileGet>>) => {
|
||||
updateIdentity(resolveMerchantSessionIdentity(data.merchant))
|
||||
form.setFieldsValue(merchantToFormValues(data.merchant))
|
||||
}
|
||||
|
||||
const fetchProfileData = async () => {
|
||||
if (!authToken?.accessToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await merchantProfileGet()
|
||||
applyProfileData(data)
|
||||
return data
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!authToken?.accessToken) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await merchantProfileGet()
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
applyProfileData(data)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载商家资料失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadProfile()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [authToken?.accessToken]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!authToken?.accessToken) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setSaving(true)
|
||||
await merchantProfileUpdate({
|
||||
merchantName: values.merchantName.trim(),
|
||||
logo: values.logo?.trim(),
|
||||
banner: values.banner?.trim(),
|
||||
notice: values.notice?.trim(),
|
||||
servicePhone: values.servicePhone?.trim(),
|
||||
serviceEmail: values.serviceEmail?.trim(),
|
||||
contactName: values.contactName?.trim(),
|
||||
contactMobile: values.contactMobile?.trim(),
|
||||
province: values.province?.trim(),
|
||||
city: values.city?.trim(),
|
||||
district: values.district?.trim(),
|
||||
addressDetail: values.addressDetail?.trim(),
|
||||
zipCode: values.zipCode?.trim(),
|
||||
})
|
||||
|
||||
toast.success('商家资料已更新')
|
||||
await fetchProfileData()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '更新商家资料失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Space direction="vertical" size={18} style={{ width: '100%' }}>
|
||||
<Card
|
||||
className="page-card"
|
||||
title="商家资料"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
loading={saving}
|
||||
disabled={!authToken?.accessToken}
|
||||
onClick={() => void handleSaveProfile()}
|
||||
>
|
||||
保存资料
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="merchantName"
|
||||
label="商家名称"
|
||||
rules={[{ required: true, message: '请输入商家名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="notice" label="商家公告">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="logo" label="Logo">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="banner" label="商家头图">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="servicePhone" label="客服电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="serviceEmail" label="客服邮箱">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="zipCode" label="邮编">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="contactName" label="联系人">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="contactMobile" label="联系电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="province" label="省份">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="city" label="城市">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="district" label="区县">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="addressDetail" label="详细地址">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
</Space>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
20
src/routes/components/ProtectedRoute.tsx
Normal file
20
src/routes/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useSessionStore } from '@/store/session'
|
||||
|
||||
interface ProtectedRouteProps extends PropsWithChildren {
|
||||
redirectTo?: string
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({
|
||||
children,
|
||||
redirectTo = '/login',
|
||||
}: ProtectedRouteProps) {
|
||||
const token = useSessionStore(state => state.token)
|
||||
|
||||
if (!token?.accessToken) {
|
||||
return <Navigate to={redirectTo} replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
63
src/routes/config.tsx
Normal file
63
src/routes/config.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { RouteObject } from 'react-router-dom'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import RootLayout from '@/layouts/RootLayout'
|
||||
import AuthLayout from '@/layouts/auth/AuthLayout'
|
||||
import MainLayout from '@/layouts/main/MainLayout'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import LoginPage from '@/pages/login'
|
||||
import ProfilePage from '@/pages/profile'
|
||||
import GoodsPage from '@/pages/goods'
|
||||
import FeaturedGoodsPage from '@/pages/featuredGoods'
|
||||
import OrdersPage from '@/pages/orders'
|
||||
import AfterSalePage from '@/pages/afterSale'
|
||||
|
||||
export const routeConfig: RouteObject[] = [
|
||||
{
|
||||
path: '/',
|
||||
element: <RootLayout />,
|
||||
children: [
|
||||
{
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{
|
||||
path: 'login',
|
||||
element: <LoginPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: 'profile',
|
||||
element: <ProfilePage />,
|
||||
},
|
||||
{
|
||||
path: 'goods',
|
||||
element: <GoodsPage />,
|
||||
},
|
||||
{
|
||||
path: 'featured-goods',
|
||||
element: <FeaturedGoodsPage />,
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
element: <OrdersPage />,
|
||||
},
|
||||
{
|
||||
path: 'after-sales',
|
||||
element: <AfterSalePage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to="/" replace />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
7
src/routes/index.tsx
Normal file
7
src/routes/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useRoutes } from 'react-router-dom'
|
||||
import { routeConfig } from './config'
|
||||
|
||||
export default function AppRoutes() {
|
||||
return useRoutes(routeConfig)
|
||||
}
|
||||
|
||||
96
src/store/session.ts
Normal file
96
src/store/session.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { create } from 'zustand'
|
||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||
import type { ITokenData } from '@/types/ITokenData'
|
||||
import type { IUserInfo } from '@/types/IUserInfo'
|
||||
|
||||
export interface SessionState {
|
||||
token: ITokenData | null
|
||||
userInfo: IUserInfo | null
|
||||
loginAccount: string
|
||||
merchantId: string | null
|
||||
merchantName: string
|
||||
setSession: (payload: Partial<Pick<SessionState, 'loginAccount' | 'merchantId' | 'merchantName'>>) => void
|
||||
setToken: (token: ITokenData | null) => void
|
||||
setUserInfo: (userInfo: IUserInfo | null) => void
|
||||
updateIdentity: (payload: Partial<Pick<SessionState, 'merchantId' | 'merchantName'>>) => void
|
||||
clearSession: () => void
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'mall-merchant-session'
|
||||
|
||||
const normalizeMerchantId = (value: unknown): string | null => {
|
||||
if (typeof value === 'string') {
|
||||
const nextValue = value.trim()
|
||||
return nextValue || null
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'bigint') {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionState>()(
|
||||
persist(
|
||||
set => ({
|
||||
token: null,
|
||||
userInfo: null,
|
||||
loginAccount: '',
|
||||
merchantId: null,
|
||||
merchantName: '',
|
||||
setSession: payload =>
|
||||
set(state => ({
|
||||
...state,
|
||||
...payload,
|
||||
merchantId:
|
||||
'merchantId' in payload ? normalizeMerchantId(payload.merchantId) : state.merchantId,
|
||||
})),
|
||||
setToken: token =>
|
||||
set(state => ({
|
||||
...state,
|
||||
token,
|
||||
})),
|
||||
setUserInfo: userInfo =>
|
||||
set(state => ({
|
||||
...state,
|
||||
userInfo,
|
||||
})),
|
||||
updateIdentity: payload =>
|
||||
set(state => ({
|
||||
...state,
|
||||
...payload,
|
||||
merchantId:
|
||||
'merchantId' in payload ? normalizeMerchantId(payload.merchantId) : state.merchantId,
|
||||
})),
|
||||
clearSession: () =>
|
||||
set({
|
||||
token: null,
|
||||
userInfo: null,
|
||||
loginAccount: '',
|
||||
merchantId: null,
|
||||
merchantName: '',
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEY,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: state => ({
|
||||
token: state.token,
|
||||
userInfo: state.userInfo,
|
||||
loginAccount: state.loginAccount,
|
||||
merchantId: state.merchantId,
|
||||
merchantName: state.merchantName,
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const state = (persistedState as Partial<SessionState> | undefined) ?? {}
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
...state,
|
||||
merchantId: normalizeMerchantId(state.merchantId),
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
7
src/types/ITokenData.ts
Normal file
7
src/types/ITokenData.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface ITokenData {
|
||||
accessToken: string
|
||||
accessExpire: number
|
||||
refreshAfter: number
|
||||
role: string[]
|
||||
uType: string
|
||||
}
|
||||
6
src/types/IUserInfo.ts
Normal file
6
src/types/IUserInfo.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface IUserInfo {
|
||||
uid: number
|
||||
name: string
|
||||
account: string
|
||||
}
|
||||
|
||||
516
src/types/mall.ts
Normal file
516
src/types/mall.ts
Normal file
@@ -0,0 +1,516 @@
|
||||
import type { PageRequest } from '@/apis/base/types'
|
||||
|
||||
export type EnableStatus = 'enabled' | 'disabled'
|
||||
export type AuditStatus = 'pending' | 'approved' | 'rejected'
|
||||
export type SettleStatus = 'pending' | 'active' | 'frozen'
|
||||
export type SaleStatus = 'off' | 'on'
|
||||
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 interface MerchantInfo {
|
||||
id: string | number
|
||||
uid: number
|
||||
merchantNo: string
|
||||
merchantName: string
|
||||
status: EnableStatus
|
||||
auditStatus: AuditStatus
|
||||
settleStatus: SettleStatus
|
||||
contactName: string
|
||||
contactMobile: string
|
||||
contactEmail: string
|
||||
licenseNo: string
|
||||
licenseImagesJson: string
|
||||
extraInfoJson: string
|
||||
remark: string
|
||||
approvedAt: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
logo: string
|
||||
banner: string
|
||||
notice: string
|
||||
servicePhone: string
|
||||
serviceEmail: string
|
||||
province: string
|
||||
city: string
|
||||
district: string
|
||||
addressDetail: string
|
||||
zipCode: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
export interface GoodsSkuInfo {
|
||||
id: number
|
||||
spuId: number
|
||||
merchantId: string
|
||||
skuNo: string
|
||||
skuName: string
|
||||
barcode: string
|
||||
imageUrl: string
|
||||
specJson: string
|
||||
salePrice: string
|
||||
originPrice: string
|
||||
costPrice: string
|
||||
weight: string
|
||||
status: EnableStatus
|
||||
stock: number
|
||||
lockedStock: number
|
||||
warnStock: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface GoodsSummary {
|
||||
id: number
|
||||
merchantId: string
|
||||
categoryId: number
|
||||
spuNo: string
|
||||
spuName: string
|
||||
subTitle: string
|
||||
coverUrl: string
|
||||
status: EnableStatus
|
||||
auditStatus: AuditStatus
|
||||
saleStatus: SaleStatus
|
||||
sort: number
|
||||
salesVolume: number
|
||||
virtualSales: number
|
||||
viewCount: number
|
||||
minPrice: string
|
||||
maxPrice: string
|
||||
totalStock: number
|
||||
lockedStock: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
categoryName: string
|
||||
}
|
||||
|
||||
export interface GoodsDetailInfo {
|
||||
id: number
|
||||
merchantId: string
|
||||
categoryId: number
|
||||
spuNo: string
|
||||
spuName: string
|
||||
subTitle: string
|
||||
coverUrl: string
|
||||
mediaJson: string
|
||||
detailJson: string
|
||||
extraInfoJson: string
|
||||
status: EnableStatus
|
||||
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 MallCategoryTreeParams {
|
||||
parentId?: number
|
||||
status?: EnableStatus
|
||||
}
|
||||
|
||||
export interface MallCategoryItem {
|
||||
id: number
|
||||
parentId: number
|
||||
categoryName: string
|
||||
categoryLevel: number
|
||||
categoryPath: string
|
||||
categoryIcon: string
|
||||
isLeaf: boolean
|
||||
hasChildren?: boolean
|
||||
sort: number
|
||||
status: EnableStatus
|
||||
goodsCount: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
children: MallCategoryItem[]
|
||||
}
|
||||
|
||||
export interface MallCategoryTreeData {
|
||||
list: MallCategoryItem[]
|
||||
}
|
||||
|
||||
export interface MerchantCategoryAttributeValue {
|
||||
valueId: string | number
|
||||
valueName: string
|
||||
sort?: number
|
||||
status?: EnableStatus
|
||||
}
|
||||
|
||||
export type MerchantCategoryAttributeType = 'SALES_PROPERTY' | 'PRODUCT_PROPERTY'
|
||||
|
||||
export interface MerchantCategoryAttributeItem {
|
||||
attributeId: string | number
|
||||
attributeName: string
|
||||
attributeType: MerchantCategoryAttributeType | string
|
||||
isRequired: boolean
|
||||
isCustomizable: boolean
|
||||
isMultipleSelection: boolean
|
||||
values: MerchantCategoryAttributeValue[]
|
||||
}
|
||||
|
||||
export interface MerchantCategoryAttributeSource {
|
||||
sourcePlatform?: string
|
||||
sourceKey?: string
|
||||
}
|
||||
|
||||
export interface MerchantCategoryAttributeListParams {
|
||||
categoryId: number
|
||||
sourcePlatform?: string
|
||||
sourceKey?: string
|
||||
status?: EnableStatus
|
||||
}
|
||||
|
||||
export interface MerchantCategoryAttributeListData {
|
||||
categoryId: number
|
||||
categoryName: string
|
||||
sourcePlatform: string
|
||||
sourceKey: string
|
||||
sources: MerchantCategoryAttributeSource[]
|
||||
list: MerchantCategoryAttributeItem[]
|
||||
}
|
||||
|
||||
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
|
||||
merchantId: string
|
||||
merchantName: 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
|
||||
merchantId: string
|
||||
merchantName: 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 AfterSaleSummary {
|
||||
id: number
|
||||
afterSaleNo: string
|
||||
tradeId: number
|
||||
orderId: number
|
||||
orderItemId: number
|
||||
orderNo: string
|
||||
uid: number
|
||||
merchantId: string
|
||||
merchantName: string
|
||||
spuName: string
|
||||
skuName: string
|
||||
afterSaleType: 1 | 2
|
||||
afterSaleStatus: AfterSaleStatus
|
||||
reason: string
|
||||
description: string
|
||||
evidenceJson: string
|
||||
applyAmount: string
|
||||
refundAmount: string
|
||||
rejectReason: string
|
||||
handleRemark: string
|
||||
auditTime: number
|
||||
successTime: number
|
||||
closeTime: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface MerchantGoodsSkuInput {
|
||||
skuId: number
|
||||
skuNo: string
|
||||
skuName: string
|
||||
barcode: string
|
||||
imageUrl: string
|
||||
specJson: string
|
||||
salePrice: string
|
||||
originPrice: string
|
||||
costPrice: string
|
||||
weight: string
|
||||
status: EnableStatus
|
||||
stock: number
|
||||
warnStock: number
|
||||
}
|
||||
|
||||
export interface MerchantProfileGetParams {
|
||||
merchantId?: string
|
||||
}
|
||||
|
||||
export interface MerchantProfileGetData {
|
||||
merchant: MerchantInfo
|
||||
}
|
||||
|
||||
export interface MerchantProfileUpdateParams {
|
||||
merchantName?: string
|
||||
logo?: string
|
||||
banner?: string
|
||||
notice?: string
|
||||
servicePhone?: string
|
||||
serviceEmail?: string
|
||||
contactName?: string
|
||||
contactMobile?: string
|
||||
province?: string
|
||||
city?: string
|
||||
district?: string
|
||||
addressDetail?: string
|
||||
zipCode?: string
|
||||
}
|
||||
|
||||
export interface MerchantProfileUpdateData {
|
||||
merchantId?: string
|
||||
}
|
||||
|
||||
export interface MerchantGoodsPageParams extends PageRequest {
|
||||
merchantId: string
|
||||
categoryId?: number
|
||||
keyword?: string
|
||||
auditStatus?: AuditStatus
|
||||
saleStatus?: SaleStatus
|
||||
}
|
||||
|
||||
export interface MerchantGoodsPageData {
|
||||
total: number
|
||||
list: GoodsSummary[]
|
||||
}
|
||||
|
||||
export interface MerchantGoodsDetailParams {
|
||||
merchantId: string
|
||||
spuId: number
|
||||
}
|
||||
|
||||
export interface MerchantGoodsDetailData {
|
||||
data: GoodsDetailInfo
|
||||
}
|
||||
|
||||
export interface MerchantGoodsSaveParams {
|
||||
merchantId: string
|
||||
spuId?: number
|
||||
categoryId: number
|
||||
spuName: string
|
||||
subTitle?: string
|
||||
coverUrl?: string
|
||||
mediaJson?: string
|
||||
detailJson?: string
|
||||
paramJson?: string
|
||||
serviceJson?: string
|
||||
extraInfoJson?: string
|
||||
status?: EnableStatus
|
||||
saleStatus?: SaleStatus
|
||||
sort?: number
|
||||
skus: MerchantGoodsSkuInput[]
|
||||
}
|
||||
|
||||
export interface MerchantGoodsSaveData {
|
||||
spuId: number
|
||||
skuIds: number[]
|
||||
}
|
||||
|
||||
export interface MerchantGoodsPublishParams {
|
||||
merchantId: string
|
||||
spuId: number
|
||||
saleStatus: SaleStatus
|
||||
}
|
||||
|
||||
export interface MerchantGoodsPublishData {
|
||||
spuId: number
|
||||
saleStatus: SaleStatus
|
||||
}
|
||||
|
||||
export interface MerchantFeaturedGoodsItem {
|
||||
id: number
|
||||
spuId: number
|
||||
spuNo: string
|
||||
spuName: string
|
||||
subTitle: string
|
||||
coverUrl: string
|
||||
goodsStatus: EnableStatus
|
||||
auditStatus: AuditStatus
|
||||
saleStatus: SaleStatus
|
||||
configStatus: EnableStatus
|
||||
sort: number
|
||||
minPrice: string
|
||||
maxPrice: string
|
||||
totalStock: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface MerchantFeaturedGoodsPageParams extends PageRequest {
|
||||
merchantId: string
|
||||
keyword?: string
|
||||
status?: EnableStatus
|
||||
}
|
||||
|
||||
export interface MerchantFeaturedGoodsPageData {
|
||||
total: number
|
||||
list: MerchantFeaturedGoodsItem[]
|
||||
}
|
||||
|
||||
export interface MerchantFeaturedGoodsSaveParams {
|
||||
merchantId: string
|
||||
id?: number
|
||||
spuId: number
|
||||
sort?: number
|
||||
status?: EnableStatus
|
||||
}
|
||||
|
||||
export interface MerchantFeaturedGoodsSaveData {
|
||||
id: number
|
||||
spuId: number
|
||||
}
|
||||
|
||||
export interface MerchantFeaturedGoodsDeleteParams {
|
||||
merchantId: string
|
||||
id: number
|
||||
}
|
||||
|
||||
export interface MerchantFeaturedGoodsDeleteData {
|
||||
id: number
|
||||
}
|
||||
|
||||
export interface MerchantOrderPageParams extends PageRequest {
|
||||
merchantId: string
|
||||
orderStatus?: OrderStatus
|
||||
afterSaleStatus?: AfterSaleStatus
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface MerchantOrderPageData {
|
||||
total: number
|
||||
list: OrderSummary[]
|
||||
}
|
||||
|
||||
export interface MerchantOrderDetailParams {
|
||||
merchantId: string
|
||||
orderId: number
|
||||
}
|
||||
|
||||
export interface MerchantOrderDetailData {
|
||||
data: OrderDetailInfo
|
||||
}
|
||||
|
||||
export interface MerchantOrderShipParams {
|
||||
merchantId: string
|
||||
orderId: number
|
||||
deliveryCompany: string
|
||||
deliveryNo: string
|
||||
sellerRemark?: string
|
||||
}
|
||||
|
||||
export interface MerchantOrderShipData {
|
||||
orderId: number
|
||||
orderStatus: OrderStatus
|
||||
shipTime: number
|
||||
}
|
||||
|
||||
export interface MerchantAfterSalePageParams extends PageRequest {
|
||||
merchantId: string
|
||||
afterSaleType?: 0 | 1 | 2
|
||||
afterSaleStatus?: AfterSaleStatus
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface MerchantAfterSalePageData {
|
||||
total: number
|
||||
list: AfterSaleSummary[]
|
||||
}
|
||||
|
||||
export interface MerchantAfterSaleAuditParams {
|
||||
merchantId: string
|
||||
afterSaleId: number
|
||||
auditStatus: Exclude<AuditStatus, 'pending'>
|
||||
refundAmount?: string
|
||||
rejectReason?: string
|
||||
handleRemark?: string
|
||||
}
|
||||
|
||||
export interface MerchantAfterSaleAuditData {
|
||||
afterSaleId: number
|
||||
afterSaleStatus: AfterSaleStatus
|
||||
}
|
||||
374
src/utils/hqbImport.ts
Normal file
374
src/utils/hqbImport.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import type { HqbGoodsInfo, HqbImageItem, HqbSkuInfo, HqbSpecGroup, HqbSpecItem } from '@/apis/hqb'
|
||||
import type { MerchantGoodsSaveParams, MerchantGoodsSkuInput } from '@/types/mall'
|
||||
|
||||
export const HQB_TARGET_CATEGORY_ID = 1126672
|
||||
export const HQB_TARGET_CATEGORY_NAME = '手机壳及手机套'
|
||||
|
||||
const COLOR_ATTRIBUTE = {
|
||||
attributeId: '100000',
|
||||
attributeName: '颜色',
|
||||
}
|
||||
|
||||
const SIZE_ATTRIBUTE = {
|
||||
attributeId: '100007',
|
||||
attributeName: '尺码',
|
||||
}
|
||||
|
||||
interface HqbSpecValueMeta {
|
||||
sourceSpecName: string
|
||||
valueName: string
|
||||
sortIndex: number
|
||||
}
|
||||
|
||||
interface MallSpecValue {
|
||||
attributeId: string
|
||||
attributeName: string
|
||||
valueName: string
|
||||
}
|
||||
|
||||
export interface HqbImportPreview {
|
||||
sourceGoodsId: string
|
||||
sourceUrl: string
|
||||
title: string
|
||||
subTitle: string
|
||||
coverUrl: string
|
||||
imageUrls: string[]
|
||||
videoUrls: string[]
|
||||
skuCount: number
|
||||
categoryName: string
|
||||
sourceCategoryPath: string
|
||||
shopName: string
|
||||
payload: MerchantGoodsSaveParams
|
||||
}
|
||||
|
||||
export const extractHqbGoodsId = (input: string) => {
|
||||
const content = input.trim()
|
||||
const patterns = [
|
||||
/(?:https?:\/\/)?(?:www\.)?52hqb\.com\/good\/(\d+)/i,
|
||||
/(?:https?:\/\/)?(?:m\.)?52hqb\.com\/pages\/[^?]+[?&]goodsId=(\d+)/i,
|
||||
/\/good\/(\d+)/i,
|
||||
/"goodsId"\s*:\s*"?(\d+)"?/i,
|
||||
/"id"\s*:\s*"?(\d{6,})"?/i,
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const matched = content.match(pattern)
|
||||
if (matched?.[1]) {
|
||||
return matched[1]
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalizeText = (value: unknown) => {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalizeImageUrl = (value: unknown) => {
|
||||
const url = normalizeText(value)
|
||||
if (!url) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (url.startsWith('//')) {
|
||||
return `https:${url}`
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
const uniqueNonEmpty = (values: Array<string | undefined | null>) => {
|
||||
const result: string[] = []
|
||||
const existed = new Set<string>()
|
||||
|
||||
values.forEach(value => {
|
||||
const normalized = normalizeImageUrl(value)
|
||||
if (!normalized || existed.has(normalized)) {
|
||||
return
|
||||
}
|
||||
|
||||
existed.add(normalized)
|
||||
result.push(normalized)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const pickImageUrl = (item?: HqbImageItem) =>
|
||||
normalizeImageUrl(item?.url || item?.imgSrc || item?.thumbUrl)
|
||||
|
||||
const toMoneyText = (value: unknown, fallback = '0.00') => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return parsed.toFixed(2)
|
||||
}
|
||||
|
||||
const toOptionalMoneyText = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return parsed.toFixed(2)
|
||||
}
|
||||
|
||||
const toStockNumber = (value: unknown) => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
const parseUnknownImageList = (value: unknown) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(item => {
|
||||
if (typeof item === 'string') {
|
||||
return normalizeImageUrl(item)
|
||||
}
|
||||
|
||||
if (item && typeof item === 'object') {
|
||||
return pickImageUrl(item as HqbImageItem)
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
return parseUnknownImageList(parsed)
|
||||
} catch {
|
||||
return Array.from(value.matchAll(/https?:\/\/[^\s"']+\.(?:jpg|jpeg|png|webp|gif)/gi)).map(
|
||||
item => normalizeImageUrl(item[0])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const buildSpecValueMap = (specsList?: Record<string, HqbSpecGroup>) => {
|
||||
const valueMap = new Map<string, HqbSpecValueMeta>()
|
||||
|
||||
Object.values(specsList ?? {}).forEach((group, groupIndex) => {
|
||||
const sourceSpecName = normalizeText(group.specName) || `规格${groupIndex + 1}`
|
||||
;(group.items ?? []).forEach((item: HqbSpecItem) => {
|
||||
const valueId = item.valueId === undefined || item.valueId === null ? '' : String(item.valueId)
|
||||
const valueName = normalizeText(item.value)
|
||||
if (!valueId || !valueName) {
|
||||
return
|
||||
}
|
||||
|
||||
valueMap.set(valueId, {
|
||||
sourceSpecName,
|
||||
valueName,
|
||||
sortIndex: groupIndex,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return valueMap
|
||||
}
|
||||
|
||||
const resolveMallAttribute = (sourceSpecName: string, sortIndex: number) => {
|
||||
if (/色/.test(sourceSpecName)) {
|
||||
return COLOR_ATTRIBUTE
|
||||
}
|
||||
|
||||
if (/尺码|尺寸|型号|机型|规格|款式/.test(sourceSpecName)) {
|
||||
return SIZE_ATTRIBUTE
|
||||
}
|
||||
|
||||
return sortIndex === 0 ? COLOR_ATTRIBUTE : SIZE_ATTRIBUTE
|
||||
}
|
||||
|
||||
const buildSkuSpecs = (sku: HqbSkuInfo, valueMap: Map<string, HqbSpecValueMeta>) => {
|
||||
const keyIds = normalizeText(sku.key).split('_').filter(Boolean)
|
||||
const keyNameValues = normalizeText(sku.keyName).split('_').filter(Boolean)
|
||||
|
||||
const specs = keyIds
|
||||
.map((keyId, index): MallSpecValue | null => {
|
||||
const meta = valueMap.get(keyId)
|
||||
const attribute = resolveMallAttribute(meta?.sourceSpecName || '', meta?.sortIndex ?? index)
|
||||
const valueName = meta?.valueName || keyNameValues[index] || ''
|
||||
|
||||
if (!valueName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
attributeId: attribute.attributeId,
|
||||
attributeName: attribute.attributeName,
|
||||
valueName,
|
||||
}
|
||||
})
|
||||
.filter((item): item is MallSpecValue => Boolean(item))
|
||||
|
||||
if (specs.length > 0) {
|
||||
return specs
|
||||
}
|
||||
|
||||
return keyNameValues.map((valueName, index) => {
|
||||
const attribute = resolveMallAttribute('', index)
|
||||
return {
|
||||
attributeId: attribute.attributeId,
|
||||
attributeName: attribute.attributeName,
|
||||
valueName,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const buildSkuName = (title: string, specs: MallSpecValue[], fallback?: string) => {
|
||||
const specName = specs.map(item => item.valueName).filter(Boolean).join('-')
|
||||
if (specName) {
|
||||
return specName
|
||||
}
|
||||
|
||||
return normalizeText(fallback) || title || '默认 SKU'
|
||||
}
|
||||
|
||||
const mapHqbSku = (
|
||||
sku: HqbSkuInfo,
|
||||
goods: HqbGoodsInfo,
|
||||
title: string,
|
||||
valueMap: Map<string, HqbSpecValueMeta>
|
||||
): MerchantGoodsSkuInput => {
|
||||
const specs = buildSkuSpecs(sku, valueMap)
|
||||
const salePrice = toMoneyText(sku.price ?? goods.price ?? goods.yprice ?? goods.maxPrice)
|
||||
|
||||
return {
|
||||
skuId: 0,
|
||||
skuNo: normalizeText(sku.skuCode || sku.id),
|
||||
skuName: buildSkuName(title, specs, sku.keyName),
|
||||
barcode: '',
|
||||
imageUrl: normalizeImageUrl(sku.skuImg || sku.imgUrl || sku.thumbUrl || goods.imageUrl),
|
||||
specJson: JSON.stringify(specs),
|
||||
salePrice,
|
||||
originPrice: toOptionalMoneyText(sku.originalPrice ?? goods.maxPrice),
|
||||
costPrice: '',
|
||||
weight: normalizeText(sku.weight) === '0' ? '' : normalizeText(sku.weight),
|
||||
status: 'enabled',
|
||||
stock: toStockNumber(sku.storeCount),
|
||||
warnStock: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const buildDefaultSku = (goods: HqbGoodsInfo, title: string): MerchantGoodsSkuInput => ({
|
||||
skuId: 0,
|
||||
skuNo: normalizeText(goods.goodsSn || goods.id),
|
||||
skuName: '默认 SKU',
|
||||
barcode: '',
|
||||
imageUrl: normalizeImageUrl(goods.imageUrl),
|
||||
specJson: '[]',
|
||||
salePrice: toMoneyText(goods.price ?? goods.yprice ?? goods.maxPrice),
|
||||
originPrice: toOptionalMoneyText(goods.maxPrice),
|
||||
costPrice: '',
|
||||
weight: '',
|
||||
status: 'enabled',
|
||||
stock: 0,
|
||||
warnStock: 0,
|
||||
})
|
||||
|
||||
export const buildMallGoodsFromHqb = (
|
||||
goods: HqbGoodsInfo,
|
||||
merchantId: string,
|
||||
sourceUrl: string
|
||||
): HqbImportPreview => {
|
||||
const title = normalizeText(goods.title) || `52hqb 商品 ${goods.id}`
|
||||
const subTitle = normalizeText(goods.remark || goods.goodsSn)
|
||||
const carouselImages = uniqueNonEmpty([
|
||||
goods.imageUrl,
|
||||
...(goods.goodsImageList ?? []).map(item => pickImageUrl(item)),
|
||||
])
|
||||
const detailImages = uniqueNonEmpty([
|
||||
...parseUnknownImageList(goods.descImageList),
|
||||
...(carouselImages.length > 0 ? carouselImages : []),
|
||||
])
|
||||
const videoUrls = uniqueNonEmpty([goods.videoUrl])
|
||||
const valueMap = buildSpecValueMap(goods.specsList)
|
||||
const skus =
|
||||
goods.skuInfoList && goods.skuInfoList.length > 0
|
||||
? goods.skuInfoList.map(item => mapHqbSku(item, goods, title, valueMap))
|
||||
: [buildDefaultSku(goods, title)]
|
||||
const sourceGoodsId = String(goods.id)
|
||||
const shopName = normalizeText(goods.shopInfo?.shopName)
|
||||
const detailBlocks = [
|
||||
...(normalizeText(goods.textDesc)
|
||||
? [{ type: 'text', value: normalizeText(goods.textDesc) }]
|
||||
: []),
|
||||
...detailImages.map(url => ({ type: 'image', value: url })),
|
||||
]
|
||||
|
||||
const payload: MerchantGoodsSaveParams = {
|
||||
merchantId,
|
||||
spuId: 0,
|
||||
categoryId: HQB_TARGET_CATEGORY_ID,
|
||||
spuName: title,
|
||||
subTitle,
|
||||
coverUrl: carouselImages[0] || normalizeImageUrl(goods.imageUrl),
|
||||
mediaJson: JSON.stringify({
|
||||
images: carouselImages,
|
||||
videos: videoUrls,
|
||||
}),
|
||||
detailJson: JSON.stringify({
|
||||
blocks: detailBlocks,
|
||||
}),
|
||||
paramJson: '[]',
|
||||
serviceJson: '[]',
|
||||
extraInfoJson: JSON.stringify({
|
||||
source: '52hqb',
|
||||
sourceUrl,
|
||||
sourceGoodsId,
|
||||
sourceSite: goods.siteCode || 'hqb',
|
||||
sourceCategoryPath: normalizeText(goods.catePath),
|
||||
sourceCategoryIdPath: normalizeText(goods.cateIdPath),
|
||||
sourceShopName: shopName,
|
||||
sourceShopId: goods.shopInfo?.id ? String(goods.shopInfo.id) : '',
|
||||
importedAt: new Date().toISOString(),
|
||||
}),
|
||||
status: 'enabled',
|
||||
saleStatus: 'off',
|
||||
sort: 0,
|
||||
skus,
|
||||
}
|
||||
|
||||
return {
|
||||
sourceGoodsId,
|
||||
sourceUrl,
|
||||
title,
|
||||
subTitle,
|
||||
coverUrl: payload.coverUrl || '',
|
||||
imageUrls: carouselImages,
|
||||
videoUrls,
|
||||
skuCount: skus.length,
|
||||
categoryName: HQB_TARGET_CATEGORY_NAME,
|
||||
sourceCategoryPath: normalizeText(goods.catePath),
|
||||
shopName,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
160
src/utils/mall.ts
Normal file
160
src/utils/mall.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import dayjs from 'dayjs'
|
||||
import type { MerchantInfo } from '@/types/mall'
|
||||
|
||||
export const ENABLE_STATUS_OPTIONS = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
] as const
|
||||
|
||||
export const AUDIT_STATUS_OPTIONS = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '审核通过', value: 'approved' },
|
||||
{ label: '审核驳回', value: 'rejected' },
|
||||
] as const
|
||||
|
||||
export const SALE_STATUS_OPTIONS = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '下架', value: 'off' },
|
||||
{ label: '上架', value: 'on' },
|
||||
] as const
|
||||
|
||||
export const ORDER_STATUS_OPTIONS = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待支付', value: 'pending_pay' },
|
||||
{ label: '待发货', value: 'paid_wait_ship' },
|
||||
{ label: '已发货', value: 'shipped' },
|
||||
{ label: '已完成', value: 'finished' },
|
||||
{ label: '已取消', value: 'canceled' },
|
||||
{ label: '售后中', value: 'after_sale' },
|
||||
{ label: '退款完成', value: 'refund_finished' },
|
||||
] as const
|
||||
|
||||
export const AFTER_SALE_STATUS_OPTIONS = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '无售后', value: 'none' },
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '审核通过', value: 'approved' },
|
||||
{ label: '审核驳回', value: 'rejected' },
|
||||
{ label: '退款中', value: 'refunding' },
|
||||
{ label: '已完成', value: 'finished' },
|
||||
{ label: '已关闭', value: 'closed' },
|
||||
] as const
|
||||
|
||||
export const AFTER_SALE_TYPE_OPTIONS = [
|
||||
{ label: '全部', value: 0 },
|
||||
{ label: '仅退款', value: 1 },
|
||||
{ label: '退货退款', value: 2 },
|
||||
] as const
|
||||
|
||||
const STATUS_LABEL_MAP: Record<string, string> = {
|
||||
enabled: '启用',
|
||||
disabled: '停用',
|
||||
pending: '待处理',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回',
|
||||
active: '正常结算',
|
||||
frozen: '结算冻结',
|
||||
off: '下架',
|
||||
on: '上架',
|
||||
none: '无售后',
|
||||
refunding: '退款中',
|
||||
finished: '已完成',
|
||||
closed: '已关闭',
|
||||
pending_pay: '待支付',
|
||||
paid_wait_ship: '待发货',
|
||||
shipped: '已发货',
|
||||
canceled: '已取消',
|
||||
after_sale: '售后中',
|
||||
refund_finished: '退款完成',
|
||||
}
|
||||
|
||||
const STATUS_COLOR_MAP: Record<string, string> = {
|
||||
enabled: 'green',
|
||||
disabled: 'default',
|
||||
pending: 'gold',
|
||||
approved: 'green',
|
||||
rejected: 'red',
|
||||
active: 'green',
|
||||
frozen: 'red',
|
||||
off: 'default',
|
||||
on: 'processing',
|
||||
none: 'default',
|
||||
refunding: 'purple',
|
||||
finished: 'green',
|
||||
closed: 'default',
|
||||
pending_pay: 'gold',
|
||||
paid_wait_ship: 'processing',
|
||||
shipped: 'cyan',
|
||||
canceled: 'default',
|
||||
after_sale: 'orange',
|
||||
refund_finished: 'green',
|
||||
}
|
||||
|
||||
export const getStatusLabel = (value?: string) => {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return STATUS_LABEL_MAP[value] ?? value
|
||||
}
|
||||
|
||||
export const getStatusColor = (value?: string) => STATUS_COLOR_MAP[value ?? ''] ?? 'default'
|
||||
|
||||
export const formatUnixTime = (value?: number) => {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return dayjs.unix(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
export const prettyJsonText = (value?: string) => {
|
||||
if (!value?.trim()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeJsonText = (value: string | undefined, fieldLabel: string, allowEmpty = true) => {
|
||||
const trimmed = value?.trim() ?? ''
|
||||
|
||||
if (!trimmed) {
|
||||
if (allowEmpty) {
|
||||
return ''
|
||||
}
|
||||
throw new Error(`${fieldLabel} 不能为空`)
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(trimmed))
|
||||
} catch {
|
||||
throw new Error(`${fieldLabel} 不是合法 JSON`)
|
||||
}
|
||||
}
|
||||
|
||||
export const compactObject = <T extends object>(payload: T): T => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(payload as Record<string, unknown>).filter(
|
||||
([, value]) => value !== '' && value !== undefined && value !== null
|
||||
)
|
||||
) as T
|
||||
}
|
||||
|
||||
export const resolveMerchantSessionIdentity = (
|
||||
merchant?: Pick<MerchantInfo, 'id' | 'merchantName'> | null
|
||||
) => {
|
||||
const merchantId =
|
||||
merchant?.id === undefined || merchant?.id === null ? null : String(merchant.id).trim() || null
|
||||
|
||||
return {
|
||||
merchantId,
|
||||
merchantName: merchant?.merchantName ?? '',
|
||||
}
|
||||
}
|
||||
2
src/vite-env.d.ts
vendored
Normal file
2
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
25
tsconfig.app.json
Normal file
25
tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
12
tsconfig.node.json
Normal file
12
tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import path from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user