1
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>商城管理端</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
2996
package-lock.json
generated
Normal file
2996
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "mall-admin",
|
||||
"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.6.3",
|
||||
"sonner": "^2.0.6",
|
||||
"zustand": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
12
src/App.tsx
Normal file
12
src/App.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Toaster } from 'sonner'
|
||||
import AppRoutes from '@/routes'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<AppRoutes />
|
||||
<Toaster position="top-center" richColors duration={2600} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
382
src/apis/apiMallAdmin.ts
Normal file
382
src/apis/apiMallAdmin.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import axiosInstance from '@/apis/base/axiosInstance'
|
||||
import type { IBaseResponse } from '@/apis/base/types'
|
||||
import type {
|
||||
AdminBannerDeleteParams,
|
||||
AdminBannerItem,
|
||||
AdminBannerPageParams,
|
||||
AdminBannerSaveParams,
|
||||
AdminCategoryDeleteParams,
|
||||
AdminCategoryItem,
|
||||
AdminCategorySaveParams,
|
||||
AdminCategoryTreeParams,
|
||||
AdminGoodsAuditParams,
|
||||
AdminGoodsPageParams,
|
||||
AdminHomeChannelDeleteParams,
|
||||
AdminHomeChannelItem,
|
||||
AdminHomeChannelPageParams,
|
||||
AdminHomeChannelSaveParams,
|
||||
AdminMerchantCreateParams,
|
||||
AdminMerchantAuditParams,
|
||||
AdminMerchantPageParams,
|
||||
AdminOrderDetailParams,
|
||||
AdminOrderPageParams,
|
||||
AdminRecommendMerchantDeleteParams,
|
||||
AdminRecommendMerchantItem,
|
||||
AdminRecommendMerchantPageParams,
|
||||
AdminRecommendMerchantSaveParams,
|
||||
AdminRecommendStoreDeleteParams,
|
||||
AdminRecommendStoreItem,
|
||||
AdminRecommendStorePageParams,
|
||||
AdminRecommendStoreSaveParams,
|
||||
AdminSearchKeywordDeleteParams,
|
||||
AdminSearchKeywordItem,
|
||||
AdminSearchKeywordPageParams,
|
||||
AdminSearchKeywordSaveParams,
|
||||
AdminStoreAuditParams,
|
||||
AdminStorePageParams,
|
||||
GoodsSummary,
|
||||
MerchantInfo,
|
||||
OrderDetailInfo,
|
||||
OrderSummary,
|
||||
PageResult,
|
||||
StoreInfo,
|
||||
} from '@/types/mall'
|
||||
|
||||
const normalizePageResult = <T>(value?: Partial<PageResult<T>> | null): PageResult<T> => ({
|
||||
total: Number(value?.total ?? 0),
|
||||
list: Array.isArray(value?.list) ? value.list : [],
|
||||
})
|
||||
|
||||
const normalizeCategoryItems = (items?: AdminCategoryItem[] | null): AdminCategoryItem[] => {
|
||||
if (!Array.isArray(items)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return items.map(item => ({
|
||||
...item,
|
||||
children: normalizeCategoryItems(item.children),
|
||||
}))
|
||||
}
|
||||
|
||||
export const apiAdminMerchantPage = async (params: AdminMerchantPageParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<PageResult<MerchantInfo>>>(
|
||||
'/api/v1/mall/admin/merchant_page',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
data: normalizePageResult(response.data.data),
|
||||
}
|
||||
}
|
||||
|
||||
export const apiAdminMerchantCreate = async (params: AdminMerchantCreateParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ uid: number; merchantId: string; merchantNo: string }>>(
|
||||
'/api/v1/mall/admin/merchant_create',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminMerchantAudit = async (params: AdminMerchantAuditParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ merchantId: string; auditStatus: string }>>(
|
||||
'/api/v1/mall/admin/merchant_audit',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminStorePage = async (params: AdminStorePageParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<PageResult<StoreInfo>>>(
|
||||
'/api/v1/mall/admin/store_page',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
data: normalizePageResult(response.data.data),
|
||||
}
|
||||
}
|
||||
|
||||
export const apiAdminStoreAudit = async (params: AdminStoreAuditParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ storeId: number; auditStatus: string }>>(
|
||||
'/api/v1/mall/admin/store_audit',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminGoodsPage = async (params: AdminGoodsPageParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<PageResult<GoodsSummary>>>(
|
||||
'/api/v1/mall/admin/goods_page',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
data: normalizePageResult(response.data.data),
|
||||
}
|
||||
}
|
||||
|
||||
export const apiAdminGoodsAudit = async (params: AdminGoodsAuditParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ spuId: number; auditStatus: string }>>(
|
||||
'/api/v1/mall/admin/goods_audit',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminOrderPage = async (params: AdminOrderPageParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<PageResult<OrderSummary>>>(
|
||||
'/api/v1/mall/admin/order_page',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
data: normalizePageResult(response.data.data),
|
||||
}
|
||||
}
|
||||
|
||||
export const apiAdminOrderDetail = async (params: AdminOrderDetailParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ data?: OrderDetailInfo | null }>>(
|
||||
'/api/v1/mall/admin/order_detail',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
data: response.data.data?.data ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export const apiAdminCategoryTree = async (params: AdminCategoryTreeParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ list?: AdminCategoryItem[] | null }>>(
|
||||
'/api/v1/mall/admin/category_tree',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
data: normalizeCategoryItems(response.data.data?.list),
|
||||
}
|
||||
}
|
||||
|
||||
export const apiAdminCategorySave = async (params: AdminCategorySaveParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ id: number; categoryLevel: number; categoryPath: string }>>(
|
||||
'/api/v1/mall/admin/category_save',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminCategoryDelete = async (params: AdminCategoryDeleteParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ id: number }>>(
|
||||
'/api/v1/mall/admin/category_delete',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminBannerPage = async (params: AdminBannerPageParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<PageResult<AdminBannerItem>>>(
|
||||
'/api/v1/mall/admin/banner_page',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
data: normalizePageResult(response.data.data),
|
||||
}
|
||||
}
|
||||
|
||||
export const apiAdminBannerSave = async (params: AdminBannerSaveParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ id: number }>>(
|
||||
'/api/v1/mall/admin/banner_save',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminBannerDelete = async (params: AdminBannerDeleteParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ id: number }>>(
|
||||
'/api/v1/mall/admin/banner_delete',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminRecommendStorePage = async (
|
||||
params: AdminRecommendStorePageParams,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<PageResult<AdminRecommendStoreItem>>>(
|
||||
'/api/v1/mall/admin/recommend_store_page',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
data: normalizePageResult(response.data.data),
|
||||
}
|
||||
}
|
||||
|
||||
export const apiAdminRecommendStoreSave = async (
|
||||
params: AdminRecommendStoreSaveParams,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ id: number; storeId: number }>>(
|
||||
'/api/v1/mall/admin/recommend_store_save',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminRecommendMerchantPage = async (
|
||||
params: AdminRecommendMerchantPageParams,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<PageResult<AdminRecommendMerchantItem>>>(
|
||||
'/api/v1/mall/admin/recommend_merchant_page',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
data: normalizePageResult(response.data.data),
|
||||
}
|
||||
}
|
||||
|
||||
export const apiAdminRecommendMerchantSave = async (
|
||||
params: AdminRecommendMerchantSaveParams,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ id: number; merchantId: string }>>(
|
||||
'/api/v1/mall/admin/recommend_merchant_save',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminRecommendMerchantDelete = async (
|
||||
params: AdminRecommendMerchantDeleteParams,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ id: number }>>(
|
||||
'/api/v1/mall/admin/recommend_merchant_delete',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminSearchKeywordPage = async (
|
||||
params: AdminSearchKeywordPageParams,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<PageResult<AdminSearchKeywordItem>>>(
|
||||
'/api/v1/mall/admin/search_keyword_page',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
data: normalizePageResult(response.data.data),
|
||||
}
|
||||
}
|
||||
|
||||
export const apiAdminSearchKeywordSave = async (
|
||||
params: AdminSearchKeywordSaveParams,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ id: number }>>(
|
||||
'/api/v1/mall/admin/search_keyword_save',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminSearchKeywordDelete = async (
|
||||
params: AdminSearchKeywordDeleteParams,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ id: number }>>(
|
||||
'/api/v1/mall/admin/search_keyword_delete',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminHomeChannelPage = async (
|
||||
params: AdminHomeChannelPageParams,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<PageResult<AdminHomeChannelItem>>>(
|
||||
'/api/v1/mall/admin/home_channel_page',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
data: normalizePageResult(response.data.data),
|
||||
}
|
||||
}
|
||||
|
||||
export const apiAdminHomeChannelSave = async (
|
||||
params: AdminHomeChannelSaveParams,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ id: number }>>(
|
||||
'/api/v1/mall/admin/home_channel_save',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminHomeChannelDelete = async (
|
||||
params: AdminHomeChannelDeleteParams,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ id: number }>>(
|
||||
'/api/v1/mall/admin/home_channel_delete',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiAdminRecommendStoreDelete = async (
|
||||
params: AdminRecommendStoreDeleteParams,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<{ id: number }>>(
|
||||
'/api/v1/mall/admin/recommend_store_delete',
|
||||
params,
|
||||
{ signal }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
23
src/apis/apisCommon.ts
Normal file
23
src/apis/apisCommon.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import axiosInstance from '@/apis/base/axiosInstance'
|
||||
import type { IBaseResponse } from '@/apis/base/types'
|
||||
|
||||
export const apiGetSmsCode = async (
|
||||
params: {
|
||||
account: string
|
||||
type: '0' | '1' | '2' | '8'
|
||||
},
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<unknown>>(
|
||||
'/api/v1/user/captcha',
|
||||
{
|
||||
...params,
|
||||
loginType: '0',
|
||||
},
|
||||
{
|
||||
signal,
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
36
src/apis/apisUser.ts
Normal file
36
src/apis/apisUser.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import axiosInstance from '@/apis/base/axiosInstance'
|
||||
import type { IBaseResponse } from '@/apis/base/types'
|
||||
import type { ITokenData } from '@/types/ITokenData'
|
||||
import type { IUserInfo } from '@/types/IUserInfo'
|
||||
|
||||
export interface UserLoginParams {
|
||||
account: string
|
||||
password: string
|
||||
captcha: string
|
||||
captchaId?: string
|
||||
loginType: '0'
|
||||
type: '0'
|
||||
remember: boolean
|
||||
}
|
||||
|
||||
export const apiUserLogin = async (params: UserLoginParams, signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<ITokenData>>(
|
||||
'/api/v1/user/super_login',
|
||||
params,
|
||||
{
|
||||
signal,
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const apiGetUserInfo = async (signal?: AbortSignal) => {
|
||||
const response = await axiosInstance.post<IBaseResponse<IUserInfo>>(
|
||||
'/api/v1/user/pur_detail',
|
||||
{},
|
||||
{
|
||||
signal,
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
113
src/apis/base/axiosInstance.ts
Normal file
113
src/apis/base/axiosInstance.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import axios, { AxiosHeaders } from 'axios'
|
||||
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import { getBaseUrl, getVersion } from '@/config/source/config'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { API_ERROR_CODES, API_ERROR_TYPES, ApiError } from './types'
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: getBaseUrl(),
|
||||
timeout: 30 * 1000,
|
||||
})
|
||||
|
||||
const handleHttpError = (status: number, data: unknown, message: string, error: unknown): never => {
|
||||
switch (status) {
|
||||
case 401:
|
||||
useUserStore.getState().clearAuth()
|
||||
throw new ApiError(API_ERROR_CODES.UNAUTHORIZED, '登录已失效', API_ERROR_TYPES.HTTP, data, error)
|
||||
case 403:
|
||||
throw new ApiError(API_ERROR_CODES.FORBIDDEN, '无权限访问', API_ERROR_TYPES.HTTP, data, error)
|
||||
case 500:
|
||||
case 503:
|
||||
throw new ApiError(API_ERROR_CODES.SERVER_ERROR, '服务器错误', API_ERROR_TYPES.HTTP, data, error)
|
||||
default:
|
||||
throw new ApiError(status, message, API_ERROR_TYPES.HTTP, data, error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAxiosError = (error: AxiosError): never => {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
throw new ApiError(API_ERROR_CODES.TIMEOUT_ERROR, '请求超时', API_ERROR_TYPES.NETWORK, undefined, error)
|
||||
}
|
||||
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
const message = (data as { message?: string; msg?: string } | undefined)?.message
|
||||
|| (data as { message?: string; msg?: string } | undefined)?.msg
|
||||
|| error.message
|
||||
return handleHttpError(status, data, message, error)
|
||||
}
|
||||
|
||||
if (error.request) {
|
||||
throw new ApiError(
|
||||
API_ERROR_CODES.NETWORK_ERROR,
|
||||
'网络错误,请检查网关地址与当前网络',
|
||||
API_ERROR_TYPES.NETWORK,
|
||||
undefined,
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
throw new ApiError(API_ERROR_CODES.SERVER_ERROR, error.message, API_ERROR_TYPES.UNKNOWN, undefined, error)
|
||||
}
|
||||
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = useUserStore.getState().token?.accessToken ?? ''
|
||||
|
||||
config.baseURL = getBaseUrl()
|
||||
config.headers = new AxiosHeaders({
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
authorization: token,
|
||||
channel: 'admin',
|
||||
version: getVersion(),
|
||||
...config.headers,
|
||||
})
|
||||
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
throw new ApiError(API_ERROR_CODES.SERVER_ERROR, '请求初始化失败', API_ERROR_TYPES.UNKNOWN, undefined, error)
|
||||
}
|
||||
)
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
response => {
|
||||
const payload = (response.data ?? {}) as {
|
||||
code?: number | string
|
||||
message?: string
|
||||
msg?: string
|
||||
data?: unknown
|
||||
}
|
||||
const code = Number(payload.code ?? API_ERROR_CODES.SERVER_ERROR)
|
||||
const message = payload.message || payload.msg || '请求失败'
|
||||
|
||||
if (code === API_ERROR_CODES.SUCCESS) {
|
||||
response.data = {
|
||||
code,
|
||||
message,
|
||||
data: payload.data,
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
if (code === API_ERROR_CODES.UNAUTHORIZED) {
|
||||
useUserStore.getState().clearAuth()
|
||||
}
|
||||
|
||||
throw new ApiError(code, message, API_ERROR_TYPES.BUSINESS, payload.data)
|
||||
},
|
||||
(error: unknown): never => {
|
||||
if (axios.isCancel(error)) {
|
||||
throw new ApiError(API_ERROR_CODES.CANCEL_ERROR, '请求已取消', API_ERROR_TYPES.UNKNOWN, undefined, error)
|
||||
}
|
||||
|
||||
if (error instanceof Error && 'isAxiosError' in error) {
|
||||
return handleAxiosError(error as AxiosError)
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : '未知错误'
|
||||
throw new ApiError(API_ERROR_CODES.SERVER_ERROR, message, API_ERROR_TYPES.UNKNOWN, undefined, error)
|
||||
}
|
||||
)
|
||||
|
||||
export default axiosInstance
|
||||
47
src/apis/base/types.ts
Normal file
47
src/apis/base/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export const API_ERROR_TYPES = {
|
||||
HTTP: 'HTTP',
|
||||
BUSINESS: 'BUSINESS',
|
||||
NETWORK: 'NETWORK',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
} as const
|
||||
|
||||
export type ApiErrorType = (typeof API_ERROR_TYPES)[keyof typeof API_ERROR_TYPES]
|
||||
|
||||
export const API_ERROR_CODES = {
|
||||
SUCCESS: 200,
|
||||
UNAUTHORIZED: 600,
|
||||
FORBIDDEN: 403,
|
||||
SERVER_ERROR: 500,
|
||||
NETWORK_ERROR: 1000,
|
||||
TIMEOUT_ERROR: 1001,
|
||||
CANCEL_ERROR: 1002,
|
||||
} as const
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: number
|
||||
type: ApiErrorType
|
||||
data?: unknown
|
||||
originalError?: unknown
|
||||
|
||||
constructor(
|
||||
code: number,
|
||||
message: string,
|
||||
type: ApiErrorType,
|
||||
data?: unknown,
|
||||
originalError?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.code = code
|
||||
this.type = type
|
||||
this.data = data
|
||||
this.originalError = originalError
|
||||
}
|
||||
}
|
||||
|
||||
export interface IBaseResponse<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
87
src/components/AuditModal.tsx
Normal file
87
src/components/AuditModal.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Form, Input, Modal, Radio, Space, Typography } from 'antd'
|
||||
import { auditActionOptions } from '@/utils/mall'
|
||||
|
||||
export interface AuditModalValues {
|
||||
auditStatus: 'approved' | 'rejected'
|
||||
remark?: string
|
||||
}
|
||||
|
||||
interface AuditModalProps {
|
||||
open: boolean
|
||||
title: string
|
||||
loading?: boolean
|
||||
summary?: ReactNode
|
||||
initialValues?: Partial<AuditModalValues>
|
||||
onCancel: () => void
|
||||
onSubmit: (values: AuditModalValues) => Promise<void> | void
|
||||
}
|
||||
|
||||
export default function AuditModal({
|
||||
open,
|
||||
title,
|
||||
loading,
|
||||
summary,
|
||||
initialValues,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: AuditModalProps) {
|
||||
const [form] = Form.useForm<AuditModalValues>()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
destroyOnClose
|
||||
confirmLoading={loading}
|
||||
okText="提交审核"
|
||||
cancelText="取消"
|
||||
onCancel={onCancel}
|
||||
onOk={() => {
|
||||
void form.submit()
|
||||
}}
|
||||
>
|
||||
{summary ? (
|
||||
<Space direction="vertical" size={12} style={{ display: 'flex', marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">审核对象</Typography.Text>
|
||||
{summary}
|
||||
</Space>
|
||||
) : null}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
auditStatus: initialValues?.auditStatus ?? 'approved',
|
||||
remark: initialValues?.remark ?? '',
|
||||
}}
|
||||
onFinish={values => onSubmit(values)}
|
||||
>
|
||||
<Form.Item
|
||||
name="auditStatus"
|
||||
label="审核结果"
|
||||
rules={[{ required: true, message: '请选择审核结果' }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Space direction="vertical">
|
||||
{auditActionOptions.map(option => (
|
||||
<Radio key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Radio>
|
||||
))}
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="remark" label="审核备注">
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder="可填写审核说明、驳回原因或内部备注"
|
||||
maxLength={200}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
22
src/components/JsonPreviewPanel.tsx
Normal file
22
src/components/JsonPreviewPanel.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Card } from 'antd'
|
||||
import { safeJsonPretty } from '@/utils/format'
|
||||
|
||||
interface JsonPreviewPanelProps {
|
||||
title: string
|
||||
value?: string | null
|
||||
}
|
||||
|
||||
export default function JsonPreviewPanel({ title, value }: JsonPreviewPanelProps) {
|
||||
const content = value?.trim()
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small" title={title}>
|
||||
<pre className="json-block">{safeJsonPretty(content)}</pre>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
11
src/components/StatusTag.tsx
Normal file
11
src/components/StatusTag.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Tag } from 'antd'
|
||||
import { getStatusColor, getStatusLabel } from '@/utils/mall'
|
||||
|
||||
interface StatusTagProps {
|
||||
value?: string | null
|
||||
}
|
||||
|
||||
export default function StatusTag({ value }: StatusTagProps) {
|
||||
return <Tag color={getStatusColor(value)}>{getStatusLabel(value)}</Tag>
|
||||
}
|
||||
|
||||
29
src/config/source/config.ts
Normal file
29
src/config/source/config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
interface WindowConfig {
|
||||
VERSION?: string
|
||||
}
|
||||
|
||||
const DEFAULT_VERSION = '0.1.0'
|
||||
const FIXED_BASE_URL = 'https://test-gateway.yuntuo.hk'
|
||||
|
||||
const getWindowConfig = (): WindowConfig | undefined => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
return (window as typeof window & { config?: WindowConfig }).config
|
||||
}
|
||||
|
||||
export const getStoredBaseUrl = (): string => {
|
||||
return FIXED_BASE_URL
|
||||
}
|
||||
|
||||
export const setStoredBaseUrl = (value: string) => {
|
||||
void value
|
||||
}
|
||||
|
||||
export const getBaseUrl = (): string => {
|
||||
return FIXED_BASE_URL
|
||||
}
|
||||
|
||||
export const getVersion = (): string => {
|
||||
return getWindowConfig()?.VERSION?.trim() || DEFAULT_VERSION
|
||||
}
|
||||
127
src/index.css
Normal file
127
src/index.css
Normal file
@@ -0,0 +1,127 @@
|
||||
:root {
|
||||
color: #162233;
|
||||
background: #f5f7f6;
|
||||
font-family: "PingFang SC", "SF Pro Display", "Helvetica Neue", "Microsoft YaHei", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(21, 111, 78, 0.08), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(5, 170, 129, 0.05), transparent 22%),
|
||||
#f5f7f6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-form.ant-form-inline {
|
||||
row-gap: 14px;
|
||||
column-gap: 18px;
|
||||
}
|
||||
|
||||
.search-form.ant-form-inline .ant-form-item {
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.search-form.ant-form-inline .ant-form-item-label {
|
||||
padding-inline-end: 10px;
|
||||
}
|
||||
|
||||
.table-top-align .ant-table-cell {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.ant-typography,
|
||||
.ant-typography.ant-typography-paragraph {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ant-card .ant-card-head {
|
||||
min-height: 46px;
|
||||
}
|
||||
|
||||
.ant-card .ant-card-head-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.merchant-create-modal .ant-modal-content {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.merchant-create-modal .ant-modal-header {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.merchant-create-form .ant-form-item {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.merchant-create-form .ant-radio-group {
|
||||
display: inline-flex;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
background: #f3f7f5;
|
||||
}
|
||||
|
||||
.merchant-create-form .ant-radio-button-wrapper {
|
||||
min-width: 92px;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.merchant-create-form .ant-radio-button-wrapper:not(:first-child)::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.merchant-create-section-title {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
color: #156f4e;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "SFMono-Regular", "JetBrains Mono", "Consolas", monospace;
|
||||
}
|
||||
|
||||
pre.json-block {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
overflow: auto;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
18
src/layouts/RootLayout.tsx
Normal file
18
src/layouts/RootLayout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
export default function RootLayout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const token = useUserStore(state => state.token)
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/') {
|
||||
navigate(token?.accessToken ? '/merchant-review' : '/login', { replace: true })
|
||||
}
|
||||
}, [location.pathname, navigate, token?.accessToken])
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
32
src/layouts/auth/AuthLayout.tsx
Normal file
32
src/layouts/auth/AuthLayout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Layout, theme } from 'antd'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
export default function AuthLayout() {
|
||||
const { token } = theme.useToken()
|
||||
|
||||
return (
|
||||
<Layout
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: `
|
||||
radial-gradient(800px 500px at 15% 10%, ${token.colorPrimaryBg} 0%, transparent 55%),
|
||||
radial-gradient(900px 500px at 100% 0%, rgba(5,170,129,0.10) 0%, transparent 52%),
|
||||
linear-gradient(180deg, #f8fbf8 0%, #f1f5f2 100%)
|
||||
`,
|
||||
}}
|
||||
>
|
||||
<Layout.Content
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 420, maxWidth: '100%' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
131
src/layouts/main/MainLayout.tsx
Normal file
131
src/layouts/main/MainLayout.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
AppstoreOutlined,
|
||||
LogoutOutlined,
|
||||
NotificationOutlined,
|
||||
PictureOutlined,
|
||||
ShoppingCartOutlined,
|
||||
StarOutlined,
|
||||
SolutionOutlined,
|
||||
TagsOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Avatar, Button, Layout, Menu, Space, Typography } from 'antd'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/merchant-review',
|
||||
icon: <SolutionOutlined />,
|
||||
label: '商家审核',
|
||||
},
|
||||
{
|
||||
key: '/categories',
|
||||
icon: <ApartmentOutlined />,
|
||||
label: '商品类目',
|
||||
},
|
||||
{
|
||||
key: '/banners',
|
||||
icon: <PictureOutlined />,
|
||||
label: '首页 Banner',
|
||||
},
|
||||
{
|
||||
key: '/goods-review',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: '商品审核',
|
||||
},
|
||||
{
|
||||
key: '/recommend-stores',
|
||||
icon: <StarOutlined />,
|
||||
label: '首页商家',
|
||||
},
|
||||
{
|
||||
key: '/search-keywords',
|
||||
icon: <TagsOutlined />,
|
||||
label: '首页热词',
|
||||
},
|
||||
{
|
||||
key: '/home-channels',
|
||||
icon: <NotificationOutlined />,
|
||||
label: '频道卡片',
|
||||
},
|
||||
{
|
||||
key: '/orders',
|
||||
icon: <ShoppingCartOutlined />,
|
||||
label: '订单总览',
|
||||
},
|
||||
]
|
||||
|
||||
const resolveSelectedKey = (pathname: string) => {
|
||||
if (pathname.startsWith('/orders')) {
|
||||
return '/orders'
|
||||
}
|
||||
return pathname
|
||||
}
|
||||
|
||||
export default function MainLayout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const logout = useUserStore(state => state.logout)
|
||||
const userInfo = useUserStore(state => state.userInfo)
|
||||
const token = useUserStore(state => state.token)
|
||||
|
||||
const displayName = useMemo(() => {
|
||||
return userInfo?.name || userInfo?.account || token?.account || '商城管理员'
|
||||
}, [token?.account, userInfo?.account, userInfo?.name])
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Layout.Sider width={228} breakpoint="lg" collapsedWidth={80}>
|
||||
<div
|
||||
style={{
|
||||
padding: '18px 16px 14px',
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ color: '#fff', margin: 0 }}>
|
||||
商城管理端
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[resolveSelectedKey(location.pathname)]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
style={{ borderInlineEnd: 0, marginTop: 8 }}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
|
||||
<Layout>
|
||||
<Layout.Header
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
paddingInline: 20,
|
||||
}}
|
||||
>
|
||||
<Space size={12}>
|
||||
<Avatar style={{ background: '#156f4e' }}>{displayName.slice(0, 1)}</Avatar>
|
||||
<Typography.Text style={{ color: '#fff' }}>{displayName}</Typography.Text>
|
||||
<Button
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={() => {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
</Space>
|
||||
</Layout.Header>
|
||||
|
||||
<Layout.Content style={{ padding: 20 }}>
|
||||
<Outlet />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
121
src/main.tsx
Normal file
121
src/main.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { App as AntdApp, ConfigProvider, theme as antdTheme, type ThemeConfig } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
const appTheme: ThemeConfig = {
|
||||
algorithm: antdTheme.defaultAlgorithm,
|
||||
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)',
|
||||
fontSize: 14,
|
||||
sizeStep: 4,
|
||||
sizeUnit: 4,
|
||||
lineHeight: 1.25,
|
||||
borderRadiusXS: 4,
|
||||
borderRadiusSM: 6,
|
||||
borderRadius: 8,
|
||||
borderRadiusLG: 12,
|
||||
controlHeight: 32,
|
||||
controlHeightSM: 28,
|
||||
controlHeightLG: 36,
|
||||
fontSizeHeading1: 30,
|
||||
fontSizeHeading2: 24,
|
||||
fontSizeHeading3: 20,
|
||||
fontSizeHeading4: 16,
|
||||
fontSizeHeading5: 14,
|
||||
wireframe: false,
|
||||
fontFamily:
|
||||
'"PingFang SC", "SF Pro Display", "Helvetica Neue", "Microsoft YaHei", sans-serif',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
defaultActiveColor: 'rgb(21,111,78)',
|
||||
defaultHoverBorderColor: 'rgb(52,145,114)',
|
||||
defaultHoverColor: 'rgb(52,145,114)',
|
||||
groupBorderColor: 'rgb(52,145,114)',
|
||||
defaultColor: 'rgb(21,111,78)',
|
||||
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)',
|
||||
defaultActiveBorderColor: 'rgb(21,111,78)',
|
||||
},
|
||||
Pagination: {
|
||||
colorPrimary: 'rgb(21,111,78)',
|
||||
},
|
||||
DatePicker: {
|
||||
activeBorderColor: 'rgb(21,111,78)',
|
||||
},
|
||||
Segmented: {
|
||||
lineWidth: 1,
|
||||
},
|
||||
Cascader: {
|
||||
colorHighlight: 'rgb(5,170,129)',
|
||||
optionSelectedBg: 'rgb(248,248,248)',
|
||||
},
|
||||
Alert: {
|
||||
colorInfoBg: 'rgb(240,244,242)',
|
||||
colorInfoBorder: 'rgb(200,215,208)',
|
||||
},
|
||||
Layout: {
|
||||
bodyBg: '#f5f7f6',
|
||||
headerBg: '#0f1720',
|
||||
siderBg: '#0f1720',
|
||||
triggerBg: '#122031',
|
||||
},
|
||||
Menu: {
|
||||
darkItemSelectedBg: '#156f4e',
|
||||
itemBorderRadius: 8,
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 12,
|
||||
bodyPadding: 18,
|
||||
},
|
||||
Table: {
|
||||
headerBg: '#f6f8f7',
|
||||
headerSplitColor: '#e3ebe7',
|
||||
cellPaddingBlock: 10,
|
||||
cellPaddingInline: 12,
|
||||
fontSize: 13,
|
||||
},
|
||||
Descriptions: {
|
||||
itemPaddingBottom: 10,
|
||||
labelBg: '#fafcfa',
|
||||
},
|
||||
Form: {
|
||||
itemMarginBottom: 14,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<ConfigProvider locale={zhCN} theme={{ ...appTheme, cssVar: true }}>
|
||||
<AntdApp>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
)
|
||||
462
src/pages/admin/banners/index.tsx
Normal file
462
src/pages/admin/banners/index.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
apiAdminBannerDelete,
|
||||
apiAdminBannerPage,
|
||||
apiAdminBannerSave,
|
||||
} from '@/apis/apiMallAdmin'
|
||||
import StatusTag from '@/components/StatusTag'
|
||||
import type {
|
||||
AdminBannerItem,
|
||||
AdminBannerPageParams,
|
||||
AdminBannerSaveParams,
|
||||
EnabledStatus,
|
||||
} from '@/types/mall'
|
||||
import { compactText, formatTimestamp } from '@/utils/format'
|
||||
import {
|
||||
bannerPositionOptions,
|
||||
enabledStatusOptions,
|
||||
getLinkTypeLabel,
|
||||
getRecommendStorePositionLabel,
|
||||
linkTypeOptions,
|
||||
withAllOption,
|
||||
} from '@/utils/mall'
|
||||
|
||||
const { TextArea } = Input
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
const DEFAULT_POSITION = 'home_top'
|
||||
const DEFAULT_STATUS: EnabledStatus = 'enabled'
|
||||
|
||||
interface BannerSearchValues {
|
||||
keyword?: string
|
||||
positionCode?: 'home_top' | ''
|
||||
status?: EnabledStatus | ''
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
interface BannerFormValues {
|
||||
title: string
|
||||
subTitle?: string
|
||||
description?: string
|
||||
buttonText?: string
|
||||
positionCode: 'home_top'
|
||||
imageUrl: string
|
||||
linkType?: string
|
||||
linkValue?: string
|
||||
sort?: number
|
||||
status: EnabledStatus
|
||||
startTime?: Dayjs | null
|
||||
endTime?: Dayjs | null
|
||||
}
|
||||
|
||||
const normalizeSearchValues = (values: BannerSearchValues): AdminBannerPageParams => ({
|
||||
keyword: values.keyword?.trim() || undefined,
|
||||
positionCode: values.positionCode || undefined,
|
||||
status: values.status || undefined,
|
||||
page: values.page,
|
||||
size: values.size,
|
||||
})
|
||||
|
||||
export default function BannerManagementPage() {
|
||||
const [form] = Form.useForm<BannerSearchValues>()
|
||||
const [modalForm] = Form.useForm<BannerFormValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [list, setList] = useState<AdminBannerItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: DEFAULT_PAGE_SIZE })
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<AdminBannerItem | null>(null)
|
||||
|
||||
const loadData = async (page = pagination.current, size = pagination.pageSize) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await apiAdminBannerPage(
|
||||
normalizeSearchValues({
|
||||
...form.getFieldsValue(),
|
||||
page,
|
||||
size,
|
||||
})
|
||||
)
|
||||
setList(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
setPagination({ current: page, pageSize: size })
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Banner 列表加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
page: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
})
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}, [])
|
||||
|
||||
const resetModal = () => {
|
||||
setModalOpen(false)
|
||||
setEditingRecord(null)
|
||||
modalForm.resetFields()
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingRecord(null)
|
||||
modalForm.setFieldsValue({
|
||||
positionCode: DEFAULT_POSITION,
|
||||
status: DEFAULT_STATUS,
|
||||
sort: 0,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (record: AdminBannerItem) => {
|
||||
setEditingRecord(record)
|
||||
modalForm.setFieldsValue({
|
||||
title: record.title,
|
||||
subTitle: record.subTitle || '',
|
||||
description: record.description || '',
|
||||
buttonText: record.buttonText || '',
|
||||
positionCode: record.positionCode,
|
||||
imageUrl: record.imageUrl,
|
||||
linkType: record.linkType || undefined,
|
||||
linkValue: record.linkValue || '',
|
||||
sort: record.sort ?? 0,
|
||||
status: record.status,
|
||||
startTime: record.startTime ? dayjs.unix(record.startTime) : null,
|
||||
endTime: record.endTime ? dayjs.unix(record.endTime) : null,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (record: AdminBannerItem) => {
|
||||
try {
|
||||
await apiAdminBannerDelete({ id: record.id })
|
||||
toast.success('Banner 已删除')
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Banner 删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await modalForm.validateFields()
|
||||
setSaving(true)
|
||||
const payload: AdminBannerSaveParams = {
|
||||
id: editingRecord?.id,
|
||||
title: values.title.trim(),
|
||||
subTitle: values.subTitle?.trim() || undefined,
|
||||
description: values.description?.trim() || undefined,
|
||||
buttonText: values.buttonText?.trim() || undefined,
|
||||
positionCode: values.positionCode,
|
||||
imageUrl: values.imageUrl.trim(),
|
||||
linkType: values.linkType || undefined,
|
||||
linkValue: values.linkValue?.trim() || undefined,
|
||||
sort: values.sort ?? 0,
|
||||
status: values.status,
|
||||
startTime: values.startTime?.unix(),
|
||||
endTime: values.endTime?.unix(),
|
||||
}
|
||||
await apiAdminBannerSave(payload)
|
||||
toast.success(editingRecord ? 'Banner 已更新' : 'Banner 已新增')
|
||||
resetModal()
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = (next: TablePaginationConfig) => {
|
||||
void loadData(next.current || 1, next.pageSize || DEFAULT_PAGE_SIZE)
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminBannerItem> = [
|
||||
{
|
||||
title: 'Banner',
|
||||
key: 'banner',
|
||||
width: 260,
|
||||
render: (_, record) => (
|
||||
<Space align="start">
|
||||
{record.imageUrl ? (
|
||||
<Image
|
||||
width={88}
|
||||
height={56}
|
||||
src={record.imageUrl}
|
||||
preview={false}
|
||||
style={{ objectFit: 'cover', borderRadius: 10 }}
|
||||
/>
|
||||
) : null}
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text strong>{compactText(record.title)}</Typography.Text>
|
||||
<Typography.Text type="secondary">{compactText(record.subTitle)}</Typography.Text>
|
||||
<Typography.Text type="secondary">{compactText(record.description)}</Typography.Text>
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '按钮文案',
|
||||
dataIndex: 'buttonText',
|
||||
width: 120,
|
||||
render: value => compactText(value),
|
||||
},
|
||||
{
|
||||
title: '位置',
|
||||
dataIndex: 'positionCode',
|
||||
width: 130,
|
||||
render: value => getRecommendStorePositionLabel(value),
|
||||
},
|
||||
{
|
||||
title: '跳转信息',
|
||||
key: 'link',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text>{getLinkTypeLabel(record.linkType)}</Typography.Text>
|
||||
<Typography.Text type="secondary" className="mono">
|
||||
{compactText(record.linkValue)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
width: 76,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 96,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'startTime',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '结束时间',
|
||||
dataIndex: 'endTime',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 144,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" onClick={() => openEditModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除该 Banner 配置吗?"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
onConfirm={() => void handleDelete(record)}
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ display: 'flex' }}>
|
||||
<div className="page-toolbar">
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
首页 Banner
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={openCreateModal}>
|
||||
新增 Banner
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form className="search-form" form={form} layout="inline" onFinish={() => void loadData(1, pagination.pageSize)}>
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input allowClear placeholder="标题 / 副标题 / 描述" style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="positionCode" label="投放位置">
|
||||
<Select allowClear options={withAllOption(bannerPositionOptions)} style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear options={withAllOption(enabledStatusOptions)} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table<AdminBannerItem>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
className="table-top-align"
|
||||
scroll={{ x: 1700 }}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: count => `共 ${count} 条`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
destroyOnHidden
|
||||
open={modalOpen}
|
||||
title={editingRecord ? '编辑首页 Banner' : '新增首页 Banner'}
|
||||
width={680}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
onCancel={resetModal}
|
||||
onOk={() => void handleSave()}
|
||||
>
|
||||
<Form form={modalForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="标题"
|
||||
rules={[
|
||||
{ required: true, message: '请输入标题' },
|
||||
{ whitespace: true, message: '标题不能为空' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={80} placeholder="请输入标题" />
|
||||
</Form.Item>
|
||||
<Form.Item name="subTitle" label="副标题">
|
||||
<Input maxLength={100} placeholder="请输入副标题" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<TextArea rows={3} maxLength={200} placeholder="请输入描述文案" />
|
||||
</Form.Item>
|
||||
<Space size={12} style={{ display: 'flex' }}>
|
||||
<Form.Item name="buttonText" label="按钮文案" style={{ flex: 1 }}>
|
||||
<Input maxLength={30} placeholder="如:立即查看" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="positionCode"
|
||||
label="投放位置"
|
||||
style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请选择投放位置' }]}
|
||||
>
|
||||
<Select options={bannerPositionOptions} placeholder="请选择投放位置" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item
|
||||
name="imageUrl"
|
||||
label="图片地址"
|
||||
rules={[
|
||||
{ required: true, message: '请输入图片地址' },
|
||||
{ whitespace: true, message: '图片地址不能为空' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入 Banner 图片地址" />
|
||||
</Form.Item>
|
||||
<Space size={12} style={{ display: 'flex' }}>
|
||||
<Form.Item name="linkType" label="跳转类型" style={{ flex: 1 }}>
|
||||
<Select allowClear options={linkTypeOptions} placeholder="请选择跳转类型" />
|
||||
</Form.Item>
|
||||
<Form.Item name="linkValue" label="跳转值" style={{ flex: 1 }}>
|
||||
<Input placeholder="请输入跳转目标值" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={12} style={{ display: 'flex' }}>
|
||||
<Form.Item name="sort" label="排序" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="默认 0" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
>
|
||||
<Select options={enabledStatusOptions} placeholder="请选择状态" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={12} style={{ display: 'flex' }}>
|
||||
<Form.Item name="startTime" label="开始时间" style={{ flex: 1 }}>
|
||||
<DatePicker showTime style={{ width: '100%' }} placeholder="请选择开始时间" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="endTime"
|
||||
label="结束时间"
|
||||
style={{ flex: 1 }}
|
||||
dependencies={['startTime']}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value?: Dayjs | null) {
|
||||
const startTime = getFieldValue('startTime') as Dayjs | null | undefined
|
||||
if (!startTime || !value || value.valueOf() >= startTime.valueOf()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error('结束时间不能早于开始时间'))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<DatePicker showTime style={{ width: '100%' }} placeholder="请选择结束时间" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
385
src/pages/admin/categories/index.tsx
Normal file
385
src/pages/admin/categories/index.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Avatar, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, TreeSelect, Typography } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
apiAdminCategoryDelete,
|
||||
apiAdminCategorySave,
|
||||
apiAdminCategoryTree,
|
||||
} from '@/apis/apiMallAdmin'
|
||||
import StatusTag from '@/components/StatusTag'
|
||||
import type {
|
||||
AdminCategoryItem,
|
||||
AdminCategorySaveParams,
|
||||
AdminCategoryTreeParams,
|
||||
EnabledStatus,
|
||||
} from '@/types/mall'
|
||||
import { compactText, formatTimestamp } from '@/utils/format'
|
||||
import {
|
||||
enabledStatusOptions,
|
||||
getBooleanLabel,
|
||||
getCategoryLevelLabel,
|
||||
withAllOption,
|
||||
} from '@/utils/mall'
|
||||
|
||||
const DEFAULT_STATUS: EnabledStatus = 'enabled'
|
||||
const ROOT_PARENT_ID = 0
|
||||
|
||||
interface CategorySearchValues {
|
||||
status?: EnabledStatus | ''
|
||||
}
|
||||
|
||||
interface CategoryFormValues {
|
||||
parentId: number
|
||||
categoryName: string
|
||||
categoryIcon?: string
|
||||
sort?: number
|
||||
status: EnabledStatus
|
||||
}
|
||||
|
||||
interface ParentTreeNode {
|
||||
title: string
|
||||
value: number
|
||||
disabled?: boolean
|
||||
children: ParentTreeNode[]
|
||||
}
|
||||
|
||||
const collectDisabledParentIds = (node?: AdminCategoryItem | null): Set<number> => {
|
||||
const ids = new Set<number>()
|
||||
|
||||
const walk = (current?: AdminCategoryItem | null) => {
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
ids.add(current.id)
|
||||
current.children?.forEach(child => walk(child))
|
||||
}
|
||||
|
||||
walk(node)
|
||||
return ids
|
||||
}
|
||||
|
||||
const buildParentTreeChildren = (
|
||||
items: AdminCategoryItem[],
|
||||
disabledIds: Set<number>
|
||||
): ParentTreeNode[] =>
|
||||
items.map(item => ({
|
||||
title: `${compactText(item.categoryName)} (ID: ${item.id})`,
|
||||
value: item.id,
|
||||
disabled: disabledIds.has(item.id),
|
||||
children: buildParentTreeChildren(item.children, disabledIds),
|
||||
}))
|
||||
|
||||
const buildParentTreeData = (items: AdminCategoryItem[], disabledIds: Set<number>): ParentTreeNode[] => [
|
||||
{
|
||||
title: '顶级类目',
|
||||
value: ROOT_PARENT_ID,
|
||||
children: buildParentTreeChildren(items, disabledIds),
|
||||
},
|
||||
]
|
||||
|
||||
const normalizeSearchValues = (values: CategorySearchValues): AdminCategoryTreeParams => ({
|
||||
status: values.status || undefined,
|
||||
})
|
||||
|
||||
export default function CategoryManagementPage() {
|
||||
const [form] = Form.useForm<CategorySearchValues>()
|
||||
const [modalForm] = Form.useForm<CategoryFormValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [list, setList] = useState<AdminCategoryItem[]>([])
|
||||
const [editingRecord, setEditingRecord] = useState<AdminCategoryItem | null>(null)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await apiAdminCategoryTree(normalizeSearchValues(form.getFieldsValue()))
|
||||
setList(response.data)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '类目列表加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [])
|
||||
|
||||
const disabledParentIds = useMemo(() => collectDisabledParentIds(editingRecord), [editingRecord])
|
||||
const parentTreeData = useMemo(
|
||||
() => buildParentTreeData(list, disabledParentIds),
|
||||
[disabledParentIds, list]
|
||||
)
|
||||
|
||||
const resetModal = () => {
|
||||
setModalOpen(false)
|
||||
setEditingRecord(null)
|
||||
modalForm.resetFields()
|
||||
}
|
||||
|
||||
const openCreateRootModal = () => {
|
||||
setEditingRecord(null)
|
||||
modalForm.setFieldsValue({
|
||||
parentId: ROOT_PARENT_ID,
|
||||
categoryName: '',
|
||||
categoryIcon: '',
|
||||
sort: 0,
|
||||
status: DEFAULT_STATUS,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openCreateChildModal = (record: AdminCategoryItem) => {
|
||||
setEditingRecord(null)
|
||||
modalForm.setFieldsValue({
|
||||
parentId: record.id,
|
||||
categoryName: '',
|
||||
categoryIcon: '',
|
||||
sort: 0,
|
||||
status: DEFAULT_STATUS,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (record: AdminCategoryItem) => {
|
||||
setEditingRecord(record)
|
||||
modalForm.setFieldsValue({
|
||||
parentId: record.parentId || ROOT_PARENT_ID,
|
||||
categoryName: record.categoryName,
|
||||
categoryIcon: record.categoryIcon || '',
|
||||
sort: record.sort ?? 0,
|
||||
status: record.status,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (record: AdminCategoryItem) => {
|
||||
try {
|
||||
await apiAdminCategoryDelete({ id: record.id })
|
||||
toast.success('类目已删除')
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '类目删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await modalForm.validateFields()
|
||||
setSaving(true)
|
||||
const payload: AdminCategorySaveParams = {
|
||||
id: editingRecord?.id,
|
||||
parentId: Number(values.parentId ?? ROOT_PARENT_ID),
|
||||
categoryName: values.categoryName.trim(),
|
||||
categoryIcon: values.categoryIcon?.trim() || undefined,
|
||||
sort: values.sort ?? 0,
|
||||
status: values.status,
|
||||
}
|
||||
await apiAdminCategorySave(payload)
|
||||
toast.success(editingRecord ? '类目已更新' : '类目已新增')
|
||||
resetModal()
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminCategoryItem> = [
|
||||
{
|
||||
title: '类目名称',
|
||||
dataIndex: 'categoryName',
|
||||
width: 240,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Avatar shape="square" size={28} src={record.categoryIcon || undefined}>
|
||||
{record.categoryName?.slice(0, 1)}
|
||||
</Avatar>
|
||||
<Typography.Text strong>{compactText(record.categoryName)}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类目 ID',
|
||||
dataIndex: 'id',
|
||||
width: 92,
|
||||
},
|
||||
{
|
||||
title: '父类目 ID',
|
||||
dataIndex: 'parentId',
|
||||
width: 106,
|
||||
render: value => Number(value || 0),
|
||||
},
|
||||
{
|
||||
title: '层级',
|
||||
dataIndex: 'categoryLevel',
|
||||
width: 88,
|
||||
render: value => getCategoryLevelLabel(value),
|
||||
},
|
||||
{
|
||||
title: '类目路径',
|
||||
dataIndex: 'categoryPath',
|
||||
width: 220,
|
||||
render: value => (
|
||||
<Typography.Text className="mono" title={compactText(value)}>
|
||||
{compactText(value)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类目图标',
|
||||
dataIndex: 'categoryIcon',
|
||||
width: 220,
|
||||
render: value => compactText(value),
|
||||
},
|
||||
{
|
||||
title: '叶子类目',
|
||||
dataIndex: 'isLeaf',
|
||||
width: 92,
|
||||
render: value => getBooleanLabel(value),
|
||||
},
|
||||
{
|
||||
title: '商品数量',
|
||||
dataIndex: 'goodsCount',
|
||||
width: 96,
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
width: 76,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 92,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" onClick={() => openCreateChildModal(record)}>
|
||||
新增子类目
|
||||
</Button>
|
||||
<Button type="link" onClick={() => openEditModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除该类目吗?"
|
||||
description="如果当前类目下仍有子类目或商品,接口会直接返回失败。"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
onConfirm={() => void handleDelete(record)}
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ display: 'flex' }}>
|
||||
<div className="page-toolbar">
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
商品类目管理
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={openCreateRootModal}>
|
||||
新增顶级类目
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form className="search-form" form={form} layout="inline" onFinish={() => void loadData()}>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear options={withAllOption(enabledStatusOptions)} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
void loadData()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table<AdminCategoryItem>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
className="table-top-align"
|
||||
pagination={false}
|
||||
defaultExpandAllRows
|
||||
scroll={{ x: 1680 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
destroyOnHidden
|
||||
open={modalOpen}
|
||||
title={editingRecord ? '编辑商品类目' : '新增商品类目'}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
onCancel={resetModal}
|
||||
onOk={() => void handleSave()}
|
||||
>
|
||||
<Form form={modalForm} layout="vertical">
|
||||
<Form.Item name="parentId" label="父级类目" rules={[{ required: true, message: '请选择父级类目' }]}>
|
||||
<TreeSelect
|
||||
treeDefaultExpandAll
|
||||
treeData={parentTreeData}
|
||||
placeholder="请选择父级类目"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="categoryName"
|
||||
label="类目名称"
|
||||
rules={[
|
||||
{ required: true, message: '请输入类目名称' },
|
||||
{ whitespace: true, message: '类目名称不能为空' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={50} placeholder="请输入类目名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="categoryIcon" label="类目图标">
|
||||
<Input placeholder="请输入图标 URL 或图标标识" />
|
||||
</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: '请选择状态' }]}>
|
||||
<Select options={enabledStatusOptions} placeholder="请选择状态" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
397
src/pages/admin/goods/index.tsx
Normal file
397
src/pages/admin/goods/index.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'
|
||||
import { toast } from 'sonner'
|
||||
import AuditModal, { type AuditModalValues } from '@/components/AuditModal'
|
||||
import JsonPreviewPanel from '@/components/JsonPreviewPanel'
|
||||
import StatusTag from '@/components/StatusTag'
|
||||
import { apiAdminGoodsAudit, apiAdminGoodsPage, apiAdminMerchantPage } from '@/apis/apiMallAdmin'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import type { AdminGoodsPageParams, GoodsSummary, MerchantInfo } from '@/types/mall'
|
||||
import { compactText, formatAmount, formatTimestamp } from '@/utils/format'
|
||||
import { auditStatusOptions, saleStatusOptions, withAllOption } from '@/utils/mall'
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
interface MerchantSelectOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface GoodsSearchValues {
|
||||
merchantId?: string
|
||||
keyword?: string
|
||||
auditStatus?: string
|
||||
saleStatus?: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
const normalizeFilterValues = (values: GoodsSearchValues): AdminGoodsPageParams => ({
|
||||
merchantId: values.merchantId || undefined,
|
||||
keyword: values.keyword?.trim() || undefined,
|
||||
auditStatus: values.auditStatus as AdminGoodsPageParams['auditStatus'],
|
||||
saleStatus: values.saleStatus as AdminGoodsPageParams['saleStatus'],
|
||||
page: values.page,
|
||||
size: values.size,
|
||||
})
|
||||
|
||||
const buildMerchantOption = (merchant: Pick<MerchantInfo, 'id' | 'merchantName'>): MerchantSelectOption => ({
|
||||
value: String(merchant.id),
|
||||
label: compactText(merchant.merchantName),
|
||||
})
|
||||
|
||||
const mergeMerchantOptions = (current: MerchantSelectOption[], next: MerchantSelectOption[]) => {
|
||||
const merged = new Map<string, MerchantSelectOption>()
|
||||
current.forEach(item => merged.set(item.value, item))
|
||||
next.forEach(item => merged.set(item.value, item))
|
||||
return Array.from(merged.values())
|
||||
}
|
||||
|
||||
export default function GoodsReviewPage() {
|
||||
const [form] = Form.useForm<GoodsSearchValues>()
|
||||
const operatorId = useUserStore(state => state.token?.uid ?? 0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [auditLoading, setAuditLoading] = useState(false)
|
||||
const [merchantLoading, setMerchantLoading] = useState(false)
|
||||
const [merchantOptions, setMerchantOptions] = useState<MerchantSelectOption[]>([])
|
||||
const [list, setList] = useState<GoodsSummary[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [selectedRecord, setSelectedRecord] = useState<GoodsSummary | null>(null)
|
||||
const [viewRecord, setViewRecord] = useState<GoodsSummary | null>(null)
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: DEFAULT_PAGE_SIZE })
|
||||
|
||||
const loadData = async (page = pagination.current, size = pagination.pageSize) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const values = form.getFieldsValue()
|
||||
const response = await apiAdminGoodsPage(
|
||||
normalizeFilterValues({
|
||||
...values,
|
||||
page,
|
||||
size,
|
||||
})
|
||||
)
|
||||
setList(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
setPagination({ current: page, pageSize: size })
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '商品列表加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMerchantOptions = async (keyword?: string) => {
|
||||
try {
|
||||
setMerchantLoading(true)
|
||||
const response = await apiAdminMerchantPage({
|
||||
keyword: keyword?.trim() || undefined,
|
||||
page: 1,
|
||||
size: 20,
|
||||
})
|
||||
const nextOptions = response.data.list.map(item => buildMerchantOption(item))
|
||||
setMerchantOptions(current => mergeMerchantOptions(current, nextOptions))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '商家筛选项加载失败')
|
||||
} finally {
|
||||
setMerchantLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
page: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
})
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}, [])
|
||||
|
||||
const columns = useMemo<ColumnsType<GoodsSummary>>(
|
||||
() => [
|
||||
{
|
||||
title: '商品信息',
|
||||
key: 'goods',
|
||||
width: 320,
|
||||
render: (_, record) => (
|
||||
<Space align="start">
|
||||
{record.coverUrl ? (
|
||||
<Image
|
||||
width={64}
|
||||
height={64}
|
||||
src={record.coverUrl}
|
||||
style={{ objectFit: 'cover', borderRadius: 12 }}
|
||||
preview={false}
|
||||
/>
|
||||
) : (
|
||||
<Avatar shape="square" size={64}>
|
||||
{record.spuName?.slice(0, 1)}
|
||||
</Avatar>
|
||||
)}
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text strong>{compactText(record.spuName)}</Typography.Text>
|
||||
<Typography.Text type="secondary">{compactText(record.subTitle)}</Typography.Text>
|
||||
<Typography.Text type="secondary" className="mono">
|
||||
SPU: {compactText(record.spuNo)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商家信息',
|
||||
key: 'owner',
|
||||
width: 160,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text>商家 ID: {record.merchantId || '--'}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
类目: {compactText(record.categoryName) !== '--' ? compactText(record.categoryName) : record.categoryId || '--'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '审核状态',
|
||||
dataIndex: 'auditStatus',
|
||||
width: 120,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '上下架',
|
||||
dataIndex: 'saleStatus',
|
||||
width: 100,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '价格区间',
|
||||
key: 'price',
|
||||
width: 160,
|
||||
render: (_, record) => `${formatAmount(record.minPrice)} - ${formatAmount(record.maxPrice)}`,
|
||||
},
|
||||
{
|
||||
title: '库存',
|
||||
key: 'stock',
|
||||
width: 130,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text>可售: {record.totalStock || 0}</Typography.Text>
|
||||
<Typography.Text type="secondary">锁定: {record.lockedStock || 0}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" onClick={() => setViewRecord(record)}>
|
||||
查看返回
|
||||
</Button>
|
||||
<Button type="link" onClick={() => setSelectedRecord(record)}>
|
||||
{record.auditStatus === 'pending' ? '审核' : '重新审核'}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const handleAuditSubmit = async (values: AuditModalValues) => {
|
||||
if (!selectedRecord) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setAuditLoading(true)
|
||||
await apiAdminGoodsAudit({
|
||||
spuId: selectedRecord.id,
|
||||
auditStatus: values.auditStatus,
|
||||
remark: values.remark?.trim() || undefined,
|
||||
operatorId,
|
||||
})
|
||||
toast.success('商品审核已提交')
|
||||
setSelectedRecord(null)
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '商品审核失败')
|
||||
} finally {
|
||||
setAuditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = (next: TablePaginationConfig) => {
|
||||
void loadData(next.current || 1, next.pageSize || DEFAULT_PAGE_SIZE)
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ display: 'flex' }}>
|
||||
<div className="page-toolbar">
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
商品审核列表
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form className="search-form" form={form} layout="inline" onFinish={() => void loadData(1, pagination.pageSize)}>
|
||||
<Form.Item name="merchantId" label="商家筛选">
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
filterOption={false}
|
||||
loading={merchantLoading}
|
||||
options={merchantOptions}
|
||||
style={{ width: 260 }}
|
||||
placeholder="请输入商家名称搜索"
|
||||
onFocus={() => {
|
||||
if (!merchantOptions.length) {
|
||||
void loadMerchantOptions()
|
||||
}
|
||||
}}
|
||||
onSearch={value => {
|
||||
void loadMerchantOptions(value)
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input allowClear placeholder="商品名称 / SPU 编号" style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="auditStatus" label="审核状态">
|
||||
<Select allowClear options={withAllOption(auditStatusOptions)} style={{ width: 150 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="saleStatus" label="上下架">
|
||||
<Select allowClear options={withAllOption(saleStatusOptions)} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table<GoodsSummary>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
className="table-top-align"
|
||||
scroll={{ x: 1480 }}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: count => `共 ${count} 条`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<AuditModal
|
||||
open={!!selectedRecord}
|
||||
title="商品审核"
|
||||
loading={auditLoading}
|
||||
onCancel={() => setSelectedRecord(null)}
|
||||
onSubmit={handleAuditSubmit}
|
||||
summary={
|
||||
selectedRecord ? (
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="商品名称">{compactText(selectedRecord.spuName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="SPU 编号">{compactText(selectedRecord.spuNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="商家 ID">{selectedRecord.merchantId}</Descriptions.Item>
|
||||
<Descriptions.Item label="商品类目">
|
||||
{compactText(selectedRecord.categoryName) !== '--'
|
||||
? compactText(selectedRecord.categoryName)
|
||||
: selectedRecord.categoryId}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="价格区间">
|
||||
{formatAmount(selectedRecord.minPrice)} - {formatAmount(selectedRecord.maxPrice)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
open={!!viewRecord}
|
||||
width={820}
|
||||
title="商品返回详情"
|
||||
onClose={() => setViewRecord(null)}
|
||||
>
|
||||
{viewRecord ? (
|
||||
<Space direction="vertical" size={16} style={{ display: 'flex' }}>
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="id">{viewRecord.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="merchantId">{viewRecord.merchantId}</Descriptions.Item>
|
||||
<Descriptions.Item label="categoryId">{viewRecord.categoryId}</Descriptions.Item>
|
||||
<Descriptions.Item label="categoryName">{compactText(viewRecord.categoryName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="spuNo">{compactText(viewRecord.spuNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="spuName">{compactText(viewRecord.spuName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="subTitle" span={2}>
|
||||
{compactText(viewRecord.subTitle)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="coverUrl" span={2}>
|
||||
{compactText(viewRecord.coverUrl)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="status">
|
||||
<StatusTag value={viewRecord.status} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="auditStatus">
|
||||
<StatusTag value={viewRecord.auditStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="saleStatus">
|
||||
<StatusTag value={viewRecord.saleStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="sort">{viewRecord.sort}</Descriptions.Item>
|
||||
<Descriptions.Item label="salesVolume">{viewRecord.salesVolume}</Descriptions.Item>
|
||||
<Descriptions.Item label="virtualSales">{viewRecord.virtualSales}</Descriptions.Item>
|
||||
<Descriptions.Item label="viewCount">{viewRecord.viewCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="minPrice">{compactText(viewRecord.minPrice)}</Descriptions.Item>
|
||||
<Descriptions.Item label="maxPrice">{compactText(viewRecord.maxPrice)}</Descriptions.Item>
|
||||
<Descriptions.Item label="totalStock">{viewRecord.totalStock}</Descriptions.Item>
|
||||
<Descriptions.Item label="lockedStock">{viewRecord.lockedStock}</Descriptions.Item>
|
||||
<Descriptions.Item label="createdAt">{formatTimestamp(viewRecord.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="updatedAt">{formatTimestamp(viewRecord.updatedAt)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<JsonPreviewPanel title="coverUrl(raw)" value={viewRecord.coverUrl} />
|
||||
</Space>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
396
src/pages/admin/homeChannels/index.tsx
Normal file
396
src/pages/admin/homeChannels/index.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
apiAdminHomeChannelDelete,
|
||||
apiAdminHomeChannelPage,
|
||||
apiAdminHomeChannelSave,
|
||||
} from '@/apis/apiMallAdmin'
|
||||
import StatusTag from '@/components/StatusTag'
|
||||
import type {
|
||||
AdminHomeChannelItem,
|
||||
AdminHomeChannelPageParams,
|
||||
AdminHomeChannelSaveParams,
|
||||
EnabledStatus,
|
||||
} from '@/types/mall'
|
||||
import { compactText, formatTimestamp } from '@/utils/format'
|
||||
import { enabledStatusOptions, getLinkTypeLabel, linkTypeOptions, withAllOption } from '@/utils/mall'
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
const DEFAULT_STATUS: EnabledStatus = 'enabled'
|
||||
|
||||
interface HomeChannelFilterValues {
|
||||
keyword?: string
|
||||
status?: EnabledStatus | ''
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
interface HomeChannelFormValues {
|
||||
title: string
|
||||
subTitle?: string
|
||||
iconUrl?: string
|
||||
linkType?: string
|
||||
linkValue?: string
|
||||
sort?: number
|
||||
status: EnabledStatus
|
||||
startTime?: Dayjs | null
|
||||
endTime?: Dayjs | null
|
||||
}
|
||||
|
||||
const normalizeSearchValues = (values: HomeChannelFilterValues): AdminHomeChannelPageParams => ({
|
||||
keyword: values.keyword?.trim() || undefined,
|
||||
status: values.status || undefined,
|
||||
page: values.page,
|
||||
size: values.size,
|
||||
})
|
||||
|
||||
export default function HomeChannelManagementPage() {
|
||||
const [form] = Form.useForm<HomeChannelFilterValues>()
|
||||
const [modalForm] = Form.useForm<HomeChannelFormValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [list, setList] = useState<AdminHomeChannelItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: DEFAULT_PAGE_SIZE })
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<AdminHomeChannelItem | null>(null)
|
||||
|
||||
const loadData = async (page = pagination.current, size = pagination.pageSize) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await apiAdminHomeChannelPage(
|
||||
normalizeSearchValues({
|
||||
...form.getFieldsValue(),
|
||||
page,
|
||||
size,
|
||||
})
|
||||
)
|
||||
setList(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
setPagination({ current: page, pageSize: size })
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '频道卡片列表加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
page: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
})
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}, [])
|
||||
|
||||
const resetModal = () => {
|
||||
setModalOpen(false)
|
||||
setEditingRecord(null)
|
||||
modalForm.resetFields()
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingRecord(null)
|
||||
modalForm.setFieldsValue({
|
||||
sort: 0,
|
||||
status: DEFAULT_STATUS,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (record: AdminHomeChannelItem) => {
|
||||
setEditingRecord(record)
|
||||
modalForm.setFieldsValue({
|
||||
title: record.title,
|
||||
subTitle: record.subTitle || '',
|
||||
iconUrl: record.iconUrl || '',
|
||||
linkType: record.linkType || undefined,
|
||||
linkValue: record.linkValue || '',
|
||||
sort: record.sort ?? 0,
|
||||
status: record.status,
|
||||
startTime: record.startTime ? dayjs.unix(record.startTime) : null,
|
||||
endTime: record.endTime ? dayjs.unix(record.endTime) : null,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (record: AdminHomeChannelItem) => {
|
||||
try {
|
||||
await apiAdminHomeChannelDelete({ id: record.id })
|
||||
toast.success('频道卡片已删除')
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '频道卡片删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await modalForm.validateFields()
|
||||
setSaving(true)
|
||||
const payload: AdminHomeChannelSaveParams = {
|
||||
id: editingRecord?.id,
|
||||
title: values.title.trim(),
|
||||
subTitle: values.subTitle?.trim() || undefined,
|
||||
iconUrl: values.iconUrl?.trim() || undefined,
|
||||
linkType: values.linkType || undefined,
|
||||
linkValue: values.linkValue?.trim() || undefined,
|
||||
sort: values.sort ?? 0,
|
||||
status: values.status,
|
||||
startTime: values.startTime?.unix(),
|
||||
endTime: values.endTime?.unix(),
|
||||
}
|
||||
await apiAdminHomeChannelSave(payload)
|
||||
toast.success(editingRecord ? '频道卡片已更新' : '频道卡片已新增')
|
||||
resetModal()
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = (next: TablePaginationConfig) => {
|
||||
void loadData(next.current || 1, next.pageSize || DEFAULT_PAGE_SIZE)
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminHomeChannelItem> = [
|
||||
{
|
||||
title: '频道卡片',
|
||||
key: 'channel',
|
||||
width: 240,
|
||||
render: (_, record) => (
|
||||
<Space align="start">
|
||||
<Avatar shape="square" size={56} src={record.iconUrl || undefined}>
|
||||
{record.title?.slice(0, 1)}
|
||||
</Avatar>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text strong>{compactText(record.title)}</Typography.Text>
|
||||
<Typography.Text type="secondary">{compactText(record.subTitle)}</Typography.Text>
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '跳转信息',
|
||||
key: 'link',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text>{getLinkTypeLabel(record.linkType)}</Typography.Text>
|
||||
<Typography.Text type="secondary" className="mono">
|
||||
{compactText(record.linkValue)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
width: 76,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 96,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'startTime',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '结束时间',
|
||||
dataIndex: 'endTime',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 144,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" onClick={() => openEditModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除该频道卡片吗?"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
onConfirm={() => void handleDelete(record)}
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ display: 'flex' }}>
|
||||
<div className="page-toolbar">
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
首页频道卡片
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={openCreateModal}>
|
||||
新增频道卡片
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form className="search-form" form={form} layout="inline" onFinish={() => void loadData(1, pagination.pageSize)}>
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input allowClear placeholder="标题 / 副标题" style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear options={withAllOption(enabledStatusOptions)} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table<AdminHomeChannelItem>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
className="table-top-align"
|
||||
scroll={{ x: 1240 }}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: count => `共 ${count} 条`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
destroyOnHidden
|
||||
open={modalOpen}
|
||||
title={editingRecord ? '编辑首页频道卡片' : '新增首页频道卡片'}
|
||||
width={620}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
onCancel={resetModal}
|
||||
onOk={() => void handleSave()}
|
||||
>
|
||||
<Form form={modalForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="标题"
|
||||
rules={[
|
||||
{ required: true, message: '请输入标题' },
|
||||
{ whitespace: true, message: '标题不能为空' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={60} placeholder="请输入标题" />
|
||||
</Form.Item>
|
||||
<Form.Item name="subTitle" label="副标题">
|
||||
<Input maxLength={100} placeholder="请输入副标题" />
|
||||
</Form.Item>
|
||||
<Form.Item name="iconUrl" label="图标地址">
|
||||
<Input placeholder="请输入图标地址" />
|
||||
</Form.Item>
|
||||
<Space size={12} style={{ display: 'flex' }}>
|
||||
<Form.Item name="linkType" label="跳转类型" style={{ flex: 1 }}>
|
||||
<Select allowClear options={linkTypeOptions} placeholder="请选择跳转类型" />
|
||||
</Form.Item>
|
||||
<Form.Item name="linkValue" label="跳转值" style={{ flex: 1 }}>
|
||||
<Input placeholder="请输入跳转目标值" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={12} style={{ display: 'flex' }}>
|
||||
<Form.Item name="sort" label="排序" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="默认 0" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
>
|
||||
<Select options={enabledStatusOptions} placeholder="请选择状态" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={12} style={{ display: 'flex' }}>
|
||||
<Form.Item name="startTime" label="开始时间" style={{ flex: 1 }}>
|
||||
<DatePicker showTime style={{ width: '100%' }} placeholder="请选择开始时间" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="endTime"
|
||||
label="结束时间"
|
||||
style={{ flex: 1 }}
|
||||
dependencies={['startTime']}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value?: Dayjs | null) {
|
||||
const startTime = getFieldValue('startTime') as Dayjs | null | undefined
|
||||
if (!startTime || !value || value.valueOf() >= startTime.valueOf()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error('结束时间不能早于开始时间'))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<DatePicker showTime style={{ width: '100%' }} placeholder="请选择结束时间" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
190
src/pages/admin/merchants/components/MerchantCreateModal.tsx
Normal file
190
src/pages/admin/merchants/components/MerchantCreateModal.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Col, Form, Input, Modal, Radio, Row, Typography } from 'antd'
|
||||
import type { AdminMerchantCreateParams, MerchantLoginType } from '@/types/mall'
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
const PHONE_PATTERN = /^\+?[0-9][0-9\s-]{5,19}$/
|
||||
|
||||
export interface MerchantCreateFormValues extends AdminMerchantCreateParams {}
|
||||
|
||||
interface MerchantCreateModalProps {
|
||||
open: boolean
|
||||
loading?: boolean
|
||||
onCancel: () => void
|
||||
onSubmit: (values: MerchantCreateFormValues) => void | Promise<void>
|
||||
}
|
||||
|
||||
const loginTypeOptions: Array<{ label: string; value: MerchantLoginType }> = [
|
||||
{ label: '手机号', value: '0' },
|
||||
{ label: '邮箱', value: '1' },
|
||||
]
|
||||
|
||||
export default function MerchantCreateModal({
|
||||
open,
|
||||
loading,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: MerchantCreateModalProps) {
|
||||
const [form] = Form.useForm<MerchantCreateFormValues>()
|
||||
const loginType = Form.useWatch('loginType', form) ?? '0'
|
||||
|
||||
const handleOk = async () => {
|
||||
const values = await form.validateFields()
|
||||
await onSubmit(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
destroyOnHidden
|
||||
open={open}
|
||||
className="merchant-create-modal"
|
||||
title="创建商家账号"
|
||||
width={700}
|
||||
confirmLoading={loading}
|
||||
okText="创建账号"
|
||||
cancelText="取消"
|
||||
styles={{ body: { paddingTop: 18, maxHeight: '72vh', overflowY: 'auto' } }}
|
||||
onCancel={onCancel}
|
||||
onOk={() => void handleOk()}
|
||||
>
|
||||
<Form
|
||||
autoComplete="off"
|
||||
className="merchant-create-form"
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
loginType: '0',
|
||||
}}
|
||||
>
|
||||
<Typography.Text className="merchant-create-section-title">账号信息</Typography.Text>
|
||||
<Form.Item
|
||||
name="loginType"
|
||||
label="登录方式"
|
||||
rules={[{ required: true, message: '请选择登录方式' }]}
|
||||
>
|
||||
<Radio.Group options={loginTypeOptions} optionType="button" buttonStyle="solid" />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="account"
|
||||
label={loginType === '0' ? '登录手机号' : '登录邮箱'}
|
||||
rules={[
|
||||
{ required: true, message: loginType === '0' ? '请输入登录手机号' : '请输入登录邮箱' },
|
||||
{
|
||||
validator(_, value?: string) {
|
||||
const text = value?.trim()
|
||||
if (!text) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (loginType === '1') {
|
||||
const emailPassed = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)
|
||||
return emailPassed
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请输入正确的登录邮箱'))
|
||||
}
|
||||
return PHONE_PATTERN.test(text)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请输入正确的登录手机号'))
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder={loginType === '0' ? '请输入登录手机号' : '请输入登录邮箱'} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="登录密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入登录密码' },
|
||||
{ min: 6, message: '密码至少 6 位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" placeholder="请输入登录密码" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Typography.Text className="merchant-create-section-title">商家信息</Typography.Text>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="merchantName"
|
||||
label="商家名称"
|
||||
rules={[
|
||||
{ required: true, message: '请输入商家名称' },
|
||||
{ whitespace: true, message: '商家名称不能为空' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={100} placeholder="请输入商家名称" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="contactName"
|
||||
label="联系人"
|
||||
rules={[
|
||||
{ required: true, message: '请输入联系人姓名' },
|
||||
{ whitespace: true, message: '联系人姓名不能为空' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={50} placeholder="请输入联系人姓名" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="contactMobile"
|
||||
label="联系电话"
|
||||
rules={[
|
||||
{
|
||||
validator(_, value?: string) {
|
||||
const text = value?.trim()
|
||||
if (!text || PHONE_PATTERN.test(text)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error('请输入正确的联系电话'))
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input maxLength={20} placeholder="请输入联系电话" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="contactEmail"
|
||||
label="联系邮箱"
|
||||
rules={[
|
||||
{
|
||||
type: 'email',
|
||||
message: '请输入正确的联系邮箱',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input maxLength={100} placeholder="请输入联系邮箱" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="licenseNo" label="营业执照号">
|
||||
<Input maxLength={100} placeholder="请输入营业执照号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="remark" label="备注">
|
||||
<TextArea rows={4} maxLength={300} placeholder="可选,补充商家背景、渠道来源或运营备注" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
353
src/pages/admin/merchants/index.tsx
Normal file
353
src/pages/admin/merchants/index.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'
|
||||
import { toast } from 'sonner'
|
||||
import AuditModal, { type AuditModalValues } from '@/components/AuditModal'
|
||||
import JsonPreviewPanel from '@/components/JsonPreviewPanel'
|
||||
import StatusTag from '@/components/StatusTag'
|
||||
import { apiAdminMerchantAudit, apiAdminMerchantCreate, apiAdminMerchantPage } from '@/apis/apiMallAdmin'
|
||||
import MerchantCreateModal, {
|
||||
type MerchantCreateFormValues,
|
||||
} from '@/pages/admin/merchants/components/MerchantCreateModal'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import type { AdminMerchantPageParams, MerchantInfo } from '@/types/mall'
|
||||
import { compactText, formatTimestamp } from '@/utils/format'
|
||||
import { auditStatusOptions, enabledStatusOptions, withAllOption } from '@/utils/mall'
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
const normalizeFilterValues = (values: MerchantSearchValues): AdminMerchantPageParams => ({
|
||||
keyword: values.keyword?.trim() || undefined,
|
||||
status: values.status as AdminMerchantPageParams['status'],
|
||||
auditStatus: values.auditStatus as AdminMerchantPageParams['auditStatus'],
|
||||
page: values.page,
|
||||
size: values.size,
|
||||
})
|
||||
|
||||
interface MerchantSearchValues {
|
||||
keyword?: string
|
||||
status?: string
|
||||
auditStatus?: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export default function MerchantReviewPage() {
|
||||
const [form] = Form.useForm<MerchantSearchValues>()
|
||||
const operatorId = useUserStore(state => state.token?.uid ?? 0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [auditLoading, setAuditLoading] = useState(false)
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const [list, setList] = useState<MerchantInfo[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [selectedRecord, setSelectedRecord] = useState<MerchantInfo | null>(null)
|
||||
const [viewRecord, setViewRecord] = useState<MerchantInfo | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: DEFAULT_PAGE_SIZE })
|
||||
|
||||
const loadData = async (page = pagination.current, size = pagination.pageSize) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const values = form.getFieldsValue()
|
||||
const response = await apiAdminMerchantPage(
|
||||
normalizeFilterValues({
|
||||
...values,
|
||||
page,
|
||||
size,
|
||||
})
|
||||
)
|
||||
|
||||
setList(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
setPagination({ current: page, pageSize: size })
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '商家列表加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
page: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
})
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}, [])
|
||||
|
||||
const columns = useMemo<ColumnsType<MerchantInfo>>(
|
||||
() => [
|
||||
{
|
||||
title: '商家信息',
|
||||
key: 'merchant',
|
||||
width: 250,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text strong>{compactText(record.merchantName)}</Typography.Text>
|
||||
<Typography.Text type="secondary" className="mono">
|
||||
编号: {compactText(record.merchantNo)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">UID: {record.uid || '--'}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '联系人',
|
||||
key: 'contact',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text>{compactText(record.contactName)}</Typography.Text>
|
||||
<Typography.Text type="secondary">{compactText(record.contactMobile)}</Typography.Text>
|
||||
<Typography.Text type="secondary">{compactText(record.contactEmail)}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '营业执照号',
|
||||
dataIndex: 'licenseNo',
|
||||
width: 180,
|
||||
render: value => <Typography.Text className="mono">{compactText(value)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '启停状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '审核状态',
|
||||
dataIndex: 'auditStatus',
|
||||
width: 120,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '结算状态',
|
||||
dataIndex: 'settleStatus',
|
||||
width: 120,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
width: 180,
|
||||
render: value => compactText(value),
|
||||
},
|
||||
{
|
||||
title: '申请时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" onClick={() => setViewRecord(record)}>
|
||||
查看返回
|
||||
</Button>
|
||||
<Button type="link" onClick={() => setSelectedRecord(record)}>
|
||||
{record.auditStatus === 'pending' ? '审核' : '重新审核'}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const handleTableChange = (next: TablePaginationConfig) => {
|
||||
void loadData(next.current || 1, next.pageSize || DEFAULT_PAGE_SIZE)
|
||||
}
|
||||
|
||||
const handleAuditSubmit = async (values: AuditModalValues) => {
|
||||
if (!selectedRecord) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setAuditLoading(true)
|
||||
await apiAdminMerchantAudit({
|
||||
merchantId: String(selectedRecord.id),
|
||||
auditStatus: values.auditStatus,
|
||||
remark: values.remark?.trim() || undefined,
|
||||
operatorId,
|
||||
})
|
||||
toast.success('商家审核已提交')
|
||||
setSelectedRecord(null)
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '商家审核失败')
|
||||
} finally {
|
||||
setAuditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateSubmit = async (values: MerchantCreateFormValues) => {
|
||||
try {
|
||||
setCreateLoading(true)
|
||||
const response = await apiAdminMerchantCreate({
|
||||
loginType: values.loginType,
|
||||
account: values.account.trim(),
|
||||
password: values.password,
|
||||
merchantName: values.merchantName.trim(),
|
||||
contactName: values.contactName.trim(),
|
||||
contactMobile: values.contactMobile?.trim() || '',
|
||||
contactEmail: values.contactEmail?.trim() || '',
|
||||
licenseNo: values.licenseNo?.trim() || '',
|
||||
licenseImagesJson: '',
|
||||
extraInfoJson: '',
|
||||
remark: values.remark?.trim() || '',
|
||||
createIp: '',
|
||||
})
|
||||
toast.success(`商家账号已创建:${response.data.merchantNo} / UID ${response.data.uid}`)
|
||||
setCreateOpen(false)
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '商家账号创建失败')
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ display: 'flex' }}>
|
||||
<div className="page-toolbar">
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
商家审核列表
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
创建商家账号
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form className="search-form" form={form} layout="inline" onFinish={() => void loadData(1, pagination.pageSize)}>
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input allowClear placeholder="商家名称 / 编号 / 联系方式" style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="启停状态">
|
||||
<Select allowClear options={withAllOption(enabledStatusOptions)} style={{ width: 150 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="auditStatus" label="审核状态">
|
||||
<Select allowClear options={withAllOption(auditStatusOptions)} style={{ width: 150 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table<MerchantInfo>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
className="table-top-align"
|
||||
scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: count => `共 ${count} 条`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<AuditModal
|
||||
open={!!selectedRecord}
|
||||
title="商家审核"
|
||||
loading={auditLoading}
|
||||
onCancel={() => setSelectedRecord(null)}
|
||||
onSubmit={handleAuditSubmit}
|
||||
summary={
|
||||
selectedRecord ? (
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="商家名称">{compactText(selectedRecord.merchantName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="商家编号">{compactText(selectedRecord.merchantNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="联系人">{compactText(selectedRecord.contactName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="联系电话">{compactText(selectedRecord.contactMobile)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算状态">
|
||||
<StatusTag value={selectedRecord.settleStatus} />
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
open={!!viewRecord}
|
||||
width={760}
|
||||
title="商家返回详情"
|
||||
onClose={() => setViewRecord(null)}
|
||||
>
|
||||
{viewRecord ? (
|
||||
<Space direction="vertical" size={16} style={{ display: 'flex' }}>
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="id">{viewRecord.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="uid">{viewRecord.uid}</Descriptions.Item>
|
||||
<Descriptions.Item label="merchantNo">{compactText(viewRecord.merchantNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="merchantName">{compactText(viewRecord.merchantName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="status">
|
||||
<StatusTag value={viewRecord.status} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="auditStatus">
|
||||
<StatusTag value={viewRecord.auditStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="settleStatus">
|
||||
<StatusTag value={viewRecord.settleStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="remark">{compactText(viewRecord.remark)}</Descriptions.Item>
|
||||
<Descriptions.Item label="contactName">{compactText(viewRecord.contactName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="contactMobile">{compactText(viewRecord.contactMobile)}</Descriptions.Item>
|
||||
<Descriptions.Item label="contactEmail">{compactText(viewRecord.contactEmail)}</Descriptions.Item>
|
||||
<Descriptions.Item label="licenseNo">{compactText(viewRecord.licenseNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="approvedAt">{formatTimestamp(viewRecord.approvedAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="createdAt">{formatTimestamp(viewRecord.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="updatedAt">{formatTimestamp(viewRecord.updatedAt)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<JsonPreviewPanel title="licenseImagesJson" value={viewRecord.licenseImagesJson} />
|
||||
<JsonPreviewPanel title="extraInfoJson" value={viewRecord.extraInfoJson} />
|
||||
</Space>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
<MerchantCreateModal
|
||||
open={createOpen}
|
||||
loading={createLoading}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onSubmit={handleCreateSubmit}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
261
src/pages/admin/orders/DetailPage.tsx
Normal file
261
src/pages/admin/orders/DetailPage.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Image,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import StatusTag from '@/components/StatusTag'
|
||||
import { apiAdminOrderDetail } from '@/apis/apiMallAdmin'
|
||||
import type { OrderDetailInfo, OrderItemInfo } from '@/types/mall'
|
||||
import {
|
||||
buildAddress,
|
||||
compactText,
|
||||
formatAmount,
|
||||
formatTimestamp,
|
||||
parseSpecSummary,
|
||||
safeJsonPretty,
|
||||
} from '@/utils/format'
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const { orderId } = useParams()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [detail, setDetail] = useState<OrderDetailInfo | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const id = Number(orderId)
|
||||
if (!id) {
|
||||
toast.error('订单 ID 无效')
|
||||
return
|
||||
}
|
||||
|
||||
const loadDetail = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await apiAdminOrderDetail({ orderId: id })
|
||||
setDetail(response.data)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '订单详情加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadDetail()
|
||||
}, [orderId])
|
||||
|
||||
const itemColumns = useMemo<ColumnsType<OrderItemInfo>>(
|
||||
() => [
|
||||
{
|
||||
title: '商品',
|
||||
key: 'goods',
|
||||
width: 340,
|
||||
render: (_, record) => (
|
||||
<Space align="start">
|
||||
{record.coverUrl ? (
|
||||
<Image
|
||||
width={64}
|
||||
height={64}
|
||||
src={record.coverUrl}
|
||||
preview={false}
|
||||
style={{ objectFit: 'cover', borderRadius: 12 }}
|
||||
/>
|
||||
) : null}
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text strong>{compactText(record.spuName)}</Typography.Text>
|
||||
<Typography.Text type="secondary">{compactText(record.skuName)}</Typography.Text>
|
||||
<Typography.Text type="secondary">{parseSpecSummary(record.skuSpecJson)}</Typography.Text>
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'buyNum',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: '成交价',
|
||||
dataIndex: 'salePrice',
|
||||
width: 120,
|
||||
render: value => formatAmount(value, detail?.currency),
|
||||
},
|
||||
{
|
||||
title: '商品金额',
|
||||
dataIndex: 'itemAmount',
|
||||
width: 140,
|
||||
render: value => formatAmount(value, detail?.currency),
|
||||
},
|
||||
{
|
||||
title: '退款金额',
|
||||
dataIndex: 'refundAmount',
|
||||
width: 140,
|
||||
render: value => formatAmount(value, detail?.currency),
|
||||
},
|
||||
{
|
||||
title: '售后状态',
|
||||
dataIndex: 'afterSaleStatus',
|
||||
width: 120,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
],
|
||||
[detail?.currency]
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '120px 0' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<Card>
|
||||
<Empty description="未获取到订单详情" />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ display: 'flex' }}>
|
||||
<div className="page-toolbar">
|
||||
<Space direction="vertical" size={6}>
|
||||
<Button type="link" icon={<ArrowLeftOutlined />} style={{ paddingInline: 0 }} onClick={() => navigate('/orders')}>
|
||||
返回订单列表
|
||||
</Button>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
订单详情
|
||||
</Typography.Title>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Descriptions column={3} size="small">
|
||||
<Descriptions.Item label="订单 ID">{detail.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="交易 ID">{detail.tradeId}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单号">{compactText(detail.orderNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="交易单号">{compactText(detail.tradeNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="商家名称">{compactText(detail.merchantName || detail.storeName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单状态">
|
||||
<StatusTag value={detail.orderStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="售后状态">
|
||||
<StatusTag value={detail.afterSaleStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支付渠道">{compactText(detail.payChannel)}</Descriptions.Item>
|
||||
<Descriptions.Item label="商家 ID">{detail.merchantId}</Descriptions.Item>
|
||||
<Descriptions.Item label="UID">{detail.uid ?? detail.memberId}</Descriptions.Item>
|
||||
<Descriptions.Item label="下单时间">{formatTimestamp(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="支付时间">{formatTimestamp(detail.payTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="发货时间">{formatTimestamp(detail.shipTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="完成时间">{formatTimestamp(detail.finishTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="取消时间">{formatTimestamp(detail.cancelTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="自动确认">{formatTimestamp(detail.autoConfirmAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">{formatTimestamp(detail.updatedAt)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="收货与物流">
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="收货人">{compactText(detail.receiverName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="联系电话">{compactText(detail.receiverMobile)}</Descriptions.Item>
|
||||
<Descriptions.Item label="国家区号">{compactText(detail.receiverCountryCode)}</Descriptions.Item>
|
||||
<Descriptions.Item label="邮编">{compactText(detail.receiverZipCode)}</Descriptions.Item>
|
||||
<Descriptions.Item label="完整地址" span={2}>
|
||||
{buildAddress(
|
||||
detail.receiverProvince,
|
||||
detail.receiverCity,
|
||||
detail.receiverDistrict,
|
||||
detail.receiverAddress
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="物流公司">{compactText(detail.deliveryCompany)}</Descriptions.Item>
|
||||
<Descriptions.Item label="运单号">{compactText(detail.deliveryNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="买家备注" span={2}>
|
||||
{compactText(detail.buyerRemark)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卖家备注" span={2}>
|
||||
{compactText(detail.sellerRemark)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="金额信息">
|
||||
<Descriptions column={4} size="small">
|
||||
<Descriptions.Item label="商品金额">{formatAmount(detail.goodsAmount, detail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="运费">{formatAmount(detail.freightAmount, detail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="优惠">{formatAmount(detail.discountAmount, detail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="应付金额">{formatAmount(detail.payAmount, detail.currency)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="商品明细">
|
||||
<Table<OrderItemInfo>
|
||||
rowKey="id"
|
||||
columns={itemColumns}
|
||||
dataSource={detail.items || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 960 }}
|
||||
expandable={{
|
||||
expandedRowRender: record => (
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="id">{record.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="orderId">{record.orderId}</Descriptions.Item>
|
||||
<Descriptions.Item label="tradeId">{record.tradeId}</Descriptions.Item>
|
||||
<Descriptions.Item label="orderNo">{compactText(record.orderNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="spuId">{record.spuId}</Descriptions.Item>
|
||||
<Descriptions.Item label="skuId">{record.skuId}</Descriptions.Item>
|
||||
<Descriptions.Item label="spuName">{compactText(record.spuName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="skuName">{compactText(record.skuName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="coverUrl" span={2}>
|
||||
{compactText(record.coverUrl)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="skuSpecJson" span={2}>
|
||||
{parseSpecSummary(record.skuSpecJson)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="buyNum">{record.buyNum}</Descriptions.Item>
|
||||
<Descriptions.Item label="salePrice">{formatAmount(record.salePrice, detail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="originPrice">{formatAmount(record.originPrice, detail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="discountAmount">
|
||||
{formatAmount(record.discountAmount, detail.currency)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="itemAmount">{formatAmount(record.itemAmount, detail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="refundAmount">
|
||||
{formatAmount(record.refundAmount, detail.currency)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="afterSaleStatus">
|
||||
<StatusTag value={record.afterSaleStatus} />
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{detail.extraInfoJson ? (
|
||||
<Card title="扩展信息">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="以下内容来自 extraInfoJson,便于联调阶段直接查看后端回传动态结构。"
|
||||
/>
|
||||
<pre className="json-block">{safeJsonPretty(detail.extraInfoJson)}</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
328
src/pages/admin/orders/index.tsx
Normal file
328
src/pages/admin/orders/index.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import StatusTag from '@/components/StatusTag'
|
||||
import { apiAdminMerchantPage, apiAdminOrderPage } from '@/apis/apiMallAdmin'
|
||||
import type { AdminOrderPageParams, MerchantInfo, OrderSummary } from '@/types/mall'
|
||||
import { compactText, formatAmount, formatTimestamp } from '@/utils/format'
|
||||
import { afterSaleStatusOptions, orderStatusOptions, withAllOption } from '@/utils/mall'
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
interface MerchantSelectOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface OrderSearchValues {
|
||||
merchantId?: string
|
||||
tradeNo?: string
|
||||
orderNo?: string
|
||||
orderStatus?: string
|
||||
afterSaleStatus?: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
const normalizeFilterValues = (values: OrderSearchValues): AdminOrderPageParams => ({
|
||||
merchantId: values.merchantId || undefined,
|
||||
tradeNo: values.tradeNo?.trim() || undefined,
|
||||
orderNo: values.orderNo?.trim() || undefined,
|
||||
orderStatus: values.orderStatus as AdminOrderPageParams['orderStatus'],
|
||||
afterSaleStatus: values.afterSaleStatus as AdminOrderPageParams['afterSaleStatus'],
|
||||
page: values.page,
|
||||
size: values.size,
|
||||
})
|
||||
|
||||
const buildMerchantOption = (merchant: Pick<MerchantInfo, 'id' | 'merchantName' | 'merchantNo'>): MerchantSelectOption => ({
|
||||
value: String(merchant.id),
|
||||
label: compactText(merchant.merchantName),
|
||||
})
|
||||
|
||||
const mergeMerchantOptions = (current: MerchantSelectOption[], next: MerchantSelectOption[]) => {
|
||||
const merged = new Map<string, MerchantSelectOption>()
|
||||
current.forEach(item => merged.set(item.value, item))
|
||||
next.forEach(item => merged.set(item.value, item))
|
||||
return Array.from(merged.values())
|
||||
}
|
||||
|
||||
export default function OrderOverviewPage() {
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm<OrderSearchValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [merchantLoading, setMerchantLoading] = useState(false)
|
||||
const [merchantOptions, setMerchantOptions] = useState<MerchantSelectOption[]>([])
|
||||
const [list, setList] = useState<OrderSummary[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [viewRecord, setViewRecord] = useState<OrderSummary | null>(null)
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: DEFAULT_PAGE_SIZE })
|
||||
|
||||
const loadData = async (page = pagination.current, size = pagination.pageSize) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const values = form.getFieldsValue()
|
||||
const response = await apiAdminOrderPage(
|
||||
normalizeFilterValues({
|
||||
...values,
|
||||
page,
|
||||
size,
|
||||
})
|
||||
)
|
||||
setList(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
setPagination({ current: page, pageSize: size })
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '订单列表加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMerchantOptions = async (keyword?: string) => {
|
||||
try {
|
||||
setMerchantLoading(true)
|
||||
const response = await apiAdminMerchantPage({
|
||||
keyword: keyword?.trim() || undefined,
|
||||
page: 1,
|
||||
size: 20,
|
||||
})
|
||||
const nextOptions = response.data.list.map(item => buildMerchantOption(item))
|
||||
setMerchantOptions(current => mergeMerchantOptions(current, nextOptions))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '商家筛选项加载失败')
|
||||
} finally {
|
||||
setMerchantLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
page: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
})
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}, [])
|
||||
|
||||
const columns = useMemo<ColumnsType<OrderSummary>>(
|
||||
() => [
|
||||
{
|
||||
title: '订单编号',
|
||||
key: 'numbers',
|
||||
width: 240,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text strong className="mono">
|
||||
{compactText(record.orderNo)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" className="mono">
|
||||
交易单: {compactText(record.tradeNo)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商家信息',
|
||||
key: 'merchant',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text>{compactText(record.merchantName || record.storeName)}</Typography.Text>
|
||||
<Typography.Text type="secondary">商家 ID: {record.merchantId || '--'}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单状态',
|
||||
dataIndex: 'orderStatus',
|
||||
width: 120,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '售后状态',
|
||||
dataIndex: 'afterSaleStatus',
|
||||
width: 120,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
key: 'amount',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text>应付: {formatAmount(record.payAmount, record.currency)}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
商品: {formatAmount(record.goodsAmount, record.currency)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
运费: {formatAmount(record.freightAmount, record.currency)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '关键时间',
|
||||
key: 'time',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text>下单: {formatTimestamp(record.createdAt)}</Typography.Text>
|
||||
<Typography.Text type="secondary">支付: {formatTimestamp(record.payTime)}</Typography.Text>
|
||||
<Typography.Text type="secondary">发货: {formatTimestamp(record.shipTime)}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" onClick={() => setViewRecord(record)}>
|
||||
查看返回
|
||||
</Button>
|
||||
<Button type="link" onClick={() => navigate(`/orders/${record.id}`)}>
|
||||
订单详情
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate]
|
||||
)
|
||||
|
||||
const handleTableChange = (next: TablePaginationConfig) => {
|
||||
void loadData(next.current || 1, next.pageSize || DEFAULT_PAGE_SIZE)
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ display: 'flex' }}>
|
||||
<div className="page-toolbar">
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
平台订单总览
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form className="search-form" form={form} layout="inline" onFinish={() => void loadData(1, pagination.pageSize)}>
|
||||
<Form.Item name="merchantId" label="商家筛选">
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
filterOption={false}
|
||||
loading={merchantLoading}
|
||||
options={merchantOptions}
|
||||
style={{ width: 260 }}
|
||||
placeholder="请输入商家名称搜索"
|
||||
onFocus={() => {
|
||||
if (!merchantOptions.length) {
|
||||
void loadMerchantOptions()
|
||||
}
|
||||
}}
|
||||
onSearch={value => {
|
||||
void loadMerchantOptions(value)
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="tradeNo" label="交易单号">
|
||||
<Input allowClear placeholder="tradeNo" style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="orderNo" label="订单号">
|
||||
<Input allowClear placeholder="orderNo" style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="orderStatus" label="订单状态">
|
||||
<Select allowClear options={withAllOption(orderStatusOptions)} style={{ width: 150 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="afterSaleStatus" label="售后状态">
|
||||
<Select allowClear options={withAllOption(afterSaleStatusOptions)} style={{ width: 150 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table<OrderSummary>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
className="table-top-align"
|
||||
scroll={{ x: 1320 }}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: count => `共 ${count} 条`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
open={!!viewRecord}
|
||||
width={820}
|
||||
title="订单列表返回详情"
|
||||
onClose={() => setViewRecord(null)}
|
||||
>
|
||||
{viewRecord ? (
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="id">{viewRecord.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="tradeId">{viewRecord.tradeId}</Descriptions.Item>
|
||||
<Descriptions.Item label="tradeNo">{compactText(viewRecord.tradeNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="orderNo">{compactText(viewRecord.orderNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="uid">{viewRecord.uid ?? viewRecord.memberId}</Descriptions.Item>
|
||||
<Descriptions.Item label="merchantId">{viewRecord.merchantId}</Descriptions.Item>
|
||||
<Descriptions.Item label="merchantName">
|
||||
{compactText(viewRecord.merchantName || viewRecord.storeName)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="orderStatus">
|
||||
<StatusTag value={viewRecord.orderStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="afterSaleStatus">
|
||||
<StatusTag value={viewRecord.afterSaleStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="currency">{compactText(viewRecord.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="goodsAmount">{compactText(viewRecord.goodsAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="freightAmount">{compactText(viewRecord.freightAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="discountAmount">{compactText(viewRecord.discountAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="payAmount">{compactText(viewRecord.payAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="payTime">{formatTimestamp(viewRecord.payTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="shipTime">{formatTimestamp(viewRecord.shipTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="finishTime">{formatTimestamp(viewRecord.finishTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="cancelTime">{formatTimestamp(viewRecord.cancelTime)}</Descriptions.Item>
|
||||
<Descriptions.Item label="createdAt">{formatTimestamp(viewRecord.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="updatedAt">{formatTimestamp(viewRecord.updatedAt)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
540
src/pages/admin/recommendStores/index.tsx
Normal file
540
src/pages/admin/recommendStores/index.tsx
Normal file
@@ -0,0 +1,540 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
apiAdminMerchantPage,
|
||||
apiAdminRecommendMerchantDelete,
|
||||
apiAdminRecommendMerchantPage,
|
||||
apiAdminRecommendMerchantSave,
|
||||
} from '@/apis/apiMallAdmin'
|
||||
import StatusTag from '@/components/StatusTag'
|
||||
import type {
|
||||
AdminRecommendMerchantItem,
|
||||
AdminRecommendMerchantPageParams,
|
||||
AdminRecommendMerchantSaveParams,
|
||||
EnabledStatus,
|
||||
MerchantInfo,
|
||||
RecommendMerchantPositionCode,
|
||||
} from '@/types/mall'
|
||||
import { compactText, formatTimestamp } from '@/utils/format'
|
||||
import {
|
||||
enabledStatusOptions,
|
||||
getRecommendStorePositionLabel,
|
||||
recommendStorePositionOptions,
|
||||
withAllOption,
|
||||
} from '@/utils/mall'
|
||||
|
||||
const { TextArea } = Input
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
const DEFAULT_STATUS: EnabledStatus = 'enabled'
|
||||
const DEFAULT_POSITION_CODE: RecommendMerchantPositionCode = 'home_featured_store'
|
||||
|
||||
interface RecommendStoreSearchValues {
|
||||
keyword?: string
|
||||
positionCode?: RecommendMerchantPositionCode | ''
|
||||
status?: EnabledStatus | ''
|
||||
merchantId?: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
interface RecommendStoreFormValues {
|
||||
positionCode: RecommendMerchantPositionCode
|
||||
merchantId?: string
|
||||
mainCategoryName?: string
|
||||
recommendReason?: string
|
||||
rankScore?: number
|
||||
sort?: number
|
||||
status: EnabledStatus
|
||||
startTime?: Dayjs | null
|
||||
endTime?: Dayjs | null
|
||||
}
|
||||
|
||||
interface MerchantSelectOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const normalizeSearchValues = (values: RecommendStoreSearchValues): AdminRecommendMerchantPageParams => ({
|
||||
keyword: values.keyword?.trim() || undefined,
|
||||
positionCode: values.positionCode || undefined,
|
||||
status: values.status || undefined,
|
||||
merchantId: values.merchantId || undefined,
|
||||
page: values.page,
|
||||
size: values.size,
|
||||
})
|
||||
|
||||
const buildMerchantOption = (merchant: Pick<MerchantInfo, 'id' | 'merchantName'>): MerchantSelectOption => ({
|
||||
value: String(merchant.id),
|
||||
label: compactText(merchant.merchantName),
|
||||
})
|
||||
|
||||
const mergeMerchantOptions = (current: MerchantSelectOption[], next: MerchantSelectOption[]) => {
|
||||
const merged = new Map<string, MerchantSelectOption>()
|
||||
current.forEach(item => merged.set(item.value, item))
|
||||
next.forEach(item => merged.set(item.value, item))
|
||||
return Array.from(merged.values())
|
||||
}
|
||||
|
||||
export default function RecommendStoreManagementPage() {
|
||||
const [form] = Form.useForm<RecommendStoreSearchValues>()
|
||||
const [modalForm] = Form.useForm<RecommendStoreFormValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [merchantLoading, setMerchantLoading] = useState(false)
|
||||
const [list, setList] = useState<AdminRecommendMerchantItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: DEFAULT_PAGE_SIZE })
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<AdminRecommendMerchantItem | null>(null)
|
||||
const [merchantOptions, setMerchantOptions] = useState<MerchantSelectOption[]>([])
|
||||
|
||||
const loadData = async (page = pagination.current, size = pagination.pageSize) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await apiAdminRecommendMerchantPage(
|
||||
normalizeSearchValues({
|
||||
...form.getFieldsValue(),
|
||||
page,
|
||||
size,
|
||||
})
|
||||
)
|
||||
setList(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
setPagination({ current: page, pageSize: size })
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '首页推荐商家列表加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMerchantOptions = async (keyword?: string) => {
|
||||
try {
|
||||
setMerchantLoading(true)
|
||||
const response = await apiAdminMerchantPage({
|
||||
keyword: keyword?.trim() || undefined,
|
||||
page: 1,
|
||||
size: 20,
|
||||
})
|
||||
const nextOptions = response.data.list.map(item => buildMerchantOption(item))
|
||||
setMerchantOptions(current => mergeMerchantOptions(current, nextOptions))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '商家选项加载失败')
|
||||
} finally {
|
||||
setMerchantLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
page: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
})
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}, [])
|
||||
|
||||
const resetModal = () => {
|
||||
setModalOpen(false)
|
||||
setEditingRecord(null)
|
||||
modalForm.resetFields()
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingRecord(null)
|
||||
modalForm.setFieldsValue({
|
||||
positionCode: DEFAULT_POSITION_CODE,
|
||||
rankScore: 0,
|
||||
sort: 0,
|
||||
status: DEFAULT_STATUS,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
})
|
||||
setModalOpen(true)
|
||||
if (!merchantOptions.length) {
|
||||
void loadMerchantOptions()
|
||||
}
|
||||
}
|
||||
|
||||
const openEditModal = (record: AdminRecommendMerchantItem) => {
|
||||
setEditingRecord(record)
|
||||
setMerchantOptions(current =>
|
||||
mergeMerchantOptions(current, [
|
||||
{
|
||||
value: record.merchantId,
|
||||
label: compactText(record.merchantName),
|
||||
},
|
||||
])
|
||||
)
|
||||
modalForm.setFieldsValue({
|
||||
positionCode: record.positionCode,
|
||||
merchantId: record.merchantId,
|
||||
mainCategoryName: record.mainCategoryName || '',
|
||||
recommendReason: record.recommendReason || '',
|
||||
rankScore: Number(record.rankScore ?? 0),
|
||||
sort: record.sort ?? 0,
|
||||
status: record.configStatus,
|
||||
startTime: record.startTime ? dayjs.unix(record.startTime) : null,
|
||||
endTime: record.endTime ? dayjs.unix(record.endTime) : null,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (record: AdminRecommendMerchantItem) => {
|
||||
try {
|
||||
await apiAdminRecommendMerchantDelete({ id: record.id })
|
||||
toast.success('首页推荐商家已删除')
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '首页推荐商家删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await modalForm.validateFields()
|
||||
setSaving(true)
|
||||
const payload: AdminRecommendMerchantSaveParams = {
|
||||
id: editingRecord?.id,
|
||||
positionCode: values.positionCode,
|
||||
merchantId: values.merchantId || '',
|
||||
mainCategoryName: values.mainCategoryName?.trim() || undefined,
|
||||
recommendReason: values.recommendReason?.trim() || undefined,
|
||||
rankScore: values.rankScore ?? 0,
|
||||
sort: values.sort ?? 0,
|
||||
status: values.status,
|
||||
startTime: values.startTime?.unix(),
|
||||
endTime: values.endTime?.unix(),
|
||||
}
|
||||
await apiAdminRecommendMerchantSave(payload)
|
||||
toast.success(editingRecord ? '首页推荐商家已更新' : '首页推荐商家已新增')
|
||||
resetModal()
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = (next: TablePaginationConfig) => {
|
||||
void loadData(next.current || 1, next.pageSize || DEFAULT_PAGE_SIZE)
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminRecommendMerchantItem> = [
|
||||
{
|
||||
title: '配置 ID',
|
||||
dataIndex: 'id',
|
||||
width: 92,
|
||||
},
|
||||
{
|
||||
title: '位置编码',
|
||||
dataIndex: 'positionCode',
|
||||
width: 140,
|
||||
render: value => getRecommendStorePositionLabel(value),
|
||||
},
|
||||
{
|
||||
title: '商家 ID',
|
||||
dataIndex: 'merchantId',
|
||||
width: 92,
|
||||
},
|
||||
{
|
||||
title: '商家名称',
|
||||
dataIndex: 'merchantName',
|
||||
width: 200,
|
||||
render: value => compactText(value),
|
||||
},
|
||||
{
|
||||
title: '商家 Logo',
|
||||
dataIndex: 'logo',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Avatar shape="square" size={48} src={record.logo || undefined}>
|
||||
{record.merchantName?.slice(0, 1)}
|
||||
</Avatar>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商家公告',
|
||||
dataIndex: 'notice',
|
||||
width: 220,
|
||||
render: value => (
|
||||
<Typography.Text title={compactText(value)}>
|
||||
{compactText(value)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商家状态',
|
||||
dataIndex: 'merchantStatus',
|
||||
width: 96,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '审核状态',
|
||||
dataIndex: 'auditStatus',
|
||||
width: 96,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '主推类目',
|
||||
dataIndex: 'mainCategoryName',
|
||||
width: 160,
|
||||
render: value => compactText(value),
|
||||
},
|
||||
{
|
||||
title: '推荐理由',
|
||||
dataIndex: 'recommendReason',
|
||||
width: 220,
|
||||
render: value => (
|
||||
<Typography.Text title={compactText(value)}>
|
||||
{compactText(value)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '排行分值',
|
||||
dataIndex: 'rankScore',
|
||||
width: 96,
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
width: 76,
|
||||
},
|
||||
{
|
||||
title: '配置状态',
|
||||
dataIndex: 'configStatus',
|
||||
width: 96,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'startTime',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '结束时间',
|
||||
dataIndex: 'endTime',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 144,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" onClick={() => openEditModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除该推荐商家配置吗?"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
onConfirm={() => void handleDelete(record)}
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ display: 'flex' }}>
|
||||
<div className="page-toolbar">
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
首页推荐商家
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={openCreateModal}>
|
||||
新增推荐商家
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form className="search-form" form={form} layout="inline" onFinish={() => void loadData(1, pagination.pageSize)}>
|
||||
<Form.Item name="merchantId" label="商家筛选">
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
filterOption={false}
|
||||
loading={merchantLoading}
|
||||
options={merchantOptions}
|
||||
style={{ width: 220 }}
|
||||
placeholder="请输入商家名称搜索"
|
||||
onFocus={() => {
|
||||
if (!merchantOptions.length) {
|
||||
void loadMerchantOptions()
|
||||
}
|
||||
}}
|
||||
onSearch={value => {
|
||||
void loadMerchantOptions(value)
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input allowClear placeholder="商家名称" style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="positionCode" label="投放位置">
|
||||
<Select
|
||||
allowClear
|
||||
options={withAllOption(recommendStorePositionOptions)}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear options={withAllOption(enabledStatusOptions)} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table<AdminRecommendMerchantItem>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
className="table-top-align"
|
||||
scroll={{ x: 2200 }}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: count => `共 ${count} 条`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
destroyOnHidden
|
||||
open={modalOpen}
|
||||
title={editingRecord ? '编辑首页推荐商家' : '新增首页推荐商家'}
|
||||
width={640}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
onCancel={resetModal}
|
||||
onOk={() => void handleSave()}
|
||||
>
|
||||
<Form form={modalForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="positionCode"
|
||||
label="投放位置"
|
||||
rules={[{ required: true, message: '请选择投放位置' }]}
|
||||
>
|
||||
<Select options={recommendStorePositionOptions} placeholder="请选择投放位置" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="merchantId"
|
||||
label="推荐商家"
|
||||
rules={[{ required: true, message: '请选择商家' }]}
|
||||
extra="当前推荐位直接选择商家。"
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
filterOption={false}
|
||||
loading={merchantLoading}
|
||||
options={merchantOptions}
|
||||
placeholder="请输入商家名称搜索"
|
||||
onFocus={() => {
|
||||
if (!merchantOptions.length) {
|
||||
void loadMerchantOptions()
|
||||
}
|
||||
}}
|
||||
onSearch={value => {
|
||||
void loadMerchantOptions(value)
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="mainCategoryName" label="主推类目">
|
||||
<Input maxLength={50} placeholder="首页展示用,如:精品水果 / 五金工具" />
|
||||
</Form.Item>
|
||||
<Form.Item name="recommendReason" label="推荐理由">
|
||||
<TextArea rows={3} maxLength={200} placeholder="请输入首页展示文案" />
|
||||
</Form.Item>
|
||||
<Space size={12} style={{ display: 'flex' }}>
|
||||
<Form.Item name="rankScore" label="排行分值" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder="默认 0" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort" label="排序" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="默认 0" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
|
||||
<Select options={enabledStatusOptions} placeholder="请选择状态" />
|
||||
</Form.Item>
|
||||
<Space size={12} style={{ display: 'flex' }}>
|
||||
<Form.Item name="startTime" label="开始时间" style={{ flex: 1 }}>
|
||||
<DatePicker showTime style={{ width: '100%' }} placeholder="请选择开始时间" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="endTime"
|
||||
label="结束时间"
|
||||
style={{ flex: 1 }}
|
||||
dependencies={['startTime']}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value?: Dayjs | null) {
|
||||
const startTime = getFieldValue('startTime') as Dayjs | null | undefined
|
||||
if (!startTime || !value || value.valueOf() >= startTime.valueOf()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error('结束时间不能早于开始时间'))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<DatePicker showTime style={{ width: '100%' }} placeholder="请选择结束时间" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
346
src/pages/admin/searchKeywords/index.tsx
Normal file
346
src/pages/admin/searchKeywords/index.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
apiAdminSearchKeywordDelete,
|
||||
apiAdminSearchKeywordPage,
|
||||
apiAdminSearchKeywordSave,
|
||||
} from '@/apis/apiMallAdmin'
|
||||
import StatusTag from '@/components/StatusTag'
|
||||
import type {
|
||||
AdminSearchKeywordItem,
|
||||
AdminSearchKeywordPageParams,
|
||||
AdminSearchKeywordSaveParams,
|
||||
EnabledStatus,
|
||||
} from '@/types/mall'
|
||||
import { compactText, formatTimestamp } from '@/utils/format'
|
||||
import { enabledStatusOptions, withAllOption } from '@/utils/mall'
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
const DEFAULT_STATUS: EnabledStatus = 'enabled'
|
||||
|
||||
interface SearchKeywordFilterValues {
|
||||
keyword?: string
|
||||
status?: EnabledStatus | ''
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
interface SearchKeywordFormValues {
|
||||
keyword: string
|
||||
sort?: number
|
||||
status: EnabledStatus
|
||||
startTime?: Dayjs | null
|
||||
endTime?: Dayjs | null
|
||||
}
|
||||
|
||||
const normalizeSearchValues = (values: SearchKeywordFilterValues): AdminSearchKeywordPageParams => ({
|
||||
keyword: values.keyword?.trim() || undefined,
|
||||
status: values.status || undefined,
|
||||
page: values.page,
|
||||
size: values.size,
|
||||
})
|
||||
|
||||
export default function SearchKeywordManagementPage() {
|
||||
const [form] = Form.useForm<SearchKeywordFilterValues>()
|
||||
const [modalForm] = Form.useForm<SearchKeywordFormValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [list, setList] = useState<AdminSearchKeywordItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: DEFAULT_PAGE_SIZE })
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<AdminSearchKeywordItem | null>(null)
|
||||
|
||||
const loadData = async (page = pagination.current, size = pagination.pageSize) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await apiAdminSearchKeywordPage(
|
||||
normalizeSearchValues({
|
||||
...form.getFieldsValue(),
|
||||
page,
|
||||
size,
|
||||
})
|
||||
)
|
||||
setList(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
setPagination({ current: page, pageSize: size })
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '热词列表加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
page: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
})
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}, [])
|
||||
|
||||
const resetModal = () => {
|
||||
setModalOpen(false)
|
||||
setEditingRecord(null)
|
||||
modalForm.resetFields()
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingRecord(null)
|
||||
modalForm.setFieldsValue({
|
||||
sort: 0,
|
||||
status: DEFAULT_STATUS,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (record: AdminSearchKeywordItem) => {
|
||||
setEditingRecord(record)
|
||||
modalForm.setFieldsValue({
|
||||
keyword: record.keyword,
|
||||
sort: record.sort ?? 0,
|
||||
status: record.status,
|
||||
startTime: record.startTime ? dayjs.unix(record.startTime) : null,
|
||||
endTime: record.endTime ? dayjs.unix(record.endTime) : null,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (record: AdminSearchKeywordItem) => {
|
||||
try {
|
||||
await apiAdminSearchKeywordDelete({ id: record.id })
|
||||
toast.success('热词已删除')
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '热词删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await modalForm.validateFields()
|
||||
setSaving(true)
|
||||
const payload: AdminSearchKeywordSaveParams = {
|
||||
id: editingRecord?.id,
|
||||
keyword: values.keyword.trim(),
|
||||
sort: values.sort ?? 0,
|
||||
status: values.status,
|
||||
startTime: values.startTime?.unix(),
|
||||
endTime: values.endTime?.unix(),
|
||||
}
|
||||
await apiAdminSearchKeywordSave(payload)
|
||||
toast.success(editingRecord ? '热词已更新' : '热词已新增')
|
||||
resetModal()
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = (next: TablePaginationConfig) => {
|
||||
void loadData(next.current || 1, next.pageSize || DEFAULT_PAGE_SIZE)
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminSearchKeywordItem> = [
|
||||
{
|
||||
title: '热词内容',
|
||||
dataIndex: 'keyword',
|
||||
width: 220,
|
||||
render: value => compactText(value),
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 96,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'startTime',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '结束时间',
|
||||
dataIndex: 'endTime',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 144,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" onClick={() => openEditModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除该热词吗?"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
onConfirm={() => void handleDelete(record)}
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ display: 'flex' }}>
|
||||
<div className="page-toolbar">
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
首页热词
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={openCreateModal}>
|
||||
新增热词
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form className="search-form" form={form} layout="inline" onFinish={() => void loadData(1, pagination.pageSize)}>
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input allowClear placeholder="请输入热词内容" style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear options={withAllOption(enabledStatusOptions)} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table<AdminSearchKeywordItem>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
className="table-top-align"
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: count => `共 ${count} 条`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
destroyOnHidden
|
||||
open={modalOpen}
|
||||
title={editingRecord ? '编辑首页热词' : '新增首页热词'}
|
||||
width={560}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
onCancel={resetModal}
|
||||
onOk={() => void handleSave()}
|
||||
>
|
||||
<Form form={modalForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="keyword"
|
||||
label="热词内容"
|
||||
rules={[
|
||||
{ required: true, message: '请输入热词内容' },
|
||||
{ whitespace: true, message: '热词内容不能为空' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={50} placeholder="请输入热词内容" />
|
||||
</Form.Item>
|
||||
<Space size={12} style={{ display: 'flex' }}>
|
||||
<Form.Item name="sort" label="排序" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="默认 0" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
>
|
||||
<Select options={enabledStatusOptions} placeholder="请选择状态" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={12} style={{ display: 'flex' }}>
|
||||
<Form.Item name="startTime" label="开始时间" style={{ flex: 1 }}>
|
||||
<DatePicker showTime style={{ width: '100%' }} placeholder="请选择开始时间" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="endTime"
|
||||
label="结束时间"
|
||||
style={{ flex: 1 }}
|
||||
dependencies={['startTime']}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value?: Dayjs | null) {
|
||||
const startTime = getFieldValue('startTime') as Dayjs | null | undefined
|
||||
if (!startTime || !value || value.valueOf() >= startTime.valueOf()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error('结束时间不能早于开始时间'))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<DatePicker showTime style={{ width: '100%' }} placeholder="请选择结束时间" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
319
src/pages/admin/stores/index.tsx
Normal file
319
src/pages/admin/stores/index.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'
|
||||
import { toast } from 'sonner'
|
||||
import AuditModal, { type AuditModalValues } from '@/components/AuditModal'
|
||||
import JsonPreviewPanel from '@/components/JsonPreviewPanel'
|
||||
import StatusTag from '@/components/StatusTag'
|
||||
import { apiAdminStoreAudit, apiAdminStorePage } from '@/apis/apiMallAdmin'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import type { AdminStorePageParams, StoreInfo } from '@/types/mall'
|
||||
import { buildAddress, compactText, formatTimestamp } from '@/utils/format'
|
||||
import { auditStatusOptions, enabledStatusOptions, withAllOption } from '@/utils/mall'
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
interface StoreSearchValues {
|
||||
merchantId?: string
|
||||
keyword?: string
|
||||
storeStatus?: string
|
||||
auditStatus?: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
const normalizeFilterValues = (values: StoreSearchValues): AdminStorePageParams => ({
|
||||
merchantId: values.merchantId || undefined,
|
||||
keyword: values.keyword?.trim() || undefined,
|
||||
storeStatus: values.storeStatus as AdminStorePageParams['storeStatus'],
|
||||
auditStatus: values.auditStatus as AdminStorePageParams['auditStatus'],
|
||||
page: values.page,
|
||||
size: values.size,
|
||||
})
|
||||
|
||||
export default function StoreReviewPage() {
|
||||
const [form] = Form.useForm<StoreSearchValues>()
|
||||
const operatorId = useUserStore(state => state.token?.uid ?? 0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [auditLoading, setAuditLoading] = useState(false)
|
||||
const [list, setList] = useState<StoreInfo[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [selectedRecord, setSelectedRecord] = useState<StoreInfo | null>(null)
|
||||
const [viewRecord, setViewRecord] = useState<StoreInfo | null>(null)
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: DEFAULT_PAGE_SIZE })
|
||||
|
||||
const loadData = async (page = pagination.current, size = pagination.pageSize) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const values = form.getFieldsValue()
|
||||
const response = await apiAdminStorePage(
|
||||
normalizeFilterValues({
|
||||
...values,
|
||||
page,
|
||||
size,
|
||||
})
|
||||
)
|
||||
setList(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
setPagination({ current: page, pageSize: size })
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '店铺列表加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
page: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
})
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}, [])
|
||||
|
||||
const columns = useMemo<ColumnsType<StoreInfo>>(
|
||||
() => [
|
||||
{
|
||||
title: '店铺信息',
|
||||
key: 'store',
|
||||
width: 260,
|
||||
render: (_, record) => (
|
||||
<Space align="start">
|
||||
<Avatar shape="square" size={48} src={record.storeLogo || undefined}>
|
||||
{record.storeName?.slice(0, 1)}
|
||||
</Avatar>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text strong>{compactText(record.storeName)}</Typography.Text>
|
||||
<Typography.Text type="secondary" className="mono">
|
||||
店铺编号: {compactText(record.storeNo)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">商家 ID: {record.merchantId || '--'}</Typography.Text>
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '服务信息',
|
||||
key: 'service',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text>{compactText(record.servicePhone)}</Typography.Text>
|
||||
<Typography.Text type="secondary">{compactText(record.serviceEmail)}</Typography.Text>
|
||||
<Typography.Text type="secondary">{compactText(record.contactName)}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '店铺地址',
|
||||
key: 'address',
|
||||
width: 240,
|
||||
render: (_, record) => buildAddress(record.province, record.city, record.district, record.addressDetail),
|
||||
},
|
||||
{
|
||||
title: '启停状态',
|
||||
dataIndex: 'storeStatus',
|
||||
width: 110,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '审核状态',
|
||||
dataIndex: 'auditStatus',
|
||||
width: 110,
|
||||
render: value => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: value => formatTimestamp(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" onClick={() => setViewRecord(record)}>
|
||||
查看返回
|
||||
</Button>
|
||||
<Button type="link" onClick={() => setSelectedRecord(record)}>
|
||||
{record.auditStatus === 'pending' ? '审核' : '重新审核'}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const handleAuditSubmit = async (values: AuditModalValues) => {
|
||||
if (!selectedRecord) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setAuditLoading(true)
|
||||
await apiAdminStoreAudit({
|
||||
storeId: selectedRecord.id,
|
||||
auditStatus: values.auditStatus,
|
||||
remark: values.remark?.trim() || undefined,
|
||||
operatorId,
|
||||
})
|
||||
toast.success('店铺审核已提交')
|
||||
setSelectedRecord(null)
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '店铺审核失败')
|
||||
} finally {
|
||||
setAuditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = (next: TablePaginationConfig) => {
|
||||
void loadData(next.current || 1, next.pageSize || DEFAULT_PAGE_SIZE)
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ display: 'flex' }}>
|
||||
<div className="page-toolbar">
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
店铺审核列表
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form className="search-form" form={form} layout="inline" onFinish={() => void loadData(1, pagination.pageSize)}>
|
||||
<Form.Item name="merchantId" label="商家 ID">
|
||||
<Input allowClear style={{ width: 160 }} placeholder="精确筛选" />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword" label="关键词">
|
||||
<Input allowClear placeholder="店铺名称 / 编号" style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="storeStatus" label="启停状态">
|
||||
<Select allowClear options={withAllOption(enabledStatusOptions)} style={{ width: 150 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="auditStatus" label="审核状态">
|
||||
<Select allowClear options={withAllOption(auditStatusOptions)} style={{ width: 150 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
void loadData(1, DEFAULT_PAGE_SIZE)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table<StoreInfo>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
className="table-top-align"
|
||||
scroll={{ x: 1320 }}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: count => `共 ${count} 条`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<AuditModal
|
||||
open={!!selectedRecord}
|
||||
title="店铺审核"
|
||||
loading={auditLoading}
|
||||
onCancel={() => setSelectedRecord(null)}
|
||||
onSubmit={handleAuditSubmit}
|
||||
summary={
|
||||
selectedRecord ? (
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="店铺名称">{compactText(selectedRecord.storeName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="店铺编号">{compactText(selectedRecord.storeNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="商家 ID">{selectedRecord.merchantId}</Descriptions.Item>
|
||||
<Descriptions.Item label="服务电话">{compactText(selectedRecord.servicePhone)}</Descriptions.Item>
|
||||
<Descriptions.Item label="店铺地址">
|
||||
{buildAddress(
|
||||
selectedRecord.province,
|
||||
selectedRecord.city,
|
||||
selectedRecord.district,
|
||||
selectedRecord.addressDetail
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
open={!!viewRecord}
|
||||
width={820}
|
||||
title="店铺返回详情"
|
||||
onClose={() => setViewRecord(null)}
|
||||
>
|
||||
{viewRecord ? (
|
||||
<Space direction="vertical" size={16} style={{ display: 'flex' }}>
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="id">{viewRecord.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="merchantId">{viewRecord.merchantId}</Descriptions.Item>
|
||||
<Descriptions.Item label="storeNo">{compactText(viewRecord.storeNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="storeName">{compactText(viewRecord.storeName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="storeStatus">
|
||||
<StatusTag value={viewRecord.storeStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="auditStatus">
|
||||
<StatusTag value={viewRecord.auditStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="storeLogo">{compactText(viewRecord.storeLogo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="storeBanner">{compactText(viewRecord.storeBanner)}</Descriptions.Item>
|
||||
<Descriptions.Item label="storeNotice">{compactText(viewRecord.storeNotice)}</Descriptions.Item>
|
||||
<Descriptions.Item label="servicePhone">{compactText(viewRecord.servicePhone)}</Descriptions.Item>
|
||||
<Descriptions.Item label="serviceEmail">{compactText(viewRecord.serviceEmail)}</Descriptions.Item>
|
||||
<Descriptions.Item label="contactName">{compactText(viewRecord.contactName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="contactMobile">{compactText(viewRecord.contactMobile)}</Descriptions.Item>
|
||||
<Descriptions.Item label="province">{compactText(viewRecord.province)}</Descriptions.Item>
|
||||
<Descriptions.Item label="city">{compactText(viewRecord.city)}</Descriptions.Item>
|
||||
<Descriptions.Item label="district">{compactText(viewRecord.district)}</Descriptions.Item>
|
||||
<Descriptions.Item label="addressDetail" span={2}>
|
||||
{compactText(viewRecord.addressDetail)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="zipCode">{compactText(viewRecord.zipCode)}</Descriptions.Item>
|
||||
<Descriptions.Item label="sort">{viewRecord.sort}</Descriptions.Item>
|
||||
<Descriptions.Item label="createdAt">{formatTimestamp(viewRecord.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="updatedAt">{formatTimestamp(viewRecord.updatedAt)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<JsonPreviewPanel title="storeNotice(raw)" value={viewRecord.storeNotice} />
|
||||
</Space>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
227
src/pages/login/index.tsx
Normal file
227
src/pages/login/index.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'antd'
|
||||
import {
|
||||
LockOutlined,
|
||||
LoginOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { apiUserLogin } from '@/apis/apisUser'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const LOGIN_REMEMBER_KEY = 'mall-admin:remember-login'
|
||||
|
||||
interface LoginFormValues {
|
||||
account: string
|
||||
password: string
|
||||
captcha: string
|
||||
remember: boolean
|
||||
}
|
||||
|
||||
interface RememberLoginData {
|
||||
account: string
|
||||
password: string
|
||||
remember: boolean
|
||||
}
|
||||
|
||||
const readRememberLogin = (): RememberLoginData => {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
account: '',
|
||||
password: '',
|
||||
remember: true,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LOGIN_REMEMBER_KEY)
|
||||
if (!raw) {
|
||||
return {
|
||||
account: '',
|
||||
password: '',
|
||||
remember: true,
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<RememberLoginData>
|
||||
return {
|
||||
account: parsed.account ?? '',
|
||||
password: parsed.password ?? '',
|
||||
remember: parsed.remember ?? true,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
account: '',
|
||||
password: '',
|
||||
remember: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const persistRememberLogin = (values: RememberLoginData) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!values.remember) {
|
||||
window.localStorage.removeItem(LOGIN_REMEMBER_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
window.localStorage.setItem(LOGIN_REMEMBER_KEY, JSON.stringify(values))
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm<LoginFormValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const token = useUserStore(state => state.token)
|
||||
const setToken = useUserStore(state => state.setToken)
|
||||
const { token: themeToken } = theme.useToken()
|
||||
|
||||
useEffect(() => {
|
||||
if (token?.accessToken) {
|
||||
navigate('/merchant-review', { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
form.setFieldsValue(readRememberLogin())
|
||||
}, [form, navigate, token?.accessToken])
|
||||
|
||||
const onFinish = async (values: LoginFormValues) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const res = await apiUserLogin({
|
||||
account: values.account.trim(),
|
||||
password: values.password,
|
||||
captcha: values.captcha.trim(),
|
||||
captchaId: '',
|
||||
loginType: '0',
|
||||
type: '0',
|
||||
remember: values.remember,
|
||||
})
|
||||
|
||||
persistRememberLogin({
|
||||
account: values.account.trim(),
|
||||
password: values.password,
|
||||
remember: values.remember,
|
||||
})
|
||||
|
||||
setToken(res.data)
|
||||
toast.success('登录成功')
|
||||
navigate('/merchant-review', { replace: true })
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{
|
||||
borderRadius: themeToken.borderRadiusLG,
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 22,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<Typography.Title level={3} style={{ marginTop: 4, marginBottom: 4 }}>
|
||||
商城平台管理端
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<Form<LoginFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
onFinish={onFinish}
|
||||
initialValues={{
|
||||
account: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
remember: true,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="account"
|
||||
rules={[{ required: true, message: '请输入登陆账号' }]}
|
||||
>
|
||||
<Input placeholder="请输入登陆账号" autoComplete="username" prefix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入登陆密码' },
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="请输入登陆密码"
|
||||
autoComplete="current-password"
|
||||
prefix={<LockOutlined />}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="captcha"
|
||||
rules={[
|
||||
{ required: true, message: '请输入谷歌验证码' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="谷歌验证码"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
prefix={<SafetyCertificateOutlined />}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Form.Item name="remember" valuePropName="checked" noStyle>
|
||||
<Checkbox>记住密码</Checkbox>
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
style={{ paddingInline: 0 }}
|
||||
onClick={() => {
|
||||
toast.info('忘记密码请联系管理员处理')
|
||||
}}
|
||||
>
|
||||
忘记密码?
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" block icon={<LoginOutlined />} loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
22
src/routes/components/ProtectedRoute.tsx
Normal file
22
src/routes/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
redirectTo?: string
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({
|
||||
children,
|
||||
redirectTo = '/login',
|
||||
}: ProtectedRouteProps) {
|
||||
const token = useUserStore(state => state.token)
|
||||
|
||||
if (!token?.accessToken) {
|
||||
return <Navigate to={redirectTo} replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
87
src/routes/config.tsx
Normal file
87
src/routes/config.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
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 '@/routes/components/ProtectedRoute'
|
||||
import LoginPage from '@/pages/login'
|
||||
import MerchantReviewPage from '@/pages/admin/merchants'
|
||||
import GoodsReviewPage from '@/pages/admin/goods'
|
||||
import OrderOverviewPage from '@/pages/admin/orders'
|
||||
import OrderDetailPage from '@/pages/admin/orders/DetailPage'
|
||||
import BannerManagementPage from '@/pages/admin/banners'
|
||||
import CategoryManagementPage from '@/pages/admin/categories'
|
||||
import HomeChannelManagementPage from '@/pages/admin/homeChannels'
|
||||
import RecommendStoreManagementPage from '@/pages/admin/recommendStores'
|
||||
import SearchKeywordManagementPage from '@/pages/admin/searchKeywords'
|
||||
|
||||
export const routeConfig: RouteObject[] = [
|
||||
{
|
||||
path: '/',
|
||||
element: <RootLayout />,
|
||||
children: [
|
||||
{
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{
|
||||
path: 'login',
|
||||
element: <LoginPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/merchant-review" replace />,
|
||||
},
|
||||
{
|
||||
path: 'merchant-review',
|
||||
element: <MerchantReviewPage />,
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
element: <CategoryManagementPage />,
|
||||
},
|
||||
{
|
||||
path: 'banners',
|
||||
element: <BannerManagementPage />,
|
||||
},
|
||||
{
|
||||
path: 'goods-review',
|
||||
element: <GoodsReviewPage />,
|
||||
},
|
||||
{
|
||||
path: 'recommend-stores',
|
||||
element: <RecommendStoreManagementPage />,
|
||||
},
|
||||
{
|
||||
path: 'search-keywords',
|
||||
element: <SearchKeywordManagementPage />,
|
||||
},
|
||||
{
|
||||
path: 'home-channels',
|
||||
element: <HomeChannelManagementPage />,
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
element: <OrderOverviewPage />,
|
||||
},
|
||||
{
|
||||
path: 'orders/:orderId',
|
||||
element: <OrderDetailPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to="/" replace />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
7
src/routes/index.tsx
Normal file
7
src/routes/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useRoutes } from 'react-router-dom'
|
||||
import { routeConfig } from './config'
|
||||
|
||||
export default function AppRoutes() {
|
||||
return useRoutes(routeConfig)
|
||||
}
|
||||
|
||||
93
src/store/user.ts
Normal file
93
src/store/user.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { create } from 'zustand'
|
||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||
import { apiGetUserInfo } from '@/apis/apisUser'
|
||||
import type { ITokenData } from '@/types/ITokenData'
|
||||
import type { IUserInfo } from '@/types/IUserInfo'
|
||||
|
||||
const STORAGE_KEY = 'mall-admin:user'
|
||||
|
||||
interface UserState {
|
||||
token: ITokenData | null
|
||||
role: string[] | null
|
||||
refreshUsering: boolean
|
||||
userInfo: IUserInfo | null
|
||||
setToken: (token: ITokenData) => void
|
||||
setRole: (role: string[] | null) => void
|
||||
setUserInfo: (userInfo: IUserInfo) => void
|
||||
refreshUser: () => Promise<void>
|
||||
refreshAll: () => Promise<void>
|
||||
logout: () => void
|
||||
clearStorage: () => void
|
||||
clearAuth: () => void
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
token: null,
|
||||
role: [],
|
||||
refreshUsering: false,
|
||||
userInfo: null,
|
||||
setToken: token => {
|
||||
set({
|
||||
token,
|
||||
role: token.role ?? [],
|
||||
})
|
||||
void get().refreshAll()
|
||||
},
|
||||
setRole: role => {
|
||||
set({ role })
|
||||
},
|
||||
setUserInfo: userInfo => {
|
||||
set({ userInfo })
|
||||
},
|
||||
refreshUser: async () => {
|
||||
if (!get().token?.accessToken) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
set({ refreshUsering: true })
|
||||
const res = await apiGetUserInfo()
|
||||
set({ userInfo: res.data })
|
||||
} catch {
|
||||
// 用户信息接口失败时保留 token,避免影响已登录联调
|
||||
} finally {
|
||||
set({ refreshUsering: false })
|
||||
}
|
||||
},
|
||||
refreshAll: async () => {
|
||||
await get().refreshUser()
|
||||
},
|
||||
logout: () => {
|
||||
get().clearStorage()
|
||||
},
|
||||
clearStorage: () => {
|
||||
set({
|
||||
token: null,
|
||||
role: [],
|
||||
refreshUsering: false,
|
||||
userInfo: null,
|
||||
})
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
},
|
||||
clearAuth: () => {
|
||||
get().clearStorage()
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEY,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: state => ({
|
||||
token: state.token,
|
||||
role: state.role,
|
||||
userInfo: state.userInfo,
|
||||
}),
|
||||
onRehydrateStorage: () => state => {
|
||||
if (state) {
|
||||
void state.refreshAll()
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
10
src/types/ITokenData.ts
Normal file
10
src/types/ITokenData.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface ITokenData {
|
||||
accessToken: string
|
||||
accessExpire?: number
|
||||
refreshAfter?: number
|
||||
role?: string[]
|
||||
utype?: string
|
||||
uid?: number
|
||||
account?: string
|
||||
}
|
||||
|
||||
6
src/types/IUserInfo.ts
Normal file
6
src/types/IUserInfo.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface IUserInfo {
|
||||
uid: number
|
||||
name: string
|
||||
account: string
|
||||
}
|
||||
|
||||
513
src/types/mall.ts
Normal file
513
src/types/mall.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
export type EnabledStatus = 'enabled' | 'disabled'
|
||||
export type AuditStatus = 'pending' | 'approved' | 'rejected'
|
||||
export type SettleStatus = 'pending' | 'active' | 'frozen'
|
||||
export type SaleStatus = 'off' | 'on'
|
||||
export type HomePositionCode =
|
||||
| 'home_top'
|
||||
| 'home_rank_store'
|
||||
| 'home_featured_store'
|
||||
| 'home_recommend_goods'
|
||||
export type RecommendMerchantPositionCode = 'home_rank_store' | 'home_featured_store'
|
||||
export type RecommendStorePositionCode = RecommendMerchantPositionCode
|
||||
export type MerchantLoginType = '0' | '1'
|
||||
export type LinkType = 'goods' | 'store' | 'topic' | 'h5'
|
||||
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 PageResult<T> {
|
||||
total: number
|
||||
list: T[]
|
||||
}
|
||||
|
||||
export interface MerchantInfo {
|
||||
id: number
|
||||
uid: number
|
||||
merchantNo: string
|
||||
merchantName: string
|
||||
status: EnabledStatus
|
||||
auditStatus: AuditStatus
|
||||
settleStatus: SettleStatus
|
||||
contactName: string
|
||||
contactMobile: string
|
||||
contactEmail: string
|
||||
licenseNo: string
|
||||
licenseImagesJson: string
|
||||
extraInfoJson: string
|
||||
remark: string
|
||||
logo?: string
|
||||
notice?: string
|
||||
approvedAt: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface StoreInfo {
|
||||
id: number
|
||||
merchantId: string
|
||||
storeNo: string
|
||||
storeName: string
|
||||
storeLogo: string
|
||||
storeBanner: string
|
||||
storeNotice: string
|
||||
storeStatus: EnabledStatus
|
||||
auditStatus: AuditStatus
|
||||
servicePhone: string
|
||||
serviceEmail: string
|
||||
contactName: string
|
||||
contactMobile: string
|
||||
province: string
|
||||
city: string
|
||||
district: string
|
||||
addressDetail: string
|
||||
zipCode: string
|
||||
sort: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface GoodsSummary {
|
||||
id: number
|
||||
merchantId: string
|
||||
storeId: number
|
||||
categoryId: number
|
||||
categoryName?: string
|
||||
spuNo: string
|
||||
spuName: string
|
||||
subTitle: string
|
||||
coverUrl: string
|
||||
status: EnabledStatus
|
||||
auditStatus: AuditStatus
|
||||
saleStatus: SaleStatus
|
||||
sort: number
|
||||
salesVolume: number
|
||||
virtualSales: number
|
||||
viewCount: number
|
||||
minPrice: string
|
||||
maxPrice: string
|
||||
totalStock: number
|
||||
lockedStock: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface 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
|
||||
memberId: number
|
||||
uid?: number
|
||||
merchantId: string
|
||||
storeId: number
|
||||
merchantName?: string
|
||||
storeName: string
|
||||
orderStatus: OrderStatus
|
||||
afterSaleStatus: AfterSaleStatus
|
||||
currency: string
|
||||
goodsAmount: string
|
||||
freightAmount: string
|
||||
discountAmount: string
|
||||
payAmount: string
|
||||
payTime: number
|
||||
shipTime: number
|
||||
finishTime: number
|
||||
cancelTime: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface OrderDetailInfo {
|
||||
id: number
|
||||
tradeId: number
|
||||
tradeNo: string
|
||||
orderNo: string
|
||||
memberId: number
|
||||
uid?: number
|
||||
merchantId: string
|
||||
storeId: number
|
||||
merchantName?: string
|
||||
storeName: string
|
||||
orderStatus: OrderStatus
|
||||
afterSaleStatus: AfterSaleStatus
|
||||
currency: string
|
||||
goodsAmount: string
|
||||
freightAmount: string
|
||||
discountAmount: string
|
||||
payAmount: string
|
||||
buyerRemark: string
|
||||
sellerRemark: string
|
||||
receiverName: string
|
||||
receiverMobile: string
|
||||
receiverCountryCode: string
|
||||
receiverProvince: string
|
||||
receiverCity: string
|
||||
receiverDistrict: string
|
||||
receiverAddress: string
|
||||
receiverZipCode: string
|
||||
deliveryCompany: string
|
||||
deliveryNo: string
|
||||
payChannel: string
|
||||
extraInfoJson: string
|
||||
payTime: number
|
||||
shipTime: number
|
||||
finishTime: number
|
||||
cancelTime: number
|
||||
autoConfirmAt: number
|
||||
items: OrderItemInfo[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface AdminMerchantPageParams {
|
||||
keyword?: string
|
||||
status?: EnabledStatus
|
||||
auditStatus?: AuditStatus
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface AdminMerchantCreateParams {
|
||||
loginType: MerchantLoginType
|
||||
account: string
|
||||
password: string
|
||||
merchantName: string
|
||||
contactName: string
|
||||
contactMobile?: string
|
||||
contactEmail?: string
|
||||
licenseNo?: string
|
||||
licenseImagesJson?: string
|
||||
extraInfoJson?: string
|
||||
remark?: string
|
||||
createIp?: string
|
||||
}
|
||||
|
||||
export interface AdminMerchantAuditParams {
|
||||
merchantId: string
|
||||
auditStatus: Extract<AuditStatus, 'approved' | 'rejected'>
|
||||
remark?: string
|
||||
operatorId: number
|
||||
}
|
||||
|
||||
export interface AdminStorePageParams {
|
||||
merchantId?: string
|
||||
keyword?: string
|
||||
storeStatus?: EnabledStatus
|
||||
auditStatus?: AuditStatus
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface AdminStoreAuditParams {
|
||||
storeId: number
|
||||
auditStatus: Extract<AuditStatus, 'approved' | 'rejected'>
|
||||
remark?: string
|
||||
operatorId: number
|
||||
}
|
||||
|
||||
export interface AdminGoodsPageParams {
|
||||
merchantId?: string
|
||||
storeId?: number
|
||||
keyword?: string
|
||||
auditStatus?: AuditStatus
|
||||
saleStatus?: SaleStatus
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface AdminGoodsAuditParams {
|
||||
spuId: number
|
||||
auditStatus: Extract<AuditStatus, 'approved' | 'rejected'>
|
||||
remark?: string
|
||||
operatorId: number
|
||||
}
|
||||
|
||||
export interface AdminOrderPageParams {
|
||||
merchantId?: string
|
||||
storeId?: number
|
||||
tradeNo?: string
|
||||
orderNo?: string
|
||||
orderStatus?: OrderStatus
|
||||
afterSaleStatus?: AfterSaleStatus
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface AdminOrderDetailParams {
|
||||
orderId: number
|
||||
}
|
||||
|
||||
export interface AdminCategoryItem {
|
||||
id: number
|
||||
parentId: number
|
||||
categoryName: string
|
||||
categoryLevel: number
|
||||
categoryPath: string
|
||||
categoryIcon: string
|
||||
isLeaf: boolean
|
||||
sort: number
|
||||
status: EnabledStatus
|
||||
goodsCount: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
children: AdminCategoryItem[]
|
||||
}
|
||||
|
||||
export interface AdminCategoryTreeParams {
|
||||
status?: EnabledStatus
|
||||
}
|
||||
|
||||
export interface AdminCategorySaveParams {
|
||||
id?: number
|
||||
parentId?: number
|
||||
categoryName: string
|
||||
categoryIcon?: string
|
||||
sort?: number
|
||||
status: EnabledStatus
|
||||
}
|
||||
|
||||
export interface AdminCategoryDeleteParams {
|
||||
id: number
|
||||
}
|
||||
|
||||
export interface AdminRecommendStoreItem {
|
||||
id: number
|
||||
positionCode: RecommendStorePositionCode
|
||||
storeId: number
|
||||
merchantId: string
|
||||
storeName: string
|
||||
storeLogo: string
|
||||
storeNotice: string
|
||||
storeStatus: EnabledStatus
|
||||
auditStatus: AuditStatus
|
||||
mainCategoryName: string
|
||||
recommendReason: string
|
||||
rankScore: number
|
||||
sort: number
|
||||
configStatus: EnabledStatus
|
||||
startTime: number
|
||||
endTime: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface AdminRecommendStorePageParams {
|
||||
keyword?: string
|
||||
positionCode?: RecommendStorePositionCode
|
||||
status?: EnabledStatus
|
||||
merchantId?: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface AdminRecommendStoreSaveParams {
|
||||
id?: number
|
||||
positionCode: RecommendStorePositionCode
|
||||
storeId: number
|
||||
mainCategoryName?: string
|
||||
recommendReason?: string
|
||||
rankScore?: number
|
||||
sort?: number
|
||||
status: EnabledStatus
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}
|
||||
|
||||
export interface AdminRecommendStoreDeleteParams {
|
||||
id: number
|
||||
}
|
||||
|
||||
export interface AdminBannerItem {
|
||||
id: number
|
||||
title: string
|
||||
subTitle: string
|
||||
description: string
|
||||
buttonText: string
|
||||
positionCode: Extract<HomePositionCode, 'home_top'>
|
||||
imageUrl: string
|
||||
linkType: LinkType | string
|
||||
linkValue: string
|
||||
sort: number
|
||||
status: EnabledStatus
|
||||
startTime: number
|
||||
endTime: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface AdminBannerPageParams {
|
||||
keyword?: string
|
||||
positionCode?: Extract<HomePositionCode, 'home_top'>
|
||||
status?: EnabledStatus
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface AdminBannerSaveParams {
|
||||
id?: number
|
||||
title: string
|
||||
subTitle?: string
|
||||
description?: string
|
||||
buttonText?: string
|
||||
positionCode: Extract<HomePositionCode, 'home_top'>
|
||||
imageUrl: string
|
||||
linkType?: LinkType | string
|
||||
linkValue?: string
|
||||
sort?: number
|
||||
status?: EnabledStatus
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}
|
||||
|
||||
export interface AdminBannerDeleteParams {
|
||||
id: number
|
||||
}
|
||||
|
||||
export interface AdminRecommendMerchantItem {
|
||||
id: number
|
||||
positionCode: RecommendMerchantPositionCode
|
||||
merchantId: string
|
||||
merchantName: string
|
||||
logo: string
|
||||
notice: string
|
||||
merchantStatus: EnabledStatus
|
||||
auditStatus: AuditStatus
|
||||
mainCategoryName: string
|
||||
recommendReason: string
|
||||
rankScore: number
|
||||
sort: number
|
||||
configStatus: EnabledStatus
|
||||
startTime: number
|
||||
endTime: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface AdminRecommendMerchantPageParams {
|
||||
keyword?: string
|
||||
positionCode?: RecommendMerchantPositionCode
|
||||
status?: EnabledStatus
|
||||
merchantId?: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface AdminRecommendMerchantSaveParams {
|
||||
id?: number
|
||||
positionCode: RecommendMerchantPositionCode
|
||||
merchantId: string
|
||||
mainCategoryName?: string
|
||||
recommendReason?: string
|
||||
rankScore?: number
|
||||
sort?: number
|
||||
status?: EnabledStatus
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}
|
||||
|
||||
export interface AdminRecommendMerchantDeleteParams {
|
||||
id: number
|
||||
}
|
||||
|
||||
export interface AdminSearchKeywordItem {
|
||||
id: number
|
||||
keyword: string
|
||||
sort: number
|
||||
status: EnabledStatus
|
||||
startTime: number
|
||||
endTime: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface AdminSearchKeywordPageParams {
|
||||
keyword?: string
|
||||
status?: EnabledStatus
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface AdminSearchKeywordSaveParams {
|
||||
id?: number
|
||||
keyword: string
|
||||
sort?: number
|
||||
status?: EnabledStatus
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}
|
||||
|
||||
export interface AdminSearchKeywordDeleteParams {
|
||||
id: number
|
||||
}
|
||||
|
||||
export interface AdminHomeChannelItem {
|
||||
id: number
|
||||
title: string
|
||||
subTitle: string
|
||||
iconUrl: string
|
||||
linkType: LinkType | string
|
||||
linkValue: string
|
||||
sort: number
|
||||
status: EnabledStatus
|
||||
startTime: number
|
||||
endTime: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface AdminHomeChannelPageParams {
|
||||
keyword?: string
|
||||
status?: EnabledStatus
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface AdminHomeChannelSaveParams {
|
||||
id?: number
|
||||
title: string
|
||||
subTitle?: string
|
||||
iconUrl?: string
|
||||
linkType?: LinkType | string
|
||||
linkValue?: string
|
||||
sort?: number
|
||||
status?: EnabledStatus
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}
|
||||
|
||||
export interface AdminHomeChannelDeleteParams {
|
||||
id: number
|
||||
}
|
||||
65
src/utils/format.ts
Normal file
65
src/utils/format.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export const formatTimestamp = (value?: number | null) => {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
return dayjs.unix(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
export const formatAmount = (value?: string | null, currency?: string | null) => {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
const prefix = currency || 'CNY'
|
||||
return `${prefix} ${value}`
|
||||
}
|
||||
|
||||
export const compactText = (value?: string | null) => {
|
||||
const text = value?.trim()
|
||||
return text || '--'
|
||||
}
|
||||
|
||||
export const safeJsonPretty = (value?: string | null) => {
|
||||
const text = value?.trim()
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
export const buildAddress = (...segments: Array<string | null | undefined>) => {
|
||||
const text = segments.filter(Boolean).join('')
|
||||
return text || '--'
|
||||
}
|
||||
|
||||
export const parseSpecSummary = (value?: string | null) => {
|
||||
const text = value?.trim()
|
||||
if (!text) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Array<{ name?: string; value?: string }>
|
||||
if (!Array.isArray(parsed)) {
|
||||
return text
|
||||
}
|
||||
const summary = parsed
|
||||
.map(item => {
|
||||
const name = item?.name?.trim()
|
||||
const specValue = item?.value?.trim()
|
||||
return name && specValue ? `${name}: ${specValue}` : undefined
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' / ')
|
||||
return summary || text
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
186
src/utils/mall.ts
Normal file
186
src/utils/mall.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type {
|
||||
AfterSaleStatus,
|
||||
AuditStatus,
|
||||
EnabledStatus,
|
||||
HomePositionCode,
|
||||
LinkType,
|
||||
OrderStatus,
|
||||
RecommendStorePositionCode,
|
||||
SaleStatus,
|
||||
SettleStatus,
|
||||
} from '@/types/mall'
|
||||
|
||||
type SelectValue = string
|
||||
|
||||
export interface LabelOption<T extends SelectValue = SelectValue> {
|
||||
label: string
|
||||
value: T
|
||||
}
|
||||
|
||||
export const enabledStatusOptions: LabelOption<EnabledStatus>[] = [
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
]
|
||||
|
||||
export const auditStatusOptions: LabelOption<AuditStatus>[] = [
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '审核通过', value: 'approved' },
|
||||
{ label: '审核驳回', value: 'rejected' },
|
||||
]
|
||||
|
||||
export const auditActionOptions: LabelOption<'approved' | 'rejected'>[] = [
|
||||
{ label: '审核通过', value: 'approved' },
|
||||
{ label: '审核驳回', value: 'rejected' },
|
||||
]
|
||||
|
||||
export const settleStatusOptions: LabelOption<SettleStatus>[] = [
|
||||
{ label: '待开通', value: 'pending' },
|
||||
{ label: '正常结算', value: 'active' },
|
||||
{ label: '结算冻结', value: 'frozen' },
|
||||
]
|
||||
|
||||
export const saleStatusOptions: LabelOption<SaleStatus>[] = [
|
||||
{ label: '下架', value: 'off' },
|
||||
{ label: '上架', value: 'on' },
|
||||
]
|
||||
|
||||
export const orderStatusOptions: LabelOption<OrderStatus>[] = [
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
export const afterSaleStatusOptions: LabelOption<AfterSaleStatus>[] = [
|
||||
{ label: '无售后', value: 'none' },
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '审核通过', value: 'approved' },
|
||||
{ label: '审核驳回', value: 'rejected' },
|
||||
{ label: '退款中', value: 'refunding' },
|
||||
{ label: '已完成', value: 'finished' },
|
||||
{ label: '已关闭', value: 'closed' },
|
||||
]
|
||||
|
||||
export const recommendStorePositionOptions: LabelOption<RecommendStorePositionCode>[] = [
|
||||
{ label: '首页排行榜', value: 'home_rank_store' },
|
||||
{ label: '首页优质商家', value: 'home_featured_store' },
|
||||
]
|
||||
|
||||
export const bannerPositionOptions: LabelOption<Extract<HomePositionCode, 'home_top'>>[] = [
|
||||
{ label: '首页顶部 Banner', value: 'home_top' },
|
||||
]
|
||||
|
||||
export const linkTypeOptions: LabelOption<LinkType>[] = [
|
||||
{ label: '商品', value: 'goods' },
|
||||
{ label: '商家', value: 'store' },
|
||||
{ label: '专题', value: 'topic' },
|
||||
{ label: 'H5', value: 'h5' },
|
||||
]
|
||||
|
||||
const STATUS_LABEL_MAP: Record<string, string> = {
|
||||
enabled: '启用',
|
||||
disabled: '停用',
|
||||
pending: '待审核',
|
||||
approved: '审核通过',
|
||||
rejected: '审核驳回',
|
||||
active: '正常结算',
|
||||
frozen: '结算冻结',
|
||||
off: '下架',
|
||||
on: '上架',
|
||||
pending_pay: '待支付',
|
||||
paid_wait_ship: '待发货',
|
||||
shipped: '已发货',
|
||||
finished: '已完成',
|
||||
canceled: '已取消',
|
||||
after_sale: '售后中',
|
||||
refund_finished: '退款完成',
|
||||
none: '无售后',
|
||||
refunding: '退款中',
|
||||
closed: '已关闭',
|
||||
}
|
||||
|
||||
const STATUS_COLOR_MAP: Record<string, string> = {
|
||||
enabled: 'green',
|
||||
disabled: 'default',
|
||||
pending: 'gold',
|
||||
approved: 'green',
|
||||
rejected: 'red',
|
||||
active: 'blue',
|
||||
frozen: 'volcano',
|
||||
off: 'default',
|
||||
on: 'cyan',
|
||||
pending_pay: 'gold',
|
||||
paid_wait_ship: 'processing',
|
||||
shipped: 'blue',
|
||||
finished: 'green',
|
||||
canceled: 'default',
|
||||
after_sale: 'orange',
|
||||
refund_finished: 'purple',
|
||||
none: 'default',
|
||||
refunding: 'processing',
|
||||
closed: 'default',
|
||||
}
|
||||
|
||||
const RECOMMEND_STORE_POSITION_LABEL_MAP: Record<RecommendStorePositionCode, string> = {
|
||||
home_rank_store: '首页排行榜',
|
||||
home_featured_store: '首页优质商家',
|
||||
}
|
||||
|
||||
const LINK_TYPE_LABEL_MAP: Record<LinkType, string> = {
|
||||
goods: '商品',
|
||||
store: '商家',
|
||||
topic: '专题',
|
||||
h5: 'H5',
|
||||
}
|
||||
|
||||
export const getStatusLabel = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
return STATUS_LABEL_MAP[value] || value
|
||||
}
|
||||
|
||||
export const getStatusColor = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return 'default'
|
||||
}
|
||||
return STATUS_COLOR_MAP[value] || 'default'
|
||||
}
|
||||
|
||||
export const getRecommendStorePositionLabel = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
if (value === 'home_top') {
|
||||
return '首页顶部 Banner'
|
||||
}
|
||||
return RECOMMEND_STORE_POSITION_LABEL_MAP[value as RecommendStorePositionCode] || value
|
||||
}
|
||||
|
||||
export const getLinkTypeLabel = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
return LINK_TYPE_LABEL_MAP[value as LinkType] || value
|
||||
}
|
||||
|
||||
export const getCategoryLevelLabel = (value?: number | null) => {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
return `L${value}`
|
||||
}
|
||||
|
||||
export const getBooleanLabel = (value?: boolean | null) => {
|
||||
if (value === undefined || value === null) {
|
||||
return '--'
|
||||
}
|
||||
return value ? '是' : '否'
|
||||
}
|
||||
|
||||
export const withAllOption = <T extends SelectValue>(options: LabelOption<T>[]) => {
|
||||
return [{ label: '全部', value: '' }, ...options]
|
||||
}
|
||||
2
src/vite-env.d.ts
vendored
Normal file
2
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
24
tsconfig.app.json
Normal file
24
tsconfig.app.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
17
tsconfig.node.json
Normal file
17
tsconfig.node.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
45
vite.config.ts
Normal file
45
vite.config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5200,
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1200,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/antd') || id.includes('node_modules/@ant-design')) {
|
||||
return 'antd-vendor'
|
||||
}
|
||||
|
||||
if (id.includes('node_modules/react-router-dom') || id.includes('node_modules/react-router')) {
|
||||
return 'router-vendor'
|
||||
}
|
||||
|
||||
if (
|
||||
id.includes('node_modules/axios')
|
||||
|| id.includes('node_modules/dayjs')
|
||||
|| id.includes('node_modules/sonner')
|
||||
|| id.includes('node_modules/zustand')
|
||||
) {
|
||||
return 'app-vendor'
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user