本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。
主要变更:
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 排除。
196 lines
7.9 KiB
JavaScript
196 lines
7.9 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: '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: '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))
|
||
);
|