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