493 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;