This commit is contained in:
魔方熊成祥
2026-04-29 21:38:16 +08:00
commit c7481718e4
45 changed files with 9530 additions and 0 deletions

4
.gitignore vendored Normal file
View File

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

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>商城管理端</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2996
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View 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
View File

@@ -0,0 +1,12 @@
import { Toaster } from 'sonner'
import AppRoutes from '@/routes'
export default function App() {
return (
<>
<AppRoutes />
<Toaster position="top-center" richColors duration={2600} />
</>
)
}

382
src/apis/apiMallAdmin.ts Normal file
View 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
View 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
View 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
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

17
tsconfig.node.json Normal file
View File

@@ -0,0 +1,17 @@
{
"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
View 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
},
},
},
},
})