This commit is contained in:
魔方熊成祥
2026-04-29 21:38:18 +08:00
commit ba0458abe2
56 changed files with 10300 additions and 0 deletions

9
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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
View 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

File diff suppressed because it is too large Load Diff

12
src/App.tsx Normal file
View 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
View 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
View 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)
}

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

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

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

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

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

View File

@@ -0,0 +1,6 @@
export * from './profile'
export * from './category'
export * from './goods'
export * from './featuredGoods'
export * from './orders'
export * from './afterSale'

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

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

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

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

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

View 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

View File

@@ -0,0 +1,2 @@
export { lightTheme } from './light'

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

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

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

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

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

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

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

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

View 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">
merchantIdspuIdsortstatus 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>
)
}

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

25
tsconfig.app.json Normal file
View 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
View File

@@ -0,0 +1,12 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}

12
tsconfig.node.json Normal file
View 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
View 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'),
},
},
})