cangku/frontend/src/config/sidebarNavigation.js
youruo f20bd3a487 feat: 侧边栏新增二级「库存动销分析」页面
按《仓库库存动销分析.xlsx》:
- 一级菜单「大货库存监控」下新增二级「库存动销分析」(路由 /planning/operation/stock-sales-analysis)
- 表头与表格结构按需求表:仓库 | 动销等级 | SKU数量 | SKU占比 | 库存数量 | 库存占比 | 销量,
  每仓 6 个动销等级(爆款/热卖款/平销款/滞销款-1/滞销款-2/销售=0)+ 1 行汇总,仓库列跨行合并
- 分档口径:日销 = 统计窗口内已发货件数 ÷ 窗口天数(7/14/30 天可选,默认 30),
  库存取 rpt.jst_inventoryInfo 实时库存,销量取 jst_Orders×jst_OrderItem 按 send_date
  (排除 Delete/Cancelled,与每日进销存发货口径一致),只统计有库存或有销量的 SKU
- 后端 /api/stock-sales-analysis:单条 SQL 一次覆盖全部仓库×等级,5 分钟缓存 + refresh=1 强制重算;
  返回逐仓 + 全仓合计、占比、无数据仓数量
- 前端页面:统计天数/仓库筛选、口径说明弹窗、合计行高亮、无数据仓计数提示
- 页面权限:前后端 sidebarPages 注册 pageKey=stock_sales_analysis,默认 planning 角色可见

验证:后端 syntax + 单测 5/5;前端 eslint、build、npm test 21/21;
本地接口 30 天窗口实测 27 仓(崔桥汇总 2080 SKU / 90822 件 / 54076 件销量),
占比逐仓闭合为 100%;浏览器实测侧边栏入口、表头、197 行渲染与截图均正常
2026-09-27 16:29:58 +08:00

213 lines
9.3 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.

