forked from lunanting/cangku
493 lines
16 KiB
JavaScript
493 lines
16 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Button,
|
||
DatePicker,
|
||
Form,
|
||
Input,
|
||
Segmented,
|
||
Select,
|
||
Spin,
|
||
Table,
|
||
Tag,
|
||
} from 'antd';
|
||
import {
|
||
ReloadOutlined,
|
||
SearchOutlined,
|
||
} from '@ant-design/icons';
|
||
import { SycmBusinessPanel, SycmDashboardMetric, SycmPage } from '../../components/Common';
|
||
import {
|
||
getMarketingEventsList,
|
||
getMarketingEventsLatestDate,
|
||
getMarketingEventsSummary,
|
||
} from '../../api/marketingEvents';
|
||
import './style.css';
|
||
|
||
const { RangePicker } = DatePicker;
|
||
|
||
function formatTimestamp(ts) {
|
||
if (!ts) return '-';
|
||
const num = Number(ts);
|
||
if (!Number.isFinite(num)) return '-';
|
||
const date = new Date(num > 1e12 ? num : num * 1000);
|
||
if (Number.isNaN(date.getTime())) return '-';
|
||
const y = date.getFullYear();
|
||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||
const d = String(date.getDate()).padStart(2, '0');
|
||
const hh = String(date.getHours()).padStart(2, '0');
|
||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||
return `${y}-${m}-${d} ${hh}:${mm}`;
|
||
}
|
||
|
||
function formatPrice(value) {
|
||
if (value === null || value === undefined || value === '') return '-';
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) return '-';
|
||
return `¥${num.toFixed(2)}`;
|
||
}
|
||
|
||
function formatNumber(value) {
|
||
if (value === null || value === undefined) return '-';
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) return '-';
|
||
return num.toLocaleString('zh-CN');
|
||
}
|
||
|
||
const STATUS_MAP = {
|
||
501: { label: '报名成功', color: 'success' },
|
||
502: { label: '审核中', color: 'processing' },
|
||
503: { label: '已拒绝', color: 'error' },
|
||
504: { label: '已取消', color: 'default' },
|
||
505: { label: '已结束', color: 'warning' },
|
||
};
|
||
|
||
function getStatusInfo(status) {
|
||
if (STATUS_MAP[status]) return STATUS_MAP[status];
|
||
return { label: `状态 ${status}`, color: 'default' };
|
||
}
|
||
|
||
const MarketingEvents = () => {
|
||
const [form] = Form.useForm();
|
||
const [loading, setLoading] = useState(false);
|
||
const [dataSource, setDataSource] = useState([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [statusCounts, setStatusCounts] = useState({ all: 0 });
|
||
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
|
||
const [sorter, setSorter] = useState({ field: 'activity_goods_start_time', order: 'descend' });
|
||
const [statusFilter, setStatusFilter] = useState('all');
|
||
const [groupByGoods, setGroupByGoods] = useState(true);
|
||
const [queryFilters, setQueryFilters] = useState({});
|
||
const [latestDate, setLatestDate] = useState('');
|
||
const [summary, setSummary] = useState(null);
|
||
const [summaryLoading, setSummaryLoading] = useState(false);
|
||
|
||
const fetchLatestDate = useCallback(async () => {
|
||
try {
|
||
const res = await getMarketingEventsLatestDate();
|
||
if (res?.data?.date) {
|
||
setLatestDate(res.data.date);
|
||
return res.data.date;
|
||
}
|
||
} catch {
|
||
// 忽略错误
|
||
}
|
||
return '';
|
||
}, []);
|
||
|
||
const fetchSummary = useCallback(async (date) => {
|
||
setSummaryLoading(true);
|
||
try {
|
||
const res = await getMarketingEventsSummary({ date });
|
||
if (res?.data) {
|
||
setSummary(res.data);
|
||
}
|
||
} catch {
|
||
setSummary(null);
|
||
} finally {
|
||
setSummaryLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchLatestDate().then((date) => {
|
||
if (date) fetchSummary(date);
|
||
});
|
||
}, [fetchLatestDate, fetchSummary]);
|
||
|
||
const fetchData = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const params = {
|
||
page: pagination.current,
|
||
pageSize: pagination.pageSize,
|
||
sortBy: sorter.field,
|
||
sortOrder: sorter.order === 'ascend' ? 'ASC' : 'DESC',
|
||
...queryFilters,
|
||
};
|
||
if (statusFilter !== 'all') {
|
||
params.status = statusFilter;
|
||
}
|
||
const result = await getMarketingEventsList(params);
|
||
const data = result?.data;
|
||
if (data) {
|
||
setDataSource(data.list || []);
|
||
setTotal(data.total || 0);
|
||
if (data.statusCounts) {
|
||
setStatusCounts(data.statusCounts);
|
||
}
|
||
if (data.filterDate) {
|
||
setLatestDate(data.filterDate);
|
||
fetchSummary(data.filterDate);
|
||
}
|
||
}
|
||
} catch {
|
||
setDataSource([]);
|
||
setTotal(0);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [pagination, sorter, statusFilter, queryFilters, fetchSummary]);
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
}, [fetchData]);
|
||
|
||
const handleTableChange = (pag, _filters, sort) => {
|
||
setPagination({ current: pag.current, pageSize: pag.pageSize });
|
||
if (sort.field) {
|
||
setSorter({ field: sort.field, order: sort.order || 'descend' });
|
||
}
|
||
};
|
||
|
||
const handleReset = () => {
|
||
form.resetFields();
|
||
setQueryFilters({});
|
||
setStatusFilter('all');
|
||
setPagination({ current: 1, pageSize: 20 });
|
||
setSorter({ field: 'activity_goods_start_time', order: 'descend' });
|
||
};
|
||
|
||
const handleFinish = (values) => {
|
||
const filters = {};
|
||
if (values.shopName) filters.shopName = values.shopName;
|
||
if (values.activityType) filters.activityType = values.activityType;
|
||
if (values.activityName) filters.activityName = values.activityName;
|
||
if (values.activityId) filters.activityId = values.activityId;
|
||
if (values.goodsName) filters.goodsName = values.goodsName;
|
||
if (values.goodsId) filters.goodsId = values.goodsId;
|
||
if (values.dateRange?.length === 2) {
|
||
filters.dateStart = values.dateRange[0].format('YYYY-MM-DD');
|
||
filters.dateEnd = values.dateRange[1].format('YYYY-MM-DD');
|
||
}
|
||
setQueryFilters(filters);
|
||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||
};
|
||
|
||
const statusOptions = useMemo(() => [
|
||
{ label: `全部 ${statusCounts.all || 0}`, value: 'all' },
|
||
{ label: `报名成功 ${statusCounts[501] || 0}`, value: 501 },
|
||
{ label: `审核中 ${statusCounts[502] || 0}`, value: 502 },
|
||
{ label: `已拒绝 ${statusCounts[503] || 0}`, value: 503 },
|
||
{ label: `已取消 ${statusCounts[504] || 0}`, value: 504 },
|
||
{ label: `已结束 ${statusCounts[505] || 0}`, value: 505 },
|
||
], [statusCounts]);
|
||
|
||
// 按商品聚合:同一商品多个活动时,最低价活动作为主行优先展示,其余折叠进展开行
|
||
const displayData = useMemo(() => {
|
||
const priceOf = (row) => {
|
||
const num = Number(row?.minPrice);
|
||
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
|
||
};
|
||
if (!groupByGoods) {
|
||
return dataSource.map((row) => ({ ...row, activityCount: 1, childActivities: null }));
|
||
}
|
||
const groups = new Map();
|
||
dataSource.forEach((row) => {
|
||
const key = String(row.goodsId || row.id);
|
||
if (!groups.has(key)) groups.set(key, []);
|
||
groups.get(key).push(row);
|
||
});
|
||
const list = [];
|
||
groups.forEach((rows) => {
|
||
const sorted = [...rows].sort((left, right) => priceOf(left) - priceOf(right));
|
||
const [primary, ...rest] = sorted;
|
||
list.push({
|
||
...primary,
|
||
activityCount: sorted.length,
|
||
childActivities: rest.length ? rest : null,
|
||
});
|
||
});
|
||
return list;
|
||
}, [dataSource, groupByGoods]);
|
||
|
||
const summaryMetrics = useMemo(() => {
|
||
if (!summary) return [];
|
||
return [
|
||
{ key: 'totalRecords', label: '活动记录数', value: formatNumber(summary.totalRecords) },
|
||
{ key: 'successCount', label: '报名成功', value: formatNumber(summary.successCount) },
|
||
{ key: 'successRate', label: '报名成功率', value: `${summary.successRate}%` },
|
||
{ key: 'goodsCount', label: '参与商品数', value: formatNumber(summary.goodsCount) },
|
||
{ key: 'shopCount', label: '参与店铺数', value: formatNumber(summary.shopCount) },
|
||
{ key: 'activityIdCount', label: '活动数', value: formatNumber(summary.activityIdCount) },
|
||
{ key: 'activityTypeCount', label: '活动类型', value: formatNumber(summary.activityTypeCount) },
|
||
{ key: 'priceRange', label: '价格区间', value: summary.globalMinPrice !== '-' ? `${formatPrice(summary.globalMinPrice)} ~ ${formatPrice(summary.globalMaxPrice)}` : '-' },
|
||
];
|
||
}, [summary]);
|
||
|
||
const columns = useMemo(() => [
|
||
{
|
||
title: '商品图片',
|
||
dataIndex: 'thumbUrl',
|
||
key: 'thumbUrl',
|
||
width: 80,
|
||
fixed: 'left',
|
||
render: (value, record) => (
|
||
value ? (
|
||
<img
|
||
className="marketing-events-thumb"
|
||
src={value}
|
||
alt={record.goodsName || '商品图片'}
|
||
loading="lazy"
|
||
/>
|
||
) : (
|
||
<span className="marketing-events-thumb is-empty">暂无</span>
|
||
)
|
||
),
|
||
},
|
||
{
|
||
title: '商品名称',
|
||
dataIndex: 'goodsName',
|
||
key: 'goodsName',
|
||
width: 200,
|
||
ellipsis: true,
|
||
render: (value, record) => (
|
||
<div className="marketing-events-goods-cell">
|
||
{value ? <span className="marketing-events-goods-name" title={value}>{value}</span> : '-'}
|
||
<span className="marketing-events-goods-id">ID: {record.goodsId || '-'}</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '活动名称',
|
||
dataIndex: 'activityName',
|
||
key: 'activityName',
|
||
width: 200,
|
||
ellipsis: true,
|
||
render: (value, record) => (
|
||
<div className="marketing-events-goods-cell">
|
||
{value ? <span title={value}>{value}</span> : '-'}
|
||
<span className="marketing-events-goods-id">ID: {record.activityId || '-'}</span>
|
||
{record.activityCount > 1 ? (
|
||
<Tag color="blue" style={{ marginRight: 0 }}>共{record.activityCount}个活动 · 已按最低价优先</Tag>
|
||
) : null}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '活动类型',
|
||
dataIndex: 'activityType',
|
||
key: 'activityType',
|
||
width: 140,
|
||
ellipsis: true,
|
||
render: (value) => value || '-',
|
||
},
|
||
{
|
||
title: '店铺',
|
||
dataIndex: 'shopName',
|
||
key: 'shopName',
|
||
width: 120,
|
||
ellipsis: true,
|
||
render: (value) => value || '-',
|
||
},
|
||
{
|
||
title: '报名时间',
|
||
dataIndex: 'enrollTime',
|
||
key: 'enrollTime',
|
||
width: 160,
|
||
sorter: true,
|
||
render: (value) => formatTimestamp(value),
|
||
},
|
||
{
|
||
title: '活动开始',
|
||
dataIndex: 'activityStartTime',
|
||
key: 'activityStartTime',
|
||
width: 160,
|
||
sorter: true,
|
||
render: (value) => formatTimestamp(value),
|
||
},
|
||
{
|
||
title: '活动结束',
|
||
dataIndex: 'activityEndTime',
|
||
key: 'activityEndTime',
|
||
width: 160,
|
||
sorter: true,
|
||
render: (value) => formatTimestamp(value),
|
||
},
|
||
{
|
||
title: '最低价',
|
||
dataIndex: 'minPrice',
|
||
key: 'minPrice',
|
||
width: 100,
|
||
align: 'right',
|
||
render: (value) => formatPrice(value),
|
||
},
|
||
{
|
||
title: '最高价',
|
||
dataIndex: 'maxPrice',
|
||
key: 'maxPrice',
|
||
width: 100,
|
||
align: 'right',
|
||
render: (value) => formatPrice(value),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
key: 'status',
|
||
width: 100,
|
||
fixed: 'right',
|
||
render: (value) => {
|
||
const info = getStatusInfo(value);
|
||
return <Tag color={info.color}>{info.label}</Tag>;
|
||
},
|
||
},
|
||
], []);
|
||
|
||
const childActivityColumns = [
|
||
{ title: '活动名称', dataIndex: 'activityName', width: 200, ellipsis: true, render: (value, record) => (
|
||
<div className="marketing-events-goods-cell">
|
||
{value ? <span title={value}>{value}</span> : '-'}
|
||
<span className="marketing-events-goods-id">ID: {record.activityId || '-'}</span>
|
||
</div>
|
||
) },
|
||
{ title: '活动类型', dataIndex: 'activityType', width: 140, ellipsis: true, render: (value) => value || '-' },
|
||
{ title: '报名时间', dataIndex: 'enrollTime', width: 160, render: formatTimestamp },
|
||
{ title: '活动开始', dataIndex: 'activityStartTime', width: 160, render: formatTimestamp },
|
||
{ title: '活动结束', dataIndex: 'activityEndTime', width: 160, render: formatTimestamp },
|
||
{ title: '最低价', dataIndex: 'minPrice', width: 100, align: 'right', render: formatPrice },
|
||
{ title: '最高价', dataIndex: 'maxPrice', width: 100, align: 'right', render: formatPrice },
|
||
{ title: '状态', dataIndex: 'status', width: 100, render: (value) => {
|
||
const info = getStatusInfo(value);
|
||
return <Tag color={info.color}>{info.label}</Tag>;
|
||
} },
|
||
];
|
||
|
||
return (
|
||
<SycmPage className="marketing-events-page">
|
||
<SycmBusinessPanel className="marketing-events-panel" aria-label="营销活动内容区">
|
||
<div className="marketing-events-panel__heading">
|
||
<h2>营销活动</h2>
|
||
<span>查看拼多多平台活动报名记录</span>
|
||
{latestDate && (
|
||
<span className="marketing-events-data-date">最新数据日期:{latestDate}</span>
|
||
)}
|
||
</div>
|
||
|
||
<Spin spinning={summaryLoading}>
|
||
<div className="marketing-events-summary">
|
||
{summaryMetrics.map((item) => (
|
||
<SycmDashboardMetric
|
||
key={item.key}
|
||
label={item.label}
|
||
value={item.value}
|
||
help={false}
|
||
/>
|
||
))}
|
||
{!summary && !summaryLoading && (
|
||
<span className="marketing-events-summary__empty">暂无汇总数据</span>
|
||
)}
|
||
</div>
|
||
</Spin>
|
||
|
||
<Form
|
||
form={form}
|
||
className="marketing-events-filter"
|
||
layout="horizontal"
|
||
colon={false}
|
||
onFinish={handleFinish}
|
||
>
|
||
<Form.Item label="店铺名称" name="shopName">
|
||
<Input allowClear placeholder="输入店铺名称" />
|
||
</Form.Item>
|
||
<Form.Item label="活动类型" name="activityType">
|
||
<Input allowClear placeholder="输入活动类型" />
|
||
</Form.Item>
|
||
<Form.Item label="活动名称" name="activityName">
|
||
<Input allowClear placeholder="输入活动名称" />
|
||
</Form.Item>
|
||
<Form.Item label="活动ID" name="activityId">
|
||
<Input allowClear placeholder="输入活动ID" />
|
||
</Form.Item>
|
||
<Form.Item label="商品名称" name="goodsName">
|
||
<Input allowClear placeholder="输入商品名称" />
|
||
</Form.Item>
|
||
<Form.Item label="商品ID" name="goodsId">
|
||
<Input allowClear placeholder="输入商品ID" />
|
||
</Form.Item>
|
||
<Form.Item label="活动时间" name="dateRange">
|
||
<RangePicker allowClear placeholder={['开始日期', '结束日期']} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<div className="marketing-events-filter__actions">
|
||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>查询</Button>
|
||
<Button htmlType="button" icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
|
||
</div>
|
||
</Form>
|
||
|
||
<div className="marketing-events-table-toolbar">
|
||
<Segmented
|
||
className="marketing-events-status-switch"
|
||
value={statusFilter}
|
||
options={statusOptions}
|
||
onChange={(value) => {
|
||
setStatusFilter(value);
|
||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||
}}
|
||
/>
|
||
<Segmented
|
||
value={groupByGoods ? 'group' : 'flat'}
|
||
options={[
|
||
{ label: '按商品聚合(最低价优先)', value: 'group' },
|
||
{ label: '按活动明细', value: 'flat' },
|
||
]}
|
||
onChange={(value) => setGroupByGoods(value === 'group')}
|
||
/>
|
||
</div>
|
||
|
||
<Spin spinning={loading}>
|
||
<Table
|
||
className="marketing-events-table"
|
||
rowKey="id"
|
||
columns={columns}
|
||
dataSource={displayData}
|
||
onChange={handleTableChange}
|
||
expandable={groupByGoods ? {
|
||
expandedRowRender: (record) => (
|
||
<Table
|
||
className="marketing-events-child-table"
|
||
rowKey="id"
|
||
size="small"
|
||
pagination={false}
|
||
columns={childActivityColumns}
|
||
dataSource={record.childActivities || []}
|
||
/>
|
||
),
|
||
rowExpandable: (record) => Boolean(record.childActivities?.length),
|
||
} : undefined}
|
||
pagination={{
|
||
current: pagination.current,
|
||
pageSize: pagination.pageSize,
|
||
total,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: ['20', '50', '100'],
|
||
showTotal: (t) => `共 ${t} 条`,
|
||
}}
|
||
scroll={{ x: 1600 }}
|
||
locale={{ emptyText: loading ? '加载中...' : '暂无活动记录' }}
|
||
size="small"
|
||
/>
|
||
</Spin>
|
||
</SycmBusinessPanel>
|
||
</SycmPage>
|
||
);
|
||
};
|
||
|
||
export default MarketingEvents;
|