541 lines
16 KiB
TypeScript
541 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|