yanhui 445347e664 feat: 启用运营分析模块,新增模块化库存监控与 MKH-BOM 资料
本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。

主要变更:
1. 恢复启用:商品监控、激励看板、营销活动、推广模块、商品分析
   - retiredFeatures 移出对应退役名单与清表语句,改为正常保留
   - 补齐后端路由、前端页面与 API 封装、侧边栏配置、桌面客户端能力项
   - 同步更新 retiredFeatures / retiredRoutes / retiredNavigation / desktopSessionService 测试
2. 新增「模块化库存监控」一级菜单
   - 配件库存监控:仅取「简尚-崔桥工厂半成品包装项目仓」SKU 的实时库存、
     当日入库/出库、近 7 日日均发货量、库存可用天数与预警(库存可用天数 ≤ 7 预警)
   - MKH-BOM资料:按商品编码展示配件明细、数量、仓位、库存,支持 A4 纵向打印
3. 新增产品资料系统数据库(PostgreSQL)连接模块:读取系统库 database_configs 中
   name = product_information_database 的配置,凭据只从配置读取,不落代码
4. 新增系统设置页配置项:商品标签配置、部门商品标签配置、部门SKU负责人配置
5. 旧 /api/dashboard 接口迁移至 /api/productAnalysis,仪表盘预览页改用新接口,
   相应移除 dashboard 路由模块及其测试
6. 修复 retiredRoutes 测试在 Windows 下挂起超时的问题:该测试用
   parent.filename.endsWith('/api/planningUpload.js') 判断是否注入 mock,
   而 Windows 的 filename 为反斜杠路径,导致判断恒为 false、mock 全部失效、
   真实模块去连中央系统库而挂起(Linux CI 正常)。改为平台无关的路径匹配。
7. .gitignore 忽略本地验证产物(outputs/、.git-status.txt)

验证:
- 前端:lint / test(16 项)/ build(含 planning entry 校验)全部通过
- 后端:语法检查 149 个文件通过;测试 38/39 通过
- 已知未通过项:resilientSecretStore.test.js 断言密钥备份文件权限为 0o600,
  Windows 下 chmod 无法表达该权限(实际为 0o666),属既有的平台相关失败,
  与本次改动无关,本次未修改该文件
- 说明:本机 frontend/ 下存在未纳入版本库的旧依赖备份目录 node_modules_old_*,
  会污染 ESLint 扫描(十余万条第三方报错),故 lint 步骤以
  --ignore-pattern node_modules_old_*/** 排除后执行,其余步骤与仓库 check 一致

未提交内容:本地验证截图 outputs/ 与 .git-status.txt 已被 .gitignore 排除。
2026-09-15 16:53:12 +08:00

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;