export const DEFAULT_SIDEBAR_CONFIG = {
collapsedByDefault: false,
darkModeByDefault: false,
entries: [
{ key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 },
{ key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 },
{ key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 },
{
key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true,
children: [
{ key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 },
{ key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 },
],
},
{
key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true,
children: [
{ key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 },
],
},
{
key: 'returns-inventory', type: 'group', label: '退货库存监控', icon: 'inbox', visible: true, order: 75, defaultExpanded: true,
children: [
{ key: 'returns-inventory-cuiqiao', label: '崔桥工厂仓客退数据监控', icon: 'table', routeKey: 'returns-inventory-cuiqiao', visible: true, order: 10 },
{ key: 'returns-inventory-subei', label: '苏北客退数据监控', icon: 'table', routeKey: 'returns-inventory-subei', visible: true, order: 20 },
{ key: 'returns-inventory-suining', label: '睢宁客退数据监控', icon: 'table', routeKey: 'returns-inventory-suining', visible: true, order: 30 },
{ key: 'returns-inventory-shandong', label: '山东客退数据监控', icon: 'table', routeKey: 'returns-inventory-shandong', visible: true, order: 40 },
{ key: 'returns-inventory-henan', label: '河南客退数据监控', icon: 'table', routeKey: 'returns-inventory-henan', visible: true, order: 50 },
],
},
{ key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
{
key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true,
children: [
{ key: 'modular-stock-monitor-accessory', label: '配件库存监控', icon: 'table', routeKey: 'modular-stock-monitor-accessory', visible: true, order: 10 },
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
],
},
{
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
children: [
{ key: 'daily-inventory', label: '每日进销存', icon: 'table', routeKey: 'daily-inventory', visible: true, order: 10 },
{ key: 'stock-sales-analysis', label: '库存动销分析', icon: 'line-chart', routeKey: 'stock-sales-analysis', visible: true, order: 20 },
],
},
{
key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,
children: [
{ key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true },
{ key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true },
{ key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true },
],
},
],
};
const cloneDefaultConfig = () => JSON.parse(JSON.stringify(DEFAULT_SIDEBAR_CONFIG));
const flattenEntryKeys = (entries = []) => entries.flatMap((entry) => [
entry.key,
...flattenEntryKeys(entry.children || []),
]).filter(Boolean);
const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [
entry.routeKey,
...flattenRouteKeys(entry.children || []),
]).filter(Boolean);
const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries));
const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries));
// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置
const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板';
const OPS_B_INCENTIVE_LEGACY_ORDER = 6;
const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']);
// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并
const extractPromotedTopLevelEntries = (entries = []) => {
const promoted = [];
const nextEntries = entries.map((entry) => {
if (!Array.isArray(entry?.children)) return entry;
const children = [];
entry.children.forEach((child) => {
if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) {
promoted.push(child);
} else {
children.push(child);
}
});
return children.length === entry.children.length ? entry : { ...entry, children };
});
return { nextEntries, promoted };
};
const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key));
const configuredKeys = new Set();
const mergeEntry = (defaultEntry = {}, configuredEntry = {}) => {
const merged = { ...defaultEntry, ...configuredEntry };
if (defaultEntry.type) {
merged.type = defaultEntry.type;
}
if (defaultEntry.routeKey) {
merged.routeKey = defaultEntry.routeKey;
}
if (typeof defaultEntry.requiresAdmin !== 'undefined') {
merged.requiresAdmin = defaultEntry.requiresAdmin;
}
if (
defaultEntry.key === 'ops-b-incentive'
&& (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL)
) {
merged.label = defaultEntry.label;
}
if (
defaultEntry.key === 'ops-b-incentive'
&& (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER)
) {
merged.order = defaultEntry.order;
}
const hasDefaultChildren = Array.isArray(defaultEntry.children);
const hasConfiguredChildren = Array.isArray(configuredEntry.children);
if (hasDefaultChildren || hasConfiguredChildren) {
if (hasDefaultChildren) {
merged.type = defaultEntry.type;
} else if (defaultEntry.routeKey) {
merged.type = defaultEntry.type || 'item';
delete merged.children;
delete merged.defaultExpanded;
return merged;
}
merged.children = mergeSidebarEntries(defaultEntry.children || [], configuredEntry.children || []);
}
return merged;
};
const configuredMerged = configuredEntries.map((entry) => {
if (entry?.key) {
configuredKeys.add(entry.key);
}
const defaultEntry = defaultByKey.get(entry?.key);
return defaultEntry ? mergeEntry(defaultEntry, entry) : entry;
});
const missingDefaults = defaultEntries
.filter((entry) => entry?.key && !configuredKeys.has(entry.key))
.map((entry) => mergeEntry(entry, {}));
return [...configuredMerged, ...missingDefaults];
};
const mergeSidebarConfigWithDefaults = (config) => {
const defaults = cloneDefaultConfig();
const nestedEntries = (config?.entries || []);
const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries);
const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean));
const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted;
return {
...defaults,
...config,
entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]),
};
};
const pruneSidebarEntry = (entry) => {
if (!entry || !ALLOWED_ENTRY_KEYS.has(entry.key)) {
return null;
}
const children = Array.isArray(entry.children)
? entry.children.map(pruneSidebarEntry).filter(Boolean)
: undefined;
const hasAllowedRoute = entry.routeKey ? ALLOWED_ROUTE_KEYS.has(entry.routeKey) : false;
const hasAllowedChildren = Array.isArray(children) && children.length > 0;
const keepEntry = hasAllowedRoute || hasAllowedChildren;
if (!keepEntry) {
return null;
}
return {
...entry,
...(Array.isArray(entry.children) ? { children } : {}),
};
};
const removeRetiredEntries = (config) => ({
...config,
entries: (config.entries || []).map(pruneSidebarEntry).filter(Boolean),
});
export const parseSidebarConfig = (value) => {
if (!value) return cloneDefaultConfig();
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (!parsed || !Array.isArray(parsed.entries)) return cloneDefaultConfig();
return removeRetiredEntries(mergeSidebarConfigWithDefaults(parsed));
} catch {
return cloneDefaultConfig();
}
};
export const sortSidebarEntries = (entries = []) => (
[...entries]
.filter((entry) => entry?.visible !== false)
.sort((a, b) => Number(a.order || 0) - Number(b.order || 0))
);