- 后端:pickingBinSalesAnalysis 配置/服务/路由,仓位库存取 rpt.jst_packInventoryInfo (bin_group 空值=拣选位、存货=存货位),销量按「SKU 占用同类仓位数平均分摊」, 恒等式「全部库位销量之和 = 有库位 SKU 销量之和」差异为 0 - 动销等级(近 N 天日销,默认 15):爆款>=20、热卖款 10-20、平销款 3-10、 滞销款-1 1-3、滞销款-2 (0,1)、销售=0 - 前端:大货库存监控下新增二级菜单 + 页面(等级卡片、仓库/类型/天数/仓位筛选、 分页表格、xlsx 导出),路由/菜单/页面权限四处注册 - 测试:后端 8 项单测通过;前端 lint/test/build 通过
214 lines
9.5 KiB
JavaScript
214 lines
9.5 KiB
JavaScript
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: 'picking-bin-sales-analysis', label: '拣选库位动销分析', icon: 'inbox', routeKey: 'picking-bin-sales-analysis', visible: true, order: 30 },
|
||
],
|
||
},
|
||
{
|
||
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))
|
||
);
